|
|
@@ -0,0 +1,964 @@
|
|
|
+/**
|
|
|
+ * dsh-voice-client — browser bundle (hand-written in the DSH __ModuleLoader__ format).
|
|
|
+ *
|
|
|
+ * Voice interaction for the DeepSeek Harness Web GUI, pure browser-native:
|
|
|
+ * - ASR: Web Speech API (SpeechRecognition) → fills the composer draft (hold-to-talk or toggle)
|
|
|
+ * - TTS: Web Speech API (speechSynthesis) → reads assistant replies aloud (manual + auto)
|
|
|
+ *
|
|
|
+ * Registered slots:
|
|
|
+ * - conversation.input.right → mic button (composer tool row)
|
|
|
+ * - conversation.chat.turnTail → per-turn read-aloud button (+ auto-read on turn close)
|
|
|
+ * - conversation.composer.dock → voice status line (listening / speaking / errors)
|
|
|
+ * - settings.section → voice settings panel (language, mode, toggles, rate)
|
|
|
+ *
|
|
|
+ * No build step: this file IS the bundle. Edit it and the HMR poll picks up the
|
|
|
+ * content change (rev change) automatically; a fresh page load picks up the graph row.
|
|
|
+ */
|
|
|
+window.__ModuleLoader__.load({
|
|
|
+ id: "dsh-voice-client",
|
|
|
+ factory: (require) => {
|
|
|
+ var module = { exports: {} };
|
|
|
+ var exports = module.exports;
|
|
|
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
|
+
|
|
|
+ let react = require("react");
|
|
|
+ let jsxRuntime = require("react/jsx-runtime");
|
|
|
+ const { jsx, jsxs, Fragment } = jsxRuntime;
|
|
|
+ const { useState, useEffect, useRef, useMemo, useSyncExternalStore } = react;
|
|
|
+
|
|
|
+ // ─────────────────────────────── settings ───────────────────────────────
|
|
|
+ const LS_PREFIX = "dshVoice.";
|
|
|
+ const DEFAULT_SETTINGS = Object.freeze({
|
|
|
+ lang: "zh-CN", // 'zh-CN' | 'en-US' | any BCP-47 the engine supports
|
|
|
+ mode: "toggle", // 'toggle' (click start/stop) | 'hold' (push-to-talk)
|
|
|
+ asrEngine: "server", // 'server' (local, offline) | 'browser' (Web Speech API) | 'auto'
|
|
|
+ autoSubmit: false, // send the message right after recognition
|
|
|
+ autoRead: false, // read assistant replies automatically
|
|
|
+ ttsEngine: "edge", // 'edge' (natural, online) | 'piper' (offline) | 'browser' (system voice)
|
|
|
+ ttsVoice: "zh-CN-YunxiNeural", // edge-tts voice (warm male — Jarvis-like)
|
|
|
+ rate: 1.0, // TTS rate
|
|
|
+ skipCode: true // skip fenced code blocks when reading
|
|
|
+ });
|
|
|
+
|
|
|
+ function loadSettings() {
|
|
|
+ const out = { ...DEFAULT_SETTINGS };
|
|
|
+ try {
|
|
|
+ for (const key of Object.keys(DEFAULT_SETTINGS)) {
|
|
|
+ const raw = localStorage.getItem(LS_PREFIX + key);
|
|
|
+ if (raw === null) continue;
|
|
|
+ const def = DEFAULT_SETTINGS[key];
|
|
|
+ if (typeof def === "boolean") out[key] = raw === "true";
|
|
|
+ else if (typeof def === "number") {
|
|
|
+ const n = Number(raw);
|
|
|
+ if (Number.isFinite(n)) out[key] = n;
|
|
|
+ } else out[key] = raw;
|
|
|
+ }
|
|
|
+ } catch { /* storage unavailable — keep defaults */ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+ function saveSettings(patch) {
|
|
|
+ try {
|
|
|
+ for (const [k, v] of Object.entries(patch)) localStorage.setItem(LS_PREFIX + k, String(v));
|
|
|
+ } catch { /* storage unavailable */ }
|
|
|
+ return { ...loadSettings(), ...patch };
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─────────────────────────── shared voice state ──────────────────────────
|
|
|
+ let voiceState = { recording: false, interim: "", speaking: false, speakingKey: null, error: null };
|
|
|
+ const voiceListeners = new Set();
|
|
|
+ function setVoice(patch) {
|
|
|
+ voiceState = { ...voiceState, ...patch };
|
|
|
+ for (const fn of voiceListeners) fn();
|
|
|
+ }
|
|
|
+ function subscribeVoice(fn) {
|
|
|
+ voiceListeners.add(fn);
|
|
|
+ return () => { voiceListeners.delete(fn); };
|
|
|
+ }
|
|
|
+ function getVoice() { return voiceState; }
|
|
|
+ function useVoice() { return useSyncExternalStore(subscribeVoice, getVoice); }
|
|
|
+
|
|
|
+ // ───────────────────────────────── TTS ───────────────────────────────────
|
|
|
+ function ttsSupported() {
|
|
|
+ return typeof window !== "undefined" && "speechSynthesis" in window;
|
|
|
+ }
|
|
|
+
|
|
|
+ let speakGeneration = 0;
|
|
|
+ let resumeTimer = null;
|
|
|
+ let currentAudio = null;
|
|
|
+ function clearResumeTimer() {
|
|
|
+ if (resumeTimer !== null) { clearInterval(resumeTimer); resumeTimer = null; }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Stop any in-flight playback WITHOUT invalidating the generation counter. */
|
|
|
+ function cancelPlayback() {
|
|
|
+ clearResumeTimer();
|
|
|
+ if (ttsSupported()) { try { window.speechSynthesis.cancel(); } catch { /* noop */ } }
|
|
|
+ if (currentAudio) {
|
|
|
+ try { currentAudio.pause(); currentAudio.currentTime = 0; } catch { /* noop */ }
|
|
|
+ try { currentAudio.removeAttribute("src"); currentAudio.load(); } catch { /* noop */ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function ttsStop() {
|
|
|
+ speakGeneration++;
|
|
|
+ cancelPlayback();
|
|
|
+ setVoice({ speaking: false, speakingKey: null });
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Sentence-aware chunking. Chrome's speechSynthesis stalls on very long utterances. */
|
|
|
+ function splitChunks(text) {
|
|
|
+ const chunks = [];
|
|
|
+ let current = "";
|
|
|
+ for (const ch of text) {
|
|
|
+ current += ch;
|
|
|
+ if ("。!?;.!?;\n".includes(ch) || current.length >= 120) {
|
|
|
+ const trimmed = current.trim();
|
|
|
+ if (trimmed) chunks.push(trimmed);
|
|
|
+ current = "";
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const rest = current.trim();
|
|
|
+ if (rest) chunks.push(rest);
|
|
|
+ return chunks;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Strip code fences + markdown syntax so TTS reads clean prose (not "星号星号"). */
|
|
|
+ function cleanMarkdown(text) {
|
|
|
+ return text
|
|
|
+ .replace(/```[\s\S]*?```/g, " ") // fenced code blocks
|
|
|
+ .replace(/`[^`\n]+`/g, " ") // inline code
|
|
|
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") // images
|
|
|
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // links → text
|
|
|
+ .replace(/^#{1,6}\s*/gm, "") // headings
|
|
|
+ .replace(/^\s*[-*+]\s+/gm, "") // bullet lists
|
|
|
+ .replace(/^\s*\d+[.)]\s+/gm, "") // numbered lists
|
|
|
+ .replace(/\*\*([^*\n]+)\*\*/g, "$1") // bold
|
|
|
+ .replace(/\*([^*\n]+)\*/g, "$1") // italic
|
|
|
+ .replace(/__([^_\n]+)__/g, "$1") // bold (underscore)
|
|
|
+ .replace(/_([^_\n]+)_/g, "$1") // italic (underscore)
|
|
|
+ .replace(/<[^>]+>/g, " ") // html tags
|
|
|
+ .replace(/\|/g, ",") // table pipes → pause
|
|
|
+ .replace(/-{3,}/g, " ") // horizontal rules
|
|
|
+ .replace(/[ \t]+/g, " ") // collapse spaces
|
|
|
+ .replace(/\s*\n\s*\n+/g, "\n") // collapse blank lines
|
|
|
+ .trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Speak text aloud. `key` identifies the message so per-message stop works
|
|
|
+ * (the same key is stored in voiceState.speakingKey while speaking).
|
|
|
+ */
|
|
|
+ function speakText(text, key) {
|
|
|
+ if (!text || !text.trim()) return;
|
|
|
+ const engine = loadSettings().ttsEngine;
|
|
|
+ if (engine === "browser" || !ttsSupported()) return speakBrowser(text, key);
|
|
|
+ return speakServer(text, key);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Browser engine: chunked speechSynthesis (system voices). */
|
|
|
+ function speakBrowser(text, key) {
|
|
|
+ if (!ttsSupported()) return;
|
|
|
+ const gen = ++speakGeneration;
|
|
|
+ clearResumeTimer();
|
|
|
+ const synth = window.speechSynthesis;
|
|
|
+ try { synth.cancel(); } catch { /* noop */ }
|
|
|
+ const chunks = splitChunks(text);
|
|
|
+ setVoice({ speaking: true, speakingKey: key ?? null, error: null });
|
|
|
+ // Heartbeat: Chrome can stall synthesis after ~15s of continuous audio.
|
|
|
+ resumeTimer = setInterval(() => { try { synth.resume(); } catch { /* noop */ } }, 10000);
|
|
|
+ let index = 0;
|
|
|
+ const next = () => {
|
|
|
+ if (gen !== speakGeneration) return; // interrupted / stopped
|
|
|
+ if (index >= chunks.length) {
|
|
|
+ if (gen === speakGeneration) {
|
|
|
+ clearResumeTimer();
|
|
|
+ setVoice({ speaking: false, speakingKey: null });
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const settings = loadSettings();
|
|
|
+ const u = new SpeechSynthesisUtterance(chunks[index++]);
|
|
|
+ u.lang = settings.lang;
|
|
|
+ u.rate = settings.rate;
|
|
|
+ u.onend = next;
|
|
|
+ u.onerror = (ev) => {
|
|
|
+ if (gen !== speakGeneration) return;
|
|
|
+ if (ev.error === "interrupted" || ev.error === "canceled") return;
|
|
|
+ clearResumeTimer();
|
|
|
+ setVoice({ speaking: false, speakingKey: null });
|
|
|
+ };
|
|
|
+ try { synth.speak(u); } catch { setVoice({ speaking: false, speakingKey: null }); }
|
|
|
+ };
|
|
|
+ next();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Server engine: edge-tts / piper via /api/voice/tts, played as audio. */
|
|
|
+ function speakServer(text, key) {
|
|
|
+ const gen = ++speakGeneration;
|
|
|
+ cancelPlayback(); // stop current audio without re-incrementing the generation
|
|
|
+ setVoice({ speaking: true, speakingKey: key ?? null, error: null });
|
|
|
+ const settings = loadSettings();
|
|
|
+ fetch("/api/voice/tts", {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "content-type": "application/json" },
|
|
|
+ body: JSON.stringify({ text, engine: settings.ttsEngine, voice: settings.ttsVoice, rate: settings.rate })
|
|
|
+ })
|
|
|
+ .then((res) => {
|
|
|
+ if (!res.ok) throw new Error("HTTP " + res.status);
|
|
|
+ return res.blob();
|
|
|
+ })
|
|
|
+ .then((blob) => {
|
|
|
+ if (gen !== speakGeneration) return;
|
|
|
+ const url = URL.createObjectURL(blob);
|
|
|
+ // Reuse a DOM-attached audio element for reliable autoplay.
|
|
|
+ if (currentAudio === null) {
|
|
|
+ currentAudio = document.createElement("audio");
|
|
|
+ currentAudio.style.display = "none";
|
|
|
+ document.body.appendChild(currentAudio);
|
|
|
+ }
|
|
|
+ const audio = currentAudio;
|
|
|
+ audio.onended = () => {
|
|
|
+ if (gen === speakGeneration) setVoice({ speaking: false, speakingKey: null });
|
|
|
+ URL.revokeObjectURL(url);
|
|
|
+ maybeResume();
|
|
|
+ };
|
|
|
+ audio.onerror = () => {
|
|
|
+ if (gen === speakGeneration) setVoice({ speaking: false, speakingKey: null, error: "语音播放失败" });
|
|
|
+ URL.revokeObjectURL(url);
|
|
|
+ };
|
|
|
+ audio.src = url;
|
|
|
+ audio.play().catch((err) => {
|
|
|
+ if (gen === speakGeneration) setVoice({ speaking: false, speakingKey: null, error: "播放被阻止: " + String(err && err.name || err) });
|
|
|
+ });
|
|
|
+ })
|
|
|
+ .catch((err) => {
|
|
|
+ if (gen === speakGeneration) setVoice({ speaking: false, speakingKey: null, error: "本地语音服务不可用: " + String(err && err.message || err) });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────────────── ASR ───────────────────────────────────
|
|
|
+ function asrSupported() {
|
|
|
+ return typeof window !== "undefined" && Boolean(window.SpeechRecognition || window.webkitSpeechRecognition);
|
|
|
+ }
|
|
|
+
|
|
|
+ let recognizer = null; // browser engine instance (SpeechRecognition)
|
|
|
+ let activeEngine = null; // 'browser' | 'server'
|
|
|
+ let listening = false; // whether the mic should stay on (held / toggled-on)
|
|
|
+ let finalTranscript = "";
|
|
|
+ let baseDraft = ""; // draft text before recording started
|
|
|
+ let lastWritten = ""; // what updateDraft last wrote (to detect user edits)
|
|
|
+ let liveWriting = true; // still allowed to overwrite the draft live
|
|
|
+ let commitOnEnd = null; // { readDraft, setDraft, submit } resolved when recording ends
|
|
|
+ let browserNetworkFailed = false; // auto-mode memory: browser ASR is unusable here
|
|
|
+ let lastMicCtx = null; // latest mic context (set by MicButton) for auto-resume
|
|
|
+ let loopActive = false; // hands-free loop running (自由交流 mode)
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Write the recognized text LIVE into the composer draft — but stop the
|
|
|
+ * moment the user edits the draft themselves, so their typing is never
|
|
|
+ * clobbered by the next recognition update.
|
|
|
+ */
|
|
|
+ function updateDraft(text) {
|
|
|
+ if (!commitOnEnd || !commitOnEnd.setDraft) return;
|
|
|
+ if (liveWriting && commitOnEnd.readDraft) {
|
|
|
+ const current = commitOnEnd.readDraft();
|
|
|
+ if (current !== lastWritten) liveWriting = false; // user took over editing
|
|
|
+ }
|
|
|
+ if (!liveWriting) return;
|
|
|
+ const prefix = baseDraft ? baseDraft + " " : "";
|
|
|
+ const next = prefix + text;
|
|
|
+ lastWritten = next;
|
|
|
+ commitOnEnd.setDraft(next);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** End-of-recording: reconcile the draft and optionally auto-submit. */
|
|
|
+ function commitFinal() {
|
|
|
+ const ctxAtEnd = commitOnEnd;
|
|
|
+ commitOnEnd = null;
|
|
|
+ const text = finalTranscript.trim();
|
|
|
+ finalTranscript = "";
|
|
|
+ if (ctxAtEnd === null) return;
|
|
|
+ // If the user edited mid-recording, append the final transcript to their draft.
|
|
|
+ if (!liveWriting && text && ctxAtEnd.readDraft && ctxAtEnd.setDraft) {
|
|
|
+ const cur = ctxAtEnd.readDraft().trimEnd();
|
|
|
+ ctxAtEnd.setDraft(cur ? cur + " " + text : text);
|
|
|
+ }
|
|
|
+ if (text && loadSettings().autoSubmit && ctxAtEnd.submit) {
|
|
|
+ try { ctxAtEnd.submit(); } catch { /* submission refused (busy) */ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Auto-resume the mic loop after the reply finishes reading (自由交流 mode). */
|
|
|
+ function maybeResume() {
|
|
|
+ const s = loadSettings();
|
|
|
+ if (!s.autoSubmit || !s.autoRead) return; // only in 自由交流 (hands-free) mode
|
|
|
+ if (!loopActive) return;
|
|
|
+ if (listening) return;
|
|
|
+ if (lastMicCtx === null) return;
|
|
|
+ setTimeout(() => {
|
|
|
+ if (!listening && loopActive && loadSettings().autoSubmit) startListening(lastMicCtx);
|
|
|
+ }, 700);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Manual stop: end the hands-free loop too. */
|
|
|
+ function stopLoop() {
|
|
|
+ loopActive = false;
|
|
|
+ stopListening();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Precise mic preflight via getUserMedia — the call that triggers the
|
|
|
+ * OS/browser permission prompt and reports WHICH layer denied access.
|
|
|
+ */
|
|
|
+ async function preflightMic() {
|
|
|
+ if (typeof navigator === "undefined" || !navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") {
|
|
|
+ return { ok: false, reason: "unsupported" };
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
|
+ for (const track of stream.getTracks()) track.stop();
|
|
|
+ return { ok: true };
|
|
|
+ } catch (err) {
|
|
|
+ const name = (err && err.name) || "";
|
|
|
+ if (name === "NotAllowedError" || name === "SecurityError") return { ok: false, reason: "permission" };
|
|
|
+ if (name === "NotFoundError" || name === "OverconstrainedError") return { ok: false, reason: "device" };
|
|
|
+ return { ok: false, reason: "other", detail: String((err && err.message) || err) };
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function micErrorMessage(res) {
|
|
|
+ return res.reason === "permission"
|
|
|
+ ? "麦克风权限被拒绝。请检查两步:① macOS「系统设置 → 隐私与安全性 → 麦克风」勾选允许 Chrome;② 地址栏左侧锁形图标 → 网站设置 → 麦克风选「允许」。改完后刷新页面再试。"
|
|
|
+ : res.reason === "device"
|
|
|
+ ? "未检测到可用的麦克风设备,请在 macOS「系统设置 → 声音 → 输入」确认有输入设备。"
|
|
|
+ : res.reason === "unsupported"
|
|
|
+ ? "此环境不支持麦克风访问(getUserMedia 不可用)。"
|
|
|
+ : "无法访问麦克风: " + (res.detail || res.reason);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Entry point: pick the engine from settings, then start it. */
|
|
|
+ function startListening(ctx) {
|
|
|
+ if (listening) return;
|
|
|
+ ttsStop(); // speaking while the user talks would be confusing — interrupt
|
|
|
+ commitOnEnd = ctx ?? null;
|
|
|
+ finalTranscript = "";
|
|
|
+ baseDraft = (ctx && ctx.readDraft ? ctx.readDraft() : "").trimEnd();
|
|
|
+ lastWritten = baseDraft;
|
|
|
+ liveWriting = true;
|
|
|
+ setVoice({ recording: true, interim: "", error: null });
|
|
|
+ const engine = loadSettings().asrEngine;
|
|
|
+ if (engine === "browser") return startBrowserEngine(ctx);
|
|
|
+ if (engine === "server") return startServerEngine(ctx);
|
|
|
+ // 'auto'
|
|
|
+ if (browserNetworkFailed) return startServerEngine(ctx);
|
|
|
+ startBrowserEngine(ctx);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── browser engine (Web Speech API — needs Google's cloud in Chrome) ────
|
|
|
+ function startBrowserEngine(ctx) {
|
|
|
+ if (!asrSupported()) {
|
|
|
+ listening = false;
|
|
|
+ commitOnEnd = null;
|
|
|
+ setVoice({ recording: false, error: "当前浏览器不支持语音识别(SpeechRecognition)。请在设置里改用「本地服务」识别引擎。" });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ preflightMic().then((res) => {
|
|
|
+ if (!res.ok) {
|
|
|
+ listening = false;
|
|
|
+ commitOnEnd = null;
|
|
|
+ setVoice({ recording: false, interim: "", error: micErrorMessage(res) });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ startRecognizer(ctx);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function startRecognizer(ctx) {
|
|
|
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
|
+ const settings = loadSettings();
|
|
|
+ const rec = new SR();
|
|
|
+ rec.lang = settings.lang;
|
|
|
+ rec.continuous = true;
|
|
|
+ rec.interimResults = true;
|
|
|
+ rec.maxAlternatives = 1;
|
|
|
+
|
|
|
+ rec.onresult = (ev) => {
|
|
|
+ let interim = "";
|
|
|
+ for (let k = ev.resultIndex; k < ev.results.length; k++) {
|
|
|
+ const result = ev.results[k];
|
|
|
+ if (result.isFinal) finalTranscript += result[0].transcript;
|
|
|
+ else interim += result[0].transcript;
|
|
|
+ }
|
|
|
+ setVoice({ interim: interim.trim() });
|
|
|
+ updateDraft((finalTranscript + interim).trim());
|
|
|
+ };
|
|
|
+ rec.onerror = (ev) => {
|
|
|
+ if (ev.error === "aborted") return; // normal when we stop() / abort()
|
|
|
+ listening = false;
|
|
|
+ const e = ev.error;
|
|
|
+ if (e === "not-allowed") {
|
|
|
+ setVoice({ error: "麦克风权限被拒绝。请检查 macOS「系统设置 → 隐私与安全性 → 麦克风」是否允许 Chrome,以及地址栏锁形图标 → 网站设置中麦克风是否为「允许」,然后刷新页面重试。", recording: false, interim: "" });
|
|
|
+ } else if (e === "service-not-allowed" || e === "network") {
|
|
|
+ browserNetworkFailed = true;
|
|
|
+ const hint = loadSettings().asrEngine === "auto"
|
|
|
+ ? "已自动切换到本地识别服务,下次点击收音将使用本地识别。"
|
|
|
+ : "请在设置里把「识别引擎」改为「本地服务」。";
|
|
|
+ setVoice({ error: "浏览器语音识别依赖 Google 服务,当前网络不可用。" + hint, recording: false, interim: "" });
|
|
|
+ } else if (e === "no-speech") {
|
|
|
+ setVoice({ recording: false, interim: "" });
|
|
|
+ } else if (e === "audio-capture") {
|
|
|
+ setVoice({ error: "无法捕获麦克风音频,请检查麦克风设备是否被其他应用占用。", recording: false, interim: "" });
|
|
|
+ } else {
|
|
|
+ setVoice({ error: "语音识别错误: " + e, recording: false, interim: "" });
|
|
|
+ }
|
|
|
+ };
|
|
|
+ rec.onend = () => {
|
|
|
+ if (listening) {
|
|
|
+ try { rec.start(); } catch { listening = false; setVoice({ recording: false, interim: "" }); }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ setVoice({ recording: false, interim: "" });
|
|
|
+ commitFinal();
|
|
|
+ };
|
|
|
+
|
|
|
+ recognizer = rec;
|
|
|
+ activeEngine = "browser";
|
|
|
+ listening = true;
|
|
|
+ try { rec.start(); } catch {
|
|
|
+ listening = false;
|
|
|
+ commitOnEnd = null;
|
|
|
+ setVoice({ recording: false, error: "无法启动语音识别" });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── server engine (local sherpa-onnx via /api/voice/asr WebSocket) ──────
|
|
|
+ let serverWs = null;
|
|
|
+ let serverMicStream = null;
|
|
|
+ let serverAudioCtx = null;
|
|
|
+ let serverSource = null;
|
|
|
+ let serverProcessor = null;
|
|
|
+
|
|
|
+ function serverError(message) {
|
|
|
+ listening = false;
|
|
|
+ commitOnEnd = null;
|
|
|
+ activeEngine = null;
|
|
|
+ if (serverWs) { try { serverWs.close(); } catch { /* noop */ } serverWs = null; }
|
|
|
+ teardownCapture();
|
|
|
+ setVoice({ recording: false, interim: "", error: message });
|
|
|
+ }
|
|
|
+
|
|
|
+ function teardownCapture() {
|
|
|
+ if (serverMicStream) { for (const t of serverMicStream.getTracks()) t.stop(); serverMicStream = null; }
|
|
|
+ if (serverSource) { try { serverSource.disconnect(); } catch { /* noop */ } serverSource = null; }
|
|
|
+ if (serverProcessor) { try { serverProcessor.disconnect(); } catch { /* noop */ } serverProcessor = null; }
|
|
|
+ if (serverAudioCtx) { try { serverAudioCtx.close(); } catch { /* noop */ } serverAudioCtx = null; }
|
|
|
+ }
|
|
|
+
|
|
|
+ function startServerEngine(ctx) {
|
|
|
+ if (typeof WebSocket === "undefined") {
|
|
|
+ serverError("当前浏览器不支持 WebSocket,无法连接本地识别服务。");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const wsUrl = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/api/voice/asr";
|
|
|
+ let ws;
|
|
|
+ try { ws = new WebSocket(wsUrl); } catch {
|
|
|
+ serverError("无法创建本地识别连接。");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ serverWs = ws;
|
|
|
+ ws.binaryType = "arraybuffer";
|
|
|
+ const isCurrent = () => serverWs === ws;
|
|
|
+ ws.onopen = () => { if (isCurrent()) startServerCapture(ws); };
|
|
|
+ ws.onmessage = (ev) => {
|
|
|
+ if (!isCurrent()) return;
|
|
|
+ let msg;
|
|
|
+ try { msg = JSON.parse(ev.data); } catch { return; }
|
|
|
+ if (msg.type === "partial") {
|
|
|
+ setVoice({ interim: msg.text || "" });
|
|
|
+ updateDraft(msg.text || "");
|
|
|
+ } else if (msg.type === "final") {
|
|
|
+ finalTranscript = msg.text || "";
|
|
|
+ setVoice({ interim: "" });
|
|
|
+ updateDraft(finalTranscript);
|
|
|
+ } else if (msg.type === "endpoint") {
|
|
|
+ // Silence detected → auto-stop in 自由交流 mode.
|
|
|
+ if (loadSettings().autoSubmit) stopListening();
|
|
|
+ } else if (msg.type === "error") serverError(msg.message || "本地识别服务错误");
|
|
|
+ };
|
|
|
+ ws.onerror = () => { if (isCurrent()) serverError("本地识别服务未连接。请确认 dsh-voice-server 已安装并已重启服务,或在设置里改用浏览器引擎。"); };
|
|
|
+ ws.onclose = () => {
|
|
|
+ if (!isCurrent()) return;
|
|
|
+ serverWs = null;
|
|
|
+ teardownCapture();
|
|
|
+ setVoice({ recording: false, interim: "" });
|
|
|
+ if (activeEngine === "server") {
|
|
|
+ activeEngine = null;
|
|
|
+ commitFinal();
|
|
|
+ }
|
|
|
+ };
|
|
|
+ activeEngine = "server";
|
|
|
+ listening = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ function startServerCapture(ws) {
|
|
|
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
|
+ serverError("此环境不支持麦克风访问(getUserMedia 不可用)。");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
|
|
|
+ if (ws.readyState !== ws.OPEN) { for (const t of stream.getTracks()) t.stop(); return; }
|
|
|
+ serverMicStream = stream;
|
|
|
+ const Ctx = window.AudioContext || window.webkitAudioContext;
|
|
|
+ const ctx = new Ctx({ sampleRate: 16000 });
|
|
|
+ serverAudioCtx = ctx;
|
|
|
+ const source = ctx.createMediaStreamSource(stream);
|
|
|
+ // ScriptProcessor is deprecated but universally supported; 16k mono PCM is what sherpa expects.
|
|
|
+ const processor = ctx.createScriptProcessor(4096, 1, 1);
|
|
|
+ const zeroGain = ctx.createGain();
|
|
|
+ zeroGain.gain.value = 0; // keep the graph connected without feeding the speakers
|
|
|
+ processor.onaudioprocess = (ev) => {
|
|
|
+ const input = ev.inputBuffer.getChannelData(0);
|
|
|
+ const pcm = new Int16Array(input.length);
|
|
|
+ for (let i = 0; i < input.length; i++) {
|
|
|
+ const s = Math.max(-1, Math.min(1, input[i]));
|
|
|
+ pcm[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
|
|
+ }
|
|
|
+ if (ws.readyState === ws.OPEN) ws.send(pcm.buffer);
|
|
|
+ };
|
|
|
+ source.connect(processor);
|
|
|
+ processor.connect(zeroGain);
|
|
|
+ zeroGain.connect(ctx.destination);
|
|
|
+ serverSource = source;
|
|
|
+ serverProcessor = processor;
|
|
|
+ setVoice({ recording: true, interim: "", error: null });
|
|
|
+ }).catch((err) => {
|
|
|
+ serverError(micErrorMessage({ reason: (err && err.name === "NotAllowedError" || err && err.name === "SecurityError") ? "permission" : (err && err.name === "NotFoundError" ? "device" : "other"), detail: String(err && err.message || err) }));
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function stopListening() {
|
|
|
+ if (!listening) return;
|
|
|
+ listening = false;
|
|
|
+ setVoice({ recording: false, interim: "" });
|
|
|
+ if (activeEngine === "browser") {
|
|
|
+ if (recognizer !== null) { try { recognizer.stop(); } catch { /* noop */ } }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (activeEngine === "server") {
|
|
|
+ teardownCapture();
|
|
|
+ if (serverWs && serverWs.readyState === serverWs.OPEN) {
|
|
|
+ serverWs.send(JSON.stringify({ type: "stop" })); // server replies final, then closes → onclose commits
|
|
|
+ } else {
|
|
|
+ commitFinal();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function cancelListening() {
|
|
|
+ if (activeEngine === "browser" && recognizer !== null) {
|
|
|
+ listening = false;
|
|
|
+ commitOnEnd = null;
|
|
|
+ try { recognizer.abort(); } catch { /* noop */ }
|
|
|
+ } else if (activeEngine === "server") {
|
|
|
+ listening = false;
|
|
|
+ commitOnEnd = null;
|
|
|
+ teardownCapture();
|
|
|
+ if (serverWs) { try { serverWs.close(); } catch { /* noop */ } serverWs = null; }
|
|
|
+ }
|
|
|
+ setVoice({ recording: false, interim: "" });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─────────────────────────────── styles ──────────────────────────────────
|
|
|
+ const css = [
|
|
|
+ ".dsh-voice-btn{display:inline-grid;place-items:center;width:28px;height:28px;padding:0;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,#a8b3c4);cursor:pointer;font-size:14px;line-height:1}",
|
|
|
+ ".dsh-voice-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,140,160,.16));color:var(--dsw-alias-label-primary,#e8edf4)}",
|
|
|
+ ".dsh-voice-btn:disabled{opacity:.35;cursor:not-allowed}",
|
|
|
+ ".dsh-voice-btn-active{color:#ff6b6b}",
|
|
|
+ ".dsh-voice-btn-recording{color:#ff5252;animation:dshVoicePulse 1.2s ease-in-out infinite}",
|
|
|
+ ".dsh-voice-mode{display:inline-flex;align-items:center;gap:4px;width:auto;height:26px;padding:0 10px;font-size:12px;font-weight:500;white-space:nowrap;color:var(--dsw-alias-label-secondary,#a8b3c4)}",
|
|
|
+ ".dsh-voice-mode:hover:not(:disabled){color:var(--dsw-alias-label-primary,#e8edf4)}",
|
|
|
+ "@keyframes dshVoicePulse{0%,100%{opacity:1}50%{opacity:.45}}",
|
|
|
+ ".dsh-voice-dock{display:flex;align-items:center;gap:8px;min-height:18px;font-size:12px;color:var(--dsw-alias-label-secondary,#a8b3c4)}",
|
|
|
+ ".dsh-voice-dock .dsh-voice-interim{color:var(--dsw-alias-label-tertiary,#7c8798);font-style:italic;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%}",
|
|
|
+ ".dsh-voice-dock .dsh-voice-error{color:#ff6b6b}",
|
|
|
+ ".dsh-voice-read{display:inline-flex;align-items:center;margin-left:6px}",
|
|
|
+ ".dsh-voice-settings{display:flex;flex-direction:column;gap:12px;padding:16px}",
|
|
|
+ ".dsh-voice-settings h3{margin:0 0 4px;font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary,#e8edf4)}",
|
|
|
+ ".dsh-voice-row{display:flex;align-items:center;justify-content:space-between;gap:16px;font-size:13px;color:var(--dsw-alias-label-secondary,#a8b3c4)}",
|
|
|
+ ".dsh-voice-row label{display:flex;align-items:center;gap:8px;cursor:pointer}",
|
|
|
+ ".dsh-voice-row input[type=range]{width:140px}",
|
|
|
+ ".dsh-voice-row select{background:var(--dsw-alias-bg-layer-2,#1b2230);color:var(--dsw-alias-label-primary,#e8edf4);border:1px solid var(--dsw-alias-border-l1,#2a3446);border-radius:8px;padding:4px 8px;font-size:13px}"
|
|
|
+ ].join("");
|
|
|
+ const styleTagId = "dsh-voice-client/styles";
|
|
|
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(styleTagId) + "]") === null) {
|
|
|
+ const tag = document.createElement("style");
|
|
|
+ tag.dataset.plugin = "dsh-voice-client";
|
|
|
+ tag.dataset.pluginCss = styleTagId;
|
|
|
+ tag.textContent = css;
|
|
|
+ document.head.appendChild(tag);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─────────────────────────────── locale ──────────────────────────────────
|
|
|
+ const NS = "voice";
|
|
|
+ const zh = {
|
|
|
+ "mic.title.hold": "语音输入:按住说话",
|
|
|
+ "mic.title.toggle": "语音输入:点击开始 / 再点结束",
|
|
|
+ "mic.title.unsupported": "当前浏览器不支持语音识别,请使用 Chrome 或 Edge",
|
|
|
+ "mic.recording": "松开完成识别",
|
|
|
+ "status.listening": "正在聆听…",
|
|
|
+ "status.speaking": "正在朗读…",
|
|
|
+ "read.speak": "朗读这条回复",
|
|
|
+ "read.stop": "停止朗读",
|
|
|
+ "settings.title": "语音交流",
|
|
|
+ "settings.lang": "识别 / 朗读语言",
|
|
|
+ "settings.engine": "识别引擎",
|
|
|
+ "settings.engine.server": "本地服务(推荐,离线)",
|
|
|
+ "settings.engine.browser": "浏览器(依赖 Google 服务)",
|
|
|
+ "settings.engine.auto": "自动(优先浏览器,失败回退本地)",
|
|
|
+ "settings.mode": "说话方式",
|
|
|
+ "settings.mode.hold": "按住说话",
|
|
|
+ "settings.mode.toggle": "点击切换",
|
|
|
+ "mode.dictation": "听写模式",
|
|
|
+ "mode.chat": "自由交流模式",
|
|
|
+ "mode.toChat": "切换到自由交流模式(说完自动发送并朗读回复)",
|
|
|
+ "mode.toDictation": "切换到听写模式(手动发送)",
|
|
|
+ "settings.autoSubmit": "识别后自动发送",
|
|
|
+ "settings.autoRead": "自动朗读回复",
|
|
|
+ "settings.tts": "朗读语音引擎",
|
|
|
+ "settings.tts.edge": "微软神经语音(自然,需联网)",
|
|
|
+ "settings.tts.piper": "Piper 本地(离线)",
|
|
|
+ "settings.tts.browser": "系统语音",
|
|
|
+ "settings.voice": "朗读音色",
|
|
|
+ "settings.rate": "朗读语速",
|
|
|
+ "settings.skipCode": "朗读时跳过代码块",
|
|
|
+ "settings.test": "试听",
|
|
|
+ "settings.test.text": "你好,我是你的语音助手。现在可以开始语音交流了。",
|
|
|
+ "settings.unsupported": "当前浏览器不支持语音识别(请使用 Chrome 或 Edge),但朗读功能可用。"
|
|
|
+ };
|
|
|
+ const en = {
|
|
|
+ "mic.title.hold": "Voice input: hold to talk",
|
|
|
+ "mic.title.toggle": "Voice input: click to start / click again to stop",
|
|
|
+ "mic.title.unsupported": "Speech recognition is not supported by this browser; use Chrome or Edge",
|
|
|
+ "mic.recording": "Release to finish",
|
|
|
+ "status.listening": "Listening…",
|
|
|
+ "status.speaking": "Speaking…",
|
|
|
+ "read.speak": "Read this reply aloud",
|
|
|
+ "read.stop": "Stop reading",
|
|
|
+ "settings.title": "Voice",
|
|
|
+ "settings.lang": "Recognition / reading language",
|
|
|
+ "settings.engine": "Recognition engine",
|
|
|
+ "settings.engine.server": "Local service (offline, recommended)",
|
|
|
+ "settings.engine.browser": "Browser (needs Google service)",
|
|
|
+ "settings.engine.auto": "Auto (browser first, fall back to local)",
|
|
|
+ "settings.mode": "Input mode",
|
|
|
+ "settings.mode.hold": "Hold to talk",
|
|
|
+ "settings.mode.toggle": "Click to toggle",
|
|
|
+ "mode.dictation": "Dictation",
|
|
|
+ "mode.chat": "Voice chat",
|
|
|
+ "mode.toChat": "Switch to voice chat (auto-send and read replies)",
|
|
|
+ "mode.toDictation": "Switch to dictation (send manually)",
|
|
|
+ "settings.autoSubmit": "Auto-send after recognition",
|
|
|
+ "settings.autoRead": "Auto-read replies",
|
|
|
+ "settings.tts": "Reading voice engine",
|
|
|
+ "settings.tts.edge": "Microsoft neural (natural, online)",
|
|
|
+ "settings.tts.piper": "Piper local (offline)",
|
|
|
+ "settings.tts.browser": "System voice",
|
|
|
+ "settings.voice": "Reading voice",
|
|
|
+ "settings.rate": "Reading rate",
|
|
|
+ "settings.skipCode": "Skip code blocks when reading",
|
|
|
+ "settings.test": "Test",
|
|
|
+ "settings.test.text": "Hi, I am your voice assistant. Let's start talking.",
|
|
|
+ "settings.unsupported": "Speech recognition is not supported by this browser (use Chrome or Edge); reading aloud still works."
|
|
|
+ };
|
|
|
+
|
|
|
+ // ─────────────────────────── helper: assistant text ──────────────────────
|
|
|
+ function assistantTextOf(node) {
|
|
|
+ if (!node || node.kind !== "assistant" || !Array.isArray(node.blocks)) return "";
|
|
|
+ const text = node.blocks.filter((b) => b.kind === "text").map((b) => b.text).join("");
|
|
|
+ const cleaned = loadSettings().skipCode ? cleanMarkdown(text) : text;
|
|
|
+ return cleaned;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────── component: VoiceModeToggle ────────────────────────
|
|
|
+ function VoiceModeToggle(props) {
|
|
|
+ const { t } = props;
|
|
|
+ const [, force] = useState(0);
|
|
|
+ const chat = loadSettings().autoSubmit; // true = 自由交流, false = 听写
|
|
|
+ const toggle = () => {
|
|
|
+ saveSettings({ autoSubmit: !chat, autoRead: !chat });
|
|
|
+ force((n) => n + 1);
|
|
|
+ };
|
|
|
+ const label = chat ? "💬 " + t("mode.chat") : "✍️ " + t("mode.dictation");
|
|
|
+ return jsx("button", {
|
|
|
+ type: "button",
|
|
|
+ className: "dsh-voice-btn dsh-voice-mode" + (chat ? " dsh-voice-btn-active" : ""),
|
|
|
+ title: chat ? t("mode.toDictation") : t("mode.toChat"),
|
|
|
+ "aria-label": chat ? t("mode.toDictation") : t("mode.toChat"),
|
|
|
+ onClick: toggle,
|
|
|
+ children: label
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─────────────────────────── component: MicButton ────────────────────────
|
|
|
+ function MicButton(props) {
|
|
|
+ const { t, input, inputActions } = props;
|
|
|
+ const voice = useVoice();
|
|
|
+ const recording = voice.recording;
|
|
|
+ const serverOk = typeof navigator !== "undefined" && !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
|
|
+ const supported = asrSupported() || serverOk;
|
|
|
+ const mode = loadSettings().mode;
|
|
|
+
|
|
|
+ const draftRef = useRef("");
|
|
|
+ draftRef.current = typeof input?.draft === "string" ? input.draft : "";
|
|
|
+ const actionsRef = useRef({ setDraft: null, submit: null });
|
|
|
+ actionsRef.current = {
|
|
|
+ setDraft: typeof inputActions?.setDraft === "function" ? inputActions.setDraft : null,
|
|
|
+ submit: typeof inputActions?.submit === "function" ? inputActions.submit : null
|
|
|
+ };
|
|
|
+
|
|
|
+ const makeCtx = () => ({
|
|
|
+ readDraft: () => draftRef.current,
|
|
|
+ setDraft: (text) => actionsRef.current.setDraft?.(text),
|
|
|
+ submit: () => actionsRef.current.submit?.()
|
|
|
+ });
|
|
|
+ // Publish the mic context so the auto-resume loop can restart listening.
|
|
|
+ useEffect(() => { lastMicCtx = makeCtx(); });
|
|
|
+
|
|
|
+ const handleStart = () => {
|
|
|
+ if (!supported || recording) return;
|
|
|
+ loopActive = loadSettings().autoSubmit === true; // hands-free loop only in 自由交流
|
|
|
+ startListening(makeCtx());
|
|
|
+ };
|
|
|
+ const handleStop = () => {
|
|
|
+ if (!recording) return;
|
|
|
+ stopLoop();
|
|
|
+ };
|
|
|
+ const handleToggle = () => {
|
|
|
+ if (recording) handleStop(); else handleStart();
|
|
|
+ };
|
|
|
+
|
|
|
+ const title = !supported ? t("mic.title.unsupported")
|
|
|
+ : recording ? t("mic.recording")
|
|
|
+ : mode === "hold" ? t("mic.title.hold") : t("mic.title.toggle");
|
|
|
+
|
|
|
+ const buttonProps = {
|
|
|
+ type: "button",
|
|
|
+ className: "dsh-voice-btn" + (recording ? " dsh-voice-btn-recording" : ""),
|
|
|
+ title,
|
|
|
+ "aria-label": title,
|
|
|
+ disabled: !supported,
|
|
|
+ children: recording ? "⏹" : "🎤"
|
|
|
+ };
|
|
|
+
|
|
|
+ if (mode === "hold") {
|
|
|
+ buttonProps.onPointerDown = (e) => {
|
|
|
+ e.preventDefault();
|
|
|
+ try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* noop */ }
|
|
|
+ handleStart();
|
|
|
+ };
|
|
|
+ buttonProps.onPointerUp = (e) => {
|
|
|
+ e.preventDefault();
|
|
|
+ handleStop();
|
|
|
+ };
|
|
|
+ buttonProps.onPointerCancel = () => { handleStop(); };
|
|
|
+ } else {
|
|
|
+ buttonProps.onClick = handleToggle;
|
|
|
+ }
|
|
|
+
|
|
|
+ return jsx("button", buttonProps);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─────────────────────────── component: ReadAloud ────────────────────────
|
|
|
+ const spokenTurns = new Set(); // session:seq keys already auto-read this fiber
|
|
|
+ function ReadAloud(props) {
|
|
|
+ const { t, messageId, useSession } = props;
|
|
|
+ const voice = useVoice();
|
|
|
+ const sessionId = useSession((s) => (s ? s.sessionId : undefined));
|
|
|
+ const node = useSession((s) => (s && Array.isArray(s.nodes) ? s.nodes.find((n) => n.kind === "assistant" && n.messageId === messageId) : undefined));
|
|
|
+ const text = useMemo(() => assistantTextOf(node), [node]);
|
|
|
+ const key = "msg:" + (sessionId ? sessionId + ":" : "") + String(messageId);
|
|
|
+ const isSpeaking = voice.speaking && voice.speakingKey === key;
|
|
|
+
|
|
|
+ // Auto-read when the turn closes (component mounts after finalize).
|
|
|
+ useEffect(() => {
|
|
|
+ if (!loadSettings().autoRead || !text || !sessionId) return;
|
|
|
+ if (spokenTurns.has(key)) return;
|
|
|
+ spokenTurns.add(key);
|
|
|
+ const timer = setTimeout(() => speakText(text, key), 400);
|
|
|
+ return () => clearTimeout(timer);
|
|
|
+ }, [key, text, sessionId]);
|
|
|
+
|
|
|
+ const toggle = () => {
|
|
|
+ if (isSpeaking) ttsStop();
|
|
|
+ else speakText(text, key);
|
|
|
+ };
|
|
|
+
|
|
|
+ return jsx("div", {
|
|
|
+ className: "dsh-voice-read",
|
|
|
+ children: jsx("button", {
|
|
|
+ type: "button",
|
|
|
+ className: "dsh-voice-btn" + (isSpeaking ? " dsh-voice-btn-active" : ""),
|
|
|
+ title: isSpeaking ? t("read.stop") : t("read.speak"),
|
|
|
+ "aria-label": isSpeaking ? t("read.stop") : t("read.speak"),
|
|
|
+ disabled: !text,
|
|
|
+ onClick: toggle,
|
|
|
+ children: isSpeaking ? "🔇" : "🔊"
|
|
|
+ })
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ──────────────────────── component: VoiceStatusLine ─────────────────────
|
|
|
+ function VoiceStatusLine(props) {
|
|
|
+ const { t } = props;
|
|
|
+ const voice = useVoice();
|
|
|
+ if (!voice.recording && !voice.speaking && !voice.error) return null;
|
|
|
+ let content;
|
|
|
+ if (voice.error) {
|
|
|
+ content = jsx("span", { className: "dsh-voice-error", children: "⚠️ " + voice.error });
|
|
|
+ } else if (voice.recording) {
|
|
|
+ content = jsxs(Fragment, {
|
|
|
+ children: [
|
|
|
+ jsx("span", { children: "🎙️ " + t("status.listening") }),
|
|
|
+ voice.interim ? jsx("span", { className: "dsh-voice-interim", children: "「" + voice.interim + "」" }) : null
|
|
|
+ ]
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ content = jsx("span", { children: "🔊 " + t("status.speaking") });
|
|
|
+ }
|
|
|
+ return jsx("div", { className: "dsh-voice-dock", children: content });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────── component: VoiceSettings ──────────────────────
|
|
|
+ function VoiceSettings(props) {
|
|
|
+ const { t } = props;
|
|
|
+ const [settings, setSettings] = useState(loadSettings);
|
|
|
+ const update = (patch) => setSettings(saveSettings(patch));
|
|
|
+
|
|
|
+ const lang = settings.lang;
|
|
|
+ const mode = settings.mode;
|
|
|
+ const engine = settings.asrEngine;
|
|
|
+ const row = (label, control) => jsx("div", { className: "dsh-voice-row", children: [jsx("span", { children: label }), control] });
|
|
|
+
|
|
|
+ const children = [
|
|
|
+ jsx("h3", { children: "🗣️ " + t("settings.title") }),
|
|
|
+ !asrSupported() ? jsx("div", { className: "dsh-voice-row dsh-voice-error", children: t("settings.unsupported") }) : null,
|
|
|
+ row(t("settings.engine"), jsx("select", {
|
|
|
+ value: engine,
|
|
|
+ onChange: (e) => update({ asrEngine: e.target.value }),
|
|
|
+ children: [
|
|
|
+ jsx("option", { value: "server", children: t("settings.engine.server") }),
|
|
|
+ jsx("option", { value: "browser", children: t("settings.engine.browser") }),
|
|
|
+ jsx("option", { value: "auto", children: t("settings.engine.auto") })
|
|
|
+ ]
|
|
|
+ })),
|
|
|
+ row(t("settings.lang"), jsx("select", {
|
|
|
+ value: lang,
|
|
|
+ onChange: (e) => update({ lang: e.target.value }),
|
|
|
+ children: [
|
|
|
+ jsx("option", { value: "zh-CN", children: "中文(普通话)" }),
|
|
|
+ jsx("option", { value: "zh-TW", children: "中文(台湾)" }),
|
|
|
+ jsx("option", { value: "en-US", children: "English (US)" }),
|
|
|
+ jsx("option", { value: "en-GB", children: "English (UK)" }),
|
|
|
+ jsx("option", { value: "ja-JP", children: "日本語" }),
|
|
|
+ jsx("option", { value: "ko-KR", children: "한국어" })
|
|
|
+ ]
|
|
|
+ })),
|
|
|
+ row(t("settings.mode"), jsxs("span", {
|
|
|
+ children: [
|
|
|
+ jsx("label", { children: [jsx("input", { type: "radio", name: "dsh-voice-mode", checked: mode === "hold", onChange: () => update({ mode: "hold" }) }), t("settings.mode.hold")] }),
|
|
|
+ jsx("label", { children: [jsx("input", { type: "radio", name: "dsh-voice-mode", checked: mode === "toggle", onChange: () => update({ mode: "toggle" }) }), t("settings.mode.toggle")] })
|
|
|
+ ]
|
|
|
+ })),
|
|
|
+ row(t("settings.autoSubmit"), jsx("input", { type: "checkbox", checked: settings.autoSubmit, onChange: (e) => update({ autoSubmit: e.target.checked }) })),
|
|
|
+ row(t("settings.autoRead"), jsx("input", { type: "checkbox", checked: settings.autoRead, onChange: (e) => update({ autoRead: e.target.checked }) })),
|
|
|
+ row(t("settings.tts"), jsx("select", {
|
|
|
+ value: settings.ttsEngine,
|
|
|
+ onChange: (e) => update({ ttsEngine: e.target.value }),
|
|
|
+ children: [
|
|
|
+ jsx("option", { value: "edge", children: t("settings.tts.edge") }),
|
|
|
+ jsx("option", { value: "piper", children: t("settings.tts.piper") }),
|
|
|
+ jsx("option", { value: "browser", children: t("settings.tts.browser") })
|
|
|
+ ]
|
|
|
+ })),
|
|
|
+ settings.ttsEngine === "edge" ? row(t("settings.voice"), jsx("select", {
|
|
|
+ value: settings.ttsVoice,
|
|
|
+ onChange: (e) => update({ ttsVoice: e.target.value }),
|
|
|
+ children: [
|
|
|
+ jsx("option", { value: "zh-CN-XiaoyiNeural", children: "晓伊(温暖活泼 女声)" }),
|
|
|
+ jsx("option", { value: "zh-CN-XiaoxiaoNeural", children: "晓晓(温柔 女声)" }),
|
|
|
+ jsx("option", { value: "zh-CN-YunxiNeural", children: "云希(温暖 男声)" }),
|
|
|
+ jsx("option", { value: "zh-CN-YunjianNeural", children: "云健(沉稳 男声)" }),
|
|
|
+ jsx("option", { value: "zh-CN-YunxiaNeural", children: "云夏(活泼 男声)" }),
|
|
|
+ jsx("option", { value: "zh-CN-YunyangNeural", children: "云扬(新闻 男声)" })
|
|
|
+ ]
|
|
|
+ })) : null,
|
|
|
+ row(t("settings.rate"), jsx("input", {
|
|
|
+ type: "range", min: 0.5, max: 2, step: 0.1, value: settings.rate,
|
|
|
+ onChange: (e) => update({ rate: Number(e.target.value) }),
|
|
|
+ title: String(settings.rate)
|
|
|
+ })),
|
|
|
+ row(t("settings.skipCode"), jsx("input", { type: "checkbox", checked: settings.skipCode, onChange: (e) => update({ skipCode: e.target.checked }) })),
|
|
|
+ row(t("settings.test"), jsx("button", {
|
|
|
+ type: "button",
|
|
|
+ className: "dsh-voice-btn",
|
|
|
+ onClick: () => speakText(t("settings.test.text"), "voice-test"),
|
|
|
+ children: "🔊"
|
|
|
+ }))
|
|
|
+ ];
|
|
|
+
|
|
|
+ return jsx("section", { className: "dsh-voice-settings", children });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─────────────────────────────── registration ────────────────────────────
|
|
|
+ const inject = ["slots", "locale"];
|
|
|
+
|
|
|
+ function apply(ctx) {
|
|
|
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-voice: dictionaries");
|
|
|
+
|
|
|
+ // Core: read-aloud button (+ auto-read) on each finalized assistant message.
|
|
|
+ ctx.slots.inject("conversation.chat.assistant-actions", () => ctx.slots.register({
|
|
|
+ name: "conversation.chat.assistant-actions",
|
|
|
+ id: "dsh-voice-read",
|
|
|
+ order: 100,
|
|
|
+ locale: NS
|
|
|
+ }, ReadAloud));
|
|
|
+
|
|
|
+ // Core: mic button in the composer tool row.
|
|
|
+ ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
|
|
|
+ name: "conversation.input.right",
|
|
|
+ id: "dsh-voice-mic",
|
|
|
+ order: 100,
|
|
|
+ locale: NS
|
|
|
+ }, MicButton));
|
|
|
+
|
|
|
+ // Voice status line under the composer.
|
|
|
+ ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({
|
|
|
+ name: "conversation.composer.dock",
|
|
|
+ id: "dsh-voice-status",
|
|
|
+ order: 100,
|
|
|
+ locale: NS
|
|
|
+ }, VoiceStatusLine));
|
|
|
+
|
|
|
+ // Voice settings section.
|
|
|
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
|
+ name: "settings.section",
|
|
|
+ id: "dsh-voice-settings",
|
|
|
+ order: 25,
|
|
|
+ label: () => ctx.locale.bind(NS)("settings.title"),
|
|
|
+ locale: NS
|
|
|
+ }, VoiceSettings));
|
|
|
+
|
|
|
+ // Dictation / voice-chat mode switch in the composer tool row (left).
|
|
|
+ ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
|
|
|
+ name: "conversation.input.left",
|
|
|
+ id: "dsh-voice-mode-toggle",
|
|
|
+ order: 100,
|
|
|
+ locale: NS
|
|
|
+ }, VoiceModeToggle));
|
|
|
+ }
|
|
|
+
|
|
|
+ exports.apply = apply;
|
|
|
+ exports.inject = inject;
|
|
|
+ return module.exports;
|
|
|
+ }
|
|
|
+});
|