Stream dictation via OpenAI Realtime and simplify retry UI
This commit is contained in:
@@ -18,7 +18,6 @@ interface DictationControlsProps {
|
||||
onRetry?: () => void;
|
||||
onDiscard?: () => void;
|
||||
disabled?: boolean;
|
||||
retryStatusText?: string;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
@@ -42,13 +41,11 @@ export function DictationControls({
|
||||
onRetry,
|
||||
onDiscard,
|
||||
disabled = false,
|
||||
retryStatusText,
|
||||
}: DictationControlsProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isRetrying = status === "retrying";
|
||||
const isFailed = status === "failed";
|
||||
const showActiveState = isRecording || isProcessing || isRetrying || isFailed;
|
||||
const actionsDisabled = isProcessing || isRetrying;
|
||||
const showActiveState = isRecording || isProcessing || isFailed;
|
||||
const actionsDisabled = isProcessing;
|
||||
const handleCancel = isFailed && onDiscard ? onDiscard : onCancel;
|
||||
|
||||
if (!showActiveState) {
|
||||
@@ -123,13 +120,6 @@ export function DictationControls({
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
{retryStatusText && (isRetrying || isFailed) && (
|
||||
<Text
|
||||
style={[styles.statusLabel, { color: theme.colors.mutedForeground }]}
|
||||
>
|
||||
{retryStatusText}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -149,12 +139,11 @@ export function DictationOverlay({
|
||||
onAcceptAndSend,
|
||||
onRetry,
|
||||
onDiscard,
|
||||
}: Omit<DictationControlsProps, "onStart" | "disabled" | "retryStatusText">) {
|
||||
}: Omit<DictationControlsProps, "onStart" | "disabled">) {
|
||||
const { theme } = useUnistyles();
|
||||
const isRetrying = status === "retrying";
|
||||
const isFailed = status === "failed";
|
||||
const showActiveState = isRecording || isProcessing || isRetrying || isFailed;
|
||||
const actionsDisabled = isProcessing || isRetrying;
|
||||
const showActiveState = isRecording || isProcessing || isFailed;
|
||||
const actionsDisabled = isProcessing;
|
||||
const handleCancel = isFailed && onDiscard ? onDiscard : onCancel;
|
||||
|
||||
if (!showActiveState) {
|
||||
|
||||
@@ -210,7 +210,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isDictating || isDictationProcessing || dictationStatus === "retrying") {
|
||||
if (isDictating || isDictationProcessing) {
|
||||
return;
|
||||
}
|
||||
sendAfterTranscriptRef.current = false;
|
||||
@@ -246,7 +246,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const showOverlay =
|
||||
isDictating ||
|
||||
isDictationProcessing ||
|
||||
dictationStatus === "retrying" ||
|
||||
dictationStatus === "failed";
|
||||
overlayTransition.value = withTiming(showOverlay ? 1 : 0, {
|
||||
duration: 200,
|
||||
|
||||
@@ -88,7 +88,7 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
|
||||
return;
|
||||
}
|
||||
|
||||
if (volume > 0.01) {
|
||||
if (volume > 0.001) {
|
||||
// Active volume - animate heights based on volume
|
||||
const target1 = MIN_HEIGHT + (MAX_HEIGHT * volume * 1.2);
|
||||
const target2 = MIN_HEIGHT + (MAX_HEIGHT * volume * 1.05);
|
||||
@@ -136,7 +136,7 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
|
||||
const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5;
|
||||
const volumeBoost = isMuted ? 0 : volume * 0.3;
|
||||
return {
|
||||
height: line1Height.value * (isMuted || volume > 0.01 ? 1 : line1Pulse.value),
|
||||
height: line1Height.value * (isMuted || volume > 0.001 ? 1 : line1Pulse.value),
|
||||
opacity: baseOpacity + volumeBoost,
|
||||
};
|
||||
});
|
||||
@@ -146,7 +146,7 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
|
||||
const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5;
|
||||
const volumeBoost = isMuted ? 0 : volume * 0.3;
|
||||
return {
|
||||
height: line2Height.value * (isMuted || volume > 0.01 ? 1 : line2Pulse.value),
|
||||
height: line2Height.value * (isMuted || volume > 0.001 ? 1 : line2Pulse.value),
|
||||
opacity: baseOpacity + volumeBoost,
|
||||
};
|
||||
});
|
||||
@@ -156,7 +156,7 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
|
||||
const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5;
|
||||
const volumeBoost = isMuted ? 0 : volume * 0.3;
|
||||
return {
|
||||
height: line3Height.value * (isMuted || volume > 0.01 ? 1 : line3Pulse.value),
|
||||
height: line3Height.value * (isMuted || volume > 0.001 ? 1 : line3Pulse.value),
|
||||
opacity: baseOpacity + volumeBoost,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DictationStreamSender } from "@/dictation/dictation-stream-sender";
|
||||
|
||||
type FakeFinish = { dictationId: string; finalSeq: number };
|
||||
type FakeStart = { dictationId: string; format: string };
|
||||
type FakeChunk = { dictationId: string; seq: number; audio: string; format: string };
|
||||
|
||||
class FakeDaemonClient {
|
||||
isConnected = true;
|
||||
starts: FakeStart[] = [];
|
||||
chunks: FakeChunk[] = [];
|
||||
finishes: FakeFinish[] = [];
|
||||
cancels: string[] = [];
|
||||
|
||||
async startDictationStream(dictationId: string, format: string): Promise<void> {
|
||||
this.starts.push({ dictationId, format });
|
||||
}
|
||||
|
||||
sendDictationStreamChunk(dictationId: string, seq: number, audio: string, format: string): void {
|
||||
this.chunks.push({ dictationId, seq, audio, format });
|
||||
}
|
||||
|
||||
async finishDictationStream(dictationId: string, finalSeq: number): Promise<{ dictationId: string; text: string }> {
|
||||
this.finishes.push({ dictationId, finalSeq });
|
||||
return { dictationId, text: "ok" };
|
||||
}
|
||||
|
||||
cancelDictationStream(dictationId: string): void {
|
||||
this.cancels.push(dictationId);
|
||||
}
|
||||
}
|
||||
|
||||
const tick = async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
describe("DictationStreamSender", () => {
|
||||
it("enqueues segments and sends them after stream start", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const ids = ["d1"];
|
||||
const sender = new DictationStreamSender({
|
||||
client: client as any,
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
createDictationId: () => ids.shift() ?? "dX",
|
||||
});
|
||||
|
||||
sender.enqueueSegment("seg0");
|
||||
sender.enqueueSegment("seg1");
|
||||
|
||||
await tick();
|
||||
|
||||
expect(client.starts).toEqual([{ dictationId: "d1", format: "audio/pcm;rate=16000;bits=16" }]);
|
||||
expect(client.chunks).toEqual([
|
||||
{ dictationId: "d1", seq: 0, audio: "seg0", format: "audio/pcm;rate=16000;bits=16" },
|
||||
{ dictationId: "d1", seq: 1, audio: "seg1", format: "audio/pcm;rate=16000;bits=16" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("restarts stream and resends from seq=0 on reconnect", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const ids = ["d1", "d2"];
|
||||
const sender = new DictationStreamSender({
|
||||
client: client as any,
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
createDictationId: () => ids.shift() ?? "dX",
|
||||
});
|
||||
|
||||
sender.enqueueSegment("seg0");
|
||||
sender.enqueueSegment("seg1");
|
||||
await tick();
|
||||
|
||||
await sender.restartStream("reconnect");
|
||||
|
||||
expect(client.starts.map((s) => s.dictationId)).toEqual(["d1", "d2"]);
|
||||
const d2Chunks = client.chunks.filter((c) => c.dictationId === "d2");
|
||||
expect(d2Chunks.map((c) => [c.seq, c.audio])).toEqual([
|
||||
[0, "seg0"],
|
||||
[1, "seg1"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("finish flushes all queued segments and sends finish with finalSeq", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const ids = ["d1"];
|
||||
const sender = new DictationStreamSender({
|
||||
client: client as any,
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
createDictationId: () => ids.shift() ?? "dX",
|
||||
});
|
||||
|
||||
sender.enqueueSegment("seg0");
|
||||
sender.enqueueSegment("seg1");
|
||||
|
||||
const finalSeq = sender.getFinalSeq();
|
||||
const result = await sender.finish(finalSeq);
|
||||
|
||||
expect(result.text).toBe("ok");
|
||||
expect(client.chunks.map((c) => c.seq)).toEqual([0, 1]);
|
||||
expect(client.finishes).toEqual([{ dictationId: "d1", finalSeq: 1 }]);
|
||||
});
|
||||
|
||||
it("keeps segments while disconnected and sends them after restart when reconnected", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
client.isConnected = false;
|
||||
const ids = ["d1"];
|
||||
const sender = new DictationStreamSender({
|
||||
client: client as any,
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
createDictationId: () => ids.shift() ?? "dX",
|
||||
});
|
||||
|
||||
sender.enqueueSegment("seg0");
|
||||
sender.enqueueSegment("seg1");
|
||||
|
||||
expect(client.starts).toHaveLength(0);
|
||||
expect(client.chunks).toHaveLength(0);
|
||||
|
||||
client.isConnected = true;
|
||||
await sender.restartStream("reconnect");
|
||||
|
||||
expect(client.chunks.map((c) => c.seq)).toEqual([0, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||
|
||||
export type DictationStreamSenderParams = {
|
||||
client: DaemonClientV2 | null;
|
||||
format: string;
|
||||
createDictationId?: () => string;
|
||||
};
|
||||
|
||||
type DictationFinishResult = { dictationId: string; text: string };
|
||||
|
||||
/**
|
||||
* Small, non-React state machine for dictation streaming.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Maintain an ordered buffer of base64 PCM segments
|
||||
* - Start/restart a dictation stream (dictationId)
|
||||
* - Send missing segments (seq) when connected
|
||||
* - Finish/cancel the stream
|
||||
*
|
||||
* This class intentionally keeps sending synchronous (no internal async mutex),
|
||||
* so enqueues can't "miss" a flush due to in-flight await/coalescing bugs.
|
||||
*/
|
||||
export class DictationStreamSender {
|
||||
private client: DaemonClientV2 | null;
|
||||
private readonly format: string;
|
||||
private readonly createDictationId: () => string;
|
||||
|
||||
private dictationId: string | null = null;
|
||||
private sendSeq = 0;
|
||||
private segments: string[] = [];
|
||||
private streamReady = false;
|
||||
|
||||
private startGeneration = 0;
|
||||
private startPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(params: DictationStreamSenderParams) {
|
||||
this.client = params.client;
|
||||
this.format = params.format;
|
||||
this.createDictationId = params.createDictationId ?? generateMessageId;
|
||||
}
|
||||
|
||||
setClient(client: DaemonClientV2 | null): void {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
getDictationId(): string | null {
|
||||
return this.dictationId;
|
||||
}
|
||||
|
||||
getSegmentCount(): number {
|
||||
return this.segments.length;
|
||||
}
|
||||
|
||||
getFinalSeq(): number {
|
||||
return this.segments.length - 1;
|
||||
}
|
||||
|
||||
hasSegments(): boolean {
|
||||
return this.segments.length > 0;
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.dictationId = null;
|
||||
this.sendSeq = 0;
|
||||
this.segments = [];
|
||||
this.streamReady = false;
|
||||
this.startPromise = null;
|
||||
this.startGeneration += 1;
|
||||
}
|
||||
|
||||
resetStreamForReplay(): void {
|
||||
this.dictationId = null;
|
||||
this.sendSeq = 0;
|
||||
this.streamReady = false;
|
||||
this.startPromise = null;
|
||||
this.startGeneration += 1;
|
||||
}
|
||||
|
||||
enqueueSegment(base64Pcm: string): void {
|
||||
this.segments.push(base64Pcm);
|
||||
|
||||
const client = this.client;
|
||||
if (!client?.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.dictationId) {
|
||||
void this.restartStream("enqueue");
|
||||
return;
|
||||
}
|
||||
|
||||
this.flush();
|
||||
}
|
||||
|
||||
flush(): number {
|
||||
const client = this.client;
|
||||
const dictationId = this.dictationId;
|
||||
if (!client?.isConnected || !dictationId || !this.streamReady) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
while (this.sendSeq < this.segments.length) {
|
||||
const seq = this.sendSeq;
|
||||
const audio = this.segments[seq]!;
|
||||
client.sendDictationStreamChunk(dictationId, seq, audio, this.format);
|
||||
this.sendSeq = seq + 1;
|
||||
sent += 1;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
async restartStream(reason: string): Promise<void> {
|
||||
const client = this.client;
|
||||
if (!client?.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.startGeneration += 1;
|
||||
const generation = this.startGeneration;
|
||||
|
||||
const dictationId = this.createDictationId();
|
||||
this.dictationId = dictationId;
|
||||
this.sendSeq = 0;
|
||||
this.streamReady = false;
|
||||
|
||||
const start = (async () => {
|
||||
await client.startDictationStream(dictationId, this.format);
|
||||
if (this.startGeneration !== generation) {
|
||||
return;
|
||||
}
|
||||
if (this.dictationId !== dictationId) {
|
||||
return;
|
||||
}
|
||||
this.streamReady = true;
|
||||
this.flush();
|
||||
})().catch((error) => {
|
||||
// If starting failed, keep the segments for retry but clear the stream so finish can error cleanly.
|
||||
if (this.startGeneration === generation && this.dictationId === dictationId) {
|
||||
this.dictationId = null;
|
||||
this.streamReady = false;
|
||||
}
|
||||
throw error;
|
||||
}).finally(() => {
|
||||
if (this.startPromise === start) {
|
||||
this.startPromise = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.startPromise = start;
|
||||
await start;
|
||||
void reason;
|
||||
}
|
||||
|
||||
async finish(finalSeq: number): Promise<DictationFinishResult> {
|
||||
const client = this.client;
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
if (!client.isConnected) {
|
||||
throw new Error("Daemon client is disconnected");
|
||||
}
|
||||
|
||||
if (!this.dictationId) {
|
||||
await this.restartStream("finalize");
|
||||
}
|
||||
if (this.startPromise) {
|
||||
await this.startPromise;
|
||||
}
|
||||
|
||||
const dictationId = this.dictationId;
|
||||
if (!dictationId || !this.streamReady) {
|
||||
throw new Error("Failed to start dictation stream");
|
||||
}
|
||||
|
||||
this.flush();
|
||||
return client.finishDictationStream(dictationId, finalSeq);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
const client = this.client;
|
||||
const dictationId = this.dictationId;
|
||||
if (client?.isConnected && dictationId) {
|
||||
client.cancelDictationStream(dictationId);
|
||||
}
|
||||
this.resetStreamForReplay();
|
||||
}
|
||||
}
|
||||
@@ -251,6 +251,10 @@ export function useAgentFormState(
|
||||
model: "",
|
||||
workingDir: "",
|
||||
}));
|
||||
const formStateRef = useRef(formState);
|
||||
useEffect(() => {
|
||||
formStateRef.current = formState;
|
||||
}, [formState]);
|
||||
|
||||
// Track if we've done initial resolution (to avoid flickering)
|
||||
const hasResolvedRef = useRef(false);
|
||||
@@ -299,16 +303,16 @@ export function useAgentFormState(
|
||||
preferences,
|
||||
availableModels,
|
||||
userModified,
|
||||
formState
|
||||
formStateRef.current
|
||||
);
|
||||
|
||||
// Only update if something changed
|
||||
if (
|
||||
resolved.serverId !== formState.serverId ||
|
||||
resolved.provider !== formState.provider ||
|
||||
resolved.modeId !== formState.modeId ||
|
||||
resolved.model !== formState.model ||
|
||||
resolved.workingDir !== formState.workingDir
|
||||
resolved.serverId !== formStateRef.current.serverId ||
|
||||
resolved.provider !== formStateRef.current.provider ||
|
||||
resolved.modeId !== formStateRef.current.modeId ||
|
||||
resolved.model !== formStateRef.current.model ||
|
||||
resolved.workingDir !== formStateRef.current.workingDir
|
||||
) {
|
||||
setFormState(resolved);
|
||||
}
|
||||
@@ -322,7 +326,6 @@ export function useAgentFormState(
|
||||
preferences,
|
||||
availableModels,
|
||||
userModified,
|
||||
formState,
|
||||
]);
|
||||
|
||||
// Persist inferred serverId so reloads keep the selection (e.g. URL serverId or first-time load).
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./use-dictation-audio-source.native";
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio";
|
||||
|
||||
import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
|
||||
|
||||
export function useDictationAudioSource(config: DictationAudioSourceConfig): DictationAudioSource {
|
||||
const onPcmSegmentRef = useRef(config.onPcmSegment);
|
||||
const onErrorRef = useRef(config.onError);
|
||||
|
||||
useEffect(() => {
|
||||
onPcmSegmentRef.current = config.onPcmSegment;
|
||||
onErrorRef.current = config.onError;
|
||||
}, [config.onPcmSegment, config.onError]);
|
||||
|
||||
const speechmatics = useSpeechmaticsAudio({
|
||||
enableContinuousStreaming: true,
|
||||
onAudioSegment: ({ audioData }) => {
|
||||
onPcmSegmentRef.current(audioData);
|
||||
},
|
||||
onError: (err) => {
|
||||
onErrorRef.current?.(err);
|
||||
},
|
||||
volumeThreshold: 0.3,
|
||||
silenceDuration: 2000,
|
||||
speechConfirmationDuration: 300,
|
||||
detectionGracePeriod: 200,
|
||||
});
|
||||
|
||||
const start = useCallback(async () => {
|
||||
await speechmatics.start();
|
||||
}, [speechmatics]);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
await speechmatics.stop();
|
||||
}, [speechmatics]);
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
volume: speechmatics.volume,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export type DictationAudioSourceConfig = {
|
||||
onPcmSegment: (pcm16Base64: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
};
|
||||
|
||||
export type DictationAudioSource = {
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
volume: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
|
||||
|
||||
const getAudioContextCtor = (): (typeof AudioContext) | null => {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
const ctor =
|
||||
(window as typeof window & { webkitAudioContext?: typeof AudioContext }).AudioContext ||
|
||||
(window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
return ctor ?? null;
|
||||
};
|
||||
|
||||
const floatToInt16 = (sample: number): number => {
|
||||
const clamped = Math.max(-1, Math.min(1, sample));
|
||||
return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff);
|
||||
};
|
||||
|
||||
const resampleToPcm16 = (input: Float32Array, inputRate: number, outputRate: number): Int16Array => {
|
||||
if (input.length === 0) {
|
||||
return new Int16Array(0);
|
||||
}
|
||||
if (inputRate === outputRate) {
|
||||
const out = new Int16Array(input.length);
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
out[i] = floatToInt16(input[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const ratio = inputRate / outputRate;
|
||||
const outputLength = Math.max(1, Math.round(input.length / ratio));
|
||||
const out = new Int16Array(outputLength);
|
||||
for (let i = 0; i < outputLength; i++) {
|
||||
const sourceIndex = i * ratio;
|
||||
const i0 = Math.floor(sourceIndex);
|
||||
const i1 = Math.min(input.length - 1, i0 + 1);
|
||||
const frac = sourceIndex - i0;
|
||||
const sample = input[i0] * (1 - frac) + input[i1] * frac;
|
||||
out[i] = floatToInt16(sample);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const concatInt16 = (a: Int16Array, b: Int16Array): Int16Array => {
|
||||
if (a.length === 0) {
|
||||
return b;
|
||||
}
|
||||
if (b.length === 0) {
|
||||
return a;
|
||||
}
|
||||
const out = new Int16Array(a.length + b.length);
|
||||
out.set(a, 0);
|
||||
out.set(b, a.length);
|
||||
return out;
|
||||
};
|
||||
|
||||
const 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 useDictationAudioSource(config: DictationAudioSourceConfig): DictationAudioSource {
|
||||
const [volume, setVolume] = useState(0);
|
||||
|
||||
const onPcmSegmentRef = useRef(config.onPcmSegment);
|
||||
const onErrorRef = useRef(config.onError);
|
||||
|
||||
useEffect(() => {
|
||||
onPcmSegmentRef.current = config.onPcmSegment;
|
||||
onErrorRef.current = config.onError;
|
||||
}, [config.onPcmSegment, config.onError]);
|
||||
|
||||
const refs = useRef<{
|
||||
stream: MediaStream | null;
|
||||
context: AudioContext | null;
|
||||
source: MediaStreamAudioSourceNode | null;
|
||||
processor: ScriptProcessorNode | null;
|
||||
gain: GainNode | null;
|
||||
pending: Int16Array;
|
||||
started: boolean;
|
||||
}>({ stream: null, context: null, source: null, processor: null, gain: null, pending: new Int16Array(0), started: false });
|
||||
|
||||
const start = useCallback(async () => {
|
||||
const missingNavigator =
|
||||
typeof navigator === "undefined" ||
|
||||
!navigator.mediaDevices ||
|
||||
typeof navigator.mediaDevices.getUserMedia !== "function";
|
||||
|
||||
const secureContext =
|
||||
typeof window !== "undefined" && typeof window.isSecureContext === "boolean"
|
||||
? window.isSecureContext
|
||||
: true;
|
||||
const currentOrigin =
|
||||
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
|
||||
|
||||
if (missingNavigator) {
|
||||
throw new Error("Microphone capture is not supported in this environment");
|
||||
}
|
||||
if (!secureContext) {
|
||||
throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`);
|
||||
}
|
||||
|
||||
const AudioContextCtor = getAudioContextCtor();
|
||||
if (!AudioContextCtor) {
|
||||
throw new Error("AudioContext unavailable");
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
noiseSuppression: true,
|
||||
echoCancellation: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
|
||||
const context = new AudioContextCtor();
|
||||
const source = context.createMediaStreamSource(stream);
|
||||
const processor = context.createScriptProcessor(4096, 1, 1);
|
||||
const gain = context.createGain();
|
||||
gain.gain.value = 0;
|
||||
|
||||
const outputRate = 16000;
|
||||
const chunkSamples = outputRate; // ~1s
|
||||
|
||||
refs.current.started = true;
|
||||
|
||||
processor.onaudioprocess = (event) => {
|
||||
if (!refs.current.started) {
|
||||
return;
|
||||
}
|
||||
const input = event.inputBuffer.getChannelData(0);
|
||||
|
||||
let sumSquares = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const sample = input[i];
|
||||
sumSquares += sample * sample;
|
||||
}
|
||||
const rms = Math.sqrt(sumSquares / Math.max(1, input.length));
|
||||
const normalized = Math.min(1, Math.max(0, rms * 2));
|
||||
setVolume(normalized);
|
||||
|
||||
const next = resampleToPcm16(input, context.sampleRate, outputRate);
|
||||
refs.current.pending = concatInt16(refs.current.pending, next);
|
||||
|
||||
while (refs.current.pending.length >= chunkSamples) {
|
||||
const chunk = refs.current.pending.slice(0, chunkSamples);
|
||||
refs.current.pending = refs.current.pending.slice(chunkSamples);
|
||||
onPcmSegmentRef.current(int16ToBase64(chunk));
|
||||
}
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(gain);
|
||||
gain.connect(context.destination);
|
||||
|
||||
refs.current = { stream, context, source, processor, gain, pending: new Int16Array(0), started: true };
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
refs.current.started = false;
|
||||
setVolume(0);
|
||||
|
||||
const { processor, source, gain, context, stream, pending } = refs.current;
|
||||
|
||||
if (processor) {
|
||||
try {
|
||||
processor.onaudioprocess = null;
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
try {
|
||||
processor.disconnect();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
if (source) {
|
||||
try {
|
||||
source.disconnect();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
if (gain) {
|
||||
try {
|
||||
gain.disconnect();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => {
|
||||
try {
|
||||
track.stop();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
});
|
||||
}
|
||||
if (context) {
|
||||
try {
|
||||
await context.close();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
if (pending.length > 0) {
|
||||
onPcmSegmentRef.current(int16ToBase64(pending));
|
||||
}
|
||||
|
||||
refs.current = { stream: null, context: null, source: null, processor: null, gain: null, pending: new Int16Array(0), started: false };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
void stop().catch((err) => {
|
||||
onErrorRef.current?.(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
};
|
||||
}, [stop]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
start: async () => {
|
||||
try {
|
||||
await start();
|
||||
} catch (err) {
|
||||
const normalized = err instanceof Error ? err : new Error(String(err));
|
||||
onErrorRef.current?.(normalized);
|
||||
throw normalized;
|
||||
}
|
||||
},
|
||||
stop,
|
||||
volume,
|
||||
}),
|
||||
[start, stop, volume]
|
||||
);
|
||||
}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./use-dictation";
|
||||
@@ -0,0 +1,40 @@
|
||||
export type DictationStatus = "idle" | "recording" | "uploading" | "failed";
|
||||
|
||||
export type UseDictationOptions = {
|
||||
client: import("@server/client/daemon-client-v2").DaemonClientV2 | null;
|
||||
onTranscript: (text: string, meta: { requestId: string }) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onPermanentFailure?: (error: Error, context: { requestId: string }) => void;
|
||||
canStart?: () => boolean;
|
||||
canConfirm?: () => boolean;
|
||||
autoStopWhenHidden?: { isVisible: boolean };
|
||||
enableDuration?: boolean;
|
||||
};
|
||||
|
||||
export type UseDictationResult = {
|
||||
isRecording: boolean;
|
||||
isProcessing: boolean;
|
||||
volume: number;
|
||||
duration: number;
|
||||
error: string | null;
|
||||
status: DictationStatus;
|
||||
startDictation: () => Promise<void>;
|
||||
cancelDictation: () => Promise<void>;
|
||||
confirmDictation: () => Promise<void>;
|
||||
retryFailedDictation: () => Promise<void>;
|
||||
discardFailedDictation: () => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export const DURATION_TICK_MS = 1000;
|
||||
export const PCM_DICTATION_FORMAT = "audio/pcm;rate=16000;bits=16";
|
||||
|
||||
export const toError = (error: unknown): Error => {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
if (typeof error === "string" && error.trim().length > 0) {
|
||||
return new Error(error);
|
||||
}
|
||||
return new Error("An unexpected error occurred while handling dictation.");
|
||||
};
|
||||
@@ -1,160 +1,23 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { useAudioRecorder } from "@/hooks/use-audio-recorder";
|
||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||
import type { TranscriptionResultMessage } from "@server/shared/messages";
|
||||
import { DictationStreamSender } from "@/dictation/dictation-stream-sender";
|
||||
import { useDictationAudioSource } from "@/hooks/use-dictation-audio-source";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
import { AttemptGuard } from "@/utils/attempt-guard";
|
||||
|
||||
export type DictationStatus = "idle" | "recording" | "uploading" | "retrying" | "failed";
|
||||
|
||||
type DictationRetryReason = "dispatch" | "timeout" | "response" | "disconnected";
|
||||
|
||||
export type DictationRetryInfo = {
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
reason: DictationRetryReason;
|
||||
errorMessage: string;
|
||||
nextRetryMs: number;
|
||||
};
|
||||
|
||||
export type FailedDictationRecording = {
|
||||
requestId: string;
|
||||
durationSeconds: number;
|
||||
sizeBytes: number;
|
||||
format: string;
|
||||
recordedAt: number;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
export type DictationOutcome =
|
||||
| { type: "success"; requestId: string; timestamp: number }
|
||||
| { type: "failure"; requestId: string; errorMessage: string; timestamp: number };
|
||||
|
||||
export type UseDictationOptions = {
|
||||
client: DaemonClientV2 | null;
|
||||
onTranscript: (text: string, meta: { requestId: string }) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onRetryAttempt?: (info: DictationRetryInfo) => void;
|
||||
onPermanentFailure?: (error: Error, context: { requestId: string }) => void;
|
||||
canStart?: () => boolean;
|
||||
canConfirm?: () => boolean;
|
||||
autoStopWhenHidden?: { isVisible: boolean };
|
||||
enableDuration?: boolean;
|
||||
};
|
||||
|
||||
export type UseDictationResult = {
|
||||
isRecording: boolean;
|
||||
isProcessing: boolean;
|
||||
volume: number;
|
||||
duration: number;
|
||||
pendingRequestId: string | null;
|
||||
error: string | null;
|
||||
status: DictationStatus;
|
||||
retryAttempt: number;
|
||||
maxRetryAttempts: number;
|
||||
retryInfo: DictationRetryInfo | null;
|
||||
failedRecording: FailedDictationRecording | null;
|
||||
lastOutcome: DictationOutcome | null;
|
||||
startDictation: () => Promise<void>;
|
||||
cancelDictation: () => Promise<void>;
|
||||
confirmDictation: () => Promise<void>;
|
||||
retryFailedDictation: () => Promise<void>;
|
||||
discardFailedDictation: () => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
const DURATION_TICK_MS = 1000;
|
||||
const MAX_AUTO_RETRY_ATTEMPTS = 5;
|
||||
const RETRY_BASE_DELAY_MS = 2000;
|
||||
const RETRY_MAX_DELAY_MS = 12000;
|
||||
const RETRY_BACKOFF_FACTOR = 1.8;
|
||||
const RETRY_JITTER_MS = 400;
|
||||
const TRANSCRIPTION_TIMEOUT_MS = 120000;
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const computeRetryDelayMs = (attempt: number): number => {
|
||||
const exponential = RETRY_BASE_DELAY_MS * RETRY_BACKOFF_FACTOR ** Math.max(0, attempt - 1);
|
||||
const capped = Math.min(RETRY_MAX_DELAY_MS, exponential);
|
||||
if (RETRY_JITTER_MS <= 0) {
|
||||
return capped;
|
||||
}
|
||||
return capped + Math.floor(Math.random() * RETRY_JITTER_MS);
|
||||
};
|
||||
|
||||
const isTimeoutError = (error: Error): boolean =>
|
||||
/timed out|timeout/i.test(error.message);
|
||||
|
||||
class DictationAttemptError extends Error {
|
||||
public readonly reason: DictationRetryReason;
|
||||
|
||||
constructor(reason: DictationRetryReason, error: Error) {
|
||||
super(error.message);
|
||||
this.name = "DictationAttemptError";
|
||||
this.reason = reason;
|
||||
(this as Error & { cause?: Error }).cause = error;
|
||||
}
|
||||
}
|
||||
|
||||
const toError = (error: unknown): Error => {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
if (typeof error === "string" && error.trim().length > 0) {
|
||||
return new Error(error);
|
||||
}
|
||||
return new Error("An unexpected error occurred while handling dictation.");
|
||||
};
|
||||
|
||||
type CapturedAudioPayload = {
|
||||
blob: Blob;
|
||||
format: string;
|
||||
sizeBytes: number;
|
||||
durationSeconds: number;
|
||||
recordedAt: number;
|
||||
};
|
||||
|
||||
type TranscriptionPayload = TranscriptionResultMessage["payload"];
|
||||
|
||||
const blobToBase64 = async (blob: Blob): Promise<string> => {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const bytes = new Uint8Array(arrayBuffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
const deriveFormatFromMime = (mimeType?: string): string => {
|
||||
if (!mimeType || mimeType.length === 0) {
|
||||
return "webm";
|
||||
}
|
||||
const slashIndex = mimeType.indexOf("/");
|
||||
let formatPart = slashIndex >= 0 ? mimeType.slice(slashIndex + 1) : mimeType;
|
||||
const semicolonIndex = formatPart.indexOf(";");
|
||||
if (semicolonIndex >= 0) {
|
||||
formatPart = formatPart.slice(0, semicolonIndex);
|
||||
}
|
||||
return formatPart.trim().length > 0 ? formatPart.trim() : "webm";
|
||||
};
|
||||
|
||||
const buildCapturedAudioPayload = (blob: Blob, durationSeconds: number): CapturedAudioPayload => ({
|
||||
blob,
|
||||
format: deriveFormatFromMime(blob.type),
|
||||
sizeBytes: typeof blob.size === "number" ? blob.size : 0,
|
||||
durationSeconds,
|
||||
recordedAt: Date.now(),
|
||||
});
|
||||
import {
|
||||
DURATION_TICK_MS,
|
||||
PCM_DICTATION_FORMAT,
|
||||
toError,
|
||||
type DictationStatus,
|
||||
type UseDictationOptions,
|
||||
type UseDictationResult,
|
||||
} from "./use-dictation.shared";
|
||||
|
||||
export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
const {
|
||||
client,
|
||||
onTranscript,
|
||||
onError,
|
||||
onRetryAttempt,
|
||||
onPermanentFailure,
|
||||
canStart,
|
||||
canConfirm,
|
||||
@@ -164,127 +27,9 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [volume, setVolume] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<DictationStatus>("idle");
|
||||
const [retryAttempt, setRetryAttempt] = useState(0);
|
||||
const [retryInfo, setRetryInfo] = useState<DictationRetryInfo | null>(null);
|
||||
const [failedRecording, setFailedRecording] = useState<FailedDictationRecording | null>(null);
|
||||
const [lastOutcome, setLastOutcome] = useState<DictationOutcome | null>(null);
|
||||
const maxRetryAttempts = MAX_AUTO_RETRY_ATTEMPTS;
|
||||
|
||||
const transcriptionMutation = useMutation({
|
||||
mutationFn: async (): Promise<TranscriptionPayload> => {
|
||||
const capturedAudio = pendingAudioRef.current;
|
||||
if (!capturedAudio) {
|
||||
throw new Error("No recorded audio available for transcription");
|
||||
}
|
||||
|
||||
setRetryAttempt(1);
|
||||
setRetryInfo(null);
|
||||
|
||||
let attempt = 1;
|
||||
|
||||
while (attempt <= MAX_AUTO_RETRY_ATTEMPTS) {
|
||||
try {
|
||||
if (!client) {
|
||||
throw new DictationAttemptError(
|
||||
"disconnected",
|
||||
new Error("Daemon client unavailable")
|
||||
);
|
||||
}
|
||||
if (!client.isConnected) {
|
||||
throw new DictationAttemptError(
|
||||
"disconnected",
|
||||
new Error("Daemon client is disconnected")
|
||||
);
|
||||
}
|
||||
|
||||
console.info("[useDictation] sending transcription request", {
|
||||
attempt,
|
||||
size: capturedAudio.sizeBytes,
|
||||
durationSeconds: capturedAudio.durationSeconds,
|
||||
});
|
||||
|
||||
setStatus("uploading");
|
||||
setRetryAttempt(attempt);
|
||||
|
||||
try {
|
||||
const base64Audio = await blobToBase64(capturedAudio.blob);
|
||||
return await client.transcribeAudio({
|
||||
audio: base64Audio,
|
||||
format: capturedAudio.format,
|
||||
timeout: TRANSCRIPTION_TIMEOUT_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
const normalized = toError(error);
|
||||
const reason: DictationRetryReason = isTimeoutError(normalized)
|
||||
? "timeout"
|
||||
: "response";
|
||||
throw new DictationAttemptError(reason, normalized);
|
||||
}
|
||||
} catch (error) {
|
||||
const attemptError =
|
||||
error instanceof DictationAttemptError
|
||||
? error
|
||||
: new DictationAttemptError("response", toError(error));
|
||||
|
||||
if (attempt >= MAX_AUTO_RETRY_ATTEMPTS) {
|
||||
throw attemptError;
|
||||
}
|
||||
|
||||
const nextAttempt = attempt + 1;
|
||||
const delayMs = computeRetryDelayMs(nextAttempt);
|
||||
const info: DictationRetryInfo = {
|
||||
attempt: nextAttempt,
|
||||
maxAttempts: MAX_AUTO_RETRY_ATTEMPTS,
|
||||
reason: attemptError.reason,
|
||||
errorMessage: attemptError.message,
|
||||
nextRetryMs: delayMs,
|
||||
};
|
||||
|
||||
setStatus("retrying");
|
||||
setRetryAttempt(nextAttempt);
|
||||
setRetryInfo(info);
|
||||
onRetryAttemptRef.current?.(info);
|
||||
console.warn("[useDictation] retry scheduled", {
|
||||
attempt: nextAttempt,
|
||||
maxAttempts: MAX_AUTO_RETRY_ATTEMPTS,
|
||||
reason: attemptError.reason,
|
||||
error: attemptError.message,
|
||||
nextRetryMs: delayMs,
|
||||
});
|
||||
|
||||
await sleep(delayMs);
|
||||
attempt = nextAttempt;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Failed to complete transcription");
|
||||
},
|
||||
});
|
||||
const {
|
||||
mutateAsync: runTranscription,
|
||||
reset: resetTranscriptionMutation,
|
||||
} = transcriptionMutation;
|
||||
|
||||
const pendingAudioRef = useRef<CapturedAudioPayload | null>(null);
|
||||
const handleAudioLevel = useCallback((level: number) => {
|
||||
setVolume(level);
|
||||
}, []);
|
||||
|
||||
const durationRef = useRef(0);
|
||||
useEffect(() => {
|
||||
durationRef.current = duration;
|
||||
}, [duration]);
|
||||
|
||||
const recorder = useAudioRecorder({ onAudioLevel: handleAudioLevel });
|
||||
const recorderRef = useRef(recorder);
|
||||
useEffect(() => {
|
||||
recorderRef.current = recorder;
|
||||
}, [recorder]);
|
||||
|
||||
const onTranscriptRef = useRef(onTranscript);
|
||||
useEffect(() => {
|
||||
@@ -296,11 +41,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
onErrorRef.current = onError;
|
||||
}, [onError]);
|
||||
|
||||
const onRetryAttemptRef = useRef(onRetryAttempt);
|
||||
useEffect(() => {
|
||||
onRetryAttemptRef.current = onRetryAttempt;
|
||||
}, [onRetryAttempt]);
|
||||
|
||||
const onPermanentFailureRef = useRef(onPermanentFailure);
|
||||
useEffect(() => {
|
||||
onPermanentFailureRef.current = onPermanentFailure;
|
||||
@@ -316,10 +56,27 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
isProcessingRef.current = isProcessing;
|
||||
}, [isProcessing]);
|
||||
|
||||
const pendingRequestIdRef = useRef<string | null>(null);
|
||||
const activeStopPromiseRef = useRef<Promise<Blob> | null>(null);
|
||||
// duration is used for UI only; no need to mirror into a ref.
|
||||
|
||||
const durationIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const attemptGuardRef = useRef(new AttemptGuard());
|
||||
const actionGateRef = useRef<{ starting: boolean; confirming: boolean; cancelling: boolean }>({
|
||||
starting: false,
|
||||
confirming: false,
|
||||
cancelling: false,
|
||||
});
|
||||
|
||||
const senderRef = useRef<DictationStreamSender | null>(null);
|
||||
if (!senderRef.current) {
|
||||
senderRef.current = new DictationStreamSender({
|
||||
client,
|
||||
format: PCM_DICTATION_FORMAT,
|
||||
createDictationId: generateMessageId,
|
||||
});
|
||||
}
|
||||
useEffect(() => {
|
||||
senderRef.current?.setClient(client);
|
||||
}, [client]);
|
||||
|
||||
const stopDurationTracking = useCallback(() => {
|
||||
if (durationIntervalRef.current) {
|
||||
@@ -330,7 +87,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
|
||||
const startDurationTracking = useCallback(() => {
|
||||
if (!enableDuration) {
|
||||
console.log("[useDictation] startDictation blocked by canStart()");
|
||||
return;
|
||||
}
|
||||
if (durationIntervalRef.current) {
|
||||
@@ -351,6 +107,9 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
const reportError = useCallback(
|
||||
(err: unknown, context?: string) => {
|
||||
const normalized = toError(err);
|
||||
if (normalized.name === "AttemptCancelledError") {
|
||||
return;
|
||||
}
|
||||
if (context) {
|
||||
console.error(`[useDictation] ${context}`, normalized);
|
||||
} else {
|
||||
@@ -362,136 +121,114 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
[setError]
|
||||
);
|
||||
|
||||
const stopRecorder = useCallback(async (): Promise<Blob> => {
|
||||
if (activeStopPromiseRef.current) {
|
||||
return activeStopPromiseRef.current;
|
||||
}
|
||||
const recorderInstance = recorderRef.current;
|
||||
if (!recorderInstance) {
|
||||
throw new Error("Recorder unavailable");
|
||||
}
|
||||
const stopPromise = (async () => {
|
||||
try {
|
||||
return await recorderInstance.stop();
|
||||
} finally {
|
||||
activeStopPromiseRef.current = null;
|
||||
}
|
||||
})();
|
||||
activeStopPromiseRef.current = stopPromise;
|
||||
return stopPromise;
|
||||
const clearStreamingState = useCallback(() => {
|
||||
senderRef.current?.clearAll();
|
||||
}, []);
|
||||
|
||||
const transmitDictation = useCallback(
|
||||
async () => runTranscription(),
|
||||
[runTranscription]
|
||||
const startNewStream = useCallback(
|
||||
async (reason: string) => {
|
||||
await senderRef.current?.restartStream(reason);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleTranscriptionSuccess = useCallback(
|
||||
(transcription: TranscriptionPayload) => {
|
||||
const requestId = transcription.requestId;
|
||||
pendingRequestIdRef.current = null;
|
||||
setPendingRequestId(null);
|
||||
const ensureFinalTranscript = useCallback(
|
||||
async (finalSeq: number): Promise<string> => {
|
||||
const result = await senderRef.current!.finish(finalSeq);
|
||||
return result.text;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
return client.subscribeConnectionStatus((next) => {
|
||||
if (next.status !== "connected") {
|
||||
return;
|
||||
}
|
||||
if (!isRecordingRef.current) {
|
||||
return;
|
||||
}
|
||||
void startNewStream("reconnect");
|
||||
});
|
||||
}, [client, startNewStream]);
|
||||
|
||||
const audio = useDictationAudioSource({
|
||||
onPcmSegment: (audioData) => {
|
||||
senderRef.current?.enqueueSegment(audioData);
|
||||
},
|
||||
onError: (err) => {
|
||||
onErrorRef.current?.(err);
|
||||
},
|
||||
});
|
||||
const audioStopRef = useRef(audio.stop);
|
||||
useEffect(() => {
|
||||
audioStopRef.current = audio.stop;
|
||||
}, [audio.stop]);
|
||||
|
||||
const handleStreamingTranscriptionSuccess = useCallback(
|
||||
(text: string, requestId: string) => {
|
||||
setIsProcessing(false);
|
||||
setDuration(0);
|
||||
setStatus("idle");
|
||||
setRetryAttempt(0);
|
||||
setRetryInfo(null);
|
||||
setFailedRecording(null);
|
||||
pendingAudioRef.current = null;
|
||||
setLastOutcome({ type: "success", requestId, timestamp: Date.now() });
|
||||
|
||||
const transcriptText = transcription.text?.trim();
|
||||
clearStreamingState();
|
||||
|
||||
const transcriptText = text.trim();
|
||||
if (!transcriptText) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[useDictation] transcription_result received", {
|
||||
requestId,
|
||||
textLength: transcriptText.length,
|
||||
});
|
||||
onTranscriptRef.current?.(transcriptText, { requestId });
|
||||
},
|
||||
[onTranscriptRef]
|
||||
[clearStreamingState]
|
||||
);
|
||||
|
||||
const handleDictationFailure = useCallback(
|
||||
(failure: unknown) => {
|
||||
const normalized = toError(failure);
|
||||
const failureId = generateMessageId();
|
||||
pendingRequestIdRef.current = null;
|
||||
setPendingRequestId(null);
|
||||
setIsProcessing(false);
|
||||
isRecordingRef.current = false;
|
||||
setIsRecording(false);
|
||||
setVolume(0);
|
||||
setRetryInfo(null);
|
||||
|
||||
const capturedAudio = pendingAudioRef.current;
|
||||
if (capturedAudio) {
|
||||
if (senderRef.current?.hasSegments()) {
|
||||
setStatus("failed");
|
||||
setFailedRecording({
|
||||
requestId: failureId,
|
||||
durationSeconds: capturedAudio.durationSeconds,
|
||||
sizeBytes: capturedAudio.sizeBytes,
|
||||
format: capturedAudio.format,
|
||||
recordedAt: capturedAudio.recordedAt,
|
||||
errorMessage: normalized.message,
|
||||
});
|
||||
onPermanentFailureRef.current?.(normalized, { requestId: failureId });
|
||||
} else {
|
||||
setStatus("idle");
|
||||
}
|
||||
|
||||
setRetryAttempt(0);
|
||||
setLastOutcome({
|
||||
type: "failure",
|
||||
requestId: failureId,
|
||||
errorMessage: normalized.message,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
reportError(normalized, "Failed to complete dictation");
|
||||
},
|
||||
[onPermanentFailureRef, reportError]
|
||||
[reportError]
|
||||
);
|
||||
|
||||
const startDictation = useCallback(async () => {
|
||||
console.log("[useDictation] startDictation requested", {
|
||||
isRecording: isRecordingRef.current,
|
||||
isProcessing,
|
||||
});
|
||||
if (isRecordingRef.current || isProcessing) {
|
||||
console.log("[useDictation] startDictation aborted: already recording/processing", {
|
||||
isRecording: isRecordingRef.current,
|
||||
isProcessing,
|
||||
});
|
||||
if (actionGateRef.current.starting || actionGateRef.current.confirming || actionGateRef.current.cancelling) {
|
||||
return;
|
||||
}
|
||||
if (isRecordingRef.current || isProcessingRef.current) {
|
||||
return;
|
||||
}
|
||||
const startAllowed = canStart ? canStart() : true;
|
||||
if (!startAllowed) {
|
||||
console.log("[useDictation] startDictation blocked by canStart()");
|
||||
return;
|
||||
}
|
||||
|
||||
actionGateRef.current.starting = true;
|
||||
setError(null);
|
||||
setVolume(0);
|
||||
setDuration(0);
|
||||
setIsProcessing(false);
|
||||
setStatus("recording");
|
||||
setRetryAttempt(0);
|
||||
setRetryInfo(null);
|
||||
setFailedRecording(null);
|
||||
pendingAudioRef.current = null;
|
||||
setLastOutcome(null);
|
||||
pendingRequestIdRef.current = null;
|
||||
setPendingRequestId(null);
|
||||
clearStreamingState();
|
||||
|
||||
try {
|
||||
const recorderInstance = recorderRef.current;
|
||||
if (!recorderInstance) {
|
||||
throw new Error("Recorder unavailable");
|
||||
await audio.start();
|
||||
if (client?.isConnected) {
|
||||
void startNewStream("start");
|
||||
}
|
||||
await recorderInstance.start();
|
||||
console.log("[useDictation] recorder.start succeeded");
|
||||
isRecordingRef.current = true;
|
||||
setIsRecording(true);
|
||||
if (enableDuration) {
|
||||
@@ -502,160 +239,155 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
isRecordingRef.current = false;
|
||||
setIsRecording(false);
|
||||
reportError(err, "Failed to start dictation");
|
||||
} finally {
|
||||
actionGateRef.current.starting = false;
|
||||
}
|
||||
}, [
|
||||
audio,
|
||||
canStart,
|
||||
clearStreamingState,
|
||||
client,
|
||||
enableDuration,
|
||||
isProcessing,
|
||||
reportError,
|
||||
startDurationTracking,
|
||||
startNewStream,
|
||||
stopDurationTracking,
|
||||
]);
|
||||
|
||||
const cancelDictation = useCallback(async () => {
|
||||
console.log("[useDictation] cancelDictation requested", {
|
||||
isRecording: isRecordingRef.current,
|
||||
hasActiveStop: Boolean(activeStopPromiseRef.current),
|
||||
});
|
||||
attemptGuardRef.current.cancel();
|
||||
if (!isRecordingRef.current && !activeStopPromiseRef.current) {
|
||||
console.log("[useDictation] cancelDictation ignored: nothing to cancel");
|
||||
if (actionGateRef.current.cancelling) {
|
||||
return;
|
||||
}
|
||||
if (!isRecordingRef.current && !isProcessingRef.current) {
|
||||
return;
|
||||
}
|
||||
actionGateRef.current.cancelling = true;
|
||||
stopDurationTracking();
|
||||
setDuration(0);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await stopRecorder();
|
||||
try {
|
||||
senderRef.current?.cancel();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
await audio.stop();
|
||||
} catch (err) {
|
||||
reportError(err, "Failed to cancel dictation");
|
||||
} finally {
|
||||
isRecordingRef.current = false;
|
||||
setIsRecording(false);
|
||||
setIsProcessing(false);
|
||||
setVolume(0);
|
||||
isProcessingRef.current = false;
|
||||
setStatus("idle");
|
||||
setRetryAttempt(0);
|
||||
setRetryInfo(null);
|
||||
pendingAudioRef.current = null;
|
||||
setFailedRecording(null);
|
||||
setLastOutcome(null);
|
||||
clearStreamingState();
|
||||
actionGateRef.current.cancelling = false;
|
||||
}
|
||||
}, [reportError, stopDurationTracking, stopRecorder]);
|
||||
}, [audio, clearStreamingState, client, reportError, stopDurationTracking]);
|
||||
|
||||
const confirmDictation = useCallback(async () => {
|
||||
console.log("[useDictation] confirmDictation requested", {
|
||||
isRecording: isRecordingRef.current,
|
||||
isProcessing,
|
||||
});
|
||||
if (!isRecordingRef.current || isProcessing) {
|
||||
console.log("[useDictation] confirmDictation ignored: recording flag", {
|
||||
isRecording: isRecordingRef.current,
|
||||
isProcessing,
|
||||
});
|
||||
if (actionGateRef.current.confirming) {
|
||||
return;
|
||||
}
|
||||
if (!isRecordingRef.current || isProcessingRef.current) {
|
||||
return;
|
||||
}
|
||||
const confirmAllowed = canConfirm ? canConfirm() : true;
|
||||
if (!confirmAllowed) {
|
||||
console.log("[useDictation] confirmDictation blocked by canConfirm()");
|
||||
return;
|
||||
}
|
||||
|
||||
actionGateRef.current.confirming = true;
|
||||
setError(null);
|
||||
stopDurationTracking();
|
||||
setIsProcessing(true);
|
||||
setRetryInfo(null);
|
||||
setRetryAttempt(0);
|
||||
setLastOutcome(null);
|
||||
isProcessingRef.current = true;
|
||||
|
||||
const attemptId = attemptGuardRef.current.next();
|
||||
|
||||
try {
|
||||
const audioData = await stopRecorder();
|
||||
await audio.stop();
|
||||
attemptGuardRef.current.assertCurrent(attemptId);
|
||||
const recordedDurationSeconds = durationRef.current;
|
||||
pendingAudioRef.current = buildCapturedAudioPayload(audioData, recordedDurationSeconds);
|
||||
|
||||
setStatus("uploading");
|
||||
isRecordingRef.current = false;
|
||||
setIsRecording(false);
|
||||
setVolume(0);
|
||||
|
||||
const transcription = await transmitDictation();
|
||||
const finalSeq = senderRef.current?.getFinalSeq() ?? -1;
|
||||
if (finalSeq < 0) {
|
||||
handleStreamingTranscriptionSuccess("", generateMessageId());
|
||||
return;
|
||||
}
|
||||
|
||||
const transcriptText = await ensureFinalTranscript(finalSeq);
|
||||
attemptGuardRef.current.assertCurrent(attemptId);
|
||||
handleTranscriptionSuccess(transcription);
|
||||
handleStreamingTranscriptionSuccess(transcriptText, generateMessageId());
|
||||
} catch (err) {
|
||||
resetTranscriptionMutation();
|
||||
if (err instanceof Error && err.name === "AttemptCancelledError") {
|
||||
return;
|
||||
}
|
||||
handleDictationFailure(err);
|
||||
} finally {
|
||||
actionGateRef.current.confirming = false;
|
||||
}
|
||||
}, [
|
||||
audio,
|
||||
canConfirm,
|
||||
isProcessing,
|
||||
handleDictationFailure,
|
||||
handleTranscriptionSuccess,
|
||||
resetTranscriptionMutation,
|
||||
handleStreamingTranscriptionSuccess,
|
||||
stopDurationTracking,
|
||||
stopRecorder,
|
||||
transmitDictation,
|
||||
ensureFinalTranscript,
|
||||
]);
|
||||
|
||||
const retryFailedDictation = useCallback(async () => {
|
||||
if (!pendingAudioRef.current) {
|
||||
if (!senderRef.current?.hasSegments()) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setRetryInfo(null);
|
||||
setRetryAttempt(0);
|
||||
setStatus("uploading");
|
||||
setIsProcessing(true);
|
||||
setLastOutcome(null);
|
||||
isProcessingRef.current = true;
|
||||
|
||||
try {
|
||||
const transcription = await transmitDictation();
|
||||
handleTranscriptionSuccess(transcription);
|
||||
if (!client?.isConnected) {
|
||||
throw new Error("Daemon client is disconnected");
|
||||
}
|
||||
senderRef.current.resetStreamForReplay();
|
||||
const finalSeq = senderRef.current.getFinalSeq();
|
||||
const text = await ensureFinalTranscript(finalSeq);
|
||||
handleStreamingTranscriptionSuccess(text, generateMessageId());
|
||||
} catch (err) {
|
||||
resetTranscriptionMutation();
|
||||
if (err instanceof Error && err.name === "AttemptCancelledError") {
|
||||
return;
|
||||
}
|
||||
handleDictationFailure(err);
|
||||
}
|
||||
}, [
|
||||
handleDictationFailure,
|
||||
handleTranscriptionSuccess,
|
||||
resetTranscriptionMutation,
|
||||
transmitDictation,
|
||||
]);
|
||||
}, [client, ensureFinalTranscript, handleDictationFailure, handleStreamingTranscriptionSuccess]);
|
||||
|
||||
const discardFailedDictation = useCallback(() => {
|
||||
pendingAudioRef.current = null;
|
||||
pendingRequestIdRef.current = null;
|
||||
setPendingRequestId(null);
|
||||
setIsProcessing(false);
|
||||
isProcessingRef.current = false;
|
||||
setDuration(0);
|
||||
setFailedRecording(null);
|
||||
setStatus("idle");
|
||||
setRetryAttempt(0);
|
||||
setRetryInfo(null);
|
||||
setError(null);
|
||||
setLastOutcome(null);
|
||||
}, []);
|
||||
clearStreamingState();
|
||||
}, [clearStreamingState]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
pendingRequestIdRef.current = null;
|
||||
setPendingRequestId(null);
|
||||
setIsRecording(false);
|
||||
isRecordingRef.current = false;
|
||||
setIsProcessing(false);
|
||||
isProcessingRef.current = false;
|
||||
stopDurationTracking();
|
||||
setDuration(0);
|
||||
setVolume(0);
|
||||
setError(null);
|
||||
setStatus("idle");
|
||||
setRetryAttempt(0);
|
||||
setRetryInfo(null);
|
||||
setFailedRecording(null);
|
||||
pendingAudioRef.current = null;
|
||||
setLastOutcome(null);
|
||||
resetTranscriptionMutation();
|
||||
}, [resetTranscriptionMutation, stopDurationTracking]);
|
||||
clearStreamingState();
|
||||
}, [clearStreamingState, stopDurationTracking]);
|
||||
|
||||
const cancelRef = useRef<(() => void) | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -668,8 +400,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
typeof autoStopWhenHidden?.isVisible === "boolean" ? autoStopWhenHidden.isVisible : null
|
||||
);
|
||||
useEffect(() => {
|
||||
const nextVisible =
|
||||
typeof autoStopWhenHidden?.isVisible === "boolean" ? autoStopWhenHidden.isVisible : null;
|
||||
const nextVisible = typeof autoStopWhenHidden?.isVisible === "boolean" ? autoStopWhenHidden.isVisible : null;
|
||||
const prevVisible = visibilityRef.current;
|
||||
visibilityRef.current = nextVisible;
|
||||
|
||||
@@ -685,48 +416,28 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
stopDurationTracking();
|
||||
setDuration(0);
|
||||
setIsProcessing(false);
|
||||
setVolume(0);
|
||||
setError(null);
|
||||
setStatus("idle");
|
||||
setRetryAttempt(0);
|
||||
setRetryInfo(null);
|
||||
pendingAudioRef.current = null;
|
||||
setFailedRecording(null);
|
||||
setLastOutcome(null);
|
||||
clearStreamingState();
|
||||
}
|
||||
}
|
||||
}, [autoStopWhenHidden?.isVisible, stopDurationTracking]);
|
||||
|
||||
}, [autoStopWhenHidden?.isVisible, clearStreamingState, stopDurationTracking]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
attemptGuardRef.current.cancel();
|
||||
stopDurationTracking();
|
||||
const activeStop = activeStopPromiseRef.current;
|
||||
if (activeStop) {
|
||||
void activeStop.catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
const recorderInstance = recorderRef.current;
|
||||
if (recorderInstance?.isRecording?.()) {
|
||||
void recorderInstance.stop().catch(() => undefined);
|
||||
}
|
||||
void audioStopRef.current().catch(() => undefined);
|
||||
};
|
||||
}, [stopDurationTracking]);
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
isProcessing,
|
||||
volume,
|
||||
volume: audio.volume,
|
||||
duration,
|
||||
pendingRequestId,
|
||||
error,
|
||||
status,
|
||||
retryAttempt,
|
||||
maxRetryAttempts,
|
||||
retryInfo,
|
||||
failedRecording,
|
||||
lastOutcome,
|
||||
startDictation,
|
||||
cancelDictation,
|
||||
confirmDictation,
|
||||
@@ -735,3 +446,9 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export type {
|
||||
DictationStatus,
|
||||
UseDictationOptions,
|
||||
UseDictationResult,
|
||||
} from "./use-dictation.shared";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Buffer } from "buffer";
|
||||
import {
|
||||
initialize,
|
||||
useMicrophonePermissions,
|
||||
@@ -14,6 +15,8 @@ export interface SpeechmaticsAudioConfig {
|
||||
onSpeechStart?: () => void;
|
||||
onSpeechEnd?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
/** When true, stream microphone PCM continuously without VAD gating. */
|
||||
enableContinuousStreaming?: boolean;
|
||||
volumeThreshold: number; // Volume threshold for speech detection (0-1)
|
||||
silenceDuration: number; // ms of silence before ending segment
|
||||
speechConfirmationDuration: number; // ms of sustained speech before confirming
|
||||
@@ -33,11 +36,17 @@ export interface SpeechmaticsAudio {
|
||||
}
|
||||
|
||||
function uint8ArrayToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
// NOTE: This is performance-sensitive during continuous streaming.
|
||||
// Buffer-backed base64 is significantly faster than manual string building.
|
||||
try {
|
||||
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
|
||||
} catch {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function concatenateUint8Arrays(arrays: Uint8Array[]): Uint8Array {
|
||||
@@ -67,6 +76,8 @@ export function useSpeechmaticsAudio(
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [segmentDuration, setSegmentDuration] = useState(0);
|
||||
|
||||
const enableContinuousStreaming = config.enableContinuousStreaming === true;
|
||||
|
||||
const audioBufferRef = useRef<Uint8Array[]>([]);
|
||||
const silenceStartRef = useRef<number | null>(null);
|
||||
const isSpeakingRef = useRef(false);
|
||||
@@ -136,6 +147,15 @@ export function useSpeechmaticsAudio(
|
||||
|
||||
const pcmData: Uint8Array = event.data;
|
||||
|
||||
if (enableContinuousStreaming) {
|
||||
audioBufferRef.current.push(pcmData);
|
||||
bufferedBytesRef.current += pcmData.length;
|
||||
if (bufferedBytesRef.current >= MIN_CHUNK_BYTES) {
|
||||
flushBufferedAudio(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Buffer the audio chunk if we're detecting or speaking
|
||||
// Start buffering from first spike to capture beginning of speech
|
||||
if (speechDetectionStartRef.current !== null || isSpeakingRef.current) {
|
||||
@@ -150,7 +170,7 @@ export function useSpeechmaticsAudio(
|
||||
}
|
||||
}
|
||||
},
|
||||
[isActive, isMuted, flushBufferedAudio, MIN_CHUNK_BYTES]
|
||||
[enableContinuousStreaming, isActive, isMuted, flushBufferedAudio, MIN_CHUNK_BYTES]
|
||||
)
|
||||
);
|
||||
|
||||
@@ -165,6 +185,7 @@ export function useSpeechmaticsAudio(
|
||||
setVolume(volumeLevel);
|
||||
|
||||
if (isMuted) return;
|
||||
if (enableContinuousStreaming) return;
|
||||
|
||||
const speechDetected = volumeLevel > VOLUME_THRESHOLD;
|
||||
|
||||
@@ -270,6 +291,7 @@ export function useSpeechmaticsAudio(
|
||||
}
|
||||
},
|
||||
[
|
||||
enableContinuousStreaming,
|
||||
isActive,
|
||||
isMuted,
|
||||
VOLUME_THRESHOLD,
|
||||
@@ -341,6 +363,10 @@ export function useSpeechmaticsAudio(
|
||||
toggleRecording(false);
|
||||
}
|
||||
|
||||
if (enableContinuousStreaming) {
|
||||
flushBufferedAudio(true);
|
||||
}
|
||||
|
||||
// Tear down audio session
|
||||
if (audioInitialized) {
|
||||
tearDown();
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface SpeechmaticsAudioConfig {
|
||||
onSpeechStart?: () => void;
|
||||
onSpeechEnd?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
enableContinuousStreaming?: boolean;
|
||||
volumeThreshold: number;
|
||||
silenceDuration: number;
|
||||
speechConfirmationDuration: number;
|
||||
|
||||
Reference in New Issue
Block a user