feat: add voice mode (STT/TTS) to terminal page
Deploy Dashboard / deploy (push) Successful in 1m10s

This commit is contained in:
Gan, Jimmy
2026-02-26 15:35:43 +08:00
parent 2f98c1f335
commit fa4da3868e
2 changed files with 176 additions and 2 deletions
+135
View File
@@ -0,0 +1,135 @@
const LANGS = ["en-US", "zh-CN", "zh-HK"];
const LANG_LABELS = ["EN", "普", "粤"];
const NOISE = /^[\s│┃┆┇┊┋─━┄┅┈┉═╌╍╔╗╚╝╠╣╦╩╬├┤┬┴┼╭╮╯╰░▒▓█▌▐▀▄⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏◐◑◒◓⣾⣽⣻⢿⡿⣟⣯⣷⠁⠂⠄⡀⢀⠠⠐⠈\-–—_=+*#~<>/.\\|,;:!?(){}\[\]]+$/;
export class VoiceSession {
constructor(onChange) {
this.onChange = onChange;
this.ttsEnabled = false;
this.speaking = false;
this.listening = false;
this.langIdx = 0;
this.term = null;
this.ws = null;
this._lastLine = 0;
this._debounce = null;
this._recog = null;
this._onWriteDispose = null;
}
get lang() { return LANGS[this.langIdx]; }
get langLabel() { return LANG_LABELS[this.langIdx]; }
attach(term, ws) {
this.term = term;
this.ws = ws;
this._lastLine = term.buffer.active.cursorY + term.buffer.active.baseY;
this._onWriteDispose = term.onWriteParsed(() => this._onOutput());
}
_notify() { this.onChange?.(); }
_onOutput() {
if (!this.ttsEnabled || this.listening) return;
clearTimeout(this._debounce);
this._debounce = setTimeout(() => this._readBuffer(), 600);
}
_readBuffer() {
const buf = this.term?.buffer?.active;
if (!buf || buf.type !== "normal") return;
const curLine = buf.baseY + buf.cursorY;
const lines = [];
for (let y = this._lastLine + 1; y <= curLine; y++) {
const row = buf.getLine(y);
if (!row) continue;
const text = row.translateToString(true).trim();
if (text.length >= 4 && !NOISE.test(text)) lines.push(text);
}
this._lastLine = curLine;
if (lines.length) this._speak(lines.join(". "));
}
_speak(text) {
if (!text) return;
speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.lang = this.lang;
const voices = speechSynthesis.getVoices();
const v = voices.find(v => v.lang.startsWith(this.lang.split("-")[0]));
if (v) u.voice = v;
u.onstart = () => { this.speaking = true; this._notify(); };
u.onend = () => { this.speaking = false; this._notify(); };
u.onerror = () => { this.speaking = false; this._notify(); };
speechSynthesis.speak(u);
}
toggleTts() {
this.ttsEnabled = !this.ttsEnabled;
if (!this.ttsEnabled) { speechSynthesis.cancel(); this.speaking = false; }
if (this.ttsEnabled && this.term) {
const buf = this.term.buffer.active;
this._lastLine = buf.baseY + buf.cursorY;
}
this._notify();
}
toggleMic() {
this.listening ? this.stopListening() : this.startListening();
}
startListening() {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) return;
speechSynthesis.cancel();
this.speaking = false;
this._recog = new SR();
this._recog.continuous = true;
this._recog.interimResults = false;
this._recog.lang = this.lang;
this._recog.onresult = (e) => {
for (let i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) {
const text = e.results[i][0].transcript.trim();
if (text && this.ws?.readyState === 1) {
this.ws.send(new TextEncoder().encode(text + "\n"));
}
}
}
};
this._recog.onerror = () => { this.listening = false; this._notify(); };
this._recog.onend = () => {
if (this.listening) {
try { this._recog.start(); } catch { this.listening = false; this._notify(); }
}
};
this._recog.start();
this.listening = true;
this._notify();
}
stopListening() {
this.listening = false;
this._recog?.stop();
this._recog = null;
this._notify();
}
cycleLang() {
this.langIdx = (this.langIdx + 1) % LANGS.length;
if (this._recog && this.listening) {
this.stopListening();
this.startListening();
}
this._notify();
}
destroy() {
clearTimeout(this._debounce);
this._onWriteDispose?.dispose();
this.stopListening();
speechSynthesis.cancel();
this.term = null;
this.ws = null;
}
}
+41 -2
View File
@@ -1,5 +1,6 @@
<script>
import { onDestroy } from "svelte";
import { VoiceSession } from "../lib/voice.js";
const hosts = [
{ id: "nas", label: "NAS" },
@@ -10,6 +11,7 @@
let tabs = $state([]);
let activeTab = $state(null);
let showMenu = $state(false);
let voiceTick = $state(0);
let tabCounter = 0;
const tabData = new Map();
@@ -24,6 +26,7 @@
function closeTab(id) {
const d = tabData.get(id);
if (d) {
d.voice?.destroy();
d.ro?.disconnect();
d.ws?.close();
d.term?.dispose();
@@ -87,11 +90,15 @@
const ro = new ResizeObserver(() => { fit.fit(); sendSize(); });
ro.observe(el);
tabData.set(tabId, { ws, term, ro, el });
const voice = new VoiceSession(() => { voiceTick++; });
voice.attach(term, ws);
tabData.set(tabId, { ws, term, ro, el, voice });
})();
}
onDestroy(() => tabData.forEach((d) => { d.ro?.disconnect(); d.ws?.close(); d.term?.dispose(); }));
function activeVoice() { return voiceTick >= 0 && tabData.get(activeTab)?.voice; }
onDestroy(() => tabData.forEach((d) => { d.voice?.destroy(); d.ro?.disconnect(); d.ws?.close(); d.term?.dispose(); }));
</script>
<div class="space-y-2">
<h1 class="text-2xl font-bold text-surface-900 tracking-tight">Terminal</h1>
@@ -135,6 +142,38 @@
</div>
{/if}
</div>
{#if activeTab && tabs.find(t => t.id === activeTab)?.connected}
{@const v = activeVoice()}
{#if v}
<div class="ml-auto flex items-center gap-1">
<button
class="px-2 py-1 rounded text-sm transition-colors {v.ttsEnabled ? (v.speaking ? 'text-indigo-400' : 'text-emerald-400') : 'text-surface-500 hover:text-surface-300'}"
title={v.ttsEnabled ? 'Disable TTS' : 'Enable TTS'}
onclick={() => { v.toggleTts(); voiceTick++; }}
>
{#if !v.ttsEnabled}
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 5L6 9H2v6h4l5 4V5z"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>
{:else if v.speaking}
<svg class="w-4 h-4 animate-pulse" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.54 8.46a5 5 0 010 7.07"/><path d="M19.07 4.93a10 10 0 010 14.14"/></svg>
{:else}
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.54 8.46a5 5 0 010 7.07"/></svg>
{/if}
</button>
<button
class="px-2 py-1 rounded text-sm transition-colors {v.listening ? 'text-rose-400 animate-pulse' : 'text-surface-500 hover:text-surface-300'}"
title={v.listening ? 'Stop mic' : 'Start mic'}
onclick={() => { v.toggleMic(); voiceTick++; }}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
</button>
<button
class="px-1.5 py-0.5 rounded text-xs font-medium transition-colors text-surface-400 hover:text-surface-200 hover:bg-surface-800"
title="Switch language"
onclick={() => { v.cycleLang(); voiceTick++; }}
>{v.langLabel}</button>
</div>
{/if}
{/if}
</div>
{#if !tabs.length}