fix: Tauri 窗口 1280x820(最小 1080x700)、identifier 更正、麦克风权限声明;VoiceInput 三通路(SR/录音上传/文本兜底)

This commit is contained in:
Jayway Liu
2026-10-04 07:55:12 +08:00
parent e022faf975
commit 6c01e84876
6 changed files with 196 additions and 91 deletions
+4
View File
@@ -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
View File
@@ -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'],
}),
);
+2 -2
View File
@@ -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"] }
+32
View File
@@ -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>
+5 -3
View File
@@ -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
View File
@@ -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>
);
}