fix: Tauri 窗口 1280x820(最小 1080x700)、identifier 更正、麦克风权限声明;VoiceInput 三通路(SR/录音上传/文本兜底)
This commit is contained in:
@@ -6,3 +6,7 @@ VITE_TYPESAFE_API_KEY=
|
|||||||
# 未配置时回退为直连模式(浏览器可能遇到 CORS)。
|
# 未配置时回退为直连模式(浏览器可能遇到 CORS)。
|
||||||
# VITE_JEV_PROXY_URL=http://localhost:8787
|
# VITE_JEV_PROXY_URL=http://localhost:8787
|
||||||
VITE_JEV_PROXY_URL=
|
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(
|
app.use(
|
||||||
'*',
|
'*',
|
||||||
cors({
|
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"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.7.1" }
|
tauri-build = { version = "2.7.1", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
tauri = { version = "2.12.1" }
|
tauri = { version = "2.12.1", features = [] }
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
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",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "jev-dating-match",
|
"productName": "jev-dating-match",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.tauri.dev",
|
"identifier": "cn.sciark.jevmatch",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
"devUrl": "http://localhost:5173",
|
"devUrl": "http://localhost:5173",
|
||||||
@@ -13,8 +13,10 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "苍梧疑冢",
|
"title": "苍梧疑冢",
|
||||||
"width": 800,
|
"width": 1280,
|
||||||
"height": 600,
|
"height": 820,
|
||||||
|
"minWidth": 1080,
|
||||||
|
"minHeight": 700,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"fullscreen": false
|
"fullscreen": false
|
||||||
}
|
}
|
||||||
|
|||||||
+152
-85
@@ -1,16 +1,16 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 语音讲述输入(Web Speech API,优雅降级)。
|
* 语音讲述输入。三条通路,按能力自动选择,文本框永远可用:
|
||||||
*
|
*
|
||||||
* 交互模型(为长讲述设计):
|
* 1. SpeechRecognition(桌面 Chrome/Edge):点按开始/结束,实时转写,沉默自动续录;
|
||||||
* - 点「开始讲述」开始,点「结束讲述」停止——不按住(记忆讲述动辄几分钟,按住不现实);
|
* 2. MediaRecorder 录音上传(Tauri WKWebView / 不支持 SR 的环境):
|
||||||
* - 浏览器沉默超时导致识别自动结束时,只要用户没点停止就**自动续录**;
|
* 录音 → 上传 server 的 /api/asr → 转写文本回填进可编辑文本框;
|
||||||
* - 识别结果实时落进**可编辑文本框**,说完可以改,点「确认提交」才进入剧情——
|
* 服务端未配置真实 ASR provider 时给出提示,文本输入兜底;
|
||||||
* 绝不自动提交半截内容;
|
* 3. 纯文本输入:任何环境下的保底路径。
|
||||||
* - 不支持 SpeechRecognition 的环境(如 Tauri WKWebView):纯文本输入,不显示为错误。
|
|
||||||
*
|
*
|
||||||
* ADR-0004:只保留转写文本,不保存任何音频(Web Speech API 天然满足——直接给文本)。
|
* 统一规则:转写一律落进可编辑文本框,用户确认后才提交——绝不自动提交半截内容。
|
||||||
|
* ADR-0004:只保留转写文本;音频 blob 上传后即弃,客户端与服务端都不落盘。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface SpeechRecognitionResultLike {
|
interface SpeechRecognitionResultLike {
|
||||||
@@ -45,6 +45,12 @@ function getSRCtor(): SRCtor | null {
|
|||||||
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? 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 {
|
interface Props {
|
||||||
/** 用户点「确认提交」后回调最终文本 */
|
/** 用户点「确认提交」后回调最终文本 */
|
||||||
onSubmit: (text: string) => void | Promise<void>;
|
onSubmit: (text: string) => void | Promise<void>;
|
||||||
@@ -53,18 +59,25 @@ interface Props {
|
|||||||
|
|
||||||
export function VoiceInput({ onSubmit, disabled }: Props) {
|
export function VoiceInput({ onSubmit, disabled }: Props) {
|
||||||
const SR = useMemo(() => getSRCtor(), []);
|
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 [interim, setInterim] = useState('');
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [hint, setHint] = useState<string | null>(null);
|
const [hint, setHint] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
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;
|
if (!SR) return;
|
||||||
try {
|
try {
|
||||||
const rec = new SR();
|
const rec = new SR();
|
||||||
@@ -81,46 +94,101 @@ export function VoiceInput({ onSubmit, disabled }: Props) {
|
|||||||
setInterim(interimText);
|
setInterim(interimText);
|
||||||
};
|
};
|
||||||
rec.onerror = (e) => {
|
rec.onerror = (e) => {
|
||||||
if (e.error === 'not-allowed' || e.error === 'service-not-allowed') {
|
if (e.error === 'not-allowed' || e.error === 'service-not-allowed') fatalRef.current = true;
|
||||||
fatalRef.current = true;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
rec.onend = () => {
|
rec.onend = () => {
|
||||||
recRef.current = null;
|
recRef.current = null;
|
||||||
setInterim('');
|
setInterim('');
|
||||||
if (wantRef.current && !fatalRef.current) {
|
if (wantRef.current && !fatalRef.current) {
|
||||||
launch(); // 沉默超时,自动续录
|
launchSr(); // 沉默超时,自动续录
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRecording(false);
|
setRecording(false);
|
||||||
if (fatalRef.current) {
|
if (fatalRef.current) setHint('浏览器识别不可用——可以录音上传,或直接打字。');
|
||||||
setHint('麦克风没授权成功——可以直接打字,内容到位就行。');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
recRef.current = rec;
|
recRef.current = rec;
|
||||||
rec.start();
|
rec.start();
|
||||||
} catch {
|
} catch {
|
||||||
recRef.current = null;
|
recRef.current = null;
|
||||||
setRecording(false);
|
setRecording(false);
|
||||||
setHint('语音启动失败——可以直接打字。');
|
fatalRef.current = true;
|
||||||
|
setHint('浏览器识别启动失败——可以录音上传,或直接打字。');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startListening() {
|
function startSr() {
|
||||||
wantRef.current = true;
|
wantRef.current = true;
|
||||||
fatalRef.current = false;
|
fatalRef.current = false;
|
||||||
setHint(null);
|
setHint(null);
|
||||||
setRecording(true);
|
setRecording(true);
|
||||||
launch();
|
launchSr();
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopListening() {
|
function stopSr() {
|
||||||
wantRef.current = false;
|
wantRef.current = false;
|
||||||
setRecording(false);
|
setRecording(false);
|
||||||
try {
|
try {
|
||||||
recRef.current?.stop();
|
recRef.current?.stop();
|
||||||
} catch {
|
} 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 {
|
try {
|
||||||
recRef.current?.stop();
|
recRef.current?.stop();
|
||||||
} catch {
|
} 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 (
|
return (
|
||||||
<div className="voice-input">
|
<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">
|
<div className="voice-controls">
|
||||||
{recording ? (
|
{!useAudioPath &&
|
||||||
<button type="button" className="hold-btn holding" disabled={disabled} onClick={stopListening}>
|
(recording ? (
|
||||||
■ 结束讲述
|
<button type="button" className="hold-btn holding" disabled={disabled} onClick={stopSr}>
|
||||||
</button>
|
■ 结束讲述
|
||||||
) : (
|
</button>
|
||||||
<button type="button" className="hold-btn" disabled={disabled || busy} onClick={startListening}>
|
) : (
|
||||||
🎙 {text ? '继续补充' : '开始讲述'}
|
<button type="button" className="hold-btn" disabled={disabled || busy} onClick={startSr}>
|
||||||
</button>
|
🎙 {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>
|
</div>
|
||||||
{(recording || text || interim) && (
|
{recording && <p className="voice-live">讲述中——停顿会自动续录,说完点「结束讲述」。</p>}
|
||||||
<textarea
|
{recAudio && <p className="voice-live">录音中——说完点「停止并转写」。</p>}
|
||||||
className="voice-textarea"
|
{hint && <p className="voice-live">{hint}</p>}
|
||||||
rows={5}
|
<div className="voice-actions">
|
||||||
value={text + interim}
|
<button type="button" className="primary" disabled={disabled || busy || !text.trim()} onClick={submit}>
|
||||||
onChange={(e) => {
|
{busy ? '档案馆听写中…' : '确认提交'}
|
||||||
setText(e.target.value);
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || busy || !text.trim()}
|
||||||
|
onClick={() => {
|
||||||
|
setText('');
|
||||||
setInterim('');
|
setInterim('');
|
||||||
}}
|
}}
|
||||||
placeholder={recording ? '正在听……说慢点没关系,停顿不会断。' : ''}
|
>
|
||||||
/>
|
清空重说
|
||||||
)}
|
</button>
|
||||||
{recording && <p className="voice-live">讲述中——停顿会自动续录,说完点「结束讲述」,还能改。</p>}
|
</div>
|
||||||
{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