fix: Tauri 窗口 1280x820(最小 1080x700)、identifier 更正、麦克风权限声明;VoiceInput 三通路(SR/录音上传/文本兜底)
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-1
@@ -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'],
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>苍梧疑冢</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>app</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon.icns</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>cn.sciark.jevmatch</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>jev-dating-match</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.1.0</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>苍梧疑冢需要麦克风来进行剧情讲述的语音输入;音频仅用于实时转写,不会保存原始录音。</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+152
-85
@@ -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<void>;
|
||||
@@ -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<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const recRef = useRef<SpeechRecognitionLike | null>(null);
|
||||
/** 用户是否仍希望处于录音中——沉默超时触发的 onend 据此自动续录 */
|
||||
const wantRef = useRef(false);
|
||||
/** 致命错误(未授权/服务不可用):不续录,提示降级打字 */
|
||||
const fatalRef = useRef(false);
|
||||
|
||||
function launch() {
|
||||
const recRef = useRef<SpeechRecognitionLike | null>(null);
|
||||
const wantRef = useRef(false);
|
||||
const fatalRef = useRef(false);
|
||||
const mediaRecRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
|
||||
/** 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 (
|
||||
<div className="voice-input">
|
||||
<textarea
|
||||
className="voice-textarea"
|
||||
placeholder="此环境不支持语音输入,打字讲也一样——内容到位就行。"
|
||||
rows={4}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
/>
|
||||
<button type="button" className="primary" disabled={disabled || busy || !text.trim()} onClick={submit}>
|
||||
{busy ? '档案馆听写中…' : '提交'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 语音路径:点按开始/结束 + 可编辑转写 + 确认提交 ----
|
||||
return (
|
||||
<div className="voice-input">
|
||||
<textarea
|
||||
className="voice-textarea"
|
||||
rows={5}
|
||||
value={text + interim}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
setInterim('');
|
||||
}}
|
||||
placeholder={
|
||||
useAudioPath
|
||||
? '按住你的想法慢慢说——说完用下面的按钮转写,或直接打字。'
|
||||
: '点「开始讲述」语音输入,或直接打字——说完可以修改,确认后才提交。'
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
{!useAudioPath &&
|
||||
(recording ? (
|
||||
<button type="button" className="hold-btn holding" disabled={disabled} onClick={stopSr}>
|
||||
■ 结束讲述
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="hold-btn" disabled={disabled || busy} onClick={startSr}>
|
||||
🎙 {text ? '继续补充' : '开始讲述'}
|
||||
</button>
|
||||
))}
|
||||
{useAudioPath &&
|
||||
canMediaRecord() &&
|
||||
(recAudio ? (
|
||||
<button type="button" className="hold-btn holding" disabled={disabled} onClick={stopAudio}>
|
||||
■ 停止并转写
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="hold-btn" disabled={disabled || busy} onClick={startAudio}>
|
||||
● {busy ? '转写中…' : text ? '继续录音' : '录音讲述'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{(recording || text || interim) && (
|
||||
<textarea
|
||||
className="voice-textarea"
|
||||
rows={5}
|
||||
value={text + interim}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
{recording && <p className="voice-live">讲述中——停顿会自动续录,说完点「结束讲述」。</p>}
|
||||
{recAudio && <p className="voice-live">录音中——说完点「停止并转写」。</p>}
|
||||
{hint && <p className="voice-live">{hint}</p>}
|
||||
<div className="voice-actions">
|
||||
<button type="button" className="primary" disabled={disabled || busy || !text.trim()} onClick={submit}>
|
||||
{busy ? '档案馆听写中…' : '确认提交'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || busy || !text.trim()}
|
||||
onClick={() => {
|
||||
setText('');
|
||||
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>
|
||||
)}
|
||||
>
|
||||
清空重说
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user