feat: voice mode bottom sheet with hold/tap/auto talk modes
Deploy Dashboard / deploy (push) Successful in 1m2s

This commit is contained in:
Gan, Jimmy
2026-02-26 16:03:43 +08:00
parent f83058c41d
commit cf8a5a65b2
2 changed files with 218 additions and 86 deletions
+142 -61
View File
@@ -1,6 +1,12 @@
const LANGS = ["en-US", "zh-CN", "zh-HK"]; const LANGS = ["en-US", "zh-CN", "zh-HK"];
const LANG_LABELS = ["EN", "普", "粤"]; const LANG_LABELS = ["EN", "普", "粤"];
const TALK_MODES = ["auto", "tap", "hold"];
const MODE_LABELS = { auto: "Auto", tap: "Tap", hold: "Hold" };
const NOISE = /^[\s│┃┆┇┊┋─━┄┅┈┉═╌╍╔╗╚╝╠╣╦╩╬├┤┬┴┼╭╮╯╰░▒▓█▌▐▀▄⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏◐◑◒◓⣾⣽⣻⢿⡿⣟⣯⣷⠁⠂⠄⡀⢀⠠⠐⠈\-–—_=+*#~<>/.\\|,;:!?(){}\[\]]+$/; const NOISE = /^[\s│┃┆┇┊┋─━┄┅┈┉═╌╍╔╗╚╝╠╣╦╩╬├┤┬┴┼╭╮╯╰░▒▓█▌▐▀▄⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏◐◑◒◓⣾⣽⣻⢿⡿⣟⣯⣷⠁⠂⠄⡀⢀⠠⠐⠈\-–—_=+*#~<>/.\\|,;:!?(){}\[\]]+$/;
const MODE_COMMANDS = {
"hold mode": "hold", "tap mode": "tap", "auto mode": "auto",
"按住模式": "hold", "点击模式": "tap", "自动模式": "auto",
};
export class VoiceSession { export class VoiceSession {
constructor(onChange) { constructor(onChange) {
@@ -10,6 +16,9 @@ export class VoiceSession {
this.listening = false; this.listening = false;
this.sttAvailable = !!(window.SpeechRecognition || window.webkitSpeechRecognition); this.sttAvailable = !!(window.SpeechRecognition || window.webkitSpeechRecognition);
this.langIdx = 0; this.langIdx = 0;
this.voiceMode = false;
this.transcript = "";
this.talkMode = "auto";
this.term = null; this.term = null;
this.ws = null; this.ws = null;
this._lastLine = 0; this._lastLine = 0;
@@ -17,10 +26,12 @@ export class VoiceSession {
this._recog = null; this._recog = null;
this._onWriteDispose = null; this._onWriteDispose = null;
this._ttsUnlocked = false; this._ttsUnlocked = false;
this._silenceTimer = null;
} }
get lang() { return LANGS[this.langIdx]; } get lang() { return LANGS[this.langIdx]; }
get langLabel() { return LANG_LABELS[this.langIdx]; } get langLabel() { return LANG_LABELS[this.langIdx]; }
get modeLabel() { return MODE_LABELS[this.talkMode]; }
attach(term, ws) { attach(term, ws) {
this.term = term; this.term = term;
@@ -31,6 +42,131 @@ export class VoiceSession {
_notify() { this.onChange?.(); } _notify() { this.onChange?.(); }
// --- Voice Mode ---
enterVoiceMode() {
this.voiceMode = true;
this.ttsEnabled = true;
if (!this._ttsUnlocked) {
speechSynthesis.speak(new SpeechSynthesisUtterance(""));
this._ttsUnlocked = true;
}
if (this.term) {
this._lastLine = this.term.buffer.active.baseY + this.term.buffer.active.cursorY;
}
if (this.talkMode === "auto") this._startSTT(true);
this._notify();
}
exitVoiceMode() {
this.voiceMode = false;
this.ttsEnabled = false;
this.transcript = "";
speechSynthesis.cancel();
this.speaking = false;
this._stopSTT();
this._notify();
}
setTalkMode(mode) {
this.talkMode = mode;
this._stopSTT();
this.transcript = "";
if (mode === "auto" && this.voiceMode) this._startSTT(true);
this._notify();
}
cycleTalkMode() {
const i = (TALK_MODES.indexOf(this.talkMode) + 1) % TALK_MODES.length;
this.setTalkMode(TALK_MODES[i]);
}
// Hold mode
startHold() {
if (this.talkMode !== "hold") return;
this._startSTT(false);
}
releaseHold() {
if (this.talkMode !== "hold") return;
this._sendAndStop();
}
// Tap mode
tapMic() {
if (this.talkMode !== "tap") return;
if (this.listening) { this._sendAndStop(); }
else { this._startSTT(false); }
}
_sendAndStop() {
const text = this.transcript.trim();
this._stopSTT();
if (text && this.ws?.readyState === 1) this._typeText(text + "\r");
this.transcript = "";
this._notify();
}
// --- STT ---
_startSTT(continuous) {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) return;
speechSynthesis.cancel();
this.speaking = false;
this._recog = new SR();
this._recog.continuous = continuous;
this._recog.interimResults = true;
this._recog.lang = this.lang;
this._finalText = "";
this._recog.onresult = (e) => {
let interim = "";
for (let i = e.resultIndex; i < e.results.length; i++) {
const t = e.results[i][0].transcript;
if (e.results[i].isFinal) {
// Check voice commands
const cmd = MODE_COMMANDS[t.trim().toLowerCase()];
if (cmd) { this.setTalkMode(cmd); return; }
this._finalText += t;
} else { interim = t; }
}
this.transcript = (this._finalText + interim).trim();
this._notify();
// Auto mode: reset silence timer on new results
if (this.talkMode === "auto" && this._finalText) {
clearTimeout(this._silenceTimer);
this._silenceTimer = setTimeout(() => this._autoSend(), 2000);
}
};
this._recog.onerror = () => { this.listening = false; this._notify(); };
this._recog.onend = () => {
if (this.listening && continuous) {
// Auto mode: send accumulated text, restart
if (this.talkMode === "auto" && this._finalText.trim()) this._autoSend();
try { this._recog.start(); } catch { this.listening = false; this._notify(); }
} else { this.listening = false; this._notify(); }
};
this._recog.start();
this.listening = true;
this._notify();
}
_stopSTT() {
this.listening = false;
clearTimeout(this._silenceTimer);
this._recog?.stop();
this._recog = null;
this._finalText = "";
}
_autoSend() {
clearTimeout(this._silenceTimer);
const text = this._finalText.trim();
this._finalText = "";
this.transcript = "";
if (text && this.ws?.readyState === 1) this._typeText(text + "\r");
this._notify();
}
// --- TTS ---
_onOutput() { _onOutput() {
if (!this.ttsEnabled || this.listening) return; if (!this.ttsEnabled || this.listening) return;
clearTimeout(this._debounce); clearTimeout(this._debounce);
@@ -66,24 +202,6 @@ export class VoiceSession {
speechSynthesis.speak(u); speechSynthesis.speak(u);
} }
toggleTts() {
this.ttsEnabled = !this.ttsEnabled;
if (!this.ttsEnabled) { speechSynthesis.cancel(); this.speaking = false; }
if (this.ttsEnabled) {
// iOS Safari requires first speak() in a user gesture to unlock audio
if (!this._ttsUnlocked) {
const u = new SpeechSynthesisUtterance("");
speechSynthesis.speak(u);
this._ttsUnlocked = true;
}
if (this.term) {
const buf = this.term.buffer.active;
this._lastLine = buf.baseY + buf.cursorY;
}
}
this._notify();
}
_typeText(text) { _typeText(text) {
let i = 0; let i = 0;
const next = () => { const next = () => {
@@ -95,58 +213,21 @@ export class VoiceSession {
next(); next();
} }
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._typeText(text + "\r");
}
}
};
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() { cycleLang() {
this.langIdx = (this.langIdx + 1) % LANGS.length; this.langIdx = (this.langIdx + 1) % LANGS.length;
if (this._recog && this.listening) { if (this.listening) {
this.stopListening(); const wasContinuous = this._recog?.continuous;
this.startListening(); this._stopSTT();
this._startSTT(!!wasContinuous);
} }
this._notify(); this._notify();
} }
destroy() { destroy() {
clearTimeout(this._debounce); clearTimeout(this._debounce);
clearTimeout(this._silenceTimer);
this._onWriteDispose?.dispose(); this._onWriteDispose?.dispose();
this.stopListening(); this._stopSTT();
speechSynthesis.cancel(); speechSynthesis.cancel();
this.term = null; this.term = null;
this.ws = null; this.ws = null;
+75 -24
View File
@@ -144,33 +144,15 @@
</div> </div>
{#if activeTab && tabs.find(t => t.id === activeTab)?.connected && activeVoice()} {#if activeTab && tabs.find(t => t.id === activeTab)?.connected && activeVoice()}
{@const v = activeVoice()} {@const v = activeVoice()}
<div class="ml-auto flex items-center gap-1"> <div class="ml-auto">
<button <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'}" class="px-2 py-1 rounded text-sm transition-colors {v.voiceMode ? 'text-rose-400' : v.sttAvailable ? 'text-surface-500 hover:text-surface-300' : 'text-surface-700 cursor-not-allowed'}"
title={v.ttsEnabled ? 'Disable TTS' : 'Enable TTS'} title={!v.sttAvailable ? 'STT not supported' : v.voiceMode ? 'Voice mode on' : 'Enter voice mode'}
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' : v.sttAvailable ? 'text-surface-500 hover:text-surface-300' : 'text-surface-700 cursor-not-allowed'}"
title={!v.sttAvailable ? 'STT not supported in this browser' : v.listening ? 'Stop mic' : 'Start mic'}
disabled={!v.sttAvailable} disabled={!v.sttAvailable}
onclick={() => { v.toggleMic(); voiceTick++; }} onclick={() => { if (!v.voiceMode) v.enterVoiceMode(); else v.exitVoiceMode(); 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> <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>
<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> </div>
{/if} {/if}
</div> </div>
@@ -185,9 +167,78 @@
<div class={activeTab === tab.id ? '' : 'hidden'}> <div class={activeTab === tab.id ? '' : 'hidden'}>
<div <div
use:initTerminal={tab} use:initTerminal={tab}
class="h-[calc(100vh-10rem)] rounded-xl overflow-hidden shadow-lg border border-surface-700" class="rounded-xl overflow-hidden shadow-lg border border-surface-700 transition-all duration-300"
style="background: #0f172a;" style="background: #0f172a; height: calc(100vh - {activeVoice()?.voiceMode ? '25rem' : '10rem'});"
></div> ></div>
</div> </div>
{/each} {/each}
{#if activeVoice()?.voiceMode}
{@const v = activeVoice()}
<div class="mt-2 rounded-xl bg-surface-800/95 border border-surface-700 shadow-xl overflow-hidden animate-slide-up" style="height: 180px;">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-2 border-b border-surface-700/50">
<span class="text-sm font-medium text-surface-300">Voice Mode</span>
<div class="flex items-center gap-2">
<button
class="px-1.5 py-0.5 rounded text-xs font-medium text-surface-400 hover:text-surface-200 hover:bg-surface-700"
onclick={() => { v.cycleLang(); voiceTick++; }}
>{v.langLabel}</button>
<button
class="px-1.5 py-0.5 rounded text-xs font-medium text-surface-400 hover:text-surface-200 hover:bg-surface-700"
onclick={() => { v.cycleTalkMode(); voiceTick++; }}
>{v.modeLabel}</button>
<button
class="px-1.5 py-0.5 rounded text-xs text-surface-500 hover:text-rose-400"
onclick={() => { v.exitVoiceMode(); voiceTick++; }}
>&times;</button>
</div> </div>
</div>
<!-- Transcript -->
<div class="px-4 py-2 h-[60px] overflow-y-auto">
{#if v.transcript}
<p class="text-sm text-surface-400 italic">"{v.transcript}"</p>
{:else}
<p class="text-sm text-surface-600 italic">{v.speaking ? 'Speaking...' : 'Waiting for speech...'}</p>
{/if}
</div>
<!-- Mic area -->
<div class="flex flex-col items-center gap-1 py-2">
{#if v.talkMode === "hold"}
<button
aria-label="Hold to talk"
class="w-14 h-14 rounded-full flex items-center justify-center transition-colors {v.listening ? 'bg-rose-500/30 text-rose-400' : 'bg-surface-700 text-surface-300 hover:bg-surface-600'}"
onpointerdown={() => { v.startHold(); voiceTick++; }}
onpointerup={() => { v.releaseHold(); voiceTick++; }}
onpointerleave={() => { if (v.listening) { v.releaseHold(); voiceTick++; } }}
>
<svg class="w-6 h-6" 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>
<span class="text-xs text-surface-500">{v.listening ? 'Release to send' : 'Hold to talk'}</span>
{:else if v.talkMode === "tap"}
<button
aria-label="Tap to talk"
class="w-14 h-14 rounded-full flex items-center justify-center transition-colors {v.listening ? 'bg-rose-500/30 text-rose-400' : 'bg-surface-700 text-surface-300 hover:bg-surface-600'}"
onclick={() => { v.tapMic(); voiceTick++; }}
>
<svg class="w-6 h-6" 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>
<span class="text-xs text-surface-500">{v.listening ? 'Tap to send' : 'Tap to talk'}</span>
{:else}
<div class="w-14 h-14 rounded-full flex items-center justify-center {v.listening ? 'bg-emerald-500/20 text-emerald-400 animate-pulse' : 'bg-surface-700 text-surface-500'}">
<svg class="w-6 h-6" 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>
</div>
<span class="text-xs text-surface-500">{v.listening ? 'Listening...' : v.speaking ? 'Speaking...' : 'Ready'}</span>
{/if}
</div>
</div>
{/if}
</div>
<style>
@keyframes slide-up {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.animate-slide-up { animation: slide-up 0.25s ease-out; }
</style>