Capture dictation/TTS debug audio and show dictation errors

This commit is contained in:
Mohamed Boudra
2026-01-25 09:35:07 +07:00
parent 9d0dd8b19e
commit ccd175b4b7
12 changed files with 311 additions and 23 deletions
@@ -136,12 +136,13 @@ export function DictationOverlay({
isRecording, isRecording,
isProcessing, isProcessing,
status, status,
errorText,
onCancel, onCancel,
onAccept, onAccept,
onAcceptAndSend, onAcceptAndSend,
onRetry, onRetry,
onDiscard, onDiscard,
}: Omit<DictationControlsProps, "onStart" | "disabled">) { }: Omit<DictationControlsProps, "onStart" | "disabled"> & { errorText?: string }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isFailed = status === "failed"; const isFailed = status === "failed";
const showActiveState = isRecording || isProcessing || isFailed; const showActiveState = isRecording || isProcessing || isFailed;
@@ -199,6 +200,17 @@ export function DictationOverlay({
{transcript} {transcript}
</Text> </Text>
)} )}
{isFailed ? (
<Text
numberOfLines={2}
style={[
overlayStyles.transcriptText,
{ color: theme.colors.palette.white, opacity: 0.95 },
]}
>
{errorText ? `Dictation failed: ${errorText}` : "Dictation failed. Tap retry."}
</Text>
) : null}
</View> </View>
<View style={overlayStyles.actionButtonsContainer}> <View style={overlayStyles.actionButtonsContainer}>
@@ -199,6 +199,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
partialTranscript: dictationPartialTranscript, partialTranscript: dictationPartialTranscript,
volume: dictationVolume, volume: dictationVolume,
duration: dictationDuration, duration: dictationDuration,
error: dictationError,
status: dictationStatus, status: dictationStatus,
startDictation, startDictation,
cancelDictation, cancelDictation,
@@ -631,6 +632,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isRecording={isDictating} isRecording={isDictating}
isProcessing={isDictationProcessing} isProcessing={isDictationProcessing}
status={dictationStatus} status={dictationStatus}
errorText={dictationStatus === "failed" ? dictationError ?? undefined : undefined}
onCancel={handleCancelRecording} onCancel={handleCancelRecording}
onAccept={handleAcceptRecording} onAccept={handleAcceptRecording}
onAcceptAndSend={handleAcceptAndSendRecording} onAcceptAndSend={handleAcceptAndSendRecording}
+9 -2
View File
@@ -200,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);
isProcessingRef.current = false;
setPartialTranscript(""); setPartialTranscript("");
setDuration(0); setDuration(0);
setStatus("idle"); setStatus("idle");
@@ -220,6 +221,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
const normalized = toError(failure); const normalized = toError(failure);
const failureId = generateMessageId(); const failureId = generateMessageId();
setIsProcessing(false); setIsProcessing(false);
isProcessingRef.current = false;
isRecordingRef.current = false; isRecordingRef.current = false;
setIsRecording(false); setIsRecording(false);
@@ -447,9 +449,14 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
stopDurationTracking(); stopDurationTracking();
setDuration(0); setDuration(0);
setIsProcessing(false); setIsProcessing(false);
isProcessingRef.current = false;
setError(null); setError(null);
setStatus("idle"); if (senderRef.current?.hasSegments()) {
clearStreamingState(); setStatus("failed");
} else {
setStatus("idle");
clearStreamingState();
}
} }
} }
}, [autoStopWhenHidden?.isVisible, clearStreamingState, stopDurationTracking]); }, [autoStopWhenHidden?.isVisible, clearStreamingState, stopDurationTracking]);
+4
View File
@@ -17,3 +17,7 @@ PASEO_PORT=6767
# Legacy web server port used by the legacy Express app # Legacy web server port used by the legacy Express app
PORT=3000 PORT=3000
NODE_ENV=development NODE_ENV=development
# Debug recordings (dictation + STT input + TTS output)
# When enabled, recordings are saved under `${cwd}/.debug/recordings/`
PASEO_DICTATION_DEBUG=1
@@ -102,6 +102,7 @@ describe("DaemonClientV2", () => {
baseRef: null, baseRef: null,
aheadBehind: null, aheadBehind: null,
hasRemote: false, hasRemote: false,
remoteUrl: null,
}, },
}, },
}; };
@@ -999,7 +999,7 @@ export class DaemonClientV2 {
} }
return msg.payload; return msg.payload;
}, },
120000, 30000,
{ skipQueue: true } { skipQueue: true }
); );
@@ -1013,7 +1013,7 @@ export class DaemonClientV2 {
} }
return msg.payload; return msg.payload;
}, },
120000, 30000,
{ skipQueue: true } { skipQueue: true }
).then((payload) => { ).then((payload) => {
throw new Error(payload.error); throw new Error(payload.error);
@@ -0,0 +1,39 @@
import type pino from "pino";
import { mkdir, writeFile } from "fs/promises";
import { join } from "path";
import { inferAudioExtension, sanitizeForFilename } from "./audio-utils.js";
import { resolveRecordingsDebugDir } from "./recordings-debug.js";
const debugDir = resolveRecordingsDebugDir("DICTATION_DEBUG_AUDIO_DIR");
let announced = false;
export interface DictationDebugAudioMetadata {
sessionId: string;
dictationId: string;
format: string;
}
export async function maybePersistDictationDebugAudio(
audio: Buffer,
metadata: DictationDebugAudioMetadata,
logger: pino.Logger
): Promise<string | null> {
if (!debugDir) {
return null;
}
if (!announced) {
logger.info({ debugDir }, "Dictation audio capture enabled");
announced = true;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const folder = join(debugDir, sanitizeForFilename(metadata.sessionId, "session"));
await mkdir(folder, { recursive: true });
const parts = [timestamp, sanitizeForFilename(metadata.dictationId, "dictation")];
const ext = inferAudioExtension(metadata.format);
const filePath = join(folder, `${parts.join("_")}.${ext}`);
await writeFile(filePath, audio);
return filePath;
}
@@ -0,0 +1,24 @@
import { resolve } from "path";
function isTruthyEnv(value: string | undefined): boolean {
const normalized = (value ?? "").trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
export function isPaseoDictationDebugEnabled(): boolean {
return isTruthyEnv(process.env.PASEO_DICTATION_DEBUG);
}
export function resolveRecordingsDebugDir(explicitEnvVarName: string): string | null {
const explicit = process.env[explicitEnvVarName];
if (explicit && explicit.trim()) {
return resolve(explicit.trim());
}
if (!isPaseoDictationDebugEnabled()) {
return null;
}
return resolve(process.cwd(), ".debug/recordings");
}
@@ -1,11 +1,10 @@
import type pino from "pino"; import type pino from "pino";
import { mkdir, writeFile } from "fs/promises"; import { mkdir, writeFile } from "fs/promises";
import { join, resolve } from "path"; import { join } from "path";
import { inferAudioExtension, sanitizeForFilename } from "./audio-utils.js"; import { inferAudioExtension, sanitizeForFilename } from "./audio-utils.js";
import { resolveRecordingsDebugDir } from "./recordings-debug.js";
const debugDir = process.env.STT_DEBUG_AUDIO_DIR const debugDir = resolveRecordingsDebugDir("STT_DEBUG_AUDIO_DIR");
? resolve(process.env.STT_DEBUG_AUDIO_DIR)
: null;
let announced = false; let announced = false;
export interface DebugAudioMetadata { export interface DebugAudioMetadata {
@@ -0,0 +1,39 @@
import type pino from "pino";
import { mkdir, writeFile } from "fs/promises";
import { join } from "path";
import { inferAudioExtension, sanitizeForFilename } from "./audio-utils.js";
import { resolveRecordingsDebugDir } from "./recordings-debug.js";
const debugDir = resolveRecordingsDebugDir("TTS_DEBUG_AUDIO_DIR");
let announced = false;
export interface TtsDebugAudioMetadata {
sessionId: string;
groupId: string;
format: string;
}
export async function maybePersistTtsDebugAudio(
audio: Buffer,
metadata: TtsDebugAudioMetadata,
logger: pino.Logger
): Promise<string | null> {
if (!debugDir) {
return null;
}
if (!announced) {
logger.info({ debugDir }, "TTS audio capture enabled");
announced = true;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const folder = join(debugDir, sanitizeForFilename(metadata.sessionId, "session"));
await mkdir(folder, { recursive: true });
const parts = [timestamp, sanitizeForFilename(metadata.groupId, "tts")];
const ext = inferAudioExtension(metadata.format);
const filePath = join(folder, `${parts.join("_")}.${ext}`);
await writeFile(filePath, audio);
return filePath;
}
+173 -14
View File
@@ -37,6 +37,9 @@ import type { OpenAISTT } from "./agent/stt-openai.js";
import type { OpenAITTS } from "./agent/tts-openai.js"; import type { OpenAITTS } from "./agent/tts-openai.js";
import { OpenAIRealtimeTranscriptionSession } from "./agent/openai-realtime-transcription.js"; import { OpenAIRealtimeTranscriptionSession } from "./agent/openai-realtime-transcription.js";
import { Pcm16MonoResampler } from "./agent/pcm16-resampler.js"; import { Pcm16MonoResampler } from "./agent/pcm16-resampler.js";
import { maybePersistDictationDebugAudio } from "./agent/dictation-debug.js";
import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
import type { VoiceConversationStore } from "./voice-conversation-store.js"; import type { VoiceConversationStore } from "./voice-conversation-store.js";
import { import {
buildConfigOverrides, buildConfigOverrides,
@@ -145,7 +148,7 @@ 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_FINAL_TIMEOUT_MS = 120000; const DICTATION_FINAL_TIMEOUT_MS = 30000;
const DICTATION_SILENCE_PEAK_THRESHOLD = Number.parseInt( const DICTATION_SILENCE_PEAK_THRESHOLD = Number.parseInt(
process.env.OPENAI_REALTIME_DICTATION_SILENCE_PEAK_THRESHOLD ?? "300", process.env.OPENAI_REALTIME_DICTATION_SILENCE_PEAK_THRESHOLD ?? "300",
10 10
@@ -322,6 +325,8 @@ export class Session {
inputFormat: string; inputFormat: string;
openai: OpenAIRealtimeTranscriptionSession; openai: OpenAIRealtimeTranscriptionSession;
resampler: Pcm16MonoResampler; resampler: Pcm16MonoResampler;
debugAudioChunks: Buffer[];
debugRecordingPath: string | null;
receivedChunks: Map<number, Buffer>; receivedChunks: Map<number, Buffer>;
nextSeqToForward: number; nextSeqToForward: number;
ackSeq: number; ackSeq: number;
@@ -342,6 +347,12 @@ export class Session {
private bufferTimeout: NodeJS.Timeout | null = null; private bufferTimeout: NodeJS.Timeout | null = null;
private audioBuffer: AudioBufferState | null = null; private audioBuffer: AudioBufferState | null = null;
// Optional TTS debug capture (persisted per utterance)
private readonly ttsDebugStreams = new Map<
string,
{ format: string; chunks: Buffer[] }
>();
// Conversation history // Conversation history
private messages: ModelMessage[] = []; private messages: ModelMessage[] = [];
private turnIndex = 0; private turnIndex = 0;
@@ -1299,6 +1310,65 @@ export class Session {
}); });
} }
private async maybePersistDictationStreamAudio(dictationId: string): Promise<string | null> {
const state = this.dictationStreams.get(dictationId) ?? null;
if (!state) {
return null;
}
if (state.debugRecordingPath) {
return state.debugRecordingPath;
}
if (state.debugAudioChunks.length === 0) {
return null;
}
const pcmBuffer = Buffer.concat(state.debugAudioChunks);
const wavBuffer = convertPCMToWavBuffer(
pcmBuffer,
DICTATION_PCM_OUTPUT_RATE,
PCM_CHANNELS,
PCM_BITS_PER_SAMPLE
);
const path = await maybePersistDictationDebugAudio(
wavBuffer,
{ sessionId: this.sessionId, dictationId, format: "audio/wav" },
this.sessionLogger
);
state.debugRecordingPath = path;
return path;
}
private async failAndCleanupDictationStream(
dictationId: string,
error: string,
retryable: boolean
): Promise<void> {
const debugRecordingPath = await this.maybePersistDictationStreamAudio(dictationId);
this.emit({
type: "dictation_stream_error",
payload: {
dictationId,
error,
retryable,
...(debugRecordingPath ? { debugRecordingPath } : {}),
},
});
if (debugRecordingPath) {
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "system",
content: `Saved dictation audio: ${debugRecordingPath}`,
metadata: { recordingPath: debugRecordingPath, dictationId },
},
});
}
this.cleanupDictationStream(dictationId);
}
private cleanupDictationStream(dictationId: string): void { private cleanupDictationStream(dictationId: string): void {
const state = this.dictationStreams.get(dictationId) ?? null; const state = this.dictationStreams.get(dictationId) ?? null;
if (!state) { if (!state) {
@@ -1354,12 +1424,30 @@ export class Session {
.join(" ") .join(" ")
.trim(); .trim();
this.emit({ void (async () => {
type: "dictation_stream_final", const debugRecordingPath = await this.maybePersistDictationStreamAudio(dictationId);
payload: { dictationId, text: orderedText }, this.emit({
}); type: "dictation_stream_final",
payload: {
this.cleanupDictationStream(dictationId); dictationId,
text: orderedText,
...(debugRecordingPath ? { debugRecordingPath } : {}),
},
});
if (debugRecordingPath) {
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "system",
content: `Saved dictation audio: ${debugRecordingPath}`,
metadata: { recordingPath: debugRecordingPath, dictationId },
},
});
}
this.cleanupDictationStream(dictationId);
})();
} }
private async handleDictationStreamStart( private async handleDictationStreamStart(
@@ -1433,8 +1521,7 @@ export class Session {
openai.on("error", (err) => { openai.on("error", (err) => {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
this.failDictationStream(dictationId, message, true); void this.failAndCleanupDictationStream(dictationId, message, true);
this.cleanupDictationStream(dictationId);
}); });
await openai.connect(); await openai.connect();
@@ -1447,6 +1534,8 @@ export class Session {
inputRate: DICTATION_PCM_INPUT_RATE, inputRate: DICTATION_PCM_INPUT_RATE,
outputRate: DICTATION_PCM_OUTPUT_RATE, outputRate: DICTATION_PCM_OUTPUT_RATE,
}), }),
debugAudioChunks: [],
debugRecordingPath: null,
receivedChunks: new Map(), receivedChunks: new Map(),
nextSeqToForward: 0, nextSeqToForward: 0,
ackSeq: -1, ackSeq: -1,
@@ -1474,7 +1563,7 @@ export class Session {
} }
if (msg.format !== state.inputFormat) { if (msg.format !== state.inputFormat) {
this.failDictationStream( void this.failAndCleanupDictationStream(
msg.dictationId, msg.dictationId,
`Mismatched dictation stream format: ${msg.format}`, `Mismatched dictation stream format: ${msg.format}`,
false false
@@ -1499,6 +1588,7 @@ export class Session {
const resampled = state.resampler.processChunk(pcm16); const resampled = state.resampler.processChunk(pcm16);
if (resampled.length > 0) { if (resampled.length > 0) {
state.openai.appendPcm16Base64(resampled.toString("base64")); state.openai.appendPcm16Base64(resampled.toString("base64"));
state.debugAudioChunks.push(resampled);
state.bytesSinceCommit += resampled.length; state.bytesSinceCommit += resampled.length;
state.peakSinceCommit = Math.max(state.peakSinceCommit, pcm16lePeakAbs(resampled)); state.peakSinceCommit = Math.max(state.peakSinceCommit, pcm16lePeakAbs(resampled));
} }
@@ -1573,7 +1663,9 @@ export class Session {
{ dictationId: msg.dictationId, silenceMs: DICTATION_FLUSH_SILENCE_MS, silenceBytes }, { dictationId: msg.dictationId, silenceMs: DICTATION_FLUSH_SILENCE_MS, silenceBytes },
"Dictation finish: appending silence tail for semantic VAD flush" "Dictation finish: appending silence tail for semantic VAD flush"
); );
state.openai.appendPcm16Base64(Buffer.alloc(silenceBytes).toString("base64")); const silence = Buffer.alloc(silenceBytes);
state.openai.appendPcm16Base64(silence.toString("base64"));
state.debugAudioChunks.push(silence);
state.bytesSinceCommit += silenceBytes; state.bytesSinceCommit += silenceBytes;
} }
state.awaitingFinalCommit = true; state.awaitingFinalCommit = true;
@@ -1586,12 +1678,11 @@ export class Session {
clearTimeout(state.finalTimeout); clearTimeout(state.finalTimeout);
} }
state.finalTimeout = setTimeout(() => { state.finalTimeout = setTimeout(() => {
this.failDictationStream( void this.failAndCleanupDictationStream(
msg.dictationId, msg.dictationId,
"Timed out waiting for final transcription", "Timed out waiting for final transcription",
true true
); );
this.cleanupDictationStream(msg.dictationId);
}, DICTATION_FINAL_TIMEOUT_MS); }, DICTATION_FINAL_TIMEOUT_MS);
this.maybeFinalizeDictationStream(msg.dictationId); this.maybeFinalizeDictationStream(msg.dictationId);
@@ -4109,7 +4200,9 @@ export class Session {
}); });
try { try {
const requestId = uuidv4();
const result = await this.sttManager.transcribe(audio, format, { const result = await this.sttManager.transcribe(audio, format, {
requestId,
label: this.isRealtimeMode ? "realtime" : "buffered", label: this.isRealtimeMode ? "realtime" : "buffered",
}); });
@@ -4137,7 +4230,7 @@ export class Session {
text: result.text, text: result.text,
language: result.language, language: result.language,
duration: result.duration, duration: result.duration,
requestId: uuidv4(), requestId,
avgLogprob: result.avgLogprob, avgLogprob: result.avgLogprob,
isLowConfidence: result.isLowConfidence, isLowConfidence: result.isLowConfidence,
byteLength: result.byteLength, byteLength: result.byteLength,
@@ -4146,6 +4239,23 @@ export class Session {
}, },
}); });
if (result.debugRecordingPath) {
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "system",
content: `Saved input audio: ${result.debugRecordingPath}`,
metadata: {
recordingPath: result.debugRecordingPath,
format: result.format,
requestId,
},
},
});
}
// Emit activity log // Emit activity log
this.emit({ this.emit({
type: "activity_log", type: "activity_log",
@@ -4657,6 +4767,7 @@ export class Session {
private createAbortController(): AbortController { private createAbortController(): AbortController {
this.abortController.abort(); this.abortController.abort();
this.abortController = new AbortController(); this.abortController = new AbortController();
this.ttsDebugStreams.clear();
return this.abortController; return this.abortController;
} }
@@ -4702,6 +4813,54 @@ export class Session {
* Emit a message to the client * Emit a message to the client
*/ */
private emit(msg: SessionOutboundMessage): void { private emit(msg: SessionOutboundMessage): void {
if (
msg.type === "audio_output" &&
(process.env.TTS_DEBUG_AUDIO_DIR || isPaseoDictationDebugEnabled()) &&
msg.payload.groupId &&
typeof msg.payload.audio === "string"
) {
const groupId = msg.payload.groupId;
const existing =
this.ttsDebugStreams.get(groupId) ??
({ format: msg.payload.format, chunks: [] } satisfies {
format: string;
chunks: Buffer[];
});
try {
existing.chunks.push(Buffer.from(msg.payload.audio, "base64"));
existing.format = msg.payload.format;
this.ttsDebugStreams.set(groupId, existing);
} catch {
// ignore malformed base64
}
if (msg.payload.isLastChunk) {
const final = this.ttsDebugStreams.get(groupId);
this.ttsDebugStreams.delete(groupId);
if (final && final.chunks.length > 0) {
void (async () => {
const recordingPath = await maybePersistTtsDebugAudio(
Buffer.concat(final.chunks),
{ sessionId: this.sessionId, groupId, format: final.format },
this.sessionLogger
);
if (recordingPath) {
this.onMessage({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "system",
content: `Saved TTS audio: ${recordingPath}`,
metadata: { recordingPath, format: final.format, groupId },
},
});
}
})();
}
}
}
this.onMessage(msg); this.onMessage(msg);
} }
+2
View File
@@ -842,6 +842,7 @@ export const DictationStreamFinalMessageSchema = z.object({
payload: z.object({ payload: z.object({
dictationId: z.string(), dictationId: z.string(),
text: z.string(), text: z.string(),
debugRecordingPath: z.string().optional(),
}), }),
}); });
@@ -851,6 +852,7 @@ export const DictationStreamErrorMessageSchema = z.object({
dictationId: z.string(), dictationId: z.string(),
error: z.string(), error: z.string(),
retryable: z.boolean(), retryable: z.boolean(),
debugRecordingPath: z.string().optional(),
}), }),
}); });