fix(dictation): show Listening while the speech model loads, and link start toasts to Voice settings (#39136)

* fix(dictation): say Listening once the mic is recording, and translate start toasts with an Open Settings action

* refactor(dictation): derive the indicator label from the listening flag
This commit is contained in:
Kelvin Amoaba
2026-11-07 00:21:33 +00:00
committed by GitHub
parent a3a0e68380
commit ea32ccb0ec
15 changed files with 140 additions and 52 deletions
@@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback } from 'react'
import { useEffect, useRef, useCallback, useState } from 'react'
import { useAppStore } from '@/store'
import { useAudioCapture } from '@/hooks/use-audio-capture'
import { toast } from 'sonner'
@@ -11,7 +11,11 @@ import {
import { formatFinalTranscriptSegment } from '../../../../shared/dictation-transcript-spacing'
import { recordStoppedSession, waitForStoppedSession } from './dictation-stopped-sessions'
import { translate } from '@/i18n/i18n'
import { showDictationStartErrorToast } from './dictation-start-error-toast'
import {
showDictationDisabledToast,
showDictationNoModelToast,
showDictationStartErrorToast
} from './dictation-start-error-toast'
import { useHoldDictationGesture } from './use-hold-dictation-gesture'
import { DICTATION_CONTROL_EVENT, type DictationControlAction } from './dictation-control-events'
import { publishDictationMeter } from './dictation-meter-store'
@@ -46,6 +50,7 @@ export function DictationController() {
// Push-to-talk restarts per utterance; report a missing preferred microphone once per preference.
const micFallbackNotifiedForRef = useRef<string | null>(null)
const stopDictationRef = useRef<(() => void) | null>(null)
const [captureLive, setCaptureLive] = useState(false)
const drainStoppedSession = useCallback((sessionId: string) => {
void waitForStoppedSession(sessionId, stoppedSessionIdsRef, stoppedResolversRef)
@@ -97,25 +102,12 @@ export function DictationController() {
const modelId = settings?.voice?.sttModel
if (!modelId) {
toast('No speech model selected. Download one in Settings > Voice.', {
// Held keys and repeated presses replace this toast instead of stacking it.
id: 'dictation-no-model',
action: {
label: translate(
'auto.components.dictation.DictationController.bb7f599ee7',
'Open Settings'
),
onClick: () => {
useAppStore.getState().openSettingsTarget({ pane: 'voice', repoId: null })
useAppStore.getState().openSettingsPage()
}
}
})
showDictationNoModelToast()
return
}
if (!settings?.voice?.enabled) {
toast('Voice dictation is disabled. Enable it in Settings > Voice.')
showDictationDisabledToast()
return
}
@@ -130,11 +122,12 @@ export function DictationController() {
insertedFinalTranscriptRef.current = ''
dictationStateRef.current = 'starting'
setDictationState('starting')
setCaptureLive(false)
let captureStarted = false
try {
// Capture and buffer before slow worker startup so speech during Starting is retained.
// Capture and buffer before slow worker startup so speech during model load is retained.
const preferredMicrophoneDeviceId = settings?.voice?.microphoneDeviceId ?? null
const captureResult = await startCapture({
bufferAudio: true,
@@ -181,6 +174,7 @@ export function DictationController() {
insertionTargetRef.current = null
return
}
setCaptureLive(true)
await window.api.speech.startDictation(modelId, undefined, sessionId)
if (dictationRunRef.current !== runId) {
@@ -409,5 +403,5 @@ export function DictationController() {
}
}, [setPartialTranscript, setDictationState, stopCapture, discardBufferedAudio])
return <DictationIndicator />
return <DictationIndicator captureLive={captureLive} />
}
@@ -74,7 +74,7 @@ describe('DictationIndicator', () => {
}
document.addEventListener(DICTATION_CONTROL_EVENT, listener)
render(<DictationIndicator />)
render(<DictationIndicator captureLive />)
fireEvent.click(screen.getByRole('button', { name: 'Stop dictation' }))
document.removeEventListener(DICTATION_CONTROL_EVENT, listener)
@@ -10,7 +10,8 @@ import { dispatchDictationControl } from './dictation-control-events'
import { DictationGrapes } from './DictationGrapes'
import { useDictationMeter } from './dictation-meter-store'
export function DictationIndicator() {
// captureLive: the mic is recording (and buffering) while the speech engine is still starting.
export function DictationIndicator({ captureLive }: { captureLive: boolean }) {
const dictationState = useAppStore((state) => state.dictationState)
const partialTranscript = useAppStore((state) => state.partialTranscript)
const dictationMeter = useDictationMeter()
@@ -22,15 +23,15 @@ export function DictationIndicator() {
return null
}
const isListening = dictationState === 'listening'
const isLoadingModel = dictationState === 'starting' && captureLive
const isListening = dictationState === 'listening' || isLoadingModel
const isClipping = isListening && dictationMeter.isClipping
const isSpeaking = isListening && dictationMeter.isSpeaking && !isClipping
const lifecycleLabel =
dictationState === 'starting'
const lifecycleLabel = isListening
? translate('auto.components.dictation.DictationIndicator.3de5a129e7', 'Listening')
: dictationState === 'starting'
? translate('auto.components.dictation.DictationIndicator.7f3660a7ba', 'Starting mic…')
: dictationState === 'stopping'
? translate('auto.components.dictation.DictationIndicator.f082d0cb9d', 'Processing…')
: translate('auto.components.dictation.DictationIndicator.3de5a129e7', 'Listening')
: translate('auto.components.dictation.DictationIndicator.f082d0cb9d', 'Processing…')
const label = isClipping
? translate('auto.components.dictation.DictationIndicator.4977162383', 'Too loud')
: isSpeaking
@@ -62,11 +63,19 @@ export function DictationIndicator() {
<DictationGrapes
level={dictationMeter.level}
active={dictationState !== 'stopping'}
transitioning={dictationState !== 'listening'}
transitioning={!isListening}
/>
<span aria-hidden className="min-w-0 truncate font-medium">
{label}
</span>
{isLoadingModel ? (
<span aria-hidden className="min-w-0 truncate text-xs text-muted-foreground">
{translate(
'auto.components.dictation.DictationIndicator.loadingModel',
'Loading speech model…'
)}
</span>
) : null}
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{announcedLabel}
</span>
@@ -4,9 +4,35 @@ import { translate } from '@/i18n/i18n'
import { compactIpcErrorMessage } from '@/lib/ipc-error'
import { hostDictationErrorMessage } from '../../../../shared/host-dictation-error-messages'
function openVoiceSettings(): void {
useAppStore.getState().openSettingsTarget({ pane: 'voice', repoId: null })
useAppStore.getState().openSettingsPage()
function openVoiceSettingsAction(): { label: string; onClick: () => void } {
return {
label: translate('auto.components.dictation.DictationController.bb7f599ee7', 'Open Settings'),
onClick: () => {
useAppStore.getState().openSettingsTarget({ pane: 'voice', repoId: null })
useAppStore.getState().openSettingsPage()
}
}
}
export function showDictationNoModelToast(): void {
toast(
translate(
'auto.components.dictation.DictationController.noModel',
'No speech model selected. Download one in Settings > Voice.'
),
// Held keys and repeated presses replace this toast instead of stacking it.
{ id: 'dictation-no-model', action: openVoiceSettingsAction() }
)
}
export function showDictationDisabledToast(): void {
toast(
translate(
'auto.components.dictation.DictationController.disabled',
'Voice dictation is disabled. Enable it in Settings > Voice.'
),
{ action: openVoiceSettingsAction() }
)
}
export function showDictationStartErrorToast(message: string): void {
@@ -21,17 +47,21 @@ export function showDictationStartErrorToast(message: string): void {
)
)
} else if (message.includes('not ready')) {
toast('Speech model not ready. Download it in Settings > Voice.')
toast(
translate(
'auto.components.dictation.DictationController.notReady',
'Speech model not ready. Download it in Settings > Voice.'
),
{ action: openVoiceSettingsAction() }
)
} else if (message.includes('Unknown model')) {
toast('Selected model is no longer available. Please choose another in Settings > Voice.', {
action: {
label: translate(
'auto.components.dictation.DictationController.bb7f599ee7',
'Open Settings'
),
onClick: openVoiceSettings
}
})
toast(
translate(
'auto.components.dictation.DictationController.unknownModel',
'Selected model is no longer available. Please choose another in Settings > Voice.'
),
{ action: openVoiceSettingsAction() }
)
} else {
toast.error(
translate(
+6 -1
View File
@@ -16917,6 +16917,10 @@
"55127a3706": "Diktat fehlgeschlagen: {{value0}}",
"bb7f599ee7": "Einstellungen öffnen",
"2d5b9fabf9": "Mikrofonzugriff verweigert. Zugriff in den Systemeinstellungen erlauben und anschließend Orca neu starten.",
"noModel": "Kein Sprachmodell ausgewählt. Laden Sie eines unter Einstellungen > Spracheingabe herunter.",
"disabled": "Spracheingabe ist deaktiviert. Aktivieren Sie sie unter Einstellungen > Spracheingabe.",
"notReady": "Sprachmodell nicht bereit. Laden Sie es unter Einstellungen > Spracheingabe herunter.",
"unknownModel": "Das ausgewählte Modell ist nicht mehr verfügbar. Wählen Sie unter Einstellungen > Spracheingabe ein anderes aus.",
"5d2c3e7ae3": "Keine Sprache erkannt.",
"micFallback": "Ausgewähltes Mikrofon nicht verfügbar. Systemstandard wird verwendet.",
"micDisconnected": "Mikrofon getrennt. Diktat gestoppt."
@@ -16927,7 +16931,8 @@
"f082d0cb9d": "Wird verarbeitet…",
"3de5a129e7": "Hört zu",
"4977162383": "Zu laut",
"25f2b7a6a5": "Sprache erkannt"
"25f2b7a6a5": "Sprache erkannt",
"loadingModel": "Sprachmodell wird geladen…"
}
},
"dashboard": {
+6 -1
View File
@@ -17341,6 +17341,10 @@
"55127a3706": "Dictation failed: {{value0}}",
"bb7f599ee7": "Open Settings",
"2d5b9fabf9": "Microphone access denied. Grant access in system settings, then restart Orca.",
"noModel": "No speech model selected. Download one in Settings > Voice.",
"disabled": "Voice dictation is disabled. Enable it in Settings > Voice.",
"notReady": "Speech model not ready. Download it in Settings > Voice.",
"unknownModel": "Selected model is no longer available. Please choose another in Settings > Voice.",
"5d2c3e7ae3": "No speech detected.",
"micFallback": "Selected microphone unavailable. Using system default.",
"micDisconnected": "Microphone disconnected. Dictation stopped."
@@ -17351,7 +17355,8 @@
"f082d0cb9d": "Processing…",
"3de5a129e7": "Listening",
"4977162383": "Too loud",
"25f2b7a6a5": "Speaking"
"25f2b7a6a5": "Speaking",
"loadingModel": "Loading speech model…"
}
},
"dashboard": {
+6 -1
View File
@@ -16646,6 +16646,10 @@
"55127a3706": "Dictado fallido: {{value0}}",
"bb7f599ee7": "Abrir configuración",
"2d5b9fabf9": "Acceso al micrófono denegado. Conceda acceso en la configuración del sistema, luego reinicie Orca.",
"noModel": "No hay ningún modelo de voz seleccionado. Descargue uno en Configuración > Voz.",
"disabled": "El dictado por voz está desactivado. Actívelo en Configuración > Voz.",
"notReady": "El modelo de voz no está listo. Descárguelo en Configuración > Voz.",
"unknownModel": "El modelo seleccionado ya no está disponible. Elija otro en Configuración > Voz.",
"5d2c3e7ae3": "No se detectó ninguna voz.",
"micFallback": "Micrófono seleccionado no disponible. Se usará el predeterminado del sistema.",
"micDisconnected": "Micrófono desconectado. Dictado detenido."
@@ -16656,7 +16660,8 @@
"f082d0cb9d": "Procesando…",
"3de5a129e7": "Escuchando",
"4977162383": "Volumen demasiado alto",
"25f2b7a6a5": "Hablando"
"25f2b7a6a5": "Hablando",
"loadingModel": "Cargando modelo de voz…"
}
},
"dashboard": {
+6 -1
View File
@@ -16870,6 +16870,10 @@
"55127a3706": "Échec de la dictée : {{value0}}",
"bb7f599ee7": "Ouvrir les paramètres",
"2d5b9fabf9": "Accès au micro refusé. Accordez l'autorisation dans les réglages système, puis redémarrez Orca.",
"noModel": "Aucun modèle vocal sélectionné. Téléchargez-en un dans Paramètres > Voix.",
"disabled": "La dictée vocale est désactivée. Activez-la dans Paramètres > Voix.",
"notReady": "Le modèle vocal n'est pas prêt. Téléchargez-le dans Paramètres > Voix.",
"unknownModel": "Le modèle sélectionné n'est plus disponible. Choisissez-en un autre dans Paramètres > Voix.",
"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."
@@ -16880,7 +16884,8 @@
"f082d0cb9d": "Traitement…",
"3de5a129e7": "Écoute en cours",
"4977162383": "Trop fort",
"25f2b7a6a5": "Vous parlez"
"25f2b7a6a5": "Vous parlez",
"loadingModel": "Chargement du modèle vocal…"
}
},
"dashboard": {
+6 -1
View File
@@ -16244,6 +16244,10 @@
"55127a3706": "ディクテーションが失敗しました: {{value0}}",
"bb7f599ee7": "設定を開く",
"2d5b9fabf9": "マイクへのアクセスが拒否されました。システム設定でアクセスを許可し、Orca を再起動。",
"noModel": "音声モデルが選択されていません。設定 > 音声 からダウンロードしてください。",
"disabled": "音声ディクテーションは無効です。設定 > 音声 で有効にしてください。",
"notReady": "音声モデルの準備ができていません。設定 > 音声 からダウンロードしてください。",
"unknownModel": "選択したモデルは使用できなくなりました。設定 > 音声 で別のモデルを選択してください。",
"5d2c3e7ae3": "音声が検出されませんでした。",
"micFallback": "選択したマイクが使用できません。システムのデフォルトを使用します。",
"micDisconnected": "マイクが接続されていません。ディクテーションが停止しました。"
@@ -16254,7 +16258,8 @@
"f082d0cb9d": "処理中…",
"3de5a129e7": "聞き取り中",
"4977162383": "音量が大きすぎます",
"25f2b7a6a5": "発話中"
"25f2b7a6a5": "発話中",
"loadingModel": "音声モデルを読み込み中…"
}
},
"dashboard": {
+6 -1
View File
@@ -16244,6 +16244,10 @@
"55127a3706": "받아쓰기 실패: {{value0}}",
"bb7f599ee7": "설정 열기",
"2d5b9fabf9": "마이크 액세스가 거부되었습니다. 시스템 설정에서 액세스 권한을 부여한 다음 Orca를 다시 시작하세요.",
"noModel": "선택된 음성 모델이 없습니다. 설정 > 음성에서 다운로드하세요.",
"disabled": "음성 받아쓰기가 비활성화되어 있습니다. 설정 > 음성에서 활성화하세요.",
"notReady": "음성 모델이 준비되지 않았습니다. 설정 > 음성에서 다운로드하세요.",
"unknownModel": "선택한 모델을 더 이상 사용할 수 없습니다. 설정 > 음성에서 다른 모델을 선택하세요.",
"5d2c3e7ae3": "음성이 감지되지 않았습니다.",
"micFallback": "선택한 마이크를 사용할 수 없습니다. 시스템 기본값을 사용합니다.",
"micDisconnected": "마이크 연결이 끊어졌습니다. 받아쓰기가 중지되었습니다."
@@ -16254,7 +16258,8 @@
"f082d0cb9d": "처리 중…",
"3de5a129e7": "듣는 중",
"4977162383": "음량이 너무 큽니다",
"25f2b7a6a5": "말하는 중"
"25f2b7a6a5": "말하는 중",
"loadingModel": "음성 모델 불러오는 중…"
}
},
"dashboard": {
+6 -1
View File
@@ -19169,6 +19169,10 @@
"55127a3706": "Falha no ditado: {{value0}}",
"bb7f599ee7": "Abrir Configurações",
"2d5b9fabf9": "Acesso ao microfone negado. Conceda acesso nas configurações do sistema e reinicie o Orca.",
"noModel": "Nenhum modelo de fala selecionado. Baixe um em Configurações > Voz.",
"disabled": "O ditado por voz está desativado. Ative-o em Configurações > Voz.",
"notReady": "O modelo de fala não está pronto. Baixe-o em Configurações > Voz.",
"unknownModel": "O modelo selecionado não está mais disponível. Escolha outro em Configurações > Voz.",
"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."
@@ -19179,7 +19183,8 @@
"f082d0cb9d": "Processando…",
"3de5a129e7": "Ouvindo",
"4977162383": "Muito alto",
"25f2b7a6a5": "Falando"
"25f2b7a6a5": "Falando",
"loadingModel": "Carregando modelo de fala…"
}
},
"link": {
+6 -1
View File
@@ -17415,6 +17415,10 @@
"55127a3706": "Ошибка диктовки: {{value0}}",
"bb7f599ee7": "Открыть настройки",
"2d5b9fabf9": "Доступ к микрофону запрещён. Разрешите доступ в системных настройках и перезапустите Orca.",
"noModel": "Модель распознавания речи не выбрана. Скачайте её в разделе «Настройки > Голос».",
"disabled": "Голосовая диктовка отключена. Включите её в разделе «Настройки > Голос».",
"notReady": "Модель распознавания речи не готова. Скачайте её в разделе «Настройки > Голос».",
"unknownModel": "Выбранная модель больше недоступна. Выберите другую в разделе «Настройки > Голос».",
"5d2c3e7ae3": "Речь не обнаружена.",
"micFallback": "Выбранный микрофон недоступен. Используется системный микрофон по умолчанию.",
"micDisconnected": "Микрофон отключён. Диктовка остановлена."
@@ -17425,7 +17429,8 @@
"f082d0cb9d": "Обработка…",
"3de5a129e7": "Прослушивание",
"4977162383": "Слишком громко",
"25f2b7a6a5": "Речь"
"25f2b7a6a5": "Речь",
"loadingModel": "Загрузка модели речи…"
}
},
"dashboard": {
+6 -1
View File
@@ -16903,6 +16903,10 @@
"55127a3706": "Dikte tamamlanamadı: {{value0}}",
"bb7f599ee7": "Ayarları Aç",
"2d5b9fabf9": "Mikrofon erişimi reddedildi. Sistem ayarlarından erişim verin, ardından Orca'yı yeniden başlatın.",
"noModel": "Konuşma modeli seçilmedi. Ayarlar > Ses bölümünden bir model indirin.",
"disabled": "Sesli dikte devre dışı. Ayarlar > Ses bölümünden etkinleştirin.",
"notReady": "Konuşma modeli hazır değil. Ayarlar > Ses bölümünden indirin.",
"unknownModel": "Seçili model artık kullanılamıyor. Ayarlar > Ses bölümünden başka bir model seçin.",
"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."
@@ -16913,7 +16917,8 @@
"f082d0cb9d": "İşleniyor…",
"3de5a129e7": "Dinliyor",
"4977162383": "Çok yüksek sesli",
"25f2b7a6a5": "Konuşuyor"
"25f2b7a6a5": "Konuşuyor",
"loadingModel": "Konuşma modeli yükleniyor…"
}
},
"dashboard": {
+6 -1
View File
@@ -16692,6 +16692,10 @@
"55127a3706": "聽寫失敗:{{value0}}",
"bb7f599ee7": "開啟設定",
"2d5b9fabf9": "麥克風存取被拒絕。在系統設定中授予存取權限,然後重新啟動 Orca。",
"noModel": "未選擇語音模型。請在「設定 > 語音」中下載一個。",
"disabled": "語音聽寫已停用。請在「設定 > 語音」中啟用。",
"notReady": "語音模型尚未就緒。請在「設定 > 語音」中下載。",
"unknownModel": "所選模型已無法使用。請在「設定 > 語音」中選擇其他模型。",
"5d2c3e7ae3": "未偵測到語音。",
"micFallback": "所選麥克風不可用。使用系統預設值。",
"micDisconnected": "麥克風已中斷連線。聽寫已停止。"
@@ -16702,7 +16706,8 @@
"f082d0cb9d": "正在處理…",
"3de5a129e7": "正在聆聽",
"4977162383": "聲音太大",
"25f2b7a6a5": "正在說話"
"25f2b7a6a5": "正在說話",
"loadingModel": "正在載入語音模型…"
}
},
"dashboard": {
+6 -1
View File
@@ -16244,6 +16244,10 @@
"55127a3706": "听写失败:{{value0}}",
"bb7f599ee7": "打开设置",
"2d5b9fabf9": "麦克风访问被拒绝。在系统设置中授予访问权限,然后重新启动 Orca。",
"noModel": "未选择语音模型。请在“设置 > 语音”中下载一个。",
"disabled": "语音听写已禁用。请在“设置 > 语音”中启用。",
"notReady": "语音模型尚未就绪。请在“设置 > 语音”中下载。",
"unknownModel": "所选模型已不可用。请在“设置 > 语音”中选择其他模型。",
"5d2c3e7ae3": "未检测到语音。",
"micFallback": "所选麦克风不可用。使用系统默认值。",
"micDisconnected": "麦克风已断开。听写停止了。"
@@ -16254,7 +16258,8 @@
"f082d0cb9d": "正在处理…",
"3de5a129e7": "正在聆听",
"4977162383": "音量过大",
"25f2b7a6a5": "正在说话"
"25f2b7a6a5": "正在说话",
"loadingModel": "正在加载语音模型…"
}
},
"dashboard": {