From 301ba10ce42aef26a2f921513e115b3cc145b0e4 Mon Sep 17 00:00:00 2001 From: Shaikh Taimoor <60020024+staimoorulhassan@users.noreply.github.com> Date: Sat, 30 May 2026 18:50:52 +0500 Subject: [PATCH] feat(voice): polish mascot TTS/STT session states and lifecycle (#2916) Co-authored-by: Taimoor --- app/src/features/human/MicComposer.test.tsx | 76 ++++++++++++- app/src/features/human/MicComposer.tsx | 102 +++++++++++++++--- app/src/features/human/useHumanMascot.test.ts | 35 ++++++ app/src/features/human/useHumanMascot.ts | 30 +++++- .../features/human/voice/audioPlayer.test.ts | 46 ++++++++ app/src/features/human/voice/audioPlayer.ts | 65 +++++++++-- app/src/lib/i18n/ar.ts | 1 + app/src/lib/i18n/bn.ts | 1 + app/src/lib/i18n/de.ts | 1 + app/src/lib/i18n/en.ts | 1 + app/src/lib/i18n/es.ts | 1 + app/src/lib/i18n/fr.ts | 1 + app/src/lib/i18n/hi.ts | 1 + app/src/lib/i18n/id.ts | 1 + app/src/lib/i18n/it.ts | 1 + app/src/lib/i18n/ko.ts | 1 + app/src/lib/i18n/pl.ts | 1 + app/src/lib/i18n/pt.ts | 1 + app/src/lib/i18n/ru.ts | 1 + app/src/lib/i18n/zh-CN.ts | 1 + 20 files changed, 342 insertions(+), 26 deletions(-) diff --git a/app/src/features/human/MicComposer.test.tsx b/app/src/features/human/MicComposer.test.tsx index b6046c941..9df481549 100644 --- a/app/src/features/human/MicComposer.test.tsx +++ b/app/src/features/human/MicComposer.test.tsx @@ -1,7 +1,12 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { isLowConfidenceTranscript, MAX_RECORDING_MS, MicComposer } from './MicComposer'; +import { + isLowConfidenceTranscript, + MAX_RECORDING_MS, + MicComposer, + STT_MAX_RETRIES, +} from './MicComposer'; // transcribeWithFactory + encodeBlobToWav are the network/heavy boundaries — // mock them here so we can drive the state machine without touching real APIs. @@ -709,6 +714,68 @@ describe('MicComposer', () => { fireEvent.click(screen.getByRole('button', { name: /stop recording and send/i })); await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('Hello, how are you?')); }); + + it('retry counter is monotone across native + WAV paths (no double-count)', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + // 3 native failures + 2 WAV failures + 1 WAV success = 6 calls total. + // + // The double-count bug only manifests on the SECOND WAV retry callback: + // fixed: snapshot nativeRetries=2 → handleRetry(2+2)=4 → "4 of 4" + // buggy: read mutable cumulativeRetries=3 → handleRetry(3+2)=5 → "5 of 4" + // + // By the time WAV attempt 2 (call 6, the success) starts executing, React + // has already flushed the setRetryCount update from WAV attempt 1's retry + // callback (which fired synchronously before the 500ms backoff sleep that + // immediately preceded call 6). We read the rendered text inside + // that mock to assert the actual value the component shows, not a literal. + let retryLabelAtSuccessCall = ''; + transcribeWithFactoryMock + .mockRejectedValueOnce(new Error('native 0')) + .mockRejectedValueOnce(new Error('native 1')) + .mockRejectedValueOnce(new Error('native 2')) + .mockRejectedValueOnce(new Error('wav 0')) + .mockRejectedValueOnce(new Error('wav 1')) + .mockImplementationOnce(async () => { + // WAV attempt 2 — the success call. At this point setRetryCount was + // called synchronously before the 500ms backoff that preceded this call. + // React flushed during that backoff (fake-timer advancement is act-wrapped), + // so the DOM reflects the actual retryCount value: 4 (fixed) or 5 (buggy). + const el = document.querySelector('span.text-xs'); + retryLabelAtSuccessCall = el?.textContent ?? ''; + return 'cross-path 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 })); + + // Drive all 6 calls to completion by advancing through backoffs. + // native: 500ms + 1000ms, WAV: 500ms + 1000ms. + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(1000); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('cross-path ok')); + expect(transcribeWithFactoryMock).toHaveBeenCalledTimes(6); + + // The label seen inside call 6 must read "Retrying... (4 of 4)", not + // "Retrying... (5 of 4)". If the snapshot fix is absent, cumulativeRetries + // would be mutated to 3 after WAV retry 0, then 3+2=5 on WAV retry 1. + expect(retryLabelAtSuccessCall).toMatch(/retrying.*4 of 4/i); + } finally { + vi.useRealTimers(); + } + }); }); // ── isLowConfidenceTranscript unit tests ────────────────────────────────── @@ -752,3 +819,10 @@ describe('isLowConfidenceTranscript', () => { expect(isLowConfidenceTranscript('বাংলা')).toBe(false); }); }); + +describe('STT_MAX_RETRIES export', () => { + it('is a positive integer', () => { + expect(Number.isInteger(STT_MAX_RETRIES)).toBe(true); + expect(STT_MAX_RETRIES).toBeGreaterThan(0); + }); +}); diff --git a/app/src/features/human/MicComposer.tsx b/app/src/features/human/MicComposer.tsx index c301f85b2..45a69a517 100644 --- a/app/src/features/human/MicComposer.tsx +++ b/app/src/features/human/MicComposer.tsx @@ -110,6 +110,12 @@ export interface MicComposerProps { type RecordingState = 'idle' | 'recording' | 'transcribing'; +/** + * Exported so tests can assert on the retry ceiling without duplicating the + * constant. + */ +export { STT_MAX_RETRIES }; + /** * Tap-to-toggle mic composer for the mascot page. Captures audio via the * browser's `MediaRecorder`, hands the resulting Blob to the factory- @@ -133,6 +139,13 @@ export function MicComposer({ }: MicComposerProps) { const { t } = useT(); const [state, setState] = useState('idle'); + // Tracks how many STT retry attempts have been made for the current + // recording, so the label can change from "Transcribing…" to + // "Retrying… (1 of 2)" while the user waits. + const [retryCount, setRetryCount] = useState(0); + // Monotonic session counter — lets log lines across startRecording / + // stopRecording / finalizeRecording be correlated without a UUID. + const sessionIdRef = useRef(0); const [devices, setDevices] = useState([]); const [selectedDeviceId, setSelectedDeviceId] = useState(''); const [deviceMenuOpen, setDeviceMenuOpen] = useState(false); @@ -413,9 +426,16 @@ export function MicComposer({ recorderRef.current = recorder; recorder.start(); setState('recording'); + setRetryCount(0); startInFlightRef.current = false; startRecordingTimer(); - composerLog('recording started mime=%s', recorder.mimeType || '(default)'); + const sessionId = ++sessionIdRef.current; + composerLog( + '[session:%d] recording started mime=%s device=%s', + sessionId, + recorder.mimeType || '(default)', + selectedDeviceId || 'default' + ); } function stopRecording() { @@ -423,6 +443,7 @@ export function MicComposer({ if (!recorder || recorder.state === 'inactive') return; clearRecordingTimer(); setState('transcribing'); + composerLog('[session:%d] recording stopped — transcribing', sessionIdRef.current); try { recorder.stop(); } catch (err) { @@ -495,8 +516,15 @@ export function MicComposer({ * 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. + * + * `onRetry` is called before each retry attempt with the 1-based attempt + * number so the UI can reflect progress (e.g. "Retrying… 1 of 2"). */ - async function transcribeWithRetry(blob: Blob, label: string): Promise { + async function transcribeWithRetry( + blob: Blob, + label: string, + onRetry?: (attempt: number) => void + ): Promise { const opts = language ? { language } : undefined; let lastErr: unknown; const throwIfDisposed = () => { @@ -506,7 +534,8 @@ export function MicComposer({ throwIfDisposed(); try { composerLog( - 'transcribe attempt=%s/%d bytes=%d mime=%s lang=%s', + '[session:%d] transcribe attempt=%s/%d bytes=%d mime=%s lang=%s', + sessionIdRef.current, label, attempt, blob.size, @@ -515,28 +544,48 @@ export function MicComposer({ ); const transcript = await transcribeWithFactory(blob, opts); throwIfDisposed(); + composerLog( + '[session:%d] transcribe ok path=%s attempt=%d', + sessionIdRef.current, + label, + attempt + ); 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); + composerLog( + '[session:%d] transcribe permanent failure path=%s attempt=%d: %s', + sessionIdRef.current, + 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', + '[session:%d] transcribe transient failure path=%s attempt=%d retrying in %dms: %s', + sessionIdRef.current, label, attempt, delay, msg ); + onRetry?.(attempt + 1); await new Promise(r => setTimeout(r, delay)); throwIfDisposed(); } else { - composerLog('transcribe exhausted retries attempt=%s/%d: %s', label, attempt, msg); + composerLog( + '[session:%d] transcribe exhausted retries path=%s attempt=%d: %s', + sessionIdRef.current, + label, + attempt, + msg + ); } } } @@ -553,26 +602,49 @@ export function MicComposer({ */ async function transcribeWithFallback(blob: Blob): Promise { const startedAt = Date.now(); + // Track the cumulative retry number across both codec paths so the label + // always shows "Retrying… N of M" relative to the overall attempt count. + let cumulativeRetries = 0; + const handleRetry = (attempt: number) => { + cumulativeRetries = attempt; + setRetryCount(cumulativeRetries); + composerLog('[session:%d] stt retry overall=%d', sessionIdRef.current, cumulativeRetries); + }; try { - const text = await transcribeWithRetry(blob, 'native'); - composerLog('transcribe ok path=native ms=%d', Math.round(Date.now() - startedAt)); + const text = await transcribeWithRetry(blob, 'native', handleRetry); + composerLog( + '[session:%d] transcribe ok path=native ms=%d', + sessionIdRef.current, + Math.round(Date.now() - startedAt) + ); return text; } catch (err) { if (isTranscriptionCancelledError(err)) throw err; const msg = err instanceof Error ? err.message : String(err); // 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); + composerLog( + '[session:%d] transcribe native path exhausted — falling back to wav: %s', + sessionIdRef.current, + msg + ); const reEncodeStart = Date.now(); const wav = await encodeBlobToWav(blob); composerLog( - 'wav fallback bytes=%d encode_ms=%d', + '[session:%d] wav fallback bytes=%d encode_ms=%d', + sessionIdRef.current, wav.size, Math.round(Date.now() - reEncodeStart) ); - const text = await transcribeWithRetry(wav, 'wav'); + // Snapshot before the WAV path so the WAV callback doesn't compound + // against a value that handleRetry already mutated on native retries. + const nativeRetries = cumulativeRetries; + const text = await transcribeWithRetry(wav, 'wav', attempt => { + handleRetry(nativeRetries + attempt); + }); composerLog( - 'transcribe ok path=wav-fallback total_ms=%d', + '[session:%d] transcribe ok path=wav-fallback total_ms=%d', + sessionIdRef.current, Math.round(Date.now() - startedAt) ); return text; @@ -584,7 +656,11 @@ export function MicComposer({ const buttonDisabled = disabled || isBusy; const label = isBusy - ? t('mic.transcribing') + ? retryCount > 0 + ? t('mic.retryingTranscription') + .replace('{attempt}', String(retryCount)) + .replace('{max}', String(STT_MAX_RETRIES * 2)) + : t('mic.transcribing') : isRecording ? remainingSecs != null && remainingSecs <= 10 ? t('mic.tapToSendCountdown').replace('{seconds}', String(remainingSecs)) diff --git a/app/src/features/human/useHumanMascot.test.ts b/app/src/features/human/useHumanMascot.test.ts index 7528de15a..c07270e8d 100644 --- a/app/src/features/human/useHumanMascot.test.ts +++ b/app/src/features/human/useHumanMascot.test.ts @@ -7,6 +7,7 @@ import { ACK_FACE_HOLD_MS, pickConversationAckFace, pickViseme, + TTS_MAX_PLAYBACK_MS, useHumanMascot, } from './useHumanMascot'; import { type PlaybackHandle, playBase64Audio } from './voice/audioPlayer'; @@ -550,6 +551,40 @@ describe('useHumanMascot state machine', () => { expect(result.current.face).toBe('listening'); expect(result.current.viseme).toEqual(VISEMES.REST); }); + + it('listening override transitions to listening face and cancels ack timer', () => { + const { result, rerender } = renderHook( + ({ listening }: { listening: boolean }) => useHumanMascot({ speakReplies: false, listening }), + { initialProps: { listening: false } } + ); + // Trigger a happy ack that starts the hold timer. + act(() => { + capturedListeners?.onDone?.( + fakeEvent({ + full_response: 'Here you go.', + reaction_emoji: null, + rounds_used: 1, + total_input_tokens: 1, + total_output_tokens: 1, + }) + ); + }); + expect(result.current.face).toBe('happy'); + // Mic activates before the hold timer expires. + rerender({ listening: true }); + expect(result.current.face).toBe('listening'); + // The hold timer should have been cancelled — advancing past its deadline + // must not flip the face back to idle. + act(() => { + vi.advanceTimersByTime(ACK_FACE_HOLD_MS + 1); + }); + expect(result.current.face).toBe('listening'); + }); + + it('TTS_MAX_PLAYBACK_MS is a positive number', () => { + expect(TTS_MAX_PLAYBACK_MS).toBeGreaterThan(0); + expect(typeof TTS_MAX_PLAYBACK_MS).toBe('number'); + }); }); describe('useHumanMascot TTS playback', () => { diff --git a/app/src/features/human/useHumanMascot.ts b/app/src/features/human/useHumanMascot.ts index 4bf6a4226..851469ea6 100644 --- a/app/src/features/human/useHumanMascot.ts +++ b/app/src/features/human/useHumanMascot.ts @@ -6,7 +6,12 @@ import { type ChatSubagentDoneEvent, subscribeChatEvents } from '../../services/ import { selectEffectiveMascotVoiceId } from '../../store/mascotSlice'; import type { MascotFace } from './Mascot'; import { lerpViseme, VISEMES, type VisemeShape } from './Mascot/visemes'; -import { type PlaybackHandle, playBase64Audio, swallowAudioStop } from './voice/audioPlayer'; +import { + type PlaybackHandle, + type PlaybackOptions, + playBase64Audio, + swallowAudioStop, +} from './voice/audioPlayer'; import { proceduralVisemes, synthesizeSpeech, @@ -20,6 +25,14 @@ const mascotLog = debug('human:mascot'); /** ms the mouth holds the target viseme before decaying back to rest. */ const VISEME_DECAY_MS = 180; +/** + * Safety ceiling for a single TTS utterance. Runaway audio (network stall, + * decoder that never emits `ended`) is auto-stopped at this limit so the + * mascot never gets stuck in a permanent `speaking` pose. + * 5 minutes comfortably covers any real reply; exported for tests. + */ +export const TTS_MAX_PLAYBACK_MS = 5 * 60 * 1_000; + /** * Heuristic — does this timeline contain at least one frame whose code maps * to a non-REST mouth shape? Used to detect the "backend shipped frames in @@ -336,9 +349,13 @@ export function useHumanMascot(options: UseHumanMascotOptions = {}): UseHumanMas useEffect(() => { if (!listening) return; clearAckTimer(); + const ttsWasInFlight = playbackRef.current != null; mascotLog( - 'voice-session listening interrupt — cancelling TTS (had playback=%s)', - !!playbackRef.current + 'voice-session listening-active tts-in-flight=%s — %s', + ttsWasInFlight, + ttsWasInFlight + ? 'user started recording while TTS was playing (interrupted)' + : 'mic activated, no TTS to cancel' ); // Treat mic-hot as an explicit interruption: stale synthesis/playback // callbacks must not switch the mascot back to speaking after we listen. @@ -413,7 +430,12 @@ export function useHumanMascot(options: UseHumanMascotOptions = {}): UseHumanMas // the user-gesture chain that authorized speech stays intact. If we // awaited anything else between the user click and play(), CEF would // reject playback under its autoplay policy. - const handle = await playBase64Audio(tts.audio_base64, tts.audio_mime ?? 'audio/mpeg'); + const ttsOptions: PlaybackOptions = { maxDurationMs: TTS_MAX_PLAYBACK_MS }; + const handle = await playBase64Audio( + tts.audio_base64, + tts.audio_mime ?? 'audio/mpeg', + ttsOptions + ); if (!isStillCurrent()) { handle.stop(); handle.ended.catch(swallowAudioStop); diff --git a/app/src/features/human/voice/audioPlayer.test.ts b/app/src/features/human/voice/audioPlayer.test.ts index 576a8fa90..dd49c1908 100644 --- a/app/src/features/human/voice/audioPlayer.test.ts +++ b/app/src/features/human/voice/audioPlayer.test.ts @@ -150,6 +150,52 @@ describe('playBase64Audio', () => { // Idempotent — second stop() is a no-op. handle.stop(); }); + + it('auto-stops after maxDurationMs and rejects ended with AudioStoppedError', async () => { + vi.useFakeTimers(); + try { + installAudio(url => new FakeAudio(url)); + const handle = await playBase64Audio('AAA=', 'audio/mpeg', { maxDurationMs: 1000 }); + // Before the deadline, ended is still pending. + let settled: 'resolved' | 'rejected' | null = null; + handle.ended + .then(() => { + settled = 'resolved'; + }) + .catch(() => { + settled = 'rejected'; + }); + await Promise.resolve(); + expect(settled).toBeNull(); + // Advance past the deadline. + await vi.advanceTimersByTimeAsync(1010); + expect(settled).toBe('rejected'); + const err = await handle.ended.catch(e => e); + expect(err).toBeInstanceOf(AudioStoppedError); + expect(handle.currentMs()).toBe(-1); + } finally { + vi.useRealTimers(); + } + }); + + it('maxDurationMs timer is cleared when audio ends naturally before the deadline', async () => { + vi.useFakeTimers(); + const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout'); + try { + const created = installAudio(url => new FakeAudio(url)); + const handle = await playBase64Audio('AAA=', 'audio/mpeg', { maxDurationMs: 5000 }); + // Simulate natural end before the 5s deadline. + created[0].emit('ended'); + await Promise.resolve(); + // clearTimeout must have been called to cancel the timer. + expect(clearTimeoutSpy).toHaveBeenCalled(); + // ended resolves without error. + await expect(handle.ended).resolves.toBeUndefined(); + } finally { + clearTimeoutSpy.mockRestore(); + vi.useRealTimers(); + } + }); }); describe('isAudioStopped / swallowAudioStop', () => { diff --git a/app/src/features/human/voice/audioPlayer.ts b/app/src/features/human/voice/audioPlayer.ts index 2f73d22fb..aed7bbd10 100644 --- a/app/src/features/human/voice/audioPlayer.ts +++ b/app/src/features/human/voice/audioPlayer.ts @@ -2,6 +2,9 @@ * Lightweight base64 → playable HTMLAudio wrapper. We don't need WebAudio * graph here; the viseme scheduler reads `currentTime` directly. */ +import debug from 'debug'; + +const audioLog = debug('human:audio-player'); /** * Sentinel thrown by the `ended` promise when `stop()` is called. Callers that @@ -49,10 +52,25 @@ export interface PlaybackHandle { ended: Promise; } +/** + * Options for {@link playBase64Audio}. + */ +export interface PlaybackOptions { + /** + * Auto-stop playback after this many milliseconds as a safety bound against + * runaway TTS. If the audio finishes naturally before the deadline the timer + * is cleared — the option has no effect on normal, reasonably-sized replies. + * When omitted, no duration limit is applied. + */ + maxDurationMs?: number; +} + export async function playBase64Audio( base64: string, - mime: string = 'audio/mpeg' + mime: string = 'audio/mpeg', + options: PlaybackOptions = {} ): Promise { + const { maxDurationMs } = options; const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0)); const blob = new Blob([bytes], { type: mime }); const url = URL.createObjectURL(blob); @@ -68,16 +86,26 @@ export async function playBase64Audio( rejectEnded = rej; }); + // Max-duration safety timer — cleared on any terminal event so it never + // fires after playback has already ended. + let durationTimer: number | null = null; + const cleanup = () => { + if (durationTimer != null) { + window.clearTimeout(durationTimer); + durationTimer = null; + } URL.revokeObjectURL(url); }; audio.addEventListener('ended', () => { endedNaturally = true; + audioLog('playback ended naturally mime=%s', mime); cleanup(); resolveEnded(); }); audio.addEventListener('error', () => { + audioLog('playback decoder error mime=%s', mime); cleanup(); rejectEnded(new Error('audio playback error')); }); @@ -95,6 +123,7 @@ export async function playBase64Audio( audio.addEventListener( 'loadedmetadata', () => { + audioLog('playback metadata ready duration=%ss mime=%s', audio.duration.toFixed(2), mime); resolveMetadata(); }, { once: true } @@ -104,6 +133,14 @@ export async function playBase64Audio( // the text-length estimate path in that case. window.setTimeout(() => resolveMetadata(), 500); + const stop = () => { + if (stopped) return; + stopped = true; + audio.pause(); + cleanup(); + rejectEnded(new AudioStoppedError()); + }; + try { await audio.play(); } catch (err) { @@ -112,17 +149,29 @@ export async function playBase64Audio( throw err; } + audioLog('playback started mime=%s maxDurationMs=%s', mime, maxDurationMs ?? 'none'); + + // Arm the max-duration safety timer after `play()` succeeds. Fires only if + // the audio hasn't ended naturally by then (e.g. very long TTS response or a + // decoder that never emits `ended`). + if (maxDurationMs != null) { + durationTimer = window.setTimeout(() => { + durationTimer = null; + if (!stopped && !endedNaturally) { + audioLog( + 'playback auto-stopped after maxDurationMs=%d — preventing runaway TTS', + maxDurationMs + ); + stop(); + } + }, maxDurationMs); + } + return { currentMs: () => (endedNaturally || stopped ? -1 : audio.currentTime * 1000), durationMs: () => (Number.isFinite(audio.duration) ? audio.duration * 1000 : 0), metadataReady, - stop: () => { - if (stopped) return; - stopped = true; - audio.pause(); - cleanup(); - rejectEnded(new AudioStoppedError()); - }, + stop, ended, }; } diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 365517f4a..136e3f3a9 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -1631,6 +1631,7 @@ const messages: TranslationMap = { 'mic.permissionDenied': 'تم رفض إذن الميكروفون', 'mic.failedToStartRecorder': 'فشل تشغيل المسجّل', 'mic.transcribing': 'جارٍ النسخ...', + 'mic.retryingTranscription': 'Retrying... ({attempt} of {max})', 'mic.tapToSend': 'انقر للإرسال', 'mic.waitingForAgent': 'في انتظار الوكيل...', 'mic.tapAndSpeak': 'انقر وتحدث', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index bd45e4b69..fc097a129 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -1664,6 +1664,7 @@ const messages: TranslationMap = { 'mic.permissionDenied': 'মাইক্রোফোন অনুমতি অস্বীকৃত', 'mic.failedToStartRecorder': 'রেকর্ডার শুরু করতে ব্যর্থ', 'mic.transcribing': 'ট্রান্সক্রাইব হচ্ছে...', + 'mic.retryingTranscription': 'Retrying... ({attempt} of {max})', 'mic.tapToSend': 'পাঠাতে ট্যাপ করুন', 'mic.waitingForAgent': 'এজেন্টের জন্য অপেক্ষা করছে...', 'mic.tapAndSpeak': 'ট্যাপ করুন ও কথা বলুন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 205508098..84a22e739 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -1708,6 +1708,7 @@ const messages: TranslationMap = { 'mic.permissionDenied': 'Mikrofonberechtigung verweigert', 'mic.failedToStartRecorder': 'Der Rekorder konnte nicht gestartet werden', 'mic.transcribing': 'Transkribieren...', + 'mic.retryingTranscription': 'Retrying... ({attempt} of {max})', 'mic.tapToSend': 'Zum Senden tippen', 'mic.waitingForAgent': 'Warten auf Agent...', 'mic.tapAndSpeak': 'Tippen und sprechen', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index d6dd86c03..79e3515e5 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1790,6 +1790,7 @@ const en: TranslationMap = { 'mic.permissionDenied': 'Microphone permission denied', 'mic.failedToStartRecorder': 'Failed to start recorder', 'mic.transcribing': 'Transcribing...', + 'mic.retryingTranscription': 'Retrying... ({attempt} of {max})', 'mic.tapToSend': 'Tap to send', 'mic.waitingForAgent': 'Waiting for agent...', 'mic.tapAndSpeak': 'Tap and speak', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index d6cd23f66..d49951397 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -1700,6 +1700,7 @@ const messages: TranslationMap = { 'mic.permissionDenied': 'Permiso de micrófono denegado', 'mic.failedToStartRecorder': 'No se pudo iniciar la grabadora', 'mic.transcribing': 'Transcribiendo...', + 'mic.retryingTranscription': 'Retrying... ({attempt} of {max})', 'mic.tapToSend': 'Toca para enviar', 'mic.waitingForAgent': 'Esperando al agente...', 'mic.tapAndSpeak': 'Toca y habla', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index e8c53c050..6918fac79 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -1706,6 +1706,7 @@ const messages: TranslationMap = { 'mic.permissionDenied': 'Permission microphone refusée', 'mic.failedToStartRecorder': "Échec du démarrage de l'enregistreur", 'mic.transcribing': 'Transcription…', + 'mic.retryingTranscription': 'Retrying... ({attempt} of {max})', 'mic.tapToSend': 'Appuie pour envoyer', 'mic.waitingForAgent': "En attente de l'agent…", 'mic.tapAndSpeak': 'Appuie et parle', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 8118b4c7e..4f60b3b8e 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -1664,6 +1664,7 @@ const messages: TranslationMap = { 'mic.permissionDenied': 'माइक्रोफोन की अनुमति नहीं मिली', 'mic.failedToStartRecorder': 'रिकॉर्डर शुरू नहीं हो पाया', 'mic.transcribing': 'ट्रांसक्राइब हो रहा है...', + 'mic.retryingTranscription': 'Retrying... ({attempt} of {max})', 'mic.tapToSend': 'भेजने के लिए टैप करें', 'mic.waitingForAgent': 'एजेंट का इंतज़ार है...', 'mic.tapAndSpeak': 'टैप करें और बोलें', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index f8d7f68e1..0d38ed2a2 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -1667,6 +1667,7 @@ const messages: TranslationMap = { 'mic.permissionDenied': 'Izin mikrofon ditolak', 'mic.failedToStartRecorder': 'Gagal memulai perekam', 'mic.transcribing': 'Mentranskripsi...', + 'mic.retryingTranscription': 'Retrying... ({attempt} of {max})', 'mic.tapToSend': 'Ketuk untuk mengirim', 'mic.waitingForAgent': 'Menunggu agen...', 'mic.tapAndSpeak': 'Ketuk dan bicara', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index d81b9a8c0..6d18c675e 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -1692,6 +1692,7 @@ const messages: TranslationMap = { 'mic.permissionDenied': 'Permesso microfono negato', 'mic.failedToStartRecorder': 'Avvio del registratore fallito', 'mic.transcribing': 'Trascrizione...', + 'mic.retryingTranscription': 'Retrying... ({attempt} of {max})', 'mic.tapToSend': 'Tocca per inviare', 'mic.waitingForAgent': "In attesa dell'agente...", 'mic.tapAndSpeak': 'Tocca e parla', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 6a518c1e4..fad862820 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -1647,6 +1647,7 @@ const messages: TranslationMap = { 'mic.permissionDenied': '마이크 권한이 거부되었습니다', 'mic.failedToStartRecorder': '녹음기를 시작하지 못했습니다', 'mic.transcribing': '전사 중...', + 'mic.retryingTranscription': 'Retrying... ({attempt} of {max})', 'mic.tapToSend': '탭하여 보내기', 'mic.waitingForAgent': '에이전트를 기다리는 중...', 'mic.tapAndSpeak': '탭하고 말하기', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 49391b129..1e2f26e62 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -1683,6 +1683,7 @@ const messages: TranslationMap = { 'mic.permissionDenied': 'Odmowa dostępu do mikrofonu', 'mic.failedToStartRecorder': 'Nie udało się uruchomić rejestratora', 'mic.transcribing': 'Transkrypcja...', + 'mic.retryingTranscription': 'Retrying... ({attempt} of {max})', 'mic.tapToSend': 'Dotknij, aby wysłać', 'mic.waitingForAgent': 'Czekam na agenta...', 'mic.tapAndSpeak': 'Dotknij i mów', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 2aad0a3cd..ade79c6cf 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -1700,6 +1700,7 @@ const messages: TranslationMap = { 'mic.permissionDenied': 'Permissão de microfone negada', 'mic.failedToStartRecorder': 'Falha ao iniciar o gravador', 'mic.transcribing': 'Transcrevendo...', + 'mic.retryingTranscription': 'Retrying... ({attempt} of {max})', 'mic.tapToSend': 'Toque para enviar', 'mic.waitingForAgent': 'Aguardando agente...', 'mic.tapAndSpeak': 'Toque e fale', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index f12745307..74b6113fb 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -1677,6 +1677,7 @@ const messages: TranslationMap = { 'mic.permissionDenied': 'Доступ к микрофону запрещён', 'mic.failedToStartRecorder': 'Не удалось запустить запись', 'mic.transcribing': 'Транскрипция...', + 'mic.retryingTranscription': 'Retrying... ({attempt} of {max})', 'mic.tapToSend': 'Нажми для отправки', 'mic.waitingForAgent': 'Ожидание агента...', 'mic.tapAndSpeak': 'Нажми и говори', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 5586cabd6..359a0312e 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -1576,6 +1576,7 @@ const messages: TranslationMap = { 'mic.permissionDenied': '麦克风权限被拒绝', 'mic.failedToStartRecorder': '启动录音失败', 'mic.transcribing': '转录中...', + 'mic.retryingTranscription': 'Retrying... ({attempt} of {max})', 'mic.tapToSend': '点击发送', 'mic.waitingForAgent': '等待助手...', 'mic.tapAndSpeak': '点击说话',