Files
buzz/desktop/src/features/dictation/lib/realtimeAudio.ts
klopez4212 f7ee8ff980 feat: add real-time voice dictation to composer
Adds dictation support using OpenAI's Realtime API over WebRTC:

Relay:
- New /transcribe/status and /transcribe/session endpoints
- BUZZ_OPENAI_API_KEY env var gates the feature (hidden when absent)
- Proxies ephemeral client-secret minting from OpenAI

Desktop:
- New features/dictation module with:
  - AudioWorklet for 24kHz PCM capture + buffering
  - WebRTC peer connection to OpenAI Realtime API
  - Real-time transcript merging into composer
  - Auto-submit on trigger phrase ('submit')
  - Mic button in composer toolbar (red pulse when recording)
- Integrated into MessageComposer via useComposerDictation hook

Signed-off-by: klopez4212 <klopez4212@gmail.com>
2026-07-11 16:18:04 +01:00

146 lines
3.8 KiB
TypeScript

import {
REALTIME_BUFFER_PROCESSOR_NAME,
createWorkletBlobUrl,
} from "./realtimeBufferWorklet";
export const OPENAI_REALTIME_WEBRTC_URL =
"https://api.openai.com/v1/realtime/calls";
export const TRANSCRIPT_DELTA_EVENT =
"conversation.item.input_audio_transcription.delta";
export const TRANSCRIPT_COMPLETED_EVENT =
"conversation.item.input_audio_transcription.completed";
const MAX_BUFFER_CHUNKS = 500; // ~10s at 20ms per chunk
export type TranscriptEvent = {
type?: string;
item_id?: string;
content_index?: number;
delta?: string;
transcript?: string;
message?: string;
error?: { message?: string };
};
export function createPeerConnection(): RTCPeerConnection {
return new RTCPeerConnection();
}
export async function connectPeerConnection(options: {
peerConnection: RTCPeerConnection;
clientSecret: string;
}): Promise<void> {
const offer = await options.peerConnection.createOffer();
await options.peerConnection.setLocalDescription(offer);
const response = await fetch(OPENAI_REALTIME_WEBRTC_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${options.clientSecret}`,
"Content-Type": "application/sdp",
},
body: offer.sdp ?? "",
});
const body = await response.text();
if (!response.ok) {
throw new Error(
`OpenAI realtime connection failed (${response.status}): ${body}`,
);
}
await options.peerConnection.setRemoteDescription({
type: "answer",
sdp: body,
});
}
export function mergeTranscriptSegment(
currentText: string,
segmentText: string,
event: TranscriptEvent,
): string {
if (!segmentText) return currentText;
if (!currentText) return segmentText;
// Completed events re-send the full segment text; skip if already present.
if (event.type === TRANSCRIPT_COMPLETED_EVENT) {
const normalizedCurrent = currentText.trimEnd().toLowerCase();
const normalizedText = segmentText.trim().toLowerCase();
if (normalizedCurrent.endsWith(normalizedText)) {
return currentText;
}
}
return currentText + segmentText;
}
// ── Audio buffer capture ──────────────────────────────────────────────────
export interface AudioBufferCapture {
chunks: Int16Array[];
close(): void;
}
export async function createAudioBufferCapture(
stream: MediaStream,
): Promise<AudioBufferCapture> {
const audioContext = new AudioContext();
const blobUrl = createWorkletBlobUrl();
try {
await audioContext.audioWorklet.addModule(blobUrl);
} finally {
URL.revokeObjectURL(blobUrl);
}
const source = audioContext.createMediaStreamSource(stream);
const worklet = new AudioWorkletNode(
audioContext,
REALTIME_BUFFER_PROCESSOR_NAME,
);
source.connect(worklet);
worklet.connect(audioContext.destination);
const chunks: Int16Array[] = [];
worklet.port.onmessage = (event: MessageEvent<ArrayBuffer>) => {
if (chunks.length < MAX_BUFFER_CHUNKS) {
chunks.push(new Int16Array(event.data));
}
};
return {
chunks,
close() {
worklet.disconnect();
source.disconnect();
void audioContext.close();
},
};
}
// ── Flush buffered PCM into the data channel ──────────────────────────────
function int16ToBase64(pcm: Int16Array): string {
const bytes = new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
export function flushAudioBuffer(
dataChannel: RTCDataChannel,
chunks: Int16Array[],
): void {
for (const chunk of chunks) {
dataChannel.send(
JSON.stringify({
type: "input_audio_buffer.append",
audio: int16ToBase64(chunk),
}),
);
}
chunks.length = 0;
}