fix: 语音讲述交互重做——点按开始/结束、沉默自动续录、转写可编辑、确认才提交(不再自动提交半截内容)

This commit is contained in:
Jayway Liu
2026-10-03 18:42:52 +08:00
parent e854b4e610
commit 3f9acb4bdc
+149 -88
View File
@@ -1,9 +1,15 @@
import { useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
/** /**
* 按住说话(Web Speech API,优雅降级)。 * 语音讲述输入(Web Speech API,优雅降级)。
* - 支持 SpeechRecognition 的环境:按住说话,实时显示转写,松开提交最终文本; *
* - 不支持的环境(如 Tauri WKWebView):降级为文本输入框,不显示为错误。 * 交互模型(为长讲述设计):
* - 点「开始讲述」开始,点「结束讲述」停止——不按住(记忆讲述动辄几分钟,按住不现实);
* - 浏览器沉默超时导致识别自动结束时,只要用户没点停止就**自动续录**;
* - 识别结果实时落进**可编辑文本框**,说完可以改,点「确认提交」才进入剧情——
* 绝不自动提交半截内容;
* - 不支持 SpeechRecognition 的环境(如 Tauri WKWebView):纯文本输入,不显示为错误。
*
* ADR-0004:只保留转写文本,不保存任何音频(Web Speech API 天然满足——直接给文本)。 * ADR-0004:只保留转写文本,不保存任何音频(Web Speech API 天然满足——直接给文本)。
*/ */
@@ -17,13 +23,17 @@ interface SpeechRecognitionEventLike {
results: ArrayLike<SpeechRecognitionResultLike>; results: ArrayLike<SpeechRecognitionResultLike>;
} }
interface SpeechRecognitionErrorLike {
error: string;
}
interface SpeechRecognitionLike { interface SpeechRecognitionLike {
lang: string; lang: string;
continuous: boolean; continuous: boolean;
interimResults: boolean; interimResults: boolean;
onresult: ((e: SpeechRecognitionEventLike) => void) | null; onresult: ((e: SpeechRecognitionEventLike) => void) | null;
onend: (() => void) | null; onend: (() => void) | null;
onerror: (() => void) | null; onerror: ((e: SpeechRecognitionErrorLike) => void) | null;
start(): void; start(): void;
stop(): void; stop(): void;
} }
@@ -36,34 +46,107 @@ function getSRCtor(): SRCtor | null {
} }
interface Props { interface Props {
/** 松开(或文本提交)后回调最终转写文本 */ /** 用户点「确认提交」后回调最终文本 */
onSubmit: (text: string) => void | Promise<void>; onSubmit: (text: string) => void | Promise<void>;
disabled?: boolean; disabled?: boolean;
} }
export function VoiceInput({ onSubmit, disabled }: Props) { export function VoiceInput({ onSubmit, disabled }: Props) {
const SR = useMemo(() => getSRCtor(), []); const SR = useMemo(() => getSRCtor(), []);
const [holding, setHolding] = useState(false); const [recording, setRecording] = useState(false);
const [live, setLive] = useState(''); const [interim, setInterim] = useState('');
const [text, setText] = useState(''); const [text, setText] = useState('');
const [hint, setHint] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const recRef = useRef<SpeechRecognitionLike | null>(null); const recRef = useRef<SpeechRecognitionLike | null>(null);
const finalRef = useRef(''); /** 用户是否仍希望处于录音中——沉默超时触发的 onend 据此自动续录 */
// 出错(权限被拒/识别失败)后 onend 仍会触发,用它阻止错误后的自动提交 const wantRef = useRef(false);
const erroredRef = useRef(false); /** 致命错误(未授权/服务不可用):不续录,提示降级打字 */
const fatalRef = useRef(false);
async function submit(t: string) { function launch() {
const trimmed = t.trim(); if (!SR) return;
if (!trimmed) return;
setBusy(true);
setError(null);
try { 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 { } finally {
setBusy(false); setBusy(false);
setLive('');
setText('');
} }
} }
@@ -74,84 +157,62 @@ export function VoiceInput({ onSubmit, disabled }: Props) {
<textarea <textarea
className="voice-textarea" className="voice-textarea"
placeholder="此环境不支持语音输入,打字讲也一样——内容到位就行。" placeholder="此环境不支持语音输入,打字讲也一样——内容到位就行。"
rows={3} rows={4}
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
/> />
<button type="button" className="primary" disabled={disabled || busy || !text.trim()} onClick={() => submit(text)}> <button type="button" className="primary" disabled={disabled || busy || !text.trim()} onClick={submit}>
提交 {busy ? '档案馆听写中…' : '提交'}
</button> </button>
{error && <p className="hint">{error}</p>}
</div> </div>
); );
} }
// ---- 语音路径:按住说话 ---- // ---- 语音路径:点按开始/结束 + 可编辑转写 + 确认提交 ----
function start() {
setError(null);
erroredRef.current = false;
const rec = new SR!();
rec.lang = 'zh-CN';
rec.continuous = true;
rec.interimResults = true;
finalRef.current = '';
setLive('');
rec.onresult = (e) => {
let interim = '';
for (let i = e.resultIndex; i < e.results.length; i++) {
const r = e.results[i];
if (r.isFinal) finalRef.current += r[0].transcript;
else interim += r[0].transcript;
}
setLive(finalRef.current + interim);
};
rec.onerror = () => {
// 权限被拒或识别失败:可见的温和提示,不自动提交已识别片段
erroredRef.current = true;
setHolding(false);
setError('没听清,或麦克风没授权成功——再按一次试试。');
};
rec.onend = () => {
setHolding(false);
const t = finalRef.current;
if (!erroredRef.current && t.trim()) void submit(t);
};
recRef.current = rec;
setHolding(true);
try {
rec.start();
} catch {
// 权限弹窗打断按住手势等场景下 start 可能裸抛
setHolding(false);
recRef.current = null;
setError('麦克风没能启动——再按一次试试。');
}
}
function stop() {
try {
recRef.current?.stop();
} catch {
// InvalidStateError:权限弹窗/来电打断手势时 stop 会裸抛,吞掉即可
}
recRef.current = null;
}
return ( return (
<div className="voice-input"> <div className="voice-input">
<button <div className="voice-controls">
type="button" {recording ? (
className={`hold-btn ${holding ? 'holding' : ''}`} <button type="button" className="hold-btn holding" disabled={disabled} onClick={stopListening}>
disabled={disabled || busy} ■ 结束讲述
onPointerDown={start} </button>
onPointerUp={stop} ) : (
onPointerLeave={() => holding && stop()} <button type="button" className="hold-btn" disabled={disabled || busy} onClick={startListening}>
onPointerCancel={() => holding && stop()} 🎙 {text ? '继续补充' : '开始讲述'}
> </button>
{busy ? '档案馆听写中…' : holding ? '松开结束 · 讲述中…' : '按住说话'} )}
</button> </div>
{live && <p className="voice-live">{live}</p>} {(recording || text || interim) && (
{error && <p className="hint">{error}</p>} <textarea
className="voice-textarea"
rows={5}
value={text + interim}
onChange={(e) => {
setText(e.target.value);
setInterim('');
}}
placeholder={recording ? '正在听……说慢点没关系,停顿不会断。' : ''}
/>
)}
{recording && <p className="voice-live">讲述中——停顿会自动续录,说完点「结束讲述」,还能改。</p>}
{hint && <p className="voice-live">{hint}</p>}
{!recording && text.trim() && (
<div className="voice-actions">
<button type="button" className="primary" disabled={disabled || busy} onClick={submit}>
{busy ? '档案馆听写中…' : '确认提交'}
</button>
<button
type="button"
disabled={disabled || busy}
onClick={() => {
setText('');
setInterim('');
}}
>
清空重说
</button>
</div>
)}
</div> </div>
); );
} }