diff --git a/.env.example b/.env.example index fc467c7..bf9e24e 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,7 @@ VITE_TYPESAFE_API_KEY= # 未配置时回退为直连模式(浏览器可能遇到 CORS)。 # VITE_JEV_PROXY_URL=http://localhost:8787 VITE_JEV_PROXY_URL= + +# ASR(语音转写)代理地址。Tauri/不支持 SpeechRecognition 的环境走录音上传通路; +# 服务端未配置真实 ASR provider 时返回占位结果,输入框兜底为打字。 +VITE_ASR_PROXY_URL=http://localhost:8787 diff --git a/server/src/index.ts b/server/src/index.ts index f1e134f..3c2bbcc 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -18,7 +18,7 @@ const app = new Hono(); app.use( '*', cors({ - origin: ['http://localhost:1420', 'http://localhost:5173'], + origin: ['http://localhost:1420', 'http://localhost:5173', 'http://localhost:5174'], }), ); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4cc0ec7..95b7481 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,12 +15,12 @@ name = "app_lib" crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] -tauri-build = { version = "2.7.1" } +tauri-build = { version = "2.7.1", features = [] } [dependencies] serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" -tauri = { version = "2.12.1" } +tauri = { version = "2.12.1", features = [] } tauri-plugin-log = "2" tauri-plugin-sql = { version = "2", features = ["sqlite"] } diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist new file mode 100644 index 0000000..f1c1c86 --- /dev/null +++ b/src-tauri/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + 苍梧疑冢 + CFBundleExecutable + app + CFBundleIconFile + icon.icns + CFBundleIdentifier + cn.sciark.jevmatch + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + jev-dating-match + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 0.1.0 + LSMinimumSystemVersion + 13.0 + NSMicrophoneUsageDescription + 苍梧疑冢需要麦克风来进行剧情讲述的语音输入;音频仅用于实时转写,不会保存原始录音。 + NSPrincipalClass + NSApplication + + diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8db21f6..2251b57 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "jev-dating-match", "version": "0.1.0", - "identifier": "com.tauri.dev", + "identifier": "cn.sciark.jevmatch", "build": { "frontendDist": "../dist", "devUrl": "http://localhost:5173", @@ -13,8 +13,10 @@ "windows": [ { "title": "苍梧疑冢", - "width": 800, - "height": 600, + "width": 1280, + "height": 820, + "minWidth": 1080, + "minHeight": 700, "resizable": true, "fullscreen": false } diff --git a/src/ui/VoiceInput.tsx b/src/ui/VoiceInput.tsx index e6aa213..a00d389 100644 --- a/src/ui/VoiceInput.tsx +++ b/src/ui/VoiceInput.tsx @@ -1,16 +1,16 @@ import { useEffect, useMemo, useRef, useState } from 'react'; /** - * 语音讲述输入(Web Speech API,优雅降级)。 + * 语音讲述输入。三条通路,按能力自动选择,文本框永远可用: * - * 交互模型(为长讲述设计): - * - 点「开始讲述」开始,点「结束讲述」停止——不按住(记忆讲述动辄几分钟,按住不现实); - * - 浏览器沉默超时导致识别自动结束时,只要用户没点停止就**自动续录**; - * - 识别结果实时落进**可编辑文本框**,说完可以改,点「确认提交」才进入剧情—— - * 绝不自动提交半截内容; - * - 不支持 SpeechRecognition 的环境(如 Tauri WKWebView):纯文本输入,不显示为错误。 + * 1. SpeechRecognition(桌面 Chrome/Edge):点按开始/结束,实时转写,沉默自动续录; + * 2. MediaRecorder 录音上传(Tauri WKWebView / 不支持 SR 的环境): + * 录音 → 上传 server 的 /api/asr → 转写文本回填进可编辑文本框; + * 服务端未配置真实 ASR provider 时给出提示,文本输入兜底; + * 3. 纯文本输入:任何环境下的保底路径。 * - * ADR-0004:只保留转写文本,不保存任何音频(Web Speech API 天然满足——直接给文本)。 + * 统一规则:转写一律落进可编辑文本框,用户确认后才提交——绝不自动提交半截内容。 + * ADR-0004:只保留转写文本;音频 blob 上传后即弃,客户端与服务端都不落盘。 */ interface SpeechRecognitionResultLike { @@ -45,6 +45,12 @@ function getSRCtor(): SRCtor | null { return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null; } +function canMediaRecord(): boolean { + return typeof MediaRecorder !== 'undefined' && !!navigator.mediaDevices?.getUserMedia; +} + +const ASR_PROXY = (import.meta.env.VITE_ASR_PROXY_URL as string | undefined) ?? 'http://localhost:8787'; + interface Props { /** 用户点「确认提交」后回调最终文本 */ onSubmit: (text: string) => void | Promise; @@ -53,18 +59,25 @@ interface Props { export function VoiceInput({ onSubmit, disabled }: Props) { const SR = useMemo(() => getSRCtor(), []); - const [recording, setRecording] = useState(false); + const [recording, setRecording] = useState(false); // SR 识别中 + const [recAudio, setRecAudio] = useState(false); // MediaRecorder 录音中 const [interim, setInterim] = useState(''); const [text, setText] = useState(''); const [hint, setHint] = useState(null); const [busy, setBusy] = useState(false); - const recRef = useRef(null); - /** 用户是否仍希望处于录音中——沉默超时触发的 onend 据此自动续录 */ - const wantRef = useRef(false); - /** 致命错误(未授权/服务不可用):不续录,提示降级打字 */ - const fatalRef = useRef(false); - function launch() { + const recRef = useRef(null); + const wantRef = useRef(false); + const fatalRef = useRef(false); + const mediaRecRef = useRef(null); + const chunksRef = useRef([]); + + /** SR 识别引擎不可用(无 SR 或已致命失败)时,改用录音上传通路 */ + const useAudioPath = !SR || fatalRef.current; + + // ---- 通路 1:SpeechRecognition ---- + + function launchSr() { if (!SR) return; try { const rec = new SR(); @@ -81,46 +94,101 @@ export function VoiceInput({ onSubmit, disabled }: Props) { setInterim(interimText); }; rec.onerror = (e) => { - if (e.error === 'not-allowed' || e.error === 'service-not-allowed') { - fatalRef.current = true; - } + 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(); // 沉默超时,自动续录 + launchSr(); // 沉默超时,自动续录 return; } setRecording(false); - if (fatalRef.current) { - setHint('麦克风没授权成功——可以直接打字,内容到位就行。'); - } + if (fatalRef.current) setHint('浏览器识别不可用——可以录音上传,或直接打字。'); }; recRef.current = rec; rec.start(); } catch { recRef.current = null; setRecording(false); - setHint('语音启动失败——可以直接打字。'); + fatalRef.current = true; + setHint('浏览器识别启动失败——可以录音上传,或直接打字。'); } } - function startListening() { + function startSr() { wantRef.current = true; fatalRef.current = false; setHint(null); setRecording(true); - launch(); + launchSr(); } - function stopListening() { + function stopSr() { wantRef.current = false; setRecording(false); try { recRef.current?.stop(); } catch { - // 识别尚未真正启动(如权限弹窗打断)时 stop 会抛 InvalidStateError,吞掉 + // 识别尚未真正启动时 stop 会抛 InvalidStateError,吞掉 + } + } + + // ---- 通路 2:MediaRecorder 录音 → server ASR ---- + + async function startAudio() { + setHint(null); + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const recorder = new MediaRecorder(stream); + chunksRef.current = []; + recorder.ondataavailable = (e) => { + if (e.data.size > 0) chunksRef.current.push(e.data); + }; + recorder.onstop = () => { + stream.getTracks().forEach((t) => t.stop()); + mediaRecRef.current = null; + setRecAudio(false); + const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm' }); + chunksRef.current = []; + if (blob.size > 0) void transcribe(blob); + }; + mediaRecRef.current = recorder; + recorder.start(); + setRecAudio(true); + } catch { + setRecAudio(false); + setHint('麦克风不可用或未授权——直接打字也完全没问题。'); + } + } + + function stopAudio() { + try { + mediaRecRef.current?.stop(); + } catch { + // 尚未启动时 stop 抛错,吞掉 + } + } + + async function transcribe(blob: Blob) { + setBusy(true); + try { + const fd = new FormData(); + fd.append('audio', blob, 'narration.webm'); + const res = await fetch(`${ASR_PROXY}/api/asr`, { method: 'POST', body: fd }); + if (!res.ok) throw new Error(`ASR ${res.status}`); + const body = (await res.json()) as { transcript?: string }; + const t = (body.transcript ?? '').trim(); + if (t && !t.startsWith('[mock-asr]')) { + setText((prev) => (prev ? prev + t : t)); + setHint(null); + } else { + setHint('语音服务还没接入真实识别(返回了占位结果)——这段请直接打字。'); + } + } catch { + setHint('语音服务连不上(需要启动 server 代理)——直接打字也完全没问题。'); + } finally { + setBusy(false); } } @@ -130,7 +198,12 @@ export function VoiceInput({ onSubmit, disabled }: Props) { try { recRef.current?.stop(); } catch { - // 卸载清理,忽略 + // 卸载清理 + } + try { + mediaRecRef.current?.stop(); + } catch { + // 卸载清理 } }, [], @@ -150,69 +223,63 @@ export function VoiceInput({ onSubmit, disabled }: Props) { } } - // ---- 降级路径:纯文本输入(不是错误,只是换个输入方式) ---- - if (!SR) { - return ( - - setText(e.target.value)} - /> - - {busy ? '档案馆听写中…' : '提交'} - - - ); - } - - // ---- 语音路径:点按开始/结束 + 可编辑转写 + 确认提交 ---- return ( + { + setText(e.target.value); + setInterim(''); + }} + placeholder={ + useAudioPath + ? '按住你的想法慢慢说——说完用下面的按钮转写,或直接打字。' + : '点「开始讲述」语音输入,或直接打字——说完可以修改,确认后才提交。' + } + /> - {recording ? ( - - ■ 结束讲述 - - ) : ( - - 🎙 {text ? '继续补充' : '开始讲述'} - - )} + {!useAudioPath && + (recording ? ( + + ■ 结束讲述 + + ) : ( + + 🎙 {text ? '继续补充' : '开始讲述'} + + ))} + {useAudioPath && + canMediaRecord() && + (recAudio ? ( + + ■ 停止并转写 + + ) : ( + + ● {busy ? '转写中…' : text ? '继续录音' : '录音讲述'} + + ))} - {(recording || text || interim) && ( - { - setText(e.target.value); + {recording && 讲述中——停顿会自动续录,说完点「结束讲述」。} + {recAudio && 录音中——说完点「停止并转写」。} + {hint && {hint}} + + + {busy ? '档案馆听写中…' : '确认提交'} + + { + setText(''); setInterim(''); }} - placeholder={recording ? '正在听……说慢点没关系,停顿不会断。' : ''} - /> - )} - {recording && 讲述中——停顿会自动续录,说完点「结束讲述」,还能改。} - {hint && {hint}} - {!recording && text.trim() && ( - - - {busy ? '档案馆听写中…' : '确认提交'} - - { - setText(''); - setInterim(''); - }} - > - 清空重说 - - - )} + > + 清空重说 + + ); }
讲述中——停顿会自动续录,说完点「结束讲述」。
录音中——说完点「停止并转写」。
{hint}
讲述中——停顿会自动续录,说完点「结束讲述」,还能改。