feat: add TTS voice picker to voice mode bottom sheet
This commit is contained in:
@@ -19,6 +19,8 @@ export class VoiceSession {
|
|||||||
this.voiceMode = false;
|
this.voiceMode = false;
|
||||||
this.transcript = "";
|
this.transcript = "";
|
||||||
this.talkMode = "auto";
|
this.talkMode = "auto";
|
||||||
|
this.voices = [];
|
||||||
|
this.voiceIdx = -1;
|
||||||
this.term = null;
|
this.term = null;
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
this._lastLine = 0;
|
this._lastLine = 0;
|
||||||
@@ -32,6 +34,22 @@ export class VoiceSession {
|
|||||||
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]; }
|
get modeLabel() { return MODE_LABELS[this.talkMode]; }
|
||||||
|
get voiceName() { return this.voices[this.voiceIdx]?.name || "Default"; }
|
||||||
|
|
||||||
|
loadVoices() {
|
||||||
|
const all = speechSynthesis.getVoices();
|
||||||
|
const prefix = this.lang.split("-")[0];
|
||||||
|
this.voices = all.filter(v => v.lang.startsWith(prefix));
|
||||||
|
// Try to keep current selection
|
||||||
|
if (this.voiceIdx >= this.voices.length) this.voiceIdx = 0;
|
||||||
|
if (!this.voices.length) this.voiceIdx = -1;
|
||||||
|
this._notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
setVoice(idx) {
|
||||||
|
this.voiceIdx = idx;
|
||||||
|
this._notify();
|
||||||
|
}
|
||||||
|
|
||||||
attach(term, ws) {
|
attach(term, ws) {
|
||||||
this.term = term;
|
this.term = term;
|
||||||
@@ -50,6 +68,8 @@ export class VoiceSession {
|
|||||||
speechSynthesis.speak(new SpeechSynthesisUtterance(""));
|
speechSynthesis.speak(new SpeechSynthesisUtterance(""));
|
||||||
this._ttsUnlocked = true;
|
this._ttsUnlocked = true;
|
||||||
}
|
}
|
||||||
|
this.loadVoices();
|
||||||
|
speechSynthesis.onvoiceschanged = () => this.loadVoices();
|
||||||
if (this.term) {
|
if (this.term) {
|
||||||
this._lastLine = this.term.buffer.active.baseY + this.term.buffer.active.cursorY;
|
this._lastLine = this.term.buffer.active.baseY + this.term.buffer.active.cursorY;
|
||||||
}
|
}
|
||||||
@@ -193,9 +213,7 @@ export class VoiceSession {
|
|||||||
speechSynthesis.cancel();
|
speechSynthesis.cancel();
|
||||||
const u = new SpeechSynthesisUtterance(text);
|
const u = new SpeechSynthesisUtterance(text);
|
||||||
u.lang = this.lang;
|
u.lang = this.lang;
|
||||||
const voices = speechSynthesis.getVoices();
|
if (this.voices[this.voiceIdx]) u.voice = this.voices[this.voiceIdx];
|
||||||
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.onstart = () => { this.speaking = true; this._notify(); };
|
||||||
u.onend = () => { this.speaking = false; this._notify(); };
|
u.onend = () => { this.speaking = false; this._notify(); };
|
||||||
u.onerror = () => { this.speaking = false; this._notify(); };
|
u.onerror = () => { this.speaking = false; this._notify(); };
|
||||||
@@ -215,6 +233,7 @@ export class VoiceSession {
|
|||||||
|
|
||||||
cycleLang() {
|
cycleLang() {
|
||||||
this.langIdx = (this.langIdx + 1) % LANGS.length;
|
this.langIdx = (this.langIdx + 1) % LANGS.length;
|
||||||
|
this.loadVoices();
|
||||||
if (this.listening) {
|
if (this.listening) {
|
||||||
const wasContinuous = this._recog?.continuous;
|
const wasContinuous = this._recog?.continuous;
|
||||||
this._stopSTT();
|
this._stopSTT();
|
||||||
|
|||||||
@@ -180,6 +180,14 @@
|
|||||||
<div class="flex items-center justify-between px-4 py-2 border-b border-surface-700/50">
|
<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>
|
<span class="text-sm font-medium text-surface-300">Voice Mode</span>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
class="bg-surface-700 text-surface-300 text-xs rounded px-1 py-0.5 outline-none max-w-[120px]"
|
||||||
|
onchange={(e) => { v.setVoice(+e.target.value); voiceTick++; }}
|
||||||
|
>
|
||||||
|
{#each v.voices as voice, i}
|
||||||
|
<option value={i} selected={i === v.voiceIdx}>{voice.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
<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"
|
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++; }}
|
onclick={() => { v.cycleLang(); voiceTick++; }}
|
||||||
|
|||||||
Reference in New Issue
Block a user