diff --git a/src/ui/VoiceInput.tsx b/src/ui/VoiceInput.tsx index 9550925..e6aa213 100644 --- a/src/ui/VoiceInput.tsx +++ b/src/ui/VoiceInput.tsx @@ -1,9 +1,15 @@ -import { useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; /** - * 按住说话(Web Speech API,优雅降级)。 - * - 支持 SpeechRecognition 的环境:按住说话,实时显示转写,松开提交最终文本; - * - 不支持的环境(如 Tauri WKWebView):降级为文本输入框,不显示为错误。 + * 语音讲述输入(Web Speech API,优雅降级)。 + * + * 交互模型(为长讲述设计): + * - 点「开始讲述」开始,点「结束讲述」停止——不按住(记忆讲述动辄几分钟,按住不现实); + * - 浏览器沉默超时导致识别自动结束时,只要用户没点停止就**自动续录**; + * - 识别结果实时落进**可编辑文本框**,说完可以改,点「确认提交」才进入剧情—— + * 绝不自动提交半截内容; + * - 不支持 SpeechRecognition 的环境(如 Tauri WKWebView):纯文本输入,不显示为错误。 + * * ADR-0004:只保留转写文本,不保存任何音频(Web Speech API 天然满足——直接给文本)。 */ @@ -17,13 +23,17 @@ interface SpeechRecognitionEventLike { results: ArrayLike; } +interface SpeechRecognitionErrorLike { + error: string; +} + interface SpeechRecognitionLike { lang: string; continuous: boolean; interimResults: boolean; onresult: ((e: SpeechRecognitionEventLike) => void) | null; onend: (() => void) | null; - onerror: (() => void) | null; + onerror: ((e: SpeechRecognitionErrorLike) => void) | null; start(): void; stop(): void; } @@ -36,34 +46,107 @@ function getSRCtor(): SRCtor | null { } interface Props { - /** 松开(或文本提交)后回调最终转写文本 */ + /** 用户点「确认提交」后回调最终文本 */ onSubmit: (text: string) => void | Promise; disabled?: boolean; } export function VoiceInput({ onSubmit, disabled }: Props) { const SR = useMemo(() => getSRCtor(), []); - const [holding, setHolding] = useState(false); - const [live, setLive] = useState(''); + const [recording, setRecording] = useState(false); + const [interim, setInterim] = useState(''); const [text, setText] = useState(''); + const [hint, setHint] = useState(null); const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); const recRef = useRef(null); - const finalRef = useRef(''); - // 出错(权限被拒/识别失败)后 onend 仍会触发,用它阻止错误后的自动提交 - const erroredRef = useRef(false); + /** 用户是否仍希望处于录音中——沉默超时触发的 onend 据此自动续录 */ + const wantRef = useRef(false); + /** 致命错误(未授权/服务不可用):不续录,提示降级打字 */ + const fatalRef = useRef(false); - async function submit(t: string) { - const trimmed = t.trim(); - if (!trimmed) return; - setBusy(true); - setError(null); + function launch() { + if (!SR) return; try { - await onSubmit(trimmed); + const rec = new SR(); + rec.lang = 'zh-CN'; + rec.continuous = true; + rec.interimResults = true; + rec.onresult = (e) => { + let interimText = ''; + for (let i = e.resultIndex; i < e.results.length; i++) { + const r = e.results[i]; + if (r.isFinal) setText((prev) => prev + r[0].transcript); + else interimText += r[0].transcript; + } + setInterim(interimText); + }; + rec.onerror = (e) => { + if (e.error === 'not-allowed' || e.error === 'service-not-allowed') { + fatalRef.current = true; + } + }; + rec.onend = () => { + recRef.current = null; + setInterim(''); + if (wantRef.current && !fatalRef.current) { + launch(); // 沉默超时,自动续录 + return; + } + setRecording(false); + if (fatalRef.current) { + setHint('麦克风没授权成功——可以直接打字,内容到位就行。'); + } + }; + recRef.current = rec; + rec.start(); + } catch { + recRef.current = null; + setRecording(false); + setHint('语音启动失败——可以直接打字。'); + } + } + + function startListening() { + wantRef.current = true; + fatalRef.current = false; + setHint(null); + setRecording(true); + launch(); + } + + function stopListening() { + wantRef.current = false; + setRecording(false); + try { + recRef.current?.stop(); + } catch { + // 识别尚未真正启动(如权限弹窗打断)时 stop 会抛 InvalidStateError,吞掉 + } + } + + useEffect( + () => () => { + wantRef.current = false; + try { + recRef.current?.stop(); + } catch { + // 卸载清理,忽略 + } + }, + [], + ); + + async function submit() { + const t = text.trim(); + if (!t) return; + setBusy(true); + try { + await onSubmit(t); + setText(''); + setInterim(''); + setHint(null); } finally { setBusy(false); - setLive(''); - setText(''); } } @@ -74,84 +157,62 @@ export function VoiceInput({ onSubmit, disabled }: Props) {