forked from akai/readest
Add 'Speak' button in the footbar (#127)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
+1
-1
Submodule packages/foliate-js updated: 238fbc216c...67741118a2
Reference in New Issue
Block a user