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,优雅降级)。
|
* 语音讲述输入(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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user