diff --git a/app/src/features/human/MicComposer.test.tsx b/app/src/features/human/MicComposer.test.tsx
index ef09e7dc4..b6046c941 100644
--- a/app/src/features/human/MicComposer.test.tsx
+++ b/app/src/features/human/MicComposer.test.tsx
@@ -1,7 +1,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { MicComposer } from './MicComposer';
+import { isLowConfidenceTranscript, MAX_RECORDING_MS, MicComposer } from './MicComposer';
// transcribeWithFactory + encodeBlobToWav are the network/heavy boundaries —
// mock them here so we can drive the state machine without touching real APIs.
@@ -84,6 +84,7 @@ describe('MicComposer', () => {
});
afterEach(() => {
+ vi.useRealTimers();
if (originalMediaDevicesDescriptor) {
Object.defineProperty(globalThis.navigator, 'mediaDevices', originalMediaDevicesDescriptor);
} else {
@@ -187,7 +188,11 @@ describe('MicComposer', () => {
});
it('falls back to wav re-encode when the native attempt fails', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ // Native path: all 3 attempts (initial + 2 retries) fail, then WAV succeeds.
transcribeWithFactoryMock
+ .mockRejectedValueOnce(new Error('codec not accepted'))
+ .mockRejectedValueOnce(new Error('codec not accepted'))
.mockRejectedValueOnce(new Error('codec not accepted'))
.mockResolvedValueOnce('after fallback');
encodeBlobToWavMock.mockResolvedValueOnce(
@@ -200,9 +205,14 @@ describe('MicComposer', () => {
expect(screen.getByRole('button', { name: /stop recording and send/i })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('button', { name: /stop recording and send/i }));
+ await waitFor(() => expect(transcribeWithFactoryMock).toHaveBeenCalledTimes(1));
+ await vi.advanceTimersByTimeAsync(500);
+ await waitFor(() => expect(transcribeWithFactoryMock).toHaveBeenCalledTimes(2));
+ await vi.advanceTimersByTimeAsync(1000);
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('after fallback'));
expect(encodeBlobToWavMock).toHaveBeenCalledTimes(1);
- expect(transcribeWithFactoryMock).toHaveBeenCalledTimes(2);
+ // 3 native attempts + 1 WAV attempt
+ expect(transcribeWithFactoryMock).toHaveBeenCalledTimes(4);
});
it('reports an error when transcription returns empty text', async () => {
@@ -485,6 +495,47 @@ describe('MicComposer', () => {
expect(screen.getByRole('menuitemradio', { name: /USB Headset/i })).toBeInTheDocument();
});
+ // ── Recording timeout (#1206) ──────────────────────────────────────────────
+
+ it('auto-stops recording after MAX_RECORDING_MS', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ transcribeWithFactoryMock.mockResolvedValueOnce('timeout transcript');
+ const onSubmit = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
+ await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled());
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: /stop recording and send/i })).toBeInTheDocument()
+ );
+
+ // Advance past MAX_RECORDING_MS — the timeout should auto-stop.
+ vi.advanceTimersByTime(MAX_RECORDING_MS + 100);
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('timeout transcript'));
+ });
+
+ it('shows countdown label when remaining time <= 10s', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ transcribeWithFactoryMock.mockResolvedValueOnce('ok');
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
+ await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled());
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: /stop recording and send/i })).toBeInTheDocument()
+ );
+
+ // Initially shows "Tap to send" (not the countdown — remainingSecs=60 > 10).
+ expect(screen.getByText('Tap to send')).toBeInTheDocument();
+
+ // Advance 51 interval ticks (51s). The decrementing counter goes
+ // from 60 → 9, which is <= 10 so the countdown label appears.
+ vi.advanceTimersByTime(51_000);
+
+ // The label pattern: "Tap to send (9s)" — match the parenthesized digit+s.
+ await waitFor(() => expect(screen.getByText(/\d+s\)/)).toBeInTheDocument());
+ });
+
it('handles enumerateDevices throwing gracefully (no crash, selector hidden)', async () => {
const enumerateDevicesMock = vi.fn().mockRejectedValue(new Error('NotAllowed'));
Object.defineProperty(globalThis.navigator, 'mediaDevices', {
@@ -501,4 +552,203 @@ describe('MicComposer', () => {
// Composer still functional
expect(screen.getByText('Tap and speak')).toBeInTheDocument();
});
+
+ // ── STT retry (#1206) ──────────────────────────────────────────────────────
+
+ it('retries transient STT failures before falling back to WAV', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ // Native path: fail twice (transient), then succeed
+ transcribeWithFactoryMock
+ .mockRejectedValueOnce(new Error('network timeout'))
+ .mockRejectedValueOnce(new Error('network timeout'))
+ .mockResolvedValueOnce('retry success');
+ const onSubmit = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
+ await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled());
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: /stop recording and send/i })).toBeInTheDocument()
+ );
+ fireEvent.click(screen.getByRole('button', { name: /stop recording and send/i }));
+ await waitFor(() => expect(transcribeWithFactoryMock).toHaveBeenCalledTimes(1));
+ await vi.advanceTimersByTimeAsync(500);
+ await waitFor(() => expect(transcribeWithFactoryMock).toHaveBeenCalledTimes(2));
+ await vi.advanceTimersByTimeAsync(1000);
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('retry success'));
+ // Should have called transcribe 3 times (initial + 2 retries), no WAV fallback
+ expect(transcribeWithFactoryMock).toHaveBeenCalledTimes(3);
+ expect(encodeBlobToWavMock).not.toHaveBeenCalled();
+ });
+
+ it('falls back to WAV after exhausting native retries', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ // Native path: all 3 attempts fail, then WAV path succeeds
+ transcribeWithFactoryMock
+ .mockRejectedValueOnce(new Error('server error'))
+ .mockRejectedValueOnce(new Error('server error'))
+ .mockRejectedValueOnce(new Error('server error'))
+ .mockResolvedValueOnce('wav retry ok');
+ encodeBlobToWavMock.mockResolvedValueOnce(
+ new Blob([new Uint8Array([0])], { type: 'audio/wav' })
+ );
+ const onSubmit = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
+ await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled());
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: /stop recording and send/i })).toBeInTheDocument()
+ );
+ fireEvent.click(screen.getByRole('button', { name: /stop recording and send/i }));
+ await waitFor(() => expect(transcribeWithFactoryMock).toHaveBeenCalledTimes(1));
+ await vi.advanceTimersByTimeAsync(500);
+ await waitFor(() => expect(transcribeWithFactoryMock).toHaveBeenCalledTimes(2));
+ await vi.advanceTimersByTimeAsync(1000);
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('wav retry ok'));
+ // 3 native attempts + 1 WAV attempt
+ expect(transcribeWithFactoryMock).toHaveBeenCalledTimes(4);
+ expect(encodeBlobToWavMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not continue retrying after unmount during STT backoff', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ transcribeWithFactoryMock
+ .mockRejectedValueOnce(new Error('network timeout'))
+ .mockResolvedValueOnce('late transcript');
+ const onSubmit = vi.fn();
+ const onError = vi.fn();
+ const view = render();
+
+ fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
+ await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled());
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: /stop recording and send/i })).toBeInTheDocument()
+ );
+ fireEvent.click(screen.getByRole('button', { name: /stop recording and send/i }));
+ await waitFor(() => expect(transcribeWithFactoryMock).toHaveBeenCalledTimes(1));
+
+ view.unmount();
+ await vi.advanceTimersByTimeAsync(500);
+
+ expect(transcribeWithFactoryMock).toHaveBeenCalledTimes(1);
+ expect(onSubmit).not.toHaveBeenCalled();
+ expect(onError).not.toHaveBeenCalled();
+ });
+
+ it('does not retry permanent errors (stale sidecar)', async () => {
+ transcribeWithFactoryMock.mockRejectedValueOnce(
+ new Error(
+ 'Voice transcription is unavailable in this build. Restart the OpenHuman desktop app to pick up the latest core sidecar.'
+ )
+ );
+ const onError = vi.fn();
+ const onSubmit = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
+ await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled());
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: /stop recording and send/i })).toBeInTheDocument()
+ );
+ fireEvent.click(screen.getByRole('button', { name: /stop recording and send/i }));
+ await waitFor(() => expect(onError).toHaveBeenCalledWith(expect.stringMatching(/failed/i)));
+ // Only 1 attempt — no retries for permanent errors
+ expect(transcribeWithFactoryMock).toHaveBeenCalledTimes(1);
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ // ── Low-confidence transcript detection (#1206) ────────────────────────────
+
+ it('rejects a single-character transcript as low confidence', async () => {
+ transcribeWithFactoryMock.mockResolvedValueOnce('I');
+ const onError = vi.fn();
+ const onSubmit = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
+ await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled());
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: /stop recording and send/i })).toBeInTheDocument()
+ );
+ fireEvent.click(screen.getByRole('button', { name: /stop recording and send/i }));
+ await waitFor(() =>
+ expect(onError).toHaveBeenCalledWith(expect.stringMatching(/could not understand/i))
+ );
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('rejects a repeated-character transcript as low confidence', async () => {
+ transcribeWithFactoryMock.mockResolvedValueOnce('aaa');
+ const onError = vi.fn();
+ const onSubmit = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
+ await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled());
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: /stop recording and send/i })).toBeInTheDocument()
+ );
+ fireEvent.click(screen.getByRole('button', { name: /stop recording and send/i }));
+ await waitFor(() =>
+ expect(onError).toHaveBeenCalledWith(expect.stringMatching(/could not understand/i))
+ );
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('accepts a normal transcript and submits it', async () => {
+ transcribeWithFactoryMock.mockResolvedValueOnce('Hello, how are you?');
+ const onSubmit = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
+ await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled());
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: /stop recording and send/i })).toBeInTheDocument()
+ );
+ fireEvent.click(screen.getByRole('button', { name: /stop recording and send/i }));
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('Hello, how are you?'));
+ });
+});
+
+// ── isLowConfidenceTranscript unit tests ──────────────────────────────────
+
+describe('isLowConfidenceTranscript', () => {
+ it('flags empty strings', () => {
+ expect(isLowConfidenceTranscript('')).toBe(true);
+ expect(isLowConfidenceTranscript(' ')).toBe(true);
+ });
+
+ it('flags single characters', () => {
+ expect(isLowConfidenceTranscript('I')).toBe(true);
+ expect(isLowConfidenceTranscript('A')).toBe(true);
+ });
+
+ it('flags repeated single characters', () => {
+ expect(isLowConfidenceTranscript('aaa')).toBe(true);
+ expect(isLowConfidenceTranscript('mmm')).toBe(true);
+ expect(isLowConfidenceTranscript('...')).toBe(true);
+ });
+
+ it('flags punctuation-only strings', () => {
+ expect(isLowConfidenceTranscript('...')).toBe(true);
+ expect(isLowConfidenceTranscript('!?')).toBe(true);
+ expect(isLowConfidenceTranscript('---')).toBe(true);
+ });
+
+ it('accepts normal text', () => {
+ expect(isLowConfidenceTranscript('Hello')).toBe(false);
+ expect(isLowConfidenceTranscript('Hi there')).toBe(false);
+ expect(isLowConfidenceTranscript('OK')).toBe(false);
+ });
+
+ it('accepts non-Latin scripts', () => {
+ expect(isLowConfidenceTranscript('Hola')).toBe(false);
+ expect(isLowConfidenceTranscript('Привет')).toBe(false);
+ expect(isLowConfidenceTranscript('こんにちは')).toBe(false);
+ expect(isLowConfidenceTranscript('안녕하세요')).toBe(false);
+ expect(isLowConfidenceTranscript('مرحبا')).toBe(false);
+ expect(isLowConfidenceTranscript('नमस्ते')).toBe(false);
+ expect(isLowConfidenceTranscript('বাংলা')).toBe(false);
+ });
});
diff --git a/app/src/features/human/MicComposer.tsx b/app/src/features/human/MicComposer.tsx
index 0d4441343..c301f85b2 100644
--- a/app/src/features/human/MicComposer.tsx
+++ b/app/src/features/human/MicComposer.tsx
@@ -14,6 +14,65 @@ interface AudioInputDevice {
const composerLog = debug('human:mic-composer');
+/**
+ * Maximum recording duration in milliseconds. Auto-stops the recorder when
+ * reached so accidental or forgotten recordings don't run indefinitely.
+ * 60 seconds matches the practical ceiling for single-utterance STT accuracy.
+ * Exported for test assertions.
+ */
+export const MAX_RECORDING_MS = 60_000;
+
+/** Maximum number of retries for transient STT failures per encoding path. */
+const STT_MAX_RETRIES = 2;
+
+/** Base delay for exponential backoff (ms). Delay = base * 2^attempt. */
+const STT_RETRY_BASE_MS = 500;
+
+/**
+ * Errors that indicate a permanent failure — retrying won't help.
+ * Matched case-insensitively against the error message.
+ */
+const PERMANENT_ERROR_PATTERNS = [
+ 'unknown method', // stale sidecar
+ 'audio blob is empty',
+ 'unavailable in this build',
+];
+
+class TranscriptionCancelledError extends Error {
+ constructor() {
+ super('transcription cancelled');
+ this.name = 'TranscriptionCancelledError';
+ }
+}
+
+function isTranscriptionCancelledError(err: unknown): err is TranscriptionCancelledError {
+ return err instanceof TranscriptionCancelledError;
+}
+
+function isTransientError(err: unknown): boolean {
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
+ return !PERMANENT_ERROR_PATTERNS.some(p => msg.includes(p));
+}
+
+/**
+ * Heuristic check for low-confidence transcripts. The backend doesn't expose
+ * a confidence score, so we detect likely bad results from text patterns:
+ * - Single character (often a stray noise → "I" or "A")
+ * - Repeated single character ("aaa", "mmm")
+ * - Only punctuation / whitespace
+ * Exported for testing.
+ */
+export function isLowConfidenceTranscript(text: string): boolean {
+ const trimmed = text.trim();
+ if (trimmed.length === 0) return true;
+ if (trimmed.length === 1) return true;
+ // All same character repeated: "aaa", "mmm", "..."
+ if (/^(.)\1+$/i.test(trimmed)) return true;
+ // Only punctuation, whitespace, or symbols — no actual words
+ if (!/\p{L}/u.test(trimmed)) return true;
+ return false;
+}
+
/** MIME types MediaRecorder will be asked to use, in priority order.
*
* AAC-in-MP4 is preferred because the hosted STT upstream (GMI Whisper)
@@ -90,6 +149,10 @@ export function MicComposer({
// Without this, two awaited `getUserMedia` calls can resolve back-to-back
// and leave one of the granted streams orphaned (mic indicator stuck on).
const startInFlightRef = useRef(false);
+ // Recording timeout — auto-stops after MAX_RECORDING_MS.
+ const recordingTimerRef = useRef(null);
+ const countdownRef = useRef(null);
+ const [remainingSecs, setRemainingSecs] = useState(null);
// If the component unmounts mid-record, release the mic so the OS indicator
// doesn't get stuck on.
@@ -97,6 +160,7 @@ export function MicComposer({
disposedRef.current = false;
return () => {
disposedRef.current = true;
+ clearRecordingTimer();
// Detach onstop first — `recorder.stop()` below is what would fire it,
// and we don't want finalizeRecording running post-unmount.
if (recorderRef.current) recorderRef.current.onstop = null;
@@ -217,6 +281,33 @@ export function MicComposer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state, disabled]);
+ function clearRecordingTimer() {
+ if (recordingTimerRef.current != null) {
+ window.clearTimeout(recordingTimerRef.current);
+ recordingTimerRef.current = null;
+ }
+ if (countdownRef.current != null) {
+ window.clearInterval(countdownRef.current);
+ countdownRef.current = null;
+ }
+ setRemainingSecs(null);
+ }
+
+ function startRecordingTimer() {
+ clearRecordingTimer();
+ const totalSecs = Math.ceil(MAX_RECORDING_MS / 1000);
+ let tick = totalSecs;
+ setRemainingSecs(tick);
+ countdownRef.current = window.setInterval(() => {
+ tick = Math.max(0, tick - 1);
+ setRemainingSecs(tick);
+ }, 1000);
+ recordingTimerRef.current = window.setTimeout(() => {
+ composerLog('recording auto-stopped after %dms', MAX_RECORDING_MS);
+ stopRecording();
+ }, MAX_RECORDING_MS);
+ }
+
function stopStream() {
if (streamRef.current) {
for (const track of streamRef.current.getTracks()) {
@@ -323,12 +414,14 @@ export function MicComposer({
recorder.start();
setState('recording');
startInFlightRef.current = false;
+ startRecordingTimer();
composerLog('recording started mime=%s', recorder.mimeType || '(default)');
}
function stopRecording() {
const recorder = recorderRef.current;
if (!recorder || recorder.state === 'inactive') return;
+ clearRecordingTimer();
setState('transcribing');
try {
recorder.stop();
@@ -372,44 +465,104 @@ export function MicComposer({
try {
const transcript = await transcribeWithFallback(blob);
+ if (disposedRef.current) return;
if (!transcript) {
onError?.(t('mic.noSpeechDetected'));
setState('idle');
return;
}
+ if (isLowConfidenceTranscript(transcript)) {
+ composerLog('low-confidence transcript detected length=%d', transcript.length);
+ onError?.(t('mic.lowConfidenceResult'));
+ setState('idle');
+ return;
+ }
await onSubmit(transcript);
} catch (err) {
+ if (disposedRef.current || isTranscriptionCancelledError(err)) {
+ composerLog('transcribe cancelled after composer disposal');
+ return;
+ }
const msg = err instanceof Error ? err.message : String(err);
composerLog('transcribe failed: %s', msg);
onError?.(t('mic.transcriptionFailed').replace('{message}', msg));
} finally {
- setState('idle');
+ if (!disposedRef.current) setState('idle');
}
}
+ /**
+ * Retry a transcription call up to `STT_MAX_RETRIES` times with
+ * exponential backoff for transient errors. Permanent errors (stale
+ * sidecar, empty blob) are rethrown immediately.
+ */
+ async function transcribeWithRetry(blob: Blob, label: string): Promise {
+ const opts = language ? { language } : undefined;
+ let lastErr: unknown;
+ const throwIfDisposed = () => {
+ if (disposedRef.current) throw new TranscriptionCancelledError();
+ };
+ for (let attempt = 0; attempt <= STT_MAX_RETRIES; attempt++) {
+ throwIfDisposed();
+ try {
+ composerLog(
+ 'transcribe attempt=%s/%d bytes=%d mime=%s lang=%s',
+ label,
+ attempt,
+ blob.size,
+ blob.type,
+ language || 'auto'
+ );
+ const transcript = await transcribeWithFactory(blob, opts);
+ throwIfDisposed();
+ return transcript;
+ } catch (err) {
+ if (isTranscriptionCancelledError(err)) throw err;
+ lastErr = err;
+ const msg = err instanceof Error ? err.message : String(err);
+ if (!isTransientError(err)) {
+ composerLog('transcribe permanent failure attempt=%s/%d: %s', label, attempt, msg);
+ throw err;
+ }
+ if (attempt < STT_MAX_RETRIES) {
+ const delay = STT_RETRY_BASE_MS * Math.pow(2, attempt);
+ composerLog(
+ 'transcribe transient failure attempt=%s/%d, retrying in %dms: %s',
+ label,
+ attempt,
+ delay,
+ msg
+ );
+ await new Promise(r => setTimeout(r, delay));
+ throwIfDisposed();
+ } else {
+ composerLog('transcribe exhausted retries attempt=%s/%d: %s', label, attempt, msg);
+ }
+ }
+ }
+ throw lastErr;
+ }
+
/**
* Send the recorder's native blob first (Opus-in-WebM ~3KB/sec) — Scribe
* accepts it natively and it uploads ~30x faster than the 16kHz mono WAV
* we used to transcode (~32KB/sec). If that ever fails (older STT
- * provider behind a feature flag, codec mismatch, …), retry once with a
- * re-encoded WAV so we don't regress correctness for the speed win.
+ * provider behind a feature flag, codec mismatch, …), re-encode to WAV
+ * and retry so we don't regress correctness for the speed win. Each path
+ * retries transient errors with exponential backoff.
*/
async function transcribeWithFallback(blob: Blob): Promise {
const startedAt = Date.now();
- const opts = language ? { language } : undefined;
try {
- composerLog(
- 'transcribe attempt=native bytes=%d mime=%s lang=%s',
- blob.size,
- blob.type,
- language || 'auto'
- );
- const text = await transcribeWithFactory(blob, opts);
- composerLog('transcribe ok attempt=native ms=%d', Math.round(Date.now() - startedAt));
+ const text = await transcribeWithRetry(blob, 'native');
+ composerLog('transcribe ok path=native ms=%d', Math.round(Date.now() - startedAt));
return text;
} catch (err) {
+ if (isTranscriptionCancelledError(err)) throw err;
const msg = err instanceof Error ? err.message : String(err);
- composerLog('transcribe failed attempt=native — falling back to wav: %s', msg);
+ // Permanent errors don't benefit from a WAV re-encode — bail early.
+ if (!isTransientError(err)) throw err;
+ composerLog('transcribe native path exhausted — falling back to wav: %s', msg);
const reEncodeStart = Date.now();
const wav = await encodeBlobToWav(blob);
composerLog(
@@ -417,9 +570,9 @@ export function MicComposer({
wav.size,
Math.round(Date.now() - reEncodeStart)
);
- const text = await transcribeWithFactory(wav, opts);
+ const text = await transcribeWithRetry(wav, 'wav');
composerLog(
- 'transcribe ok attempt=wav-fallback total_ms=%d',
+ 'transcribe ok path=wav-fallback total_ms=%d',
Math.round(Date.now() - startedAt)
);
return text;
@@ -433,7 +586,9 @@ export function MicComposer({
const label = isBusy
? t('mic.transcribing')
: isRecording
- ? t('mic.tapToSend')
+ ? remainingSecs != null && remainingSecs <= 10
+ ? t('mic.tapToSendCountdown').replace('{seconds}', String(remainingSecs))
+ : t('mic.tapToSend')
: disabled
? t('mic.waitingForAgent')
: t('mic.tapAndSpeak');
diff --git a/app/src/features/human/useHumanMascot.test.ts b/app/src/features/human/useHumanMascot.test.ts
index eac358254..2d1b07037 100644
--- a/app/src/features/human/useHumanMascot.test.ts
+++ b/app/src/features/human/useHumanMascot.test.ts
@@ -366,7 +366,7 @@ describe('useHumanMascot state machine', () => {
expect(result.current.face).toBe('thinking');
});
- it('listening does not override speaking', () => {
+ it('listening overrides streaming speech deltas', () => {
const { result, rerender } = renderHook(
({ listening }: { listening: boolean }) => useHumanMascot({ listening }),
{ initialProps: { listening: false } }
@@ -375,7 +375,8 @@ describe('useHumanMascot state machine', () => {
capturedListeners?.onTextDelta?.(fakeEvent({ round: 1, delta: 'hi' }));
});
rerender({ listening: true });
- expect(result.current.face).toBe('speaking');
+ expect(result.current.face).toBe('listening');
+ expect(result.current.viseme).toEqual(VISEMES.REST);
});
});
@@ -519,6 +520,81 @@ describe('useHumanMascot TTS playback', () => {
});
});
+ it('stops in-flight TTS and shows listening when the microphone becomes active', async () => {
+ const fake = makeFakePlayback(1000);
+ const stopSpy = vi.spyOn(fake.handle, 'stop');
+ (synthesizeSpeech as ReturnType).mockResolvedValueOnce({
+ audio_base64: 'AAA=',
+ audio_mime: 'audio/mpeg',
+ visemes: [{ viseme: 'aa', start_ms: 0, end_ms: 100 }],
+ });
+ (playBase64Audio as ReturnType).mockResolvedValueOnce(fake.handle);
+
+ const { result, rerender } = renderHook(
+ ({ listening }: { listening: boolean }) => useHumanMascot({ speakReplies: true, listening }),
+ { initialProps: { listening: false } }
+ );
+ await act(async () => {
+ capturedListeners?.onDone?.(fakeDone('hello'));
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(result.current.face).toBe('speaking');
+
+ act(() => {
+ rerender({ listening: true });
+ });
+
+ expect(stopSpy).toHaveBeenCalledTimes(1);
+ expect(result.current.face).toBe('listening');
+ expect(result.current.viseme).toEqual(VISEMES.REST);
+ });
+
+ it('drops pending TTS synthesis when listening starts before playback', async () => {
+ type TestTtsPayload = {
+ audio_base64: string;
+ audio_mime: string;
+ visemes: { viseme: string; start_ms: number; end_ms: number }[];
+ };
+ let resolveSynth!: (value: TestTtsPayload) => void;
+ const pendingSynth = new Promise(resolve => {
+ resolveSynth = resolve;
+ });
+ (synthesizeSpeech as ReturnType).mockReturnValueOnce(pendingSynth);
+
+ const { result, rerender } = renderHook(
+ ({ listening }: { listening: boolean }) => useHumanMascot({ speakReplies: true, listening }),
+ { initialProps: { listening: false } }
+ );
+ await act(async () => {
+ capturedListeners?.onDone?.(fakeDone('hello'));
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(result.current.face).toBe('thinking');
+
+ act(() => {
+ rerender({ listening: true });
+ });
+ expect(result.current.face).toBe('listening');
+
+ await act(async () => {
+ resolveSynth({
+ audio_base64: 'AAA=',
+ audio_mime: 'audio/mpeg',
+ visemes: [{ viseme: 'aa', start_ms: 0, end_ms: 100 }],
+ });
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(playBase64Audio).not.toHaveBeenCalled();
+ expect(result.current.face).toBe('listening');
+ expect(result.current.viseme).toEqual(VISEMES.REST);
+ });
+
it('does not surface an unhandledrejection when a newer turn cancels in-flight playback (#1472)', async () => {
// Two back-to-back turns: the first reaches the `await playBase64Audio`
// point and then a second onDone bumps the playback seq. When the first
diff --git a/app/src/features/human/useHumanMascot.ts b/app/src/features/human/useHumanMascot.ts
index 63bf607e9..ecaab022f 100644
--- a/app/src/features/human/useHumanMascot.ts
+++ b/app/src/features/human/useHumanMascot.ts
@@ -146,6 +146,8 @@ export function useHumanMascot(options: UseHumanMascotOptions = {}): UseHumanMas
const { speakReplies = false, listening = false } = options;
const speakRef = useRef(speakReplies);
speakRef.current = speakReplies;
+ const listeningRef = useRef(listening);
+ listeningRef.current = listening;
// Effective mascot voice id: resolves the manual override, the
// locale-default toggle, and the build-time fallback into a single
@@ -191,21 +193,25 @@ export function useHumanMascot(options: UseHumanMascotOptions = {}): UseHumanMas
const unsub = subscribeChatEvents({
onInferenceStart: () => {
clearAckTimer();
+ mascotLog('voice-session transition → thinking (inference_start)');
setFace('thinking');
},
onIterationStart: e => {
// Subsequent iterations mean the agent is grinding through tool rounds.
if (e.round > 1) {
clearAckTimer();
+ mascotLog('voice-session transition → confused (iteration round=%d)', e.round);
setFace('confused');
}
},
onToolCall: () => {
clearAckTimer();
+ mascotLog('voice-session transition → confused (tool_call)');
setFace('confused');
},
onToolResult: e => {
if (!e.success) {
+ mascotLog('voice-session transition → concerned (tool_result failed)');
// Don't fully derail — let the next inference step take over.
setFace('concerned');
} else {
@@ -214,6 +220,10 @@ export function useHumanMascot(options: UseHumanMascotOptions = {}): UseHumanMas
},
onTextDelta: e => {
// Pseudo-lipsync only kicks in if no real audio is playing.
+ if (listeningRef.current) {
+ mascotLog('voice-session text_delta suppressed — listening is active');
+ return;
+ }
if (playbackRef.current) return;
clearAckTimer();
setFace('speaking');
@@ -221,6 +231,10 @@ export function useHumanMascot(options: UseHumanMascotOptions = {}): UseHumanMas
lastDeltaAtRef.current = window.performance.now();
},
onDone: e => {
+ if (listeningRef.current) {
+ mascotLog('voice-session onDone suppressed — listening is active');
+ return;
+ }
const ackFace = pickConversationAckFace(e) ?? 'happy';
if (!speakRef.current || !e.full_response?.trim()) {
// Soft acknowledgement beat instead of snapping back to idle.
@@ -231,6 +245,7 @@ export function useHumanMascot(options: UseHumanMascotOptions = {}): UseHumanMas
void startTtsPlayback(e.full_response, ackFace).catch(() => {});
},
onError: () => {
+ mascotLog('voice-session transition → concerned (chat_error), cancelling in-flight TTS');
// Bump seq to invalidate any in-flight startTtsPlayback awaiters.
playbackSeqRef.current++;
const orphan = playbackRef.current;
@@ -260,6 +275,29 @@ export function useHumanMascot(options: UseHumanMascotOptions = {}): UseHumanMas
};
}, []);
+ useEffect(() => {
+ if (!listening) return;
+ clearAckTimer();
+ mascotLog(
+ 'voice-session listening interrupt — cancelling TTS (had playback=%s)',
+ !!playbackRef.current
+ );
+ // Treat mic-hot as an explicit interruption: stale synthesis/playback
+ // callbacks must not switch the mascot back to speaking after we listen.
+ playbackSeqRef.current++;
+ const orphan = playbackRef.current;
+ playbackRef.current = null;
+ if (orphan) {
+ orphan.stop();
+ orphan.ended.catch(swallowAudioStop);
+ }
+ visemeFramesRef.current = [];
+ visemeCursorRef.current = 0;
+ targetRef.current = VISEMES.REST;
+ lastDeltaAtRef.current = 0;
+ setFace('idle');
+ }, [listening]);
+
async function startTtsPlayback(
text: string,
ackFace: ConversationAckFace = 'happy'
@@ -402,7 +440,8 @@ export function useHumanMascot(options: UseHumanMascotOptions = {}): UseHumanMas
// `listening` is an external override so callers wiring dictation state
// can reflect mic-on without racing the chat event subscription.
- const effectiveFace: MascotFace = listening && face !== 'speaking' ? 'listening' : face;
+ const effectiveFace: MascotFace = listening ? 'listening' : face;
+ const effectiveViseme: VisemeShape = listening ? VISEMES.REST : viseme;
- return { face: effectiveFace, viseme };
+ return { face: effectiveFace, viseme: effectiveViseme };
}
diff --git a/app/src/lib/i18n/chunks/ar-2.ts b/app/src/lib/i18n/chunks/ar-2.ts
index 84aa3b7f9..1a9093407 100644
--- a/app/src/lib/i18n/chunks/ar-2.ts
+++ b/app/src/lib/i18n/chunks/ar-2.ts
@@ -413,6 +413,7 @@ const ar2: TranslationMap = {
'devOptions.menuComposioTriggersDesc':
'تكوين إعدادات فرز الذكاء الاصطناعي لمشغلات التكامل Composio',
'mic.deviceSelector': 'جهاز الميكروفون',
+ 'mic.tapToSendCountdown': 'انقر للإرسال ({seconds} ث)',
};
export default ar2;
diff --git a/app/src/lib/i18n/chunks/ar-3.ts b/app/src/lib/i18n/chunks/ar-3.ts
index 781460753..14719c082 100644
--- a/app/src/lib/i18n/chunks/ar-3.ts
+++ b/app/src/lib/i18n/chunks/ar-3.ts
@@ -261,6 +261,7 @@ const ar3: TranslationMap = {
'memory.ingestingTitle': 'جارٍ استيعاب {title}',
'mic.noAudioCaptured': 'لم يُلتقط أي صوت',
'mic.noSpeechDetected': 'لم يُكتشف أي كلام',
+ 'mic.lowConfidenceResult': 'تعذّر فهم الصوت بوضوح — يرجى المحاولة مرة أخرى',
'mic.failedToStopRecording': 'فشل إيقاف التسجيل: {message}',
'mic.transcriptionFailed': 'فشل النسخ: {message}',
'reflections.kind.retrospective': 'مراجعة',
diff --git a/app/src/lib/i18n/chunks/bn-2.ts b/app/src/lib/i18n/chunks/bn-2.ts
index 28cd58546..cff1106f8 100644
--- a/app/src/lib/i18n/chunks/bn-2.ts
+++ b/app/src/lib/i18n/chunks/bn-2.ts
@@ -425,6 +425,7 @@ const bn2: TranslationMap = {
'devOptions.menuComposioTriggersDesc':
'Composio ইন্টিগ্রেশন ট্রিগারের জন্য AI ট্রাইজ সেটিংস কনফিগার করুন',
'mic.deviceSelector': 'মাইক্রোফোন ডিভাইস',
+ 'mic.tapToSendCountdown': 'পাঠাতে ট্যাপ করুন ({seconds}স)',
};
export default bn2;
diff --git a/app/src/lib/i18n/chunks/bn-3.ts b/app/src/lib/i18n/chunks/bn-3.ts
index d2e981816..0bfc49d90 100644
--- a/app/src/lib/i18n/chunks/bn-3.ts
+++ b/app/src/lib/i18n/chunks/bn-3.ts
@@ -266,6 +266,7 @@ const bn3: TranslationMap = {
'memory.ingestingTitle': '{title} ইনজেস্ট হচ্ছে',
'mic.noAudioCaptured': 'কোনো অডিও ক্যাপচার হয়নি',
'mic.noSpeechDetected': 'কোনো কথা শনাক্ত হয়নি',
+ 'mic.lowConfidenceResult': 'অডিও স্পষ্টভাবে বোঝা যায়নি — আবার চেষ্টা করুন',
'mic.failedToStopRecording': 'রেকর্ডিং বন্ধ করতে ব্যর্থ: {message}',
'mic.transcriptionFailed': 'ট্রান্সক্রিপশন ব্যর্থ: {message}',
'reflections.kind.retrospective': 'পূর্বদর্শন',
diff --git a/app/src/lib/i18n/chunks/de-2.ts b/app/src/lib/i18n/chunks/de-2.ts
index ce2b47b4d..b56e70c15 100644
--- a/app/src/lib/i18n/chunks/de-2.ts
+++ b/app/src/lib/i18n/chunks/de-2.ts
@@ -436,6 +436,7 @@ const de2: TranslationMap = {
'devOptions.menuComposioTriggersDesc':
'Konfiguriere KI-Triage-Einstellungen für Composio-Integrationsauslöser',
'mic.deviceSelector': 'Mikrofongerät',
+ 'mic.tapToSendCountdown': 'Zum Senden tippen ({seconds}s)',
};
export default de2;
diff --git a/app/src/lib/i18n/chunks/de-3.ts b/app/src/lib/i18n/chunks/de-3.ts
index 6e8a480aa..36083a093 100644
--- a/app/src/lib/i18n/chunks/de-3.ts
+++ b/app/src/lib/i18n/chunks/de-3.ts
@@ -268,6 +268,7 @@ const de3: TranslationMap = {
'memory.ingestingTitle': 'Einnahme von {title}',
'mic.noAudioCaptured': 'Kein Ton aufgenommen',
'mic.noSpeechDetected': 'Keine Sprache erkannt',
+ 'mic.lowConfidenceResult': 'Audio konnte nicht klar verstanden werden — bitte erneut versuchen',
'mic.failedToStopRecording': 'Aufzeichnung konnte nicht gestoppt werden: {message}',
'mic.transcriptionFailed': 'Transkription fehlgeschlagen: {message}',
'reflections.kind.retrospective': 'Retrospektive',
diff --git a/app/src/lib/i18n/chunks/en-2.ts b/app/src/lib/i18n/chunks/en-2.ts
index 011d9ecc2..7339fd849 100644
--- a/app/src/lib/i18n/chunks/en-2.ts
+++ b/app/src/lib/i18n/chunks/en-2.ts
@@ -350,6 +350,7 @@ const en2: TranslationMap = {
'mic.stopRecording': 'Stop recording and send',
'mic.startRecording': 'Start recording',
'mic.deviceSelector': 'Microphone device',
+ 'mic.tapToSendCountdown': 'Tap to send ({seconds}s)',
'token.usageLimitReached': 'Usage limit reached',
'token.approachingLimit': 'Approaching limit',
'token.planClickForDetails': 'plan - click for details',
diff --git a/app/src/lib/i18n/chunks/en-3.ts b/app/src/lib/i18n/chunks/en-3.ts
index c507836dc..f0fadf531 100644
--- a/app/src/lib/i18n/chunks/en-3.ts
+++ b/app/src/lib/i18n/chunks/en-3.ts
@@ -266,6 +266,7 @@ const en3: TranslationMap = {
'memory.ingestingTitle': 'Ingesting {title}',
'mic.noAudioCaptured': 'No audio captured',
'mic.noSpeechDetected': 'No speech detected',
+ 'mic.lowConfidenceResult': 'Could not understand the audio clearly — please try again',
'mic.failedToStopRecording': 'Failed to stop recording: {message}',
'mic.transcriptionFailed': 'Transcription failed: {message}',
'reflections.kind.retrospective': 'Retrospective',
diff --git a/app/src/lib/i18n/chunks/es-2.ts b/app/src/lib/i18n/chunks/es-2.ts
index 5bb92da2c..8f8f1eff9 100644
--- a/app/src/lib/i18n/chunks/es-2.ts
+++ b/app/src/lib/i18n/chunks/es-2.ts
@@ -435,6 +435,7 @@ const es2: TranslationMap = {
'devOptions.menuComposioTriggersDesc':
'Configurar los ajustes de clasificación de IA para los activadores de integración Composio',
'mic.deviceSelector': 'Dispositivo de micrófono',
+ 'mic.tapToSendCountdown': 'Toca para enviar ({seconds}s)',
};
export default es2;
diff --git a/app/src/lib/i18n/chunks/es-3.ts b/app/src/lib/i18n/chunks/es-3.ts
index ed4f48afe..14e18506e 100644
--- a/app/src/lib/i18n/chunks/es-3.ts
+++ b/app/src/lib/i18n/chunks/es-3.ts
@@ -269,6 +269,7 @@ const es3: TranslationMap = {
'memory.ingestingTitle': 'Ingiriendo {title}',
'mic.noAudioCaptured': 'No se capturó audio',
'mic.noSpeechDetected': 'No se detectó habla',
+ 'mic.lowConfidenceResult': 'No se pudo entender el audio con claridad — intenta de nuevo',
'mic.failedToStopRecording': 'No se pudo detener la grabación: {message}',
'mic.transcriptionFailed': 'Transcripción fallida: {message}',
'reflections.kind.retrospective': 'Retrospectiva',
diff --git a/app/src/lib/i18n/chunks/fr-2.ts b/app/src/lib/i18n/chunks/fr-2.ts
index 7614d6eaa..2b35ff7b9 100644
--- a/app/src/lib/i18n/chunks/fr-2.ts
+++ b/app/src/lib/i18n/chunks/fr-2.ts
@@ -437,6 +437,7 @@ const fr2: TranslationMap = {
'devOptions.menuComposioTriggersDesc':
"Configurez les paramètres de triage IA pour les déclencheurs d'intégration Composio",
'mic.deviceSelector': 'Dispositif de microphone',
+ 'mic.tapToSendCountdown': 'Appuie pour envoyer ({seconds}s)',
};
export default fr2;
diff --git a/app/src/lib/i18n/chunks/fr-3.ts b/app/src/lib/i18n/chunks/fr-3.ts
index 638502c7a..1a97109bb 100644
--- a/app/src/lib/i18n/chunks/fr-3.ts
+++ b/app/src/lib/i18n/chunks/fr-3.ts
@@ -270,6 +270,7 @@ const fr3: TranslationMap = {
'memory.ingestingTitle': 'Ingestion de {title}',
'mic.noAudioCaptured': 'Aucun audio capturé',
'mic.noSpeechDetected': 'Aucune parole détectée',
+ 'mic.lowConfidenceResult': "Impossible de comprendre l'audio clairement — réessaie",
'mic.failedToStopRecording': "Échec de l'arrêt de l'enregistrement : {message}",
'mic.transcriptionFailed': 'Échec de la transcription : {message}',
'reflections.kind.retrospective': 'Rétrospective',
diff --git a/app/src/lib/i18n/chunks/hi-2.ts b/app/src/lib/i18n/chunks/hi-2.ts
index c7bc82ee5..7f1d800f9 100644
--- a/app/src/lib/i18n/chunks/hi-2.ts
+++ b/app/src/lib/i18n/chunks/hi-2.ts
@@ -423,6 +423,7 @@ const hi2: TranslationMap = {
'devOptions.menuComposioTriggersDesc':
'Composio एकीकरण ट्रिगर के लिए AI ट्राइएज सेटिंग्स कॉन्फ़िगर करें',
'mic.deviceSelector': 'माइक्रोफोन डिवाइस',
+ 'mic.tapToSendCountdown': 'भेजने के लिए टैप करें ({seconds}स)',
};
export default hi2;
diff --git a/app/src/lib/i18n/chunks/hi-3.ts b/app/src/lib/i18n/chunks/hi-3.ts
index 21193e4ff..4c154b420 100644
--- a/app/src/lib/i18n/chunks/hi-3.ts
+++ b/app/src/lib/i18n/chunks/hi-3.ts
@@ -266,6 +266,7 @@ const hi3: TranslationMap = {
'memory.ingestingTitle': '{title} इन्जेस्ट हो रहा है',
'mic.noAudioCaptured': 'कोई ऑडियो कैप्चर नहीं हुआ',
'mic.noSpeechDetected': 'कोई बोलना नहीं मिला',
+ 'mic.lowConfidenceResult': 'ऑडियो स्पष्ट रूप से समझ नहीं आया — कृपया पुनः प्रयास करें',
'mic.failedToStopRecording': 'रिकॉर्डिंग रोकने में दिक्कत: {message}',
'mic.transcriptionFailed': 'ट्रांसक्रिप्शन विफल: {message}',
'reflections.kind.retrospective': 'रेट्रोस्पेक्टिव',
diff --git a/app/src/lib/i18n/chunks/id-2.ts b/app/src/lib/i18n/chunks/id-2.ts
index d0daf13d3..fc96dc229 100644
--- a/app/src/lib/i18n/chunks/id-2.ts
+++ b/app/src/lib/i18n/chunks/id-2.ts
@@ -423,6 +423,7 @@ const id2: TranslationMap = {
'devOptions.menuComposioTriggersDesc':
'Konfigurasikan pengaturan triase AI untuk pemicu integrasi Composio',
'mic.deviceSelector': 'Perangkat mikrofon',
+ 'mic.tapToSendCountdown': 'Ketuk untuk mengirim ({seconds}d)',
};
export default id2;
diff --git a/app/src/lib/i18n/chunks/id-3.ts b/app/src/lib/i18n/chunks/id-3.ts
index ddd54f590..58d0d0368 100644
--- a/app/src/lib/i18n/chunks/id-3.ts
+++ b/app/src/lib/i18n/chunks/id-3.ts
@@ -266,6 +266,7 @@ const id3: TranslationMap = {
'memory.ingestingTitle': 'Mengingesti {title}',
'mic.noAudioCaptured': 'Tidak ada audio tertangkap',
'mic.noSpeechDetected': 'Tidak ada suara terdeteksi',
+ 'mic.lowConfidenceResult': 'Tidak dapat memahami audio dengan jelas — silakan coba lagi',
'mic.failedToStopRecording': 'Gagal menghentikan perekaman: {message}',
'mic.transcriptionFailed': 'Transkripsi gagal: {message}',
'reflections.kind.retrospective': 'Retrospektif',
diff --git a/app/src/lib/i18n/chunks/it-2.ts b/app/src/lib/i18n/chunks/it-2.ts
index 534d70db0..dae8dcd8e 100644
--- a/app/src/lib/i18n/chunks/it-2.ts
+++ b/app/src/lib/i18n/chunks/it-2.ts
@@ -431,6 +431,7 @@ const it2: TranslationMap = {
'devOptions.menuComposioTriggersDesc':
'Configura le impostazioni di triage AI per i trigger di integrazione Composio',
'mic.deviceSelector': 'Dispositivo microfono',
+ 'mic.tapToSendCountdown': 'Tocca per inviare ({seconds}s)',
};
export default it2;
diff --git a/app/src/lib/i18n/chunks/it-3.ts b/app/src/lib/i18n/chunks/it-3.ts
index 25929368b..231ae13c1 100644
--- a/app/src/lib/i18n/chunks/it-3.ts
+++ b/app/src/lib/i18n/chunks/it-3.ts
@@ -269,6 +269,7 @@ const it3: TranslationMap = {
'memory.ingestingTitle': 'Ingestione di {title}',
'mic.noAudioCaptured': 'Nessun audio catturato',
'mic.noSpeechDetected': 'Nessun parlato rilevato',
+ 'mic.lowConfidenceResult': "Impossibile comprendere l'audio chiaramente — riprova",
'mic.failedToStopRecording': 'Impossibile fermare la registrazione: {message}',
'mic.transcriptionFailed': 'Trascrizione fallita: {message}',
'reflections.kind.retrospective': 'Retrospettiva',
diff --git a/app/src/lib/i18n/chunks/ko-2.ts b/app/src/lib/i18n/chunks/ko-2.ts
index 2483daaa6..a56c0e13b 100644
--- a/app/src/lib/i18n/chunks/ko-2.ts
+++ b/app/src/lib/i18n/chunks/ko-2.ts
@@ -382,6 +382,7 @@ const ko2: TranslationMap = {
'localModel.ollamaServer.unreachable': '연결할 수 없음',
'localModel.ollamaServer.validationError': '유효한 http:// 또는 https:// URL이어야 합니다.',
'mic.deviceSelector': '마이크 장치',
+ 'mic.tapToSendCountdown': '탭하여 보내기 ({seconds}초)',
'devOptions.menuAi': 'AI 구성',
'devOptions.menuAiDesc': '클라우드 공급자, 로컬 Ollama 모델 및 워크로드별 라우팅',
'devOptions.menuScreenAware': '화면 인식',
diff --git a/app/src/lib/i18n/chunks/ko-3.ts b/app/src/lib/i18n/chunks/ko-3.ts
index e5bdca6dd..0318d4ce2 100644
--- a/app/src/lib/i18n/chunks/ko-3.ts
+++ b/app/src/lib/i18n/chunks/ko-3.ts
@@ -264,6 +264,7 @@ const ko3: TranslationMap = {
'memory.ingestingTitle': '{title} 수집 중',
'mic.noAudioCaptured': '캡처된 오디오가 없습니다',
'mic.noSpeechDetected': '음성이 감지되지 않았습니다',
+ 'mic.lowConfidenceResult': '오디오를 명확하게 이해할 수 없습니다 — 다시 시도해 주세요',
'mic.failedToStopRecording': '녹음을 중지하지 못했습니다: {message}',
'mic.transcriptionFailed': '전사에 실패했습니다: {message}',
'reflections.kind.retrospective': '회고',
diff --git a/app/src/lib/i18n/chunks/pt-2.ts b/app/src/lib/i18n/chunks/pt-2.ts
index 8ec9d145b..5681471c0 100644
--- a/app/src/lib/i18n/chunks/pt-2.ts
+++ b/app/src/lib/i18n/chunks/pt-2.ts
@@ -435,6 +435,7 @@ const pt2: TranslationMap = {
'devOptions.menuComposioTriggersDesc':
'Definir configurações de triagem de IA para gatilhos de integração Composio',
'mic.deviceSelector': 'Dispositivo de microfone',
+ 'mic.tapToSendCountdown': 'Toque para enviar ({seconds}s)',
};
export default pt2;
diff --git a/app/src/lib/i18n/chunks/pt-3.ts b/app/src/lib/i18n/chunks/pt-3.ts
index 004434225..8625d1c2c 100644
--- a/app/src/lib/i18n/chunks/pt-3.ts
+++ b/app/src/lib/i18n/chunks/pt-3.ts
@@ -268,6 +268,7 @@ const pt3: TranslationMap = {
'memory.ingestingTitle': 'Ingerindo {title}',
'mic.noAudioCaptured': 'Nenhum áudio capturado',
'mic.noSpeechDetected': 'Nenhuma fala detectada',
+ 'mic.lowConfidenceResult': 'Não foi possível entender o áudio com clareza — tente novamente',
'mic.failedToStopRecording': 'Falha ao parar a gravação: {message}',
'mic.transcriptionFailed': 'Falha na transcrição: {message}',
'reflections.kind.retrospective': 'Retrospectiva',
diff --git a/app/src/lib/i18n/chunks/ru-2.ts b/app/src/lib/i18n/chunks/ru-2.ts
index 342e5c9d2..575b2a15d 100644
--- a/app/src/lib/i18n/chunks/ru-2.ts
+++ b/app/src/lib/i18n/chunks/ru-2.ts
@@ -430,6 +430,7 @@ const ru2: TranslationMap = {
'devOptions.menuComposioTriggersDesc':
'Настройка параметров сортировки AI для триггеров интеграции Composio',
'mic.deviceSelector': 'Микрофонное устройство',
+ 'mic.tapToSendCountdown': 'Нажми для отправки ({seconds}с)',
};
export default ru2;
diff --git a/app/src/lib/i18n/chunks/ru-3.ts b/app/src/lib/i18n/chunks/ru-3.ts
index a3b15a76e..5f90b8b0d 100644
--- a/app/src/lib/i18n/chunks/ru-3.ts
+++ b/app/src/lib/i18n/chunks/ru-3.ts
@@ -267,6 +267,7 @@ const ru3: TranslationMap = {
'memory.ingestingTitle': 'Загрузка {title}',
'mic.noAudioCaptured': 'Аудио не захвачено',
'mic.noSpeechDetected': 'Речь не обнаружена',
+ 'mic.lowConfidenceResult': 'Не удалось чётко распознать аудио — попробуйте ещё раз',
'mic.failedToStopRecording': 'Не удалось остановить запись: {message}',
'mic.transcriptionFailed': 'Ошибка транскрипции: {message}',
'reflections.kind.retrospective': 'Ретроспектива',
diff --git a/app/src/lib/i18n/chunks/zh-CN-2.ts b/app/src/lib/i18n/chunks/zh-CN-2.ts
index d54ab2c8e..3b7938aa9 100644
--- a/app/src/lib/i18n/chunks/zh-CN-2.ts
+++ b/app/src/lib/i18n/chunks/zh-CN-2.ts
@@ -399,6 +399,7 @@ const zhCN2: TranslationMap = {
'devOptions.menuComposioTriggers': '集成触发器',
'devOptions.menuComposioTriggersDesc': '为 Composio 集成触发器配置 AI 分级设置',
'mic.deviceSelector': '麦克风装置',
+ 'mic.tapToSendCountdown': '点击发送 ({seconds}秒)',
};
export default zhCN2;
diff --git a/app/src/lib/i18n/chunks/zh-CN-3.ts b/app/src/lib/i18n/chunks/zh-CN-3.ts
index 27dde20db..e98adbefa 100644
--- a/app/src/lib/i18n/chunks/zh-CN-3.ts
+++ b/app/src/lib/i18n/chunks/zh-CN-3.ts
@@ -255,6 +255,7 @@ const zhCN3: TranslationMap = {
'memory.ingestingTitle': '正在摄取 {title}',
'mic.noAudioCaptured': '未捕获到音频',
'mic.noSpeechDetected': '未检测到语音',
+ 'mic.lowConfidenceResult': '无法清楚地理解音频 — 请重试',
'mic.failedToStopRecording': '停止录音失败: {message}',
'mic.transcriptionFailed': '转录失败: {message}',
'reflections.kind.retrospective': '回顾',
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index 1f5bd20da..26144fe1b 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -1650,6 +1650,7 @@ const en: TranslationMap = {
'mic.stopRecording': 'Stop recording and send',
'mic.startRecording': 'Start recording',
'mic.deviceSelector': 'Microphone device',
+ 'mic.tapToSendCountdown': 'Tap to send ({seconds}s)',
// Token
'token.usageLimitReached': 'Usage limit reached',
@@ -2019,6 +2020,7 @@ const en: TranslationMap = {
// Mic: error messages
'mic.noAudioCaptured': 'No audio captured',
'mic.noSpeechDetected': 'No speech detected',
+ 'mic.lowConfidenceResult': 'Could not understand the audio clearly — please try again',
'mic.failedToStopRecording': 'Failed to stop recording: {message}',
'mic.transcriptionFailed': 'Transcription failed: {message}',