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