fix: 语音讲述交互重做——点按开始/结束、沉默自动续录、转写可编辑、确认才提交(不再自动提交半截内容)
This commit is contained in:
+149
-88
@@ -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<SpeechRecognitionResultLike>;
|
||||
}
|
||||
|
||||
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<void>;
|
||||
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<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const recRef = useRef<SpeechRecognitionLike | null>(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) {
|
||||
<textarea
|
||||
className="voice-textarea"
|
||||
placeholder="此环境不支持语音输入,打字讲也一样——内容到位就行。"
|
||||
rows={3}
|
||||
rows={4}
|
||||
value={text}
|
||||
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>
|
||||
{error && <p className="hint">{error}</p>}
|
||||
</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 (
|
||||
<div className="voice-input">
|
||||
<button
|
||||
type="button"
|
||||
className={`hold-btn ${holding ? 'holding' : ''}`}
|
||||
disabled={disabled || busy}
|
||||
onPointerDown={start}
|
||||
onPointerUp={stop}
|
||||
onPointerLeave={() => holding && stop()}
|
||||
onPointerCancel={() => holding && stop()}
|
||||
>
|
||||
{busy ? '档案馆听写中…' : holding ? '松开结束 · 讲述中…' : '按住说话'}
|
||||
</button>
|
||||
{live && <p className="voice-live">{live}</p>}
|
||||
{error && <p className="hint">{error}</p>}
|
||||
<div className="voice-controls">
|
||||
{recording ? (
|
||||
<button type="button" className="hold-btn holding" disabled={disabled} onClick={stopListening}>
|
||||
■ 结束讲述
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="hold-btn" disabled={disabled || busy} onClick={startListening}>
|
||||
🎙 {text ? '继续补充' : '开始讲述'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(recording || text || interim) && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user