Files
readest/apps/readest-app/src/services/tts/EdgeTTSClient.ts
T

296 lines
8.6 KiB
TypeScript

import { getUserLocale } from '@/utils/misc';
import { TTSClient, TTSMessageEvent } from './TTSClient';
import { EdgeSpeechTTS, EdgeTTSPayload } from '@/libs/edgeTTS';
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
import { TTSController } from './TTSController';
import { TTSUtils } from './TTSUtils';
import { TTSGranularity, TTSVoice, TTSVoicesGroup } from './types';
export class EdgeTTSClient implements TTSClient {
name = 'edge-tts';
initialized = false;
controller?: TTSController;
#voices: TTSVoice[] = [];
#primaryLang = 'en';
#speakingLang = '';
#currentVoiceId = '';
#rate = 1.0;
#pitch = 1.0;
#edgeTTS: EdgeSpeechTTS;
#audioElement: HTMLAudioElement | null = null;
#isPlaying = false;
#pausedAt = 0;
#startedAt = 0;
constructor(controller?: TTSController) {
this.controller = controller;
this.#edgeTTS = new EdgeSpeechTTS();
}
async init() {
this.#voices = EdgeSpeechTTS.voices;
try {
await this.#edgeTTS.create({
lang: 'en',
text: 'test',
voice: 'en-US-AriaNeural',
rate: 1.0,
pitch: 1.0,
});
this.initialized = true;
} catch {
this.initialized = false;
}
return this.initialized;
}
getPayload = (lang: string, text: string, voiceId: string) => {
return { lang, text, voice: voiceId, rate: this.#rate, pitch: this.#pitch } as EdgeTTSPayload;
};
getVoiceIdFromLang = async (lang: string) => {
const preferredVoiceId = TTSUtils.getPreferredVoice(this.name, lang);
const preferredVoice = this.#voices.find((v) => v.id === preferredVoiceId);
const defaultVoice = preferredVoice
? preferredVoice
: (await this.getVoices(lang))[0]?.voices[0] || null;
return defaultVoice?.id || this.#currentVoiceId || 'en-US-AriaNeural';
};
async *speak(ssml: string, signal: AbortSignal, preload = false) {
const { marks } = parseSSMLMarks(ssml);
let defaultLang = parseSSMLLang(ssml) || 'en';
if (defaultLang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') {
defaultLang = this.#primaryLang;
}
if (preload) {
// preload the first 2 marks immediately and the rest in the background
const maxImmediate = 2;
for (let i = 0; i < Math.min(maxImmediate, marks.length); i++) {
const mark = marks[i]!;
const { language } = mark;
const voiceLang = language || defaultLang;
const voiceId = await this.getVoiceIdFromLang(voiceLang);
this.#currentVoiceId = voiceId;
await this.#edgeTTS
.createAudio(this.getPayload(voiceLang, mark.text, voiceId))
.catch((err) => {
console.warn('Error preloading mark', i, err);
});
}
if (marks.length > maxImmediate) {
(async () => {
for (let i = maxImmediate; i < marks.length; i++) {
const mark = marks[i]!;
try {
const { language } = mark;
const voiceLang = language || defaultLang;
const voiceId = await this.getVoiceIdFromLang(voiceLang);
await this.#edgeTTS.createAudio(this.getPayload(voiceLang, mark.text, voiceId));
} catch (err) {
console.warn('Error preloading mark (bg)', i, err);
}
}
})();
}
yield {
code: 'end',
message: 'Preload finished',
} as TTSMessageEvent;
return;
} else {
await this.stopInternal();
}
for (const mark of marks) {
if (signal.aborted) {
yield {
code: 'error',
message: 'Aborted',
} as TTSMessageEvent;
break;
}
try {
const { language } = mark;
const voiceLang = language || defaultLang;
const voiceId = await this.getVoiceIdFromLang(voiceLang);
this.#speakingLang = voiceLang;
const blob = await this.#edgeTTS.createAudio(
this.getPayload(voiceLang, mark.text, voiceId),
);
const url = URL.createObjectURL(blob);
this.#audioElement = new Audio(url);
const audio = this.#audioElement;
audio.setAttribute('x-webkit-airplay', 'deny');
audio.preload = 'auto';
this.controller?.dispatchSpeakMark(mark);
yield {
code: 'boundary',
message: `Start chunk: ${mark.name}`,
mark: mark.name,
} as TTSMessageEvent;
const result = await new Promise<TTSMessageEvent>((resolve) => {
const cleanUp = () => {
audio.onended = null;
audio.onerror = null;
audio.pause();
audio.src = '';
URL.revokeObjectURL(url);
};
audio.onended = () => {
cleanUp();
if (signal.aborted) {
resolve({ code: 'error', message: 'Aborted' });
} else {
resolve({ code: 'end', message: `Chunk finished: ${mark.name}` });
}
};
audio.onerror = (e) => {
cleanUp();
console.warn('Audio playback error:', e);
resolve({ code: 'error', message: 'Audio playback error' });
};
if (signal.aborted) {
cleanUp();
resolve({ code: 'error', message: 'Aborted' });
return;
}
this.#isPlaying = true;
audio.play().catch((err) => {
cleanUp();
console.error('Failed to play audio:', err);
resolve({ code: 'error', message: 'Playback failed: ' + err.message });
});
});
yield result;
} catch (error) {
if (error instanceof Error && error.message === 'No audio data received.') {
console.warn('No audio data received for:', mark.text);
yield {
code: 'end',
message: `Chunk finished: ${mark.name}`,
} as TTSMessageEvent;
continue;
}
console.log('Error:', error);
yield {
code: 'error',
message: error instanceof Error ? error.message : String(error),
} as TTSMessageEvent;
break;
}
await this.stopInternal();
}
}
async pause() {
if (!this.#isPlaying || !this.#audioElement) return true;
this.#pausedAt = this.#audioElement.currentTime - this.#startedAt;
await this.#audioElement.pause();
this.#isPlaying = false;
return true;
}
async resume() {
if (this.#isPlaying || !this.#audioElement) return true;
await this.#audioElement.play();
this.#isPlaying = true;
this.#startedAt = this.#audioElement.currentTime - this.#pausedAt;
return true;
}
async stop() {
await this.stopInternal();
}
private async stopInternal() {
this.#isPlaying = false;
this.#pausedAt = 0;
this.#startedAt = 0;
if (this.#audioElement) {
this.#audioElement.pause();
this.#audioElement.currentTime = 0;
if (this.#audioElement?.onended) {
this.#audioElement.onended(new Event('stopped'));
}
if (this.#audioElement.src?.startsWith('blob:')) {
URL.revokeObjectURL(this.#audioElement.src);
}
this.#audioElement.src = '';
this.#audioElement = null;
}
}
async setRate(rate: number) {
// The Edge TTS API uses rate in [0.5 .. 2.0].
this.#rate = rate;
}
async setPitch(pitch: number) {
// The Edge TTS API uses pitch in [0.5 .. 1.5].
this.#pitch = pitch;
}
async setVoice(voice: string) {
const selectedVoice = this.#voices.find((v) => v.id === voice);
if (selectedVoice) {
this.#currentVoiceId = selectedVoice.id;
}
}
async getAllVoices(): Promise<TTSVoice[]> {
this.#voices.forEach((voice) => {
voice.disabled = !this.initialized;
});
return this.#voices;
}
async getVoices(lang: string) {
const locale = lang === 'en' ? getUserLocale(lang) || lang : lang;
const voices = await this.getAllVoices();
const filteredVoices = voices.filter(
(v) => v.lang.startsWith(locale) || (lang === 'en' && ['en-US', 'en-GB'].includes(v.lang)),
);
const voicesGroup: TTSVoicesGroup = {
id: 'edge-tts',
name: 'Edge TTS',
voices: filteredVoices.sort(TTSUtils.sortVoicesFunc),
disabled: !this.initialized || filteredVoices.length === 0,
};
return [voicesGroup];
}
setPrimaryLang(lang: string) {
this.#primaryLang = lang;
}
getGranularities(): TTSGranularity[] {
return ['sentence'];
}
getVoiceId(): string {
return this.#currentVoiceId;
}
getSpeakingLang(): string {
return this.#speakingLang;
}
async shutdown(): Promise<void> {
this.initialized = false;
this.#audioElement = null;
this.#voices = [];
}
}