This commit is contained in:
Timothy Jaeryang Baek
2026-04-19 15:49:42 +09:00
parent a4d62253df
commit 5087492e25
2 changed files with 75 additions and 66 deletions
@@ -185,6 +185,7 @@
let speakingIdx: number | undefined;
let loadingSpeech = false;
let speakAbort: AbortController | null = null;
let showRateComment = false;
@@ -202,39 +203,39 @@
};
const stopAudio = () => {
speakAbort?.abort();
speakAbort = null;
try {
speechSynthesis.cancel();
$audioQueue?.stop();
} catch {}
if (speaking) {
speaking = false;
speakingIdx = undefined;
}
speaking = false;
speakingIdx = undefined;
loadingSpeech = false;
};
// Resolve voice: model-specific > user settings > config default
const getVoiceId = () =>
model?.info?.meta?.tts?.voice ??
($settings?.audio?.tts?.defaultVoice === $config.audio.tts.voice
? $settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice
: $config?.audio?.tts?.voice);
const speak = async () => {
if (!(message?.content ?? '').trim().length) {
toast.info($i18n.t('No content to speak'));
return;
}
stopAudio();
speakAbort = new AbortController();
const { signal } = speakAbort;
speaking = true;
const content = removeAllDetails(message.content);
// Get voice: model-specific > user settings > config default
const getVoiceId = () => {
// Check for model-specific TTS voice first
if (model?.info?.meta?.tts?.voice) {
return model.info.meta.tts.voice;
}
// Fall back to user settings or config default
if ($settings?.audio?.tts?.defaultVoice === $config.audio.tts.voice) {
return $settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice;
}
return $config?.audio?.tts?.voice;
};
if ($config.audio.tts.engine === '') {
let voices = [];
const getVoicesLoop = setInterval(() => {
@@ -242,16 +243,10 @@
if (voices.length > 0) {
clearInterval(getVoicesLoop);
const voiceId = getVoiceId();
const voice = voices?.filter((v) => v.voiceURI === voiceId)?.at(0) ?? undefined;
console.log(voice);
const voice = voices.find((v) => v.voiceURI === getVoiceId());
const speech = new SpeechSynthesisUtterance(content);
speech.rate = $settings.audio?.tts?.playbackRate ?? 1;
console.log(speech);
speech.onend = () => {
speaking = false;
if ($settings.conversationMode) {
@@ -281,9 +276,7 @@
);
if (!messageContentParts.length) {
console.log('No content to speak');
toast.info($i18n.t('No content to speak'));
speaking = false;
loadingSpeech = false;
return;
@@ -303,41 +296,43 @@
await $TTSWorker.init();
}
for (const [idx, sentence] of messageContentParts.entries()) {
for (const [, sentence] of messageContentParts.entries()) {
if (signal.aborted) return;
const url = await $TTSWorker
.generate({
text: sentence,
voice: voiceId
})
.generate({ text: sentence, voice: voiceId })
.catch((error) => {
console.error(error);
toast.error(`${error}`);
speaking = false;
loadingSpeech = false;
});
if (signal.aborted) return;
if (url && speaking) {
$audioQueue.enqueue(url);
loadingSpeech = false;
}
}
} else {
for (const [idx, sentence] of messageContentParts.entries()) {
for (const [, sentence] of messageContentParts.entries()) {
if (signal.aborted) return;
const res = await synthesizeOpenAISpeech(localStorage.token, voiceId, sentence).catch(
(error) => {
console.error(error);
toast.error(`${error}`);
speaking = false;
loadingSpeech = false;
}
);
if (signal.aborted) return;
if (res && speaking) {
const blob = await res.blob();
const url = URL.createObjectURL(blob);
$audioQueue.enqueue(url);
loadingSpeech = false;
}
+45 -31
View File
@@ -1,32 +1,39 @@
type AudioQueueEvent = 'stop' | 'empty-queue' | 'id-change';
interface AudioQueueStopDetail {
event: AudioQueueEvent;
id: string | null;
}
export type OnStoppedCallback = (detail: AudioQueueStopDetail) => void;
export class AudioQueue {
constructor(audioElement) {
private audio: HTMLAudioElement;
private queue: string[] = [];
private current: string | null = null;
private readonly _onEnded = () => this.next();
id: string | null = null;
onStopped: OnStoppedCallback | null = null;
constructor(audioElement: HTMLAudioElement) {
this.audio = audioElement;
this.queue = [];
this.current = null;
this.id = null;
this._onEnded = () => this.next();
this.audio.addEventListener('ended', this._onEnded);
this.onStopped = null; // optional callback
}
setId(newId) {
console.log('Setting audio queue ID to:', newId);
if (this.id !== newId) {
this.stop();
this.id = newId;
if (this.onStopped) this.onStopped({ event: 'id-change', id: newId });
}
setId(newId: string) {
if (this.id === newId) return;
this.#halt();
this.id = newId;
this.onStopped?.({ event: 'id-change', id: newId });
}
setPlaybackRate(rate) {
console.log('Setting audio playback rate to:', rate);
setPlaybackRate(rate: number) {
this.audio.playbackRate = rate;
}
enqueue(url) {
console.log('Enqueuing audio URL:', url);
enqueue(url: string) {
this.queue.push(url);
// Auto-play if nothing is currently playing or loaded
@@ -44,30 +51,37 @@ export class AudioQueue {
}
next() {
this.current = this.queue.shift();
this.current = this.queue.shift() ?? null;
if (this.current) {
this.audio.src = this.current;
this.audio.play();
console.log('Playing audio URL:', this.current);
} else {
this.stop();
if (this.onStopped) this.onStopped({ event: 'empty-queue', id: this.id });
this.#halt();
this.onStopped?.({ event: 'empty-queue', id: this.id });
}
}
stop() {
this.#halt();
this.onStopped?.({ event: 'stop', id: this.id });
}
destroy() {
this.audio.removeEventListener('ended', this._onEnded);
this.#halt();
this.onStopped = null;
}
/**
* Pause audio and clear queue without firing onStopped.
* Callers that need the callback should invoke it themselves.
*/
#halt() {
this.audio.pause();
this.audio.currentTime = 0;
this.audio.src = '';
this.queue = [];
this.current = null;
if (this.onStopped) this.onStopped({ event: 'stop', id: this.id });
}
destroy() {
this.audio.removeEventListener('ended', this._onEnded);
this.stop();
this.onStopped = null;
this.audio = null;
}
}