From 8038fed16c0cb33e4e905897c324feafe00f55de Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:11:20 +0000 Subject: [PATCH] feat(dictation): stop desktop dictation after a minute of silence (#39131) * feat(dictation): stop desktop dictation after a minute of silence A toggle dictation left running keeps the mic open indefinitely. Stop it a minute after the last speech, with a toast, like the phone's tap mode. A held push-to-talk key is never stopped; its release ends the session. Mobile's timer moves into src/shared so both apps use the same rule, and the shared meter now records when it last heard speech. * fix(dictation): gate the silence stop on hold mode, as the phone does * refactor(dictation): let the desktop silence hook own the hold-mode rule, as mobile's does * refactor(dictation): move the capture meter into its own hook * refactor(dictation): initialise the capture meter ref without a render write --- .../use-mobile-dictation-silence-stop.ts | 18 +------ .../dictation/DictationController.tsx | 11 ++++- .../dictation/use-dictation-silence-stop.ts | 34 +++++++++++++ .../src/hooks/use-audio-capture-meter.ts | 49 +++++++++++++++++++ src/renderer/src/hooks/use-audio-capture.ts | 45 ++--------------- src/renderer/src/i18n/locales/de.json | 3 +- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/fr.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/pt.json | 3 +- src/renderer/src/i18n/locales/ru.json | 3 +- src/renderer/src/i18n/locales/tr.json | 3 +- src/renderer/src/i18n/locales/zh-Hant.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- src/shared/dictation-audio-meter.ts | 11 +++-- src/shared/dictation-silence-stop.ts | 24 +++++++++ 18 files changed, 154 insertions(+), 71 deletions(-) create mode 100644 src/renderer/src/components/dictation/use-dictation-silence-stop.ts create mode 100644 src/renderer/src/hooks/use-audio-capture-meter.ts create mode 100644 src/shared/dictation-silence-stop.ts diff --git a/mobile/src/session/use-mobile-dictation-silence-stop.ts b/mobile/src/session/use-mobile-dictation-silence-stop.ts index 7dcf2afa96a..11cc537bccc 100644 --- a/mobile/src/session/use-mobile-dictation-silence-stop.ts +++ b/mobile/src/session/use-mobile-dictation-silence-stop.ts @@ -1,9 +1,8 @@ import { useEffect, useEffectEvent } from 'react' +import { watchDictationSilence } from '../../../src/shared/dictation-silence-stop' import type { UseMobileDictationResult } from '../hooks/mobile-dictation-session-state' import { nativeChatDictationCopy } from './mobile-session-native-chat-dictation-copy' -const DICTATION_SILENCE_STOP_MS = 60_000 - // A forgotten tap-mode mic keeps the screen awake; a finger holding the button is never forgotten. export function useMobileDictationSilenceStop( dictation: Pick, @@ -22,19 +21,6 @@ export function useMobileDictationSilenceStop( if (!watching) { return } - // Wall clock, so a stalled audio stream still counts as silence. - const recordingAt = Date.now() - let timer: ReturnType - const check = () => { - const lastSpeechAt = Math.max(recordingAt, meter.lastSpeechAt()) - const remainingMs = lastSpeechAt + DICTATION_SILENCE_STOP_MS - Date.now() - if (remainingMs > 0) { - timer = setTimeout(check, remainingMs) - } else { - stopForSilence() - } - } - check() - return () => clearTimeout(timer) + return watchDictationSilence(meter.lastSpeechAt, Date.now, () => stopForSilence()) }, [meter, watching]) } diff --git a/src/renderer/src/components/dictation/DictationController.tsx b/src/renderer/src/components/dictation/DictationController.tsx index 88a0b8a455f..3a93df2c571 100644 --- a/src/renderer/src/components/dictation/DictationController.tsx +++ b/src/renderer/src/components/dictation/DictationController.tsx @@ -19,6 +19,7 @@ import { useHoldDictationGesture } from './use-hold-dictation-gesture' import { DICTATION_CONTROL_EVENT, type DictationControlAction } from './dictation-control-events' import { publishDictationMeter } from './dictation-meter-store' import { stopDictationAtAudioLimit } from './dictation-audio-limit-stop' +import { useDictationSilenceStop } from './use-dictation-silence-stop' export function DictationController() { const dictationState = useAppStore((s) => s.dictationState) @@ -33,7 +34,8 @@ export function DictationController() { finish: finishCapture, flushBufferedAudio, discardBufferedAudio, - getCapturedChunkCount + getCapturedChunkCount, + getLastSpeechAt } = useAudioCapture(publishDictationMeter) const dictationStateRef = useRef(dictationState) @@ -319,6 +321,13 @@ export function DictationController() { return () => document.removeEventListener(DICTATION_CONTROL_EVENT, handleControl) }, [settings?.voice?.enabled, startDictation, stopDictation]) + useDictationSilenceStop( + dictationState === 'listening', + settings?.voice?.dictationMode, + getLastSpeechAt, + () => void stopDictation() + ) + useHoldDictationGesture({ dictationStateRef, keybindings, diff --git a/src/renderer/src/components/dictation/use-dictation-silence-stop.ts b/src/renderer/src/components/dictation/use-dictation-silence-stop.ts new file mode 100644 index 00000000000..06389b2fc8f --- /dev/null +++ b/src/renderer/src/components/dictation/use-dictation-silence-stop.ts @@ -0,0 +1,34 @@ +import { useEffect, useEffectEvent } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { watchDictationSilence } from '../../../../shared/dictation-silence-stop' + +// A forgotten toggle mic stays open; a held push-to-talk key or button ends the session on release. +export function useDictationSilenceStop( + listening: boolean, + dictationMode: string | undefined, + getLastSpeechAt: () => number, + stopDictation: () => void +): void { + const watching = listening && dictationMode !== 'hold' + const stopForSilence = useEffectEvent(() => { + toast.message( + translate( + 'auto.components.dictation.DictationController.stoppedAfterSilence', + 'Stopped after a minute of silence' + ) + ) + stopDictation() + }) + + useEffect(() => { + if (!watching) { + return + } + return watchDictationSilence( + getLastSpeechAt, + () => performance.now(), + () => stopForSilence() + ) + }, [watching, getLastSpeechAt]) +} diff --git a/src/renderer/src/hooks/use-audio-capture-meter.ts b/src/renderer/src/hooks/use-audio-capture-meter.ts new file mode 100644 index 00000000000..1f3a96dea07 --- /dev/null +++ b/src/renderer/src/hooks/use-audio-capture-meter.ts @@ -0,0 +1,49 @@ +import { useCallback, useRef } from 'react' +import { + DEFAULT_DICTATION_METER, + analyzeDictationAudioChunk, + createDictationMeterAnalyzerState, + dictationMeterStatesEqual, + toPublicDictationMeterState, + type DictationMeterAnalyzerState, + type DictationMeterState +} from '../../../shared/dictation-audio-meter' + +const METER_PUBLISH_INTERVAL_MS = 1000 / 15 + +export type DictationMeterPublisher = (meter: DictationMeterState) => void + +export function useAudioCaptureMeter(publishMeter?: DictationMeterPublisher) { + const analyzerRef = useRef(createDictationMeterAnalyzerState()) + const publishedMeterRef = useRef(DEFAULT_DICTATION_METER) + const lastPublishedAtRef = useRef(Number.NEGATIVE_INFINITY) + + const resetMeter = useCallback(() => { + analyzerRef.current = createDictationMeterAnalyzerState() + publishedMeterRef.current = DEFAULT_DICTATION_METER + lastPublishedAtRef.current = Number.NEGATIVE_INFINITY + publishMeter?.(DEFAULT_DICTATION_METER) + }, [publishMeter]) + + const measureChunk = useCallback( + (samples: Float32Array) => { + const now = performance.now() + analyzerRef.current = analyzeDictationAudioChunk(samples, now, analyzerRef.current) + if (document.hidden || now - lastPublishedAtRef.current < METER_PUBLISH_INTERVAL_MS) { + return + } + lastPublishedAtRef.current = now + const meter = toPublicDictationMeterState(analyzerRef.current) + if (!dictationMeterStatesEqual(publishedMeterRef.current, meter)) { + publishedMeterRef.current = meter + publishMeter?.(meter) + } + }, + [publishMeter] + ) + + // On the performance.now() clock measureChunk feeds the analyzer with. + const getLastSpeechAt = useCallback(() => analyzerRef.current.lastSpeechAt, []) + + return { resetMeter, measureChunk, getLastSpeechAt } +} diff --git a/src/renderer/src/hooks/use-audio-capture.ts b/src/renderer/src/hooks/use-audio-capture.ts index a7ed8cbde35..dce102cf32a 100644 --- a/src/renderer/src/hooks/use-audio-capture.ts +++ b/src/renderer/src/hooks/use-audio-capture.ts @@ -1,13 +1,6 @@ import { useRef, useCallback } from 'react' import { openMicrophoneCaptureStream } from '@/components/dictation/microphone-devices' -import { - DEFAULT_DICTATION_METER, - analyzeDictationAudioChunk, - createDictationMeterAnalyzerState, - dictationMeterStatesEqual, - toPublicDictationMeterState, - type DictationMeterState -} from '../../../shared/dictation-audio-meter' +import { useAudioCaptureMeter, type DictationMeterPublisher } from './use-audio-capture-meter' import { useAudioCaptureStopTail, type StopCaptureOptions } from './use-audio-capture-stop-tail' type BufferedAudioChunk = { @@ -35,9 +28,6 @@ export type StartAudioCaptureResult = { const MAX_BUFFERED_AUDIO_SECONDS = 30 const MAX_BUFFERED_AUDIO_BYTES = 8 * 1024 * 1024 -const METER_PUBLISH_INTERVAL_MS = 1000 / 15 - -type DictationMeterPublisher = (meter: DictationMeterState) => void export function useAudioCapture(publishMeter?: DictationMeterPublisher) { const streamRef = useRef(null) @@ -54,10 +44,7 @@ export function useAudioCapture(publishMeter?: DictationMeterPublisher) { const capturedChunkCountRef = useRef(0) const sessionIdRef = useRef('desktop') const trackLostCleanupRef = useRef<(() => void) | null>(null) - const meterAnalyzerRef = useRef>(undefined!) - meterAnalyzerRef.current ??= createDictationMeterAnalyzerState() - const publishedMeterRef = useRef(DEFAULT_DICTATION_METER) - const lastMeterPublishedAtRef = useRef(Number.NEGATIVE_INFINITY) + const { resetMeter, measureChunk, getLastSpeechAt } = useAudioCaptureMeter(publishMeter) const cleanupCaptureResources = useCallback(() => { trackLostCleanupRef.current?.() @@ -84,13 +71,6 @@ export function useAudioCapture(publishMeter?: DictationMeterPublisher) { bufferedAudioSecondsRef.current = 0 }, []) - const resetMeter = useCallback(() => { - meterAnalyzerRef.current = createDictationMeterAnalyzerState() - publishedMeterRef.current = DEFAULT_DICTATION_METER - lastMeterPublishedAtRef.current = Number.NEGATIVE_INFINITY - publishMeter?.(DEFAULT_DICTATION_METER) - }, [publishMeter]) - const removeOldestBufferedAudioChunk = useCallback(() => { const chunk = bufferedAudioRef.current.shift() if (!chunk) { @@ -190,23 +170,7 @@ export function useAudioCapture(publishMeter?: DictationMeterPublisher) { return } const samples = new Float32Array(e.inputBuffer.getChannelData(0)) - const now = performance.now() - meterAnalyzerRef.current = analyzeDictationAudioChunk( - samples, - now, - meterAnalyzerRef.current - ) - if ( - !document.hidden && - now - lastMeterPublishedAtRef.current >= METER_PUBLISH_INTERVAL_MS - ) { - lastMeterPublishedAtRef.current = now - const meter = toPublicDictationMeterState(meterAnalyzerRef.current) - if (!dictationMeterStatesEqual(publishedMeterRef.current, meter)) { - publishedMeterRef.current = meter - publishMeter?.(meter) - } - } + measureChunk(samples) capturedChunkCountRef.current += 1 if (bufferAudioRef.current) { appendBufferedAudioChunk({ @@ -277,7 +241,7 @@ export function useAudioCapture(publishMeter?: DictationMeterPublisher) { [ appendBufferedAudioChunk, cleanupCaptureResources, - publishMeter, + measureChunk, resetBufferedAudio, resetMeter ] @@ -336,6 +300,7 @@ export function useAudioCapture(publishMeter?: DictationMeterPublisher) { flushBufferedAudio, discardBufferedAudio, getCapturedChunkCount, + getLastSpeechAt, isCapturingRef } } diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index 11a197b2bb3..1551c58fd71 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -16970,7 +16970,8 @@ "5d2c3e7ae3": "Keine Sprache erkannt.", "micFallback": "Ausgewähltes Mikrofon nicht verfügbar. Systemstandard wird verwendet.", "micDisconnected": "Mikrofon getrennt. Diktat gestoppt.", - "cloudLimitReached": "Diktat am 10-Minuten-Limit der Cloud gestoppt." + "cloudLimitReached": "Diktat am 10-Minuten-Limit der Cloud gestoppt.", + "stoppedAfterSilence": "Nach einer Minute Stille beendet" }, "dictation-transcript-delivery": { "copiedAfterFailedInsert": "Das Diktat konnte nicht eingefügt werden und wurde in die Zwischenablage kopiert.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 91eb5445400..b6a40b254b1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17355,7 +17355,8 @@ "5d2c3e7ae3": "No speech detected.", "micFallback": "Selected microphone unavailable. Using system default.", "micDisconnected": "Microphone disconnected. Dictation stopped.", - "cloudLimitReached": "Dictation stopped at the 10-minute cloud limit." + "cloudLimitReached": "Dictation stopped at the 10-minute cloud limit.", + "stoppedAfterSilence": "Stopped after a minute of silence" }, "dictation-transcript-delivery": { "copiedAfterFailedInsert": "Dictation couldn't be inserted, so it was copied to the clipboard.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d375321b656..b8f6ec17e7d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -16697,7 +16697,8 @@ "5d2c3e7ae3": "No se detectó ninguna voz.", "micFallback": "Micrófono seleccionado no disponible. Se usará el predeterminado del sistema.", "micDisconnected": "Micrófono desconectado. Dictado detenido.", - "cloudLimitReached": "Dictado detenido al llegar al límite de 10 minutos en la nube." + "cloudLimitReached": "Dictado detenido al llegar al límite de 10 minutos en la nube.", + "stoppedAfterSilence": "Se detuvo tras un minuto de silencio" }, "dictation-transcript-delivery": { "copiedAfterFailedInsert": "No se pudo insertar el dictado, así que se copió al portapapeles.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index c5124af8e28..57471fef88f 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -16922,7 +16922,8 @@ "5d2c3e7ae3": "Aucune parole détectée.", "micFallback": "Micro sélectionné indisponible. Utilisation du micro par défaut du système.", "micDisconnected": "Micro déconnecté. Dictée arrêtée.", - "cloudLimitReached": "Dictée arrêtée à la limite cloud de 10 minutes." + "cloudLimitReached": "Dictée arrêtée à la limite cloud de 10 minutes.", + "stoppedAfterSilence": "Arrêt après une minute de silence" }, "dictation-transcript-delivery": { "copiedAfterFailedInsert": "La dictée n'a pas pu être insérée, elle a donc été copiée dans le presse-papiers.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index f3b29833060..6ebe3100fd2 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -16294,7 +16294,8 @@ "5d2c3e7ae3": "音声が検出されませんでした。", "micFallback": "選択したマイクが使用できません。システムのデフォルトを使用します。", "micDisconnected": "マイクが接続されていません。ディクテーションが停止しました。", - "cloudLimitReached": "クラウドの10分の上限に達したため、ディクテーションが停止しました。" + "cloudLimitReached": "クラウドの10分の上限に達したため、ディクテーションが停止しました。", + "stoppedAfterSilence": "1分間音声がなかったため停止しました。" }, "dictation-transcript-delivery": { "copiedAfterFailedInsert": "音声入力を挿入できなかったため、クリップボードにコピーしました。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 51e794cbd93..0f23a680ee8 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -16294,7 +16294,8 @@ "5d2c3e7ae3": "음성이 감지되지 않았습니다.", "micFallback": "선택한 마이크를 사용할 수 없습니다. 시스템 기본값을 사용합니다.", "micDisconnected": "마이크 연결이 끊어졌습니다. 받아쓰기가 중지되었습니다.", - "cloudLimitReached": "클라우드 10분 제한에 도달해 받아쓰기가 중지되었습니다." + "cloudLimitReached": "클라우드 10분 제한에 도달해 받아쓰기가 중지되었습니다.", + "stoppedAfterSilence": "1분 동안 말소리가 없어 중지했습니다." }, "dictation-transcript-delivery": { "copiedAfterFailedInsert": "받아쓰기를 삽입할 수 없어 클립보드에 복사했습니다.", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 0c8cf367643..6d5bf189d26 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -19224,7 +19224,8 @@ "5d2c3e7ae3": "Nenhuma fala detectada.", "micFallback": "O microfone selecionado não está disponível. Usando o padrão do sistema.", "micDisconnected": "Microfone desconectado. O ditado foi interrompido.", - "cloudLimitReached": "O ditado foi interrompido no limite de 10 minutos da nuvem." + "cloudLimitReached": "O ditado foi interrompido no limite de 10 minutos da nuvem.", + "stoppedAfterSilence": "Parou após um minuto de silêncio" }, "dictation-transcript-delivery": { "copiedAfterFailedInsert": "Não foi possível inserir o ditado, então ele foi copiado para a área de transferência.", diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index 1af6135b6d7..225536eaea1 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -17468,7 +17468,8 @@ "5d2c3e7ae3": "Речь не обнаружена.", "micFallback": "Выбранный микрофон недоступен. Используется системный микрофон по умолчанию.", "micDisconnected": "Микрофон отключён. Диктовка остановлена.", - "cloudLimitReached": "Диктовка остановлена: достигнут 10-минутный облачный лимит." + "cloudLimitReached": "Диктовка остановлена: достигнут 10-минутный облачный лимит.", + "stoppedAfterSilence": "Остановлено после минуты тишины" }, "dictation-transcript-delivery": { "copiedAfterFailedInsert": "Не удалось вставить диктовку, поэтому она скопирована в буфер обмена.", diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index 6a7e904915b..65ba7966e34 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -16954,7 +16954,8 @@ "5d2c3e7ae3": "Konuşma algılanmadı.", "micFallback": "Seçili mikrofon kullanılamıyor. Sistem varsayılanı kullanılıyor.", "micDisconnected": "Mikrofon bağlantısı kesildi. Dikte durduruldu.", - "cloudLimitReached": "Dikte, 10 dakikalık bulut sınırında durduruldu." + "cloudLimitReached": "Dikte, 10 dakikalık bulut sınırında durduruldu.", + "stoppedAfterSilence": "Bir dakika sessizlikten sonra durduruldu" }, "dictation-transcript-delivery": { "copiedAfterFailedInsert": "Dikte eklenemedi, bu yüzden panoya kopyalandı.", diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index c7e595aeef0..108cc0bdced 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -16743,7 +16743,8 @@ "5d2c3e7ae3": "未偵測到語音。", "micFallback": "所選麥克風不可用。使用系統預設值。", "micDisconnected": "麥克風已中斷連線。聽寫已停止。", - "cloudLimitReached": "已達 10 分鐘雲端上限。聽寫已停止。" + "cloudLimitReached": "已達 10 分鐘雲端上限。聽寫已停止。", + "stoppedAfterSilence": "靜音一分鐘後已停止" }, "dictation-transcript-delivery": { "copiedAfterFailedInsert": "無法插入聽寫內容,已複製到剪貼簿。", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d1c3a10a1db..37a60cb252c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -16294,7 +16294,8 @@ "5d2c3e7ae3": "未检测到语音。", "micFallback": "所选麦克风不可用。使用系统默认值。", "micDisconnected": "麦克风已断开。听写停止了。", - "cloudLimitReached": "已达 10 分钟云端上限。听写已停止。" + "cloudLimitReached": "已达 10 分钟云端上限。听写已停止。", + "stoppedAfterSilence": "静音一分钟后已停止" }, "dictation-transcript-delivery": { "copiedAfterFailedInsert": "无法插入听写内容,已复制到剪贴板。", diff --git a/src/shared/dictation-audio-meter.ts b/src/shared/dictation-audio-meter.ts index 6ab8f68a845..57bda355d56 100644 --- a/src/shared/dictation-audio-meter.ts +++ b/src/shared/dictation-audio-meter.ts @@ -10,6 +10,8 @@ export type DictationMeterAnalyzerState = DictationMeterState & { noiseFloor: number smoothedLevel: number clippingUntil: number + /** The `now` of the last chunk measured as speech; 0 until then. */ + lastSpeechAt: number } export const DEFAULT_DICTATION_METER: DictationMeterState = { @@ -32,7 +34,8 @@ export function createDictationMeterAnalyzerState(): DictationMeterAnalyzerState lastUpdatedAt: 0, noiseFloor: 0.008, smoothedLevel: 0, - clippingUntil: Number.NEGATIVE_INFINITY + clippingUntil: Number.NEGATIVE_INFINITY, + lastSpeechAt: 0 } } @@ -71,16 +74,18 @@ export function analyzeDictationAudioChunk( const smoothing = rawLevel > previous.smoothedLevel ? 0.58 : 0.2 const smoothedLevel = previous.smoothedLevel + (rawLevel - previous.smoothedLevel) * smoothing const clippingUntil = peak >= CLIPPING_THRESHOLD ? now + CLIPPING_HOLD_MS : previous.clippingUntil + const isSpeaking = smoothedLevel >= 0.1 || peak >= 0.18 return { level: smoothedLevel, peak, - isSpeaking: smoothedLevel >= 0.1 || peak >= 0.18, + isSpeaking, isClipping: now <= clippingUntil, lastUpdatedAt: now, noiseFloor, smoothedLevel, - clippingUntil + clippingUntil, + lastSpeechAt: isSpeaking ? now : previous.lastSpeechAt } } diff --git a/src/shared/dictation-silence-stop.ts b/src/shared/dictation-silence-stop.ts new file mode 100644 index 00000000000..475a8411fa5 --- /dev/null +++ b/src/shared/dictation-silence-stop.ts @@ -0,0 +1,24 @@ +const DICTATION_SILENCE_STOP_MS = 60_000 + +/** + * Calls onSilence once a minute passes with no speech; returns a cancel. + * Timing runs on now(), not on audio, so a stalled stream still counts as silence. + */ +export function watchDictationSilence( + lastSpeechAt: () => number, + now: () => number, + onSilence: () => void +): () => void { + const startedAt = now() + let timer: ReturnType | undefined + const check = (): void => { + const remainingMs = Math.max(startedAt, lastSpeechAt()) + DICTATION_SILENCE_STOP_MS - now() + if (remainingMs > 0) { + timer = setTimeout(check, remainingMs) + } else { + onSilence() + } + } + check() + return () => clearTimeout(timer) +}