Add 'Speak' button in the footbar (#127)

This commit is contained in:
Huang Xin
2025-01-08 19:44:04 +01:00
committed by GitHub
parent c6defb97ee
commit 814a0032d0
6 changed files with 90 additions and 67 deletions
@@ -2,10 +2,14 @@ import React from 'react';
import clsx from 'clsx';
import { RiArrowLeftWideLine, RiArrowRightWideLine } from 'react-icons/ri';
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
import { FaHeadphones } from 'react-icons/fa6';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getBookLangCode } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import Button from '@/components/Button';
interface FooterBarProps {
@@ -16,9 +20,12 @@ interface FooterBarProps {
const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim }) => {
const _ = useTranslation();
const { hoveredBookKey, setHoveredBookKey, getView } = useReaderStore();
const { hoveredBookKey, setHoveredBookKey, getView, getProgress } = useReaderStore();
const { getBookData } = useBookDataStore();
const { isSideBarVisible } = useSidebarStore();
const view = getView(bookKey);
const bookData = getBookData(bookKey);
const progress = getProgress(bookKey);
const handleProgressChange = (event: React.ChangeEvent) => {
const newProgress = parseInt((event.target as HTMLInputElement).value, 10);
@@ -41,6 +48,16 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
view?.history.forward();
};
const handleSpeakText = async () => {
if (!view || !progress) return;
const lang = getBookLangCode(bookData?.bookDoc!.metadata.language);
const granularity = ['zh', 'ja', 'ko'].includes(lang) ? 'sentence' : 'word';
await view.initTTS(granularity);
const { range } = progress;
const ssml = view.tts.from(range);
eventDispatcher.dispatch('speak', { bookKey, ssml });
};
const pageinfoValid = pageinfo && pageinfo.total > 0 && pageinfo.current >= 0;
const progressFraction = pageinfoValid ? pageinfo.current / pageinfo.total : 0;
return (
@@ -83,6 +100,7 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
value={pageinfoValid ? progressFraction * 100 : 0}
onChange={(e) => handleProgressChange(e)}
/>
<Button icon={<FaHeadphones size={20} />} onClick={handleSpeakText} tooltip={_('Speak')} />
<Button
icon={<RiArrowRightWideLine size={20} />}
onClick={handleGoNext}
@@ -1,13 +1,14 @@
import React, { useState, useRef, useEffect } from 'react';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { TTSController } from '@/services/tts/TTSController';
import { WebSpeechClient } from '@/services/tts';
import { getPopupPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { useReaderStore } from '@/store/readerStore';
import { parseSSMLLang } from '@/utils/ssml';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
import { useTranslation } from '@/hooks/useTranslation';
const POPUP_WIDTH = 260;
const POPUP_HEIGHT = 80;
@@ -47,8 +48,9 @@ const TTSControl = () => {
if (!view) return;
try {
const lang = parseSSMLLang(ssml) || 'en';
const ttsClient = new WebSpeechClient();
const ttsController = new TTSController(ttsClient, view);
const ttsController = new TTSController(ttsClient, view, lang);
ttsControllerRef.current = ttsController;
ttsControllerRef.current.speak(ssml);
setIsPlaying(true);
@@ -7,17 +7,20 @@ export class TTSController extends EventTarget {
state: TTSState = 'stopped';
ttsClient: TTSClient;
view: FoliateView;
lang: string;
#nossmlCnt: number = 0;
constructor(ttsClient: TTSClient, view: FoliateView) {
constructor(ttsClient: TTSClient, view: FoliateView, lang: string) {
super();
this.ttsClient = ttsClient;
this.view = view;
this.lang = lang;
}
async #init() {
await this.ttsClient.stop();
await this.view.initTTS();
const granularity = ['zh', 'ja', 'ko'].includes(this.lang) ? 'sentence' : 'word';
await this.view.initTTS(granularity);
}
async #speak(ssml: string | undefined | Promise<string>) {
@@ -1,5 +1,6 @@
import { TTSClient, TTSMessageEvent } from './TTSClient';
import { AsyncQueue } from '@/utils/queue';
import { findSSMLMark, parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
interface TTSBoundaryEvent {
type: 'boundary' | 'end' | 'error';
@@ -11,66 +12,8 @@ interface TTSBoundaryEvent {
error?: string;
}
type TTSMark = {
offset: number;
name: string;
text: string;
};
function getXmlLang(ssml: string): string | null {
const match = ssml.match(/xml:lang\s*=\s*"([^"]+)"/);
return match ? match[1]! : null;
}
function parseSSMLMarks(ssml: string) {
ssml = ssml.replace(/<speak[^>]*>/i, '');
ssml = ssml.replace(/<\/speak>/i, '');
const markRegex = /<mark\s+name="([^"]+)"\s*\/>/g;
let plainText = '';
const marks: TTSMark[] = [];
let match;
while ((match = markRegex.exec(ssml)) !== null) {
const markTagEndIndex = markRegex.lastIndex;
const nextMarkIndex = ssml.indexOf('<mark', markTagEndIndex);
const nextChunk = ssml.slice(
markTagEndIndex,
nextMarkIndex !== -1 ? nextMarkIndex : ssml.length,
);
const cleanedChunk = nextChunk.replace(/<[^>]+>/g, '').trimStart();
plainText += cleanedChunk;
const offset = plainText.length - cleanedChunk.length;
const markName = match[1]!;
marks.push({ offset, name: markName, text: cleanedChunk });
}
return { plainText, marks };
}
function findMark(charIndex: number, marks: TTSMark[]) {
let left = 0;
let right = marks.length - 1;
let result: TTSMark | null = null;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const mark = marks[mid]!;
if (mark.offset <= charIndex) {
result = mark;
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
}
async function* speakWithBoundary(ssml: string) {
const lang = getXmlLang(ssml);
const lang = parseSSMLLang(ssml);
const { plainText, marks } = parseSSMLMarks(ssml);
// console.log('ssml', ssml, marks);
// console.log('text', plainText);
@@ -85,7 +28,7 @@ async function* speakWithBoundary(ssml: string) {
const queue = new AsyncQueue<TTSBoundaryEvent>();
utterance.onboundary = (event: SpeechSynthesisEvent) => {
const mark = findMark(event.charIndex, marks);
const mark = findSSMLMark(event.charIndex, marks);
// console.log('boundary', event.charIndex, mark);
queue.enqueue({
type: 'boundary',
+57
View File
@@ -0,0 +1,57 @@
type TTSMark = {
offset: number;
name: string;
text: string;
};
export const parseSSMLMarks = (ssml: string) => {
ssml = ssml.replace(/<speak[^>]*>/i, '');
ssml = ssml.replace(/<\/speak>/i, '');
const markRegex = /<mark\s+name="([^"]+)"\s*\/>/g;
let plainText = '';
const marks: TTSMark[] = [];
let match;
while ((match = markRegex.exec(ssml)) !== null) {
const markTagEndIndex = markRegex.lastIndex;
const nextMarkIndex = ssml.indexOf('<mark', markTagEndIndex);
const nextChunk = ssml.slice(
markTagEndIndex,
nextMarkIndex !== -1 ? nextMarkIndex : ssml.length,
);
const cleanedChunk = nextChunk.replace(/<[^>]+>/g, '').trimStart();
plainText += cleanedChunk;
const offset = plainText.length - cleanedChunk.length;
const markName = match[1]!;
marks.push({ offset, name: markName, text: cleanedChunk });
}
return { plainText, marks };
};
export const findSSMLMark = (charIndex: number, marks: TTSMark[]) => {
let left = 0;
let right = marks.length - 1;
let result: TTSMark | null = null;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const mark = marks[mid]!;
if (mark.offset <= charIndex) {
result = mark;
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
};
export const parseSSMLLang = (ssml: string): string | null => {
const match = ssml.match(/xml:lang\s*=\s*"([^"]+)"/);
return match ? match[1]! : null;
};