forked from akai/readest
Fix multiple voices when setting rate and voices in Firefox (#146)
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { TTSController } from '@/services/tts/TTSController';
|
||||
import { getPopupPosition, Position } from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { parseSSMLLang } from '@/utils/ssml';
|
||||
import { throttle } from '@/utils/ui';
|
||||
import Popup from '@/components/Popup';
|
||||
import TTSPanel from './TTSPanel';
|
||||
import TTSIcon from './TTSIcon';
|
||||
@@ -97,10 +98,12 @@ const TTSControl = () => {
|
||||
if (!ttsController) return;
|
||||
|
||||
if (isPlaying) {
|
||||
await ttsController.pause();
|
||||
setIsPlaying(false);
|
||||
setIsPaused(true);
|
||||
await ttsController.pause();
|
||||
} else if (isPaused) {
|
||||
setIsPlaying(true);
|
||||
setIsPaused(false);
|
||||
// start for forward/backward/setvoice-paused
|
||||
// set rate don't pause the tts
|
||||
if (ttsController.state === 'paused') {
|
||||
@@ -108,8 +111,6 @@ const TTSControl = () => {
|
||||
} else {
|
||||
await ttsController.start();
|
||||
}
|
||||
setIsPlaying(true);
|
||||
setIsPaused(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -139,31 +140,39 @@ const TTSControl = () => {
|
||||
};
|
||||
|
||||
// rate range: 0.5 - 3, 1.0 is normal speed
|
||||
const handleSetRate = async (rate: number) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
if (ttsController.state === 'playing') {
|
||||
await ttsController.pause();
|
||||
await ttsController.setRate(rate);
|
||||
await ttsController.start();
|
||||
} else {
|
||||
await ttsController.setRate(rate);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleSetRate = useCallback(
|
||||
throttle(async (rate: number) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
if (ttsController.state === 'playing') {
|
||||
await ttsController.stop();
|
||||
await ttsController.setRate(rate);
|
||||
await ttsController.start();
|
||||
} else {
|
||||
await ttsController.setRate(rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}, 2000),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSetVoice = async (voice: string) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
if (ttsController.state === 'playing') {
|
||||
await ttsController.pause();
|
||||
await ttsController.setVoice(voice);
|
||||
await ttsController.start();
|
||||
} else {
|
||||
await ttsController.setVoice(voice);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleSetVoice = useCallback(
|
||||
throttle(async (voice: string) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
if (ttsController.state === 'playing') {
|
||||
await ttsController.stop();
|
||||
await ttsController.setVoice(voice);
|
||||
await ttsController.start();
|
||||
} else {
|
||||
await ttsController.setVoice(voice);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}, 2000),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleGetVoices = async (lang: string) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
|
||||
@@ -44,7 +44,7 @@ export class EdgeTTSClient implements TTSClient {
|
||||
return { lang, text, voice: voiceId, rate: this.#rate, pitch: this.#pitch } as EdgeTTSPayload;
|
||||
};
|
||||
|
||||
async *speak(ssml: string): AsyncGenerator<TTSMessageEvent> {
|
||||
async *speak(ssml: string, signal: AbortSignal): AsyncGenerator<TTSMessageEvent> {
|
||||
const { marks } = parseSSMLMarks(ssml);
|
||||
const lang = parseSSMLLang(ssml) || 'en';
|
||||
|
||||
@@ -69,6 +69,13 @@ export class EdgeTTSClient implements TTSClient {
|
||||
}
|
||||
|
||||
for (const mark of marks) {
|
||||
if (signal.aborted) {
|
||||
yield {
|
||||
code: 'error',
|
||||
message: 'Aborted',
|
||||
};
|
||||
break;
|
||||
}
|
||||
try {
|
||||
this.#audioBuffer = await this.#edgeTTS.createAudio(
|
||||
this.getPayload(lang, mark.text, voiceId),
|
||||
@@ -147,7 +154,9 @@ export class EdgeTTSClient implements TTSClient {
|
||||
try {
|
||||
this.#sourceNode.stop();
|
||||
} catch (err) {
|
||||
console.error('Error stopping source node:', err);
|
||||
if (!(err instanceof Error) || err.name !== 'InvalidStateError') {
|
||||
console.log('Error stopping source node:', err);
|
||||
}
|
||||
}
|
||||
this.#sourceNode.disconnect();
|
||||
this.#sourceNode = null;
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface TTSVoice {
|
||||
|
||||
export interface TTSClient {
|
||||
init(): Promise<boolean>;
|
||||
speak(ssml: string): AsyncIterable<TTSMessageEvent>;
|
||||
speak(ssml: string, signal: AbortSignal): AsyncIterable<TTSMessageEvent>;
|
||||
pause(): Promise<void>;
|
||||
resume(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
|
||||
@@ -9,12 +9,14 @@ type TTSState =
|
||||
| 'paused'
|
||||
| 'backward-paused'
|
||||
| 'forward-paused'
|
||||
| 'setrate-paused'
|
||||
| 'setvoice-paused';
|
||||
|
||||
export class TTSController extends EventTarget {
|
||||
state: TTSState = 'stopped';
|
||||
view: FoliateView;
|
||||
#nossmlCnt: number = 0;
|
||||
#currentSpeakAbortController: AbortController | null = null;
|
||||
|
||||
ttsRate: number = 1.0;
|
||||
ttsClient: TTSClient;
|
||||
@@ -49,37 +51,44 @@ export class TTSController extends EventTarget {
|
||||
if (!supportedGranularities.includes(granularity)) {
|
||||
granularity = supportedGranularities[0]!;
|
||||
}
|
||||
await this.ttsClient.stop();
|
||||
await this.view.initTTS(granularity);
|
||||
}
|
||||
|
||||
async #speak(ssml: string | undefined | Promise<string>) {
|
||||
console.log('TTS speak');
|
||||
this.state = 'playing';
|
||||
ssml = await ssml;
|
||||
if (!ssml) {
|
||||
this.#nossmlCnt++;
|
||||
// FIXME: in case we are at the end of the book, need a better way to handle this
|
||||
if (this.#nossmlCnt < 10 && this.state === 'playing') {
|
||||
await this.view.next(1);
|
||||
await this.stop();
|
||||
this.#currentSpeakAbortController = new AbortController();
|
||||
const { signal } = this.#currentSpeakAbortController;
|
||||
|
||||
try {
|
||||
console.log('TTS speak');
|
||||
this.state = 'playing';
|
||||
ssml = await ssml;
|
||||
if (!ssml) {
|
||||
this.#nossmlCnt++;
|
||||
// FIXME: in case we are at the end of the book, need a better way to handle this
|
||||
if (this.#nossmlCnt < 10 && this.state === 'playing') {
|
||||
await this.view.next(1);
|
||||
await this.forward();
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
this.#nossmlCnt = 0;
|
||||
}
|
||||
|
||||
const iter = await this.ttsClient.speak(ssml, signal);
|
||||
let lastCode: TTSMessageCode = 'boundary';
|
||||
for await (const { code, mark } of iter) {
|
||||
if (mark && this.state === 'playing') {
|
||||
this.view.tts?.setMark(mark);
|
||||
}
|
||||
lastCode = code;
|
||||
}
|
||||
|
||||
if (lastCode === 'end' && this.state === 'playing') {
|
||||
await this.forward();
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
this.#nossmlCnt = 0;
|
||||
}
|
||||
|
||||
const iter = await this.ttsClient.speak(ssml);
|
||||
let lastCode: TTSMessageCode = 'boundary';
|
||||
for await (const { code, mark } of iter) {
|
||||
if (mark && this.state === 'playing') {
|
||||
this.view.tts?.setMark(mark);
|
||||
}
|
||||
lastCode = code;
|
||||
}
|
||||
|
||||
if (lastCode === 'end' && this.state === 'playing') {
|
||||
await this.forward();
|
||||
} finally {
|
||||
this.#currentSpeakAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +125,9 @@ export class TTSController extends EventTarget {
|
||||
|
||||
async stop() {
|
||||
this.state = 'stopped';
|
||||
if (this.#currentSpeakAbortController) {
|
||||
this.#currentSpeakAbortController.abort();
|
||||
}
|
||||
await this.ttsClient.stop().catch((e) => this.error(e));
|
||||
}
|
||||
|
||||
@@ -144,6 +156,7 @@ export class TTSController extends EventTarget {
|
||||
}
|
||||
|
||||
async setRate(rate: number) {
|
||||
this.state = 'setrate-paused';
|
||||
this.ttsRate = rate;
|
||||
await this.ttsClient.setRate(this.ttsRate);
|
||||
}
|
||||
@@ -156,7 +169,6 @@ export class TTSController extends EventTarget {
|
||||
|
||||
async setVoice(voiceId: string) {
|
||||
this.state = 'setvoice-paused';
|
||||
await this.ttsClient.stop();
|
||||
const useEdgeTTS = !!this.ttsEdgeVoices.find(
|
||||
(voice) => (voiceId === '' || voice.id === voiceId) && !voice.disabled,
|
||||
);
|
||||
|
||||
@@ -132,9 +132,9 @@ async function* speakWithMarks(
|
||||
|
||||
const synth = window.speechSynthesis;
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance();
|
||||
for (const mark of marks) {
|
||||
const utterance = new SpeechSynthesisUtterance(mark.text);
|
||||
|
||||
utterance.text = mark.text;
|
||||
utterance.rate = getRate();
|
||||
utterance.pitch = getPitch();
|
||||
const voice = getVoice();
|
||||
@@ -205,7 +205,7 @@ export class WebSpeechClient implements TTSClient {
|
||||
return this.available;
|
||||
}
|
||||
|
||||
async *speak(ssml: string): AsyncGenerator<TTSMessageEvent> {
|
||||
async *speak(ssml: string, signal: AbortSignal): AsyncGenerator<TTSMessageEvent> {
|
||||
const lang = parseSSMLLang(ssml) || 'en';
|
||||
if (!this.#voice) {
|
||||
const voices = await this.getVoices(lang);
|
||||
@@ -218,6 +218,11 @@ export class WebSpeechClient implements TTSClient {
|
||||
() => this.#pitch,
|
||||
() => this.#voice,
|
||||
)) {
|
||||
if (signal.aborted) {
|
||||
console.log('TTS aborted');
|
||||
yield { code: 'error', message: 'Aborted' } as TTSMessageEvent;
|
||||
return;
|
||||
}
|
||||
if (ev.type === 'boundary') {
|
||||
yield {
|
||||
code: 'boundary',
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export const throttle = <T extends (...args: Parameters<T>) => void | Promise<void>>(
|
||||
func: T,
|
||||
delay: number,
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let lastCall = 0;
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
return (...args: Parameters<T>): void => {
|
||||
const now = Date.now();
|
||||
const remaining = delay - (now - lastCall);
|
||||
|
||||
const callFunc = () => {
|
||||
lastCall = Date.now();
|
||||
timeout = null;
|
||||
func(...args);
|
||||
};
|
||||
|
||||
if (remaining <= 0) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
callFunc();
|
||||
} else if (!timeout) {
|
||||
timeout = setTimeout(callFunc, remaining);
|
||||
}
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user