Capture dictation/TTS debug audio and show dictation errors
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user