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
This commit is contained in:
Kelvin Amoaba
2026-11-07 02:11:20 +00:00
committed by GitHub
parent 7823b122f1
commit 8038fed16c
18 changed files with 154 additions and 71 deletions
@@ -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<UseMobileDictationResult, 'isRecording' | 'meter'>,
@@ -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<typeof setTimeout>
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])
}
@@ -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,
@@ -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])
}
@@ -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<DictationMeterAnalyzerState>(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 }
}
+5 -40
View File
@@ -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<MediaStream | null>(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<ReturnType<typeof createDictationMeterAnalyzerState>>(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
}
}
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -16294,7 +16294,8 @@
"5d2c3e7ae3": "音声が検出されませんでした。",
"micFallback": "選択したマイクが使用できません。システムのデフォルトを使用します。",
"micDisconnected": "マイクが接続されていません。ディクテーションが停止しました。",
"cloudLimitReached": "クラウドの10分の上限に達したため、ディクテーションが停止しました。"
"cloudLimitReached": "クラウドの10分の上限に達したため、ディクテーションが停止しました。",
"stoppedAfterSilence": "1分間音声がなかったため停止しました。"
},
"dictation-transcript-delivery": {
"copiedAfterFailedInsert": "音声入力を挿入できなかったため、クリップボードにコピーしました。",
+2 -1
View File
@@ -16294,7 +16294,8 @@
"5d2c3e7ae3": "음성이 감지되지 않았습니다.",
"micFallback": "선택한 마이크를 사용할 수 없습니다. 시스템 기본값을 사용합니다.",
"micDisconnected": "마이크 연결이 끊어졌습니다. 받아쓰기가 중지되었습니다.",
"cloudLimitReached": "클라우드 10분 제한에 도달해 받아쓰기가 중지되었습니다."
"cloudLimitReached": "클라우드 10분 제한에 도달해 받아쓰기가 중지되었습니다.",
"stoppedAfterSilence": "1분 동안 말소리가 없어 중지했습니다."
},
"dictation-transcript-delivery": {
"copiedAfterFailedInsert": "받아쓰기를 삽입할 수 없어 클립보드에 복사했습니다.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -17468,7 +17468,8 @@
"5d2c3e7ae3": "Речь не обнаружена.",
"micFallback": "Выбранный микрофон недоступен. Используется системный микрофон по умолчанию.",
"micDisconnected": "Микрофон отключён. Диктовка остановлена.",
"cloudLimitReached": "Диктовка остановлена: достигнут 10-минутный облачный лимит."
"cloudLimitReached": "Диктовка остановлена: достигнут 10-минутный облачный лимит.",
"stoppedAfterSilence": "Остановлено после минуты тишины"
},
"dictation-transcript-delivery": {
"copiedAfterFailedInsert": "Не удалось вставить диктовку, поэтому она скопирована в буфер обмена.",
+2 -1
View File
@@ -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ı.",
+2 -1
View File
@@ -16743,7 +16743,8 @@
"5d2c3e7ae3": "未偵測到語音。",
"micFallback": "所選麥克風不可用。使用系統預設值。",
"micDisconnected": "麥克風已中斷連線。聽寫已停止。",
"cloudLimitReached": "已達 10 分鐘雲端上限。聽寫已停止。"
"cloudLimitReached": "已達 10 分鐘雲端上限。聽寫已停止。",
"stoppedAfterSilence": "靜音一分鐘後已停止"
},
"dictation-transcript-delivery": {
"copiedAfterFailedInsert": "無法插入聽寫內容,已複製到剪貼簿。",
+2 -1
View File
@@ -16294,7 +16294,8 @@
"5d2c3e7ae3": "未检测到语音。",
"micFallback": "所选麦克风不可用。使用系统默认值。",
"micDisconnected": "麦克风已断开。听写停止了。",
"cloudLimitReached": "已达 10 分钟云端上限。听写已停止。"
"cloudLimitReached": "已达 10 分钟云端上限。听写已停止。",
"stoppedAfterSilence": "静音一分钟后已停止"
},
"dictation-transcript-delivery": {
"copiedAfterFailedInsert": "无法插入听写内容,已复制到剪贴板。",
+8 -3
View File
@@ -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
}
}
+24
View File
@@ -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<typeof setTimeout> | 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)
}