Add streaming ASR partial transcripts and reset hosts to env default
- Stream partial transcripts to the client during dictation for live feedback - Use OpenAI semantic VAD instead of manual commit intervals for better word boundaries - Append silence tail on finish to flush final utterance through VAD - Skip committing silence-only audio tails - Reset hosts to env-defined daemon when env var is present, overriding stored state
This commit is contained in:
@@ -8,6 +8,7 @@ import type { DictationStatus } from "@/hooks/use-dictation";
|
|||||||
interface DictationControlsProps {
|
interface DictationControlsProps {
|
||||||
volume: number;
|
volume: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
transcript?: string;
|
||||||
isRecording: boolean;
|
isRecording: boolean;
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
status: DictationStatus;
|
status: DictationStatus;
|
||||||
@@ -131,6 +132,7 @@ export function DictationControls({
|
|||||||
export function DictationOverlay({
|
export function DictationOverlay({
|
||||||
volume,
|
volume,
|
||||||
duration,
|
duration,
|
||||||
|
transcript,
|
||||||
isRecording,
|
isRecording,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
status,
|
status,
|
||||||
@@ -169,21 +171,34 @@ export function DictationOverlay({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<View style={overlayStyles.centerContainer}>
|
<View style={overlayStyles.centerContainer}>
|
||||||
<VolumeMeter
|
<View style={overlayStyles.meterRow}>
|
||||||
volume={volume}
|
<VolumeMeter
|
||||||
isMuted={false}
|
volume={volume}
|
||||||
isDetecting
|
isMuted={false}
|
||||||
isSpeaking={false}
|
isDetecting
|
||||||
orientation="horizontal"
|
isSpeaking={false}
|
||||||
/>
|
orientation="horizontal"
|
||||||
<Text
|
/>
|
||||||
style={[
|
<Text
|
||||||
overlayStyles.timerText,
|
style={[
|
||||||
{ color: theme.colors.palette.white },
|
overlayStyles.timerText,
|
||||||
]}
|
{ color: theme.colors.palette.white },
|
||||||
>
|
]}
|
||||||
{formatDuration(duration)}
|
>
|
||||||
</Text>
|
{formatDuration(duration)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{!!transcript && (
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
style={[
|
||||||
|
overlayStyles.transcriptText,
|
||||||
|
{ color: theme.colors.palette.white },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{transcript}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={overlayStyles.actionButtonsContainer}>
|
<View style={overlayStyles.actionButtonsContainer}>
|
||||||
@@ -334,6 +349,12 @@ const overlayStyles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
centerContainer: {
|
centerContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
meterRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -344,6 +365,13 @@ const overlayStyles = StyleSheet.create((theme) => ({
|
|||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
fontVariant: ["tabular-nums"],
|
fontVariant: ["tabular-nums"],
|
||||||
},
|
},
|
||||||
|
transcriptText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
textAlign: "center",
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
actionButtonsContainer: {
|
actionButtonsContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
const {
|
const {
|
||||||
isRecording: isDictating,
|
isRecording: isDictating,
|
||||||
isProcessing: isDictationProcessing,
|
isProcessing: isDictationProcessing,
|
||||||
|
partialTranscript: dictationPartialTranscript,
|
||||||
volume: dictationVolume,
|
volume: dictationVolume,
|
||||||
duration: dictationDuration,
|
duration: dictationDuration,
|
||||||
status: dictationStatus,
|
status: dictationStatus,
|
||||||
@@ -591,6 +592,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
<DictationOverlay
|
<DictationOverlay
|
||||||
volume={dictationVolume}
|
volume={dictationVolume}
|
||||||
duration={dictationDuration}
|
duration={dictationDuration}
|
||||||
|
transcript={dictationPartialTranscript}
|
||||||
isRecording={isDictating}
|
isRecording={isDictating}
|
||||||
isProcessing={isDictationProcessing}
|
isProcessing={isDictationProcessing}
|
||||||
status={dictationStatus}
|
status={dictationStatus}
|
||||||
|
|||||||
@@ -329,6 +329,14 @@ function migrateLegacyToHostProfile(legacy: LegacyDaemonProfile): HostProfile {
|
|||||||
|
|
||||||
async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
|
async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
|
||||||
try {
|
try {
|
||||||
|
// When env vars define a default daemon, always reset to that daemon only.
|
||||||
|
// This ensures the app uses the configured daemon regardless of stored state.
|
||||||
|
const envDefaults = parseEnvDaemonDefaults();
|
||||||
|
if (envDefaults.length > 0) {
|
||||||
|
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(envDefaults));
|
||||||
|
return envDefaults;
|
||||||
|
}
|
||||||
|
|
||||||
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
|
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed = JSON.parse(stored) as unknown;
|
const parsed = JSON.parse(stored) as unknown;
|
||||||
@@ -390,12 +398,6 @@ async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const envDefaults = parseEnvDaemonDefaults();
|
|
||||||
if (envDefaults.length > 0) {
|
|
||||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(envDefaults));
|
|
||||||
return envDefaults;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No implicit localhost fallback: a fresh install starts with zero hosts.
|
// No implicit localhost fallback: a fresh install starts with zero hosts.
|
||||||
return [];
|
return [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type DictationStatus = "idle" | "recording" | "uploading" | "failed";
|
|||||||
export type UseDictationOptions = {
|
export type UseDictationOptions = {
|
||||||
client: import("@server/client/daemon-client-v2").DaemonClientV2 | null;
|
client: import("@server/client/daemon-client-v2").DaemonClientV2 | null;
|
||||||
onTranscript: (text: string, meta: { requestId: string }) => void;
|
onTranscript: (text: string, meta: { requestId: string }) => void;
|
||||||
|
onPartialTranscript?: (text: string, meta: { requestId: string }) => void;
|
||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
onPermanentFailure?: (error: Error, context: { requestId: string }) => void;
|
onPermanentFailure?: (error: Error, context: { requestId: string }) => void;
|
||||||
canStart?: () => boolean;
|
canStart?: () => boolean;
|
||||||
@@ -14,6 +15,7 @@ export type UseDictationOptions = {
|
|||||||
export type UseDictationResult = {
|
export type UseDictationResult = {
|
||||||
isRecording: boolean;
|
isRecording: boolean;
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
|
partialTranscript: string;
|
||||||
volume: number;
|
volume: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
const {
|
const {
|
||||||
client,
|
client,
|
||||||
onTranscript,
|
onTranscript,
|
||||||
|
onPartialTranscript,
|
||||||
onError,
|
onError,
|
||||||
onPermanentFailure,
|
onPermanentFailure,
|
||||||
canStart,
|
canStart,
|
||||||
@@ -27,6 +28,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
|
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [partialTranscript, setPartialTranscript] = useState("");
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [status, setStatus] = useState<DictationStatus>("idle");
|
const [status, setStatus] = useState<DictationStatus>("idle");
|
||||||
@@ -36,6 +38,11 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
onTranscriptRef.current = onTranscript;
|
onTranscriptRef.current = onTranscript;
|
||||||
}, [onTranscript]);
|
}, [onTranscript]);
|
||||||
|
|
||||||
|
const onPartialTranscriptRef = useRef(onPartialTranscript);
|
||||||
|
useEffect(() => {
|
||||||
|
onPartialTranscriptRef.current = onPartialTranscript;
|
||||||
|
}, [onPartialTranscript]);
|
||||||
|
|
||||||
const onErrorRef = useRef(onError);
|
const onErrorRef = useRef(onError);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onErrorRef.current = onError;
|
onErrorRef.current = onError;
|
||||||
@@ -123,6 +130,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
|
|
||||||
const clearStreamingState = useCallback(() => {
|
const clearStreamingState = useCallback(() => {
|
||||||
senderRef.current?.clearAll();
|
senderRef.current?.clearAll();
|
||||||
|
setPartialTranscript("");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startNewStream = useCallback(
|
const startNewStream = useCallback(
|
||||||
@@ -155,6 +163,27 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
});
|
});
|
||||||
}, [client, startNewStream]);
|
}, [client, startNewStream]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return client.on("dictation_stream_partial", (message) => {
|
||||||
|
if (message.type !== "dictation_stream_partial") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const activeDictationId = senderRef.current?.getDictationId();
|
||||||
|
if (!activeDictationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.payload.dictationId !== activeDictationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = message.payload.text ?? "";
|
||||||
|
setPartialTranscript(next);
|
||||||
|
onPartialTranscriptRef.current?.(next, { requestId: generateMessageId() });
|
||||||
|
});
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
const audio = useDictationAudioSource({
|
const audio = useDictationAudioSource({
|
||||||
onPcmSegment: (audioData) => {
|
onPcmSegment: (audioData) => {
|
||||||
senderRef.current?.enqueueSegment(audioData);
|
senderRef.current?.enqueueSegment(audioData);
|
||||||
@@ -171,6 +200,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
const handleStreamingTranscriptionSuccess = useCallback(
|
const handleStreamingTranscriptionSuccess = useCallback(
|
||||||
(text: string, requestId: string) => {
|
(text: string, requestId: string) => {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
|
setPartialTranscript("");
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
|
|
||||||
@@ -219,6 +249,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
|
|
||||||
actionGateRef.current.starting = true;
|
actionGateRef.current.starting = true;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setPartialTranscript("");
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
setStatus("recording");
|
setStatus("recording");
|
||||||
@@ -434,6 +465,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
return {
|
return {
|
||||||
isRecording,
|
isRecording,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
|
partialTranscript,
|
||||||
volume: audio.volume,
|
volume: audio.volume,
|
||||||
duration,
|
duration,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -2,6 +2,21 @@ import type pino from "pino";
|
|||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
|
type OpenAITurnDetection =
|
||||||
|
| null
|
||||||
|
| {
|
||||||
|
type: "server_vad";
|
||||||
|
create_response?: boolean;
|
||||||
|
threshold?: number;
|
||||||
|
prefix_padding_ms?: number;
|
||||||
|
silence_duration_ms?: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "semantic_vad";
|
||||||
|
create_response?: boolean;
|
||||||
|
eagerness?: "low" | "medium" | "high";
|
||||||
|
};
|
||||||
|
|
||||||
type OpenAIClientEvent =
|
type OpenAIClientEvent =
|
||||||
| {
|
| {
|
||||||
type: "session.update";
|
type: "session.update";
|
||||||
@@ -15,7 +30,7 @@ type OpenAIClientEvent =
|
|||||||
language?: string;
|
language?: string;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
};
|
};
|
||||||
turn_detection: null;
|
turn_detection: OpenAITurnDetection;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -31,6 +46,13 @@ type OpenAIServerEvent =
|
|||||||
item_id: string;
|
item_id: string;
|
||||||
previous_item_id: string | null;
|
previous_item_id: string | null;
|
||||||
}
|
}
|
||||||
|
| { type: "input_audio_buffer.speech_started" }
|
||||||
|
| { type: "input_audio_buffer.speech_stopped" }
|
||||||
|
| {
|
||||||
|
type: "conversation.item.input_audio_transcription.delta";
|
||||||
|
item_id: string;
|
||||||
|
delta: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "conversation.item.input_audio_transcription.completed";
|
type: "conversation.item.input_audio_transcription.completed";
|
||||||
item_id: string;
|
item_id: string;
|
||||||
@@ -43,22 +65,29 @@ export class OpenAIRealtimeTranscriptionSession extends EventEmitter {
|
|||||||
private readonly logger: pino.Logger;
|
private readonly logger: pino.Logger;
|
||||||
private readonly transcriptionModel: string;
|
private readonly transcriptionModel: string;
|
||||||
private readonly language?: string;
|
private readonly language?: string;
|
||||||
|
private readonly prompt?: string;
|
||||||
|
private readonly turnDetection: OpenAITurnDetection;
|
||||||
|
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private ready: Promise<void> | null = null;
|
private ready: Promise<void> | null = null;
|
||||||
private closing = false;
|
private closing = false;
|
||||||
|
private partialByItemId = new Map<string, string>();
|
||||||
|
|
||||||
constructor(params: {
|
constructor(params: {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
logger: pino.Logger;
|
logger: pino.Logger;
|
||||||
transcriptionModel: string;
|
transcriptionModel: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
|
prompt?: string;
|
||||||
|
turnDetection?: OpenAITurnDetection;
|
||||||
}) {
|
}) {
|
||||||
super();
|
super();
|
||||||
this.apiKey = params.apiKey;
|
this.apiKey = params.apiKey;
|
||||||
this.logger = params.logger.child({ provider: "openai", component: "realtime-transcription" });
|
this.logger = params.logger.child({ provider: "openai", component: "realtime-transcription" });
|
||||||
this.transcriptionModel = params.transcriptionModel;
|
this.transcriptionModel = params.transcriptionModel;
|
||||||
this.language = params.language;
|
this.language = params.language;
|
||||||
|
this.prompt = params.prompt;
|
||||||
|
this.turnDetection = params.turnDetection ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async connect(): Promise<void> {
|
public async connect(): Promise<void> {
|
||||||
@@ -99,9 +128,9 @@ export class OpenAIRealtimeTranscriptionSession extends EventEmitter {
|
|||||||
transcription: {
|
transcription: {
|
||||||
model: this.transcriptionModel,
|
model: this.transcriptionModel,
|
||||||
...(this.language ? { language: this.language } : {}),
|
...(this.language ? { language: this.language } : {}),
|
||||||
|
...(this.prompt ? { prompt: this.prompt } : {}),
|
||||||
},
|
},
|
||||||
// We commit periodically ourselves; no server-side VAD for dictation.
|
turn_detection: this.turnDetection,
|
||||||
turn_detection: null,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -138,8 +167,28 @@ export class OpenAIRealtimeTranscriptionSession extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.type === "input_audio_buffer.speech_started") {
|
||||||
|
this.emit("speech_started");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "input_audio_buffer.speech_stopped") {
|
||||||
|
this.emit("speech_stopped");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "conversation.item.input_audio_transcription.delta") {
|
||||||
|
const replaceDelta = this.transcriptionModel === "whisper-1";
|
||||||
|
const prev = this.partialByItemId.get(event.item_id) ?? "";
|
||||||
|
const next = replaceDelta ? event.delta : prev + event.delta;
|
||||||
|
this.partialByItemId.set(event.item_id, next);
|
||||||
|
this.emit("transcript", { itemId: event.item_id, transcript: next, isFinal: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.type === "conversation.item.input_audio_transcription.completed") {
|
if (event.type === "conversation.item.input_audio_transcription.completed") {
|
||||||
this.emit("transcript", { itemId: event.item_id, transcript: event.transcript });
|
this.partialByItemId.set(event.item_id, event.transcript);
|
||||||
|
this.emit("transcript", { itemId: event.item_id, transcript: event.transcript, isFinal: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,20 +141,63 @@ const MIN_STREAMING_SEGMENT_BYTES = Math.round(
|
|||||||
|
|
||||||
const DICTATION_PCM_INPUT_RATE = 16000;
|
const DICTATION_PCM_INPUT_RATE = 16000;
|
||||||
const DICTATION_PCM_OUTPUT_RATE = 24000;
|
const DICTATION_PCM_OUTPUT_RATE = 24000;
|
||||||
const DICTATION_COMMIT_INTERVAL_MS = Number.parseInt(
|
const DICTATION_FINAL_TIMEOUT_MS = 120000;
|
||||||
process.env.OPENAI_REALTIME_DICTATION_COMMIT_MS ?? "5000",
|
const DICTATION_SILENCE_PEAK_THRESHOLD = Number.parseInt(
|
||||||
|
process.env.OPENAI_REALTIME_DICTATION_SILENCE_PEAK_THRESHOLD ?? "300",
|
||||||
10
|
10
|
||||||
);
|
);
|
||||||
const DICTATION_COMMIT_TARGET_BYTES = Math.round(
|
const DICTATION_TURN_DETECTION = (process.env.OPENAI_REALTIME_DICTATION_TURN_DETECTION ?? "semantic_vad").trim();
|
||||||
(DICTATION_PCM_OUTPUT_RATE * 2 * DICTATION_COMMIT_INTERVAL_MS) / 1000
|
const DICTATION_SEMANTIC_VAD_EAGERNESS = (process.env.OPENAI_REALTIME_DICTATION_SEMANTIC_VAD_EAGERNESS ?? "medium").trim();
|
||||||
|
const DICTATION_FLUSH_SILENCE_MS = Number.parseInt(
|
||||||
|
process.env.OPENAI_REALTIME_DICTATION_FLUSH_SILENCE_MS ?? "800",
|
||||||
|
10
|
||||||
);
|
);
|
||||||
const DICTATION_MIN_COMMIT_MS = 100;
|
|
||||||
const DICTATION_MIN_COMMIT_BYTES = Math.round(
|
|
||||||
(DICTATION_PCM_OUTPUT_RATE * 2 * DICTATION_MIN_COMMIT_MS) / 1000
|
|
||||||
);
|
|
||||||
const DICTATION_FINAL_TIMEOUT_MS = 120000;
|
|
||||||
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
|
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
|
||||||
|
|
||||||
|
function pcm16lePeakAbs(pcm16le: Buffer): number {
|
||||||
|
if (pcm16le.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (pcm16le.length % 2 !== 0) {
|
||||||
|
throw new Error(`PCM16 chunk byteLength must be even, got ${pcm16le.length}`);
|
||||||
|
}
|
||||||
|
const samples = new Int16Array(
|
||||||
|
pcm16le.buffer,
|
||||||
|
pcm16le.byteOffset,
|
||||||
|
pcm16le.byteLength / 2
|
||||||
|
);
|
||||||
|
let peak = 0;
|
||||||
|
for (let i = 0; i < samples.length; i += 1) {
|
||||||
|
const v = samples[i]!;
|
||||||
|
const abs = v < 0 ? -v : v;
|
||||||
|
if (abs > peak) {
|
||||||
|
peak = abs;
|
||||||
|
if (peak >= 32767) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return peak;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDictationTurnDetection():
|
||||||
|
| null
|
||||||
|
| { type: "server_vad"; create_response: false; threshold?: number; prefix_padding_ms?: number; silence_duration_ms?: number }
|
||||||
|
| { type: "semantic_vad"; create_response: false; eagerness?: "low" | "medium" | "high" } {
|
||||||
|
if (!DICTATION_TURN_DETECTION || DICTATION_TURN_DETECTION === "none" || DICTATION_TURN_DETECTION === "null") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (DICTATION_TURN_DETECTION === "server_vad") {
|
||||||
|
return { type: "server_vad", create_response: false };
|
||||||
|
}
|
||||||
|
const eagerness =
|
||||||
|
DICTATION_SEMANTIC_VAD_EAGERNESS === "low" ||
|
||||||
|
DICTATION_SEMANTIC_VAD_EAGERNESS === "high"
|
||||||
|
? (DICTATION_SEMANTIC_VAD_EAGERNESS as "low" | "high")
|
||||||
|
: ("medium" as const);
|
||||||
|
return { type: "semantic_vad", create_response: false, eagerness };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type for present_artifact tool arguments
|
* Type for present_artifact tool arguments
|
||||||
*/
|
*/
|
||||||
@@ -278,10 +321,11 @@ export class Session {
|
|||||||
nextSeqToForward: number;
|
nextSeqToForward: number;
|
||||||
ackSeq: number;
|
ackSeq: number;
|
||||||
bytesSinceCommit: number;
|
bytesSinceCommit: number;
|
||||||
expectedCommits: number;
|
peakSinceCommit: number;
|
||||||
committedCount: number;
|
|
||||||
committedItemIds: string[];
|
committedItemIds: string[];
|
||||||
transcriptsByItemId: Map<string, string>;
|
transcriptsByItemId: Map<string, string>;
|
||||||
|
finalTranscriptItemIds: Set<string>;
|
||||||
|
awaitingFinalCommit: boolean;
|
||||||
finishRequested: boolean;
|
finishRequested: boolean;
|
||||||
finalSeq: number | null;
|
finalSeq: number | null;
|
||||||
finalTimeout: ReturnType<typeof setTimeout> | null;
|
finalTimeout: ReturnType<typeof setTimeout> | null;
|
||||||
@@ -1258,6 +1302,13 @@ export class Session {
|
|||||||
this.dictationStreams.delete(dictationId);
|
this.dictationStreams.delete(dictationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private emitDictationPartial(dictationId: string, text: string): void {
|
||||||
|
this.emit({
|
||||||
|
type: "dictation_stream_partial",
|
||||||
|
payload: { dictationId, text },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private maybeFinalizeDictationStream(dictationId: string): void {
|
private maybeFinalizeDictationStream(dictationId: string): void {
|
||||||
const state = this.dictationStreams.get(dictationId);
|
const state = this.dictationStreams.get(dictationId);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
@@ -1270,12 +1321,16 @@ export class Session {
|
|||||||
if (state.ackSeq < state.finalSeq) {
|
if (state.ackSeq < state.finalSeq) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state.committedCount < state.expectedCommits) {
|
if (state.awaitingFinalCommit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.committedItemIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allTranscriptsReady = state.committedItemIds.every((itemId) =>
|
const allTranscriptsReady = state.committedItemIds.every((itemId) =>
|
||||||
state.transcriptsByItemId.has(itemId)
|
state.finalTranscriptItemIds.has(itemId)
|
||||||
);
|
);
|
||||||
if (!allTranscriptsReady) {
|
if (!allTranscriptsReady) {
|
||||||
return;
|
return;
|
||||||
@@ -1308,12 +1363,17 @@ export class Session {
|
|||||||
|
|
||||||
const transcriptionModel =
|
const transcriptionModel =
|
||||||
process.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ?? "gpt-4o-transcribe";
|
process.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ?? "gpt-4o-transcribe";
|
||||||
|
const transcriptionPrompt =
|
||||||
|
process.env.OPENAI_REALTIME_DICTATION_TRANSCRIPTION_PROMPT ??
|
||||||
|
"Transcribe only what the speaker says. Do not add words. Preserve punctuation and casing. If the audio is silence or non-speech noise, return an empty transcript.";
|
||||||
|
|
||||||
const openai = new OpenAIRealtimeTranscriptionSession({
|
const openai = new OpenAIRealtimeTranscriptionSession({
|
||||||
apiKey,
|
apiKey,
|
||||||
logger: this.sessionLogger.child({ dictationId }),
|
logger: this.sessionLogger.child({ dictationId }),
|
||||||
transcriptionModel,
|
transcriptionModel,
|
||||||
language: "en",
|
language: "en",
|
||||||
|
prompt: transcriptionPrompt,
|
||||||
|
turnDetection: parseDictationTurnDetection(),
|
||||||
});
|
});
|
||||||
|
|
||||||
openai.on("committed", ({ itemId }: { itemId: string }) => {
|
openai.on("committed", ({ itemId }: { itemId: string }) => {
|
||||||
@@ -1321,23 +1381,46 @@ export class Session {
|
|||||||
if (!state) {
|
if (!state) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.committedCount += 1;
|
|
||||||
state.committedItemIds.push(itemId);
|
state.committedItemIds.push(itemId);
|
||||||
|
state.bytesSinceCommit = 0;
|
||||||
|
state.peakSinceCommit = 0;
|
||||||
|
|
||||||
|
// When finishing, we require at least one commit after finish if we flushed pending audio.
|
||||||
|
if (state.finishRequested && state.awaitingFinalCommit) {
|
||||||
|
state.awaitingFinalCommit = false;
|
||||||
|
}
|
||||||
|
|
||||||
this.maybeFinalizeDictationStream(dictationId);
|
this.maybeFinalizeDictationStream(dictationId);
|
||||||
});
|
});
|
||||||
|
|
||||||
openai.on("transcript", ({ itemId, transcript }: { itemId: string; transcript: string }) => {
|
openai.on(
|
||||||
const state = this.dictationStreams.get(dictationId);
|
"transcript",
|
||||||
if (!state) {
|
({ itemId, transcript, isFinal }: { itemId: string; transcript: string; isFinal: boolean }) => {
|
||||||
return;
|
const state = this.dictationStreams.get(dictationId);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.transcriptsByItemId.set(itemId, transcript);
|
||||||
|
if (isFinal) {
|
||||||
|
state.finalTranscriptItemIds.add(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedIds = state.committedItemIds.includes(itemId)
|
||||||
|
? state.committedItemIds
|
||||||
|
: [...state.committedItemIds, itemId];
|
||||||
|
const partialText = orderedIds
|
||||||
|
.map((id) => state.transcriptsByItemId.get(id) ?? "")
|
||||||
|
.join(" ")
|
||||||
|
.trim();
|
||||||
|
this.emitDictationPartial(dictationId, partialText);
|
||||||
|
|
||||||
|
this.maybeFinalizeDictationStream(dictationId);
|
||||||
}
|
}
|
||||||
state.transcriptsByItemId.set(itemId, transcript);
|
);
|
||||||
this.maybeFinalizeDictationStream(dictationId);
|
|
||||||
});
|
|
||||||
|
|
||||||
openai.on("error", (err) => {
|
openai.on("error", (err) => {
|
||||||
const error = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
this.failDictationStream(dictationId, error, true);
|
this.failDictationStream(dictationId, message, true);
|
||||||
this.cleanupDictationStream(dictationId);
|
this.cleanupDictationStream(dictationId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1355,10 +1438,11 @@ export class Session {
|
|||||||
nextSeqToForward: 0,
|
nextSeqToForward: 0,
|
||||||
ackSeq: -1,
|
ackSeq: -1,
|
||||||
bytesSinceCommit: 0,
|
bytesSinceCommit: 0,
|
||||||
expectedCommits: 0,
|
peakSinceCommit: 0,
|
||||||
committedCount: 0,
|
|
||||||
committedItemIds: [],
|
committedItemIds: [],
|
||||||
transcriptsByItemId: new Map(),
|
transcriptsByItemId: new Map(),
|
||||||
|
finalTranscriptItemIds: new Set(),
|
||||||
|
awaitingFinalCommit: false,
|
||||||
finishRequested: false,
|
finishRequested: false,
|
||||||
finalSeq: null,
|
finalSeq: null,
|
||||||
finalTimeout: null,
|
finalTimeout: null,
|
||||||
@@ -1403,20 +1487,14 @@ export class Session {
|
|||||||
if (resampled.length > 0) {
|
if (resampled.length > 0) {
|
||||||
state.openai.appendPcm16Base64(resampled.toString("base64"));
|
state.openai.appendPcm16Base64(resampled.toString("base64"));
|
||||||
state.bytesSinceCommit += resampled.length;
|
state.bytesSinceCommit += resampled.length;
|
||||||
|
state.peakSinceCommit = Math.max(state.peakSinceCommit, pcm16lePeakAbs(resampled));
|
||||||
}
|
}
|
||||||
|
|
||||||
state.nextSeqToForward += 1;
|
state.nextSeqToForward += 1;
|
||||||
state.ackSeq = state.nextSeqToForward - 1;
|
state.ackSeq = state.nextSeqToForward - 1;
|
||||||
|
|
||||||
if (state.bytesSinceCommit >= DICTATION_COMMIT_TARGET_BYTES) {
|
|
||||||
state.expectedCommits += 1;
|
|
||||||
state.openai.commit();
|
|
||||||
state.bytesSinceCommit = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emitDictationAck(msg.dictationId, state.ackSeq);
|
this.emitDictationAck(msg.dictationId, state.ackSeq);
|
||||||
this.maybeFinalizeDictationStream(msg.dictationId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleDictationStreamFinish(
|
private async handleDictationStreamFinish(
|
||||||
@@ -1441,8 +1519,7 @@ export class Session {
|
|||||||
ackSeq: state.ackSeq,
|
ackSeq: state.ackSeq,
|
||||||
nextSeqToForward: state.nextSeqToForward,
|
nextSeqToForward: state.nextSeqToForward,
|
||||||
receivedChunks: state.receivedChunks.size,
|
receivedChunks: state.receivedChunks.size,
|
||||||
expectedCommits: state.expectedCommits,
|
bytesSinceCommit: state.bytesSinceCommit,
|
||||||
committedCount: state.committedCount,
|
|
||||||
},
|
},
|
||||||
"Dictation finish: no chunks received (failing fast)"
|
"Dictation finish: no chunks received (failing fast)"
|
||||||
);
|
);
|
||||||
@@ -1455,31 +1532,41 @@ export class Session {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit any remaining uncommitted audio.
|
// Force-flush any remaining audio so we get a final transcription even if VAD hasn't committed yet.
|
||||||
// IMPORTANT: OpenAI requires at least ~100ms of audio in the buffer for a commit.
|
|
||||||
// Also, committing an empty buffer throws "buffer too small ... 0.00ms".
|
|
||||||
if (state.bytesSinceCommit > 0) {
|
if (state.bytesSinceCommit > 0) {
|
||||||
this.sessionLogger.debug(
|
if (state.peakSinceCommit < DICTATION_SILENCE_PEAK_THRESHOLD) {
|
||||||
{ dictationId: msg.dictationId, bytesSinceCommit: state.bytesSinceCommit },
|
|
||||||
"Dictation finish: committing pending audio"
|
|
||||||
);
|
|
||||||
if (state.bytesSinceCommit < DICTATION_MIN_COMMIT_BYTES) {
|
|
||||||
const padBytes = DICTATION_MIN_COMMIT_BYTES - state.bytesSinceCommit;
|
|
||||||
this.sessionLogger.debug(
|
this.sessionLogger.debug(
|
||||||
{ dictationId: msg.dictationId, padBytes },
|
{
|
||||||
"Dictation finish: padding to minimum commit size"
|
dictationId: msg.dictationId,
|
||||||
|
bytesSinceCommit: state.bytesSinceCommit,
|
||||||
|
peakSinceCommit: state.peakSinceCommit,
|
||||||
|
},
|
||||||
|
"Dictation finish: clearing silence-only tail (skip final commit)"
|
||||||
);
|
);
|
||||||
state.openai.appendPcm16Base64(Buffer.alloc(padBytes).toString("base64"));
|
state.openai.clear();
|
||||||
state.bytesSinceCommit += padBytes;
|
state.bytesSinceCommit = 0;
|
||||||
|
state.peakSinceCommit = 0;
|
||||||
|
state.awaitingFinalCommit = false;
|
||||||
|
} else {
|
||||||
|
// We do not manually commit for dictation (to avoid chopping words). Instead we rely on
|
||||||
|
// OpenAI semantic VAD to commit utterances. When the user presses OK, we append a short
|
||||||
|
// silence tail so VAD has enough silence to close out the final utterance promptly.
|
||||||
|
const silenceBytes = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round((DICTATION_PCM_OUTPUT_RATE * 2 * DICTATION_FLUSH_SILENCE_MS) / 1000)
|
||||||
|
);
|
||||||
|
if (silenceBytes > 0) {
|
||||||
|
this.sessionLogger.debug(
|
||||||
|
{ dictationId: msg.dictationId, silenceMs: DICTATION_FLUSH_SILENCE_MS, silenceBytes },
|
||||||
|
"Dictation finish: appending silence tail for semantic VAD flush"
|
||||||
|
);
|
||||||
|
state.openai.appendPcm16Base64(Buffer.alloc(silenceBytes).toString("base64"));
|
||||||
|
state.bytesSinceCommit += silenceBytes;
|
||||||
|
}
|
||||||
|
state.awaitingFinalCommit = true;
|
||||||
}
|
}
|
||||||
state.expectedCommits += 1;
|
|
||||||
state.openai.commit();
|
|
||||||
state.bytesSinceCommit = 0;
|
|
||||||
} else {
|
} else {
|
||||||
this.sessionLogger.debug(
|
state.awaitingFinalCommit = false;
|
||||||
{ dictationId: msg.dictationId },
|
|
||||||
"Dictation finish: no pending audio to commit"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.finalTimeout) {
|
if (state.finalTimeout) {
|
||||||
|
|||||||
@@ -813,6 +813,14 @@ export const DictationStreamAckMessageSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const DictationStreamPartialMessageSchema = z.object({
|
||||||
|
type: z.literal("dictation_stream_partial"),
|
||||||
|
payload: z.object({
|
||||||
|
dictationId: z.string(),
|
||||||
|
text: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const DictationStreamFinalMessageSchema = z.object({
|
export const DictationStreamFinalMessageSchema = z.object({
|
||||||
type: z.literal("dictation_stream_final"),
|
type: z.literal("dictation_stream_final"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -1324,6 +1332,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
AudioOutputMessageSchema,
|
AudioOutputMessageSchema,
|
||||||
TranscriptionResultMessageSchema,
|
TranscriptionResultMessageSchema,
|
||||||
DictationStreamAckMessageSchema,
|
DictationStreamAckMessageSchema,
|
||||||
|
DictationStreamPartialMessageSchema,
|
||||||
DictationStreamFinalMessageSchema,
|
DictationStreamFinalMessageSchema,
|
||||||
DictationStreamErrorMessageSchema,
|
DictationStreamErrorMessageSchema,
|
||||||
StatusMessageSchema,
|
StatusMessageSchema,
|
||||||
|
|||||||
Reference in New Issue
Block a user