Add speech speed slider in the TTS panel (#128)

This commit is contained in:
Huang Xin
2025-01-08 21:31:12 +01:00
committed by GitHub
parent 814a0032d0
commit f26de3ab0d
19 changed files with 129 additions and 29 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ module.exports = {
output: '.',
options: {
debug: false,
sort: true,
sort: false,
func: {
list: ['_'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
@@ -33,6 +33,7 @@
"Enter your custom CSS here...": "Geben Sie hier Ihr benutzerdefiniertes CSS ein...",
"Excerpts": "Auszüge",
"Failed to import book(s): {{filenames}}": "Fehler beim Importieren von Buch/Büchern: {{filenames}}",
"Fast": "Schnell",
"Font": "Schriftart",
"Font & Layout": "Schrift & Layout",
"Font Face": "Schriftschnitt",
@@ -108,10 +109,12 @@
"Sign In": "Anmelden",
"Sign Out": "Abmelden",
"Sky": "Himmelblau",
"Slow": "Langsam",
"Solarized": "Solarisiert",
"Speak": "Sprechen",
"Subjects:": "Themen:",
"System Fonts": "System-Schriftarten",
"TTS not supported in this device": "TTS wird auf diesem Gerät nicht unterstützt",
"Theme Color": "Themenfarbe",
"Theme Mode": "Themenmodus",
"Translate": "Übersetzen",
@@ -33,6 +33,7 @@
"Enter your custom CSS here...": "Introduce tu CSS personalizado aquí...",
"Excerpts": "Extractos",
"Failed to import book(s): {{filenames}}": "Error al importar libro(s): {{filenames}}",
"Fast": "Rápido",
"Font": "Fuente",
"Font & Layout": "Fuente y diseño",
"Font Face": "Tipo de fuente",
@@ -108,10 +109,12 @@
"Sign In": "Iniciar sesión",
"Sign Out": "Cerrar sesión",
"Sky": "Cielo",
"Slow": "Lento",
"Solarized": "Solarizado",
"Speak": "Leer",
"Subjects:": "Temas:",
"System Fonts": "Fuentes del sistema",
"TTS not supported in this device": "TTS no es compatible con este dispositivo",
"Theme Color": "Color del tema",
"Theme Mode": "Modo del tema",
"Translate": "Traducir",
@@ -33,6 +33,7 @@
"Enter your custom CSS here...": "Saisissez votre CSS personnalisé ici...",
"Excerpts": "Extraits",
"Failed to import book(s): {{filenames}}": "Échec de l'importation du/des livre(s) : {{filenames}}",
"Fast": "Rapide",
"Font": "Police",
"Font & Layout": "Police et mise en page",
"Font Face": "Style de police",
@@ -108,10 +109,12 @@
"Sign In": "Se connecter",
"Sign Out": "Se déconnecter",
"Sky": "Ciel",
"Slow": "Lent",
"Solarized": "Solarisé",
"Speak": "Lire",
"Subjects:": "Sujets :",
"System Fonts": "Polices système",
"TTS not supported in this device": "La synthèse vocale n'est pas prise en charge sur cet appareil",
"Theme Color": "Couleur du thème",
"Theme Mode": "Mode du thème",
"Translate": "Traduire",
@@ -33,6 +33,7 @@
"Enter your custom CSS here...": "Masukkan CSS kustom Anda di sini...",
"Excerpts": "Kutipan",
"Failed to import book(s): {{filenames}}": "Gagal mengimpor buku: {{filenames}}",
"Fast": "Cepat",
"Font": "Font",
"Font & Layout": "Font & Tata Letak",
"Font Face": "Jenis Font",
@@ -108,10 +109,12 @@
"Sign In": "Masuk",
"Sign Out": "Keluar",
"Sky": "Langit",
"Slow": "Lambat",
"Solarized": "Solarized",
"Speak": "Bicara",
"Subjects:": "Subjek:",
"System Fonts": "Font Sistem",
"TTS not supported in this device": "TTS tidak didukung di perangkat ini",
"Theme Color": "Warna Tema",
"Theme Mode": "Mode Tema",
"Translate": "Terjemahkan",
@@ -33,6 +33,7 @@
"Enter your custom CSS here...": "Inserisci qui il tuo CSS personalizzato...",
"Excerpts": "Estratti",
"Failed to import book(s): {{filenames}}": "Impossibile importare il/i libro/i: {{filenames}}",
"Fast": "Veloce",
"Font": "Font",
"Font & Layout": "Font e Layout",
"Font Face": "Tipo di carattere",
@@ -108,10 +109,12 @@
"Sign In": "Accedi",
"Sign Out": "Esci",
"Sky": "Cielo",
"Slow": "Lento",
"Solarized": "Solarizzato",
"Speak": "Leggi",
"Subjects:": "Argomenti:",
"System Fonts": "Font di sistema",
"TTS not supported in this device": "TTS non supportato su questo dispositivo",
"Theme Color": "Colore tema",
"Theme Mode": "Modalità tema",
"Translate": "Traduci",
@@ -33,6 +33,7 @@
"Enter your custom CSS here...": "ここにカスタムCSSを入力...",
"Excerpts": "抜粋",
"Failed to import book(s): {{filenames}}": "書籍のインポートに失敗しました:{{filenames}}",
"Fast": "高速",
"Font": "フォント",
"Font & Layout": "フォントとレイアウト",
"Font Face": "書体",
@@ -108,10 +109,12 @@
"Sign In": "サインイン",
"Sign Out": "サインアウト",
"Sky": "スカイ",
"Slow": "低速",
"Solarized": "ソーラライズド",
"Speak": "読み上げ",
"Subjects:": "主題:",
"System Fonts": "システムフォント",
"TTS not supported in this device": "このデバイスではTTSがサポートされていません",
"Theme Color": "テーマカラー",
"Theme Mode": "テーマモード",
"Translate": "翻訳",
@@ -33,6 +33,7 @@
"Enter your custom CSS here...": "여기에 사용자 정의 CSS를 입력하세요...",
"Excerpts": "발췌",
"Failed to import book(s): {{filenames}}": "책 가져오기 실패: {{filenames}}",
"Fast": "빠름",
"Font": "글꼴",
"Font & Layout": "글꼴 및 레이아웃",
"Font Face": "글꼴 스타일",
@@ -108,10 +109,12 @@
"Sign In": "로그인",
"Sign Out": "로그아웃",
"Sky": "하늘색",
"Slow": "느림",
"Solarized": "솔라라이즈드",
"Speak": "말하기",
"Subjects:": "주제:",
"System Fonts": "시스템 글꼴",
"TTS not supported in this device": "이 장치에서 TTS가 지원되지 않음",
"Theme Color": "테마 색상",
"Theme Mode": "테마 모드",
"Translate": "번역",
@@ -33,6 +33,7 @@
"Enter your custom CSS here...": "Insira seu CSS personalizado aqui...",
"Excerpts": "Trechos",
"Failed to import book(s): {{filenames}}": "Falha ao importar livro(s): {{filenames}}",
"Fast": "Rápido",
"Font": "Fonte",
"Font & Layout": "Fonte e Layout",
"Font Face": "Estilo da Fonte",
@@ -108,10 +109,12 @@
"Sign In": "Entrar",
"Sign Out": "Sair",
"Sky": "Céu",
"Slow": "Lento",
"Solarized": "Solarizado",
"Speak": "Falar",
"Subjects:": "Assuntos:",
"System Fonts": "Fontes do Sistema",
"TTS not supported in this device": "TTS não suportado neste dispositivo",
"Theme Color": "Cor do Tema",
"Theme Mode": "Modo do Tema",
"Translate": "Traduzir",
@@ -33,6 +33,7 @@
"Enter your custom CSS here...": "Введите ваш пользовательский CSS здесь...",
"Excerpts": "Отрывки",
"Failed to import book(s): {{filenames}}": "Не удалось импортировать книгу(и): {{filenames}}",
"Fast": "Быстро",
"Font": "Шрифт",
"Font & Layout": "Шрифт и макет",
"Font Face": "Начертание шрифта",
@@ -108,10 +109,12 @@
"Sign In": "Войти",
"Sign Out": "Выйти",
"Sky": "Небесный",
"Slow": "Медленно",
"Solarized": "Солнечный",
"Speak": "Произнести",
"Subjects:": "Темы:",
"System Fonts": "Системные шрифты",
"TTS not supported in this device": "TTS не поддерживается на этом устройстве",
"Theme Color": "Цвет темы",
"Theme Mode": "Режим темы",
"Translate": "Перевести",
@@ -33,6 +33,7 @@
"Enter your custom CSS here...": "Özel CSS'nizi buraya girin...",
"Excerpts": "Alıntılar",
"Failed to import book(s): {{filenames}}": "Kitap(lar) içe aktarılamadı: {{filenames}}",
"Fast": "Hızlı",
"Font": "Yazı Tipi",
"Font & Layout": "Yazı Tipi ve Düzen",
"Font Face": "Yazı Tipi Yüzü",
@@ -108,10 +109,12 @@
"Sign In": "Giriş Yap",
"Sign Out": "Çıkış Yap",
"Sky": "Gökyüzü",
"Slow": "Yavaş",
"Solarized": "Solarized",
"Speak": "Konuş",
"Subjects:": "Konular:",
"System Fonts": "Sistem Yazı Tipleri",
"TTS not supported in this device": "Bu cihazda TTS desteklenmiyor",
"Theme Color": "Tema Rengi",
"Theme Mode": "Tema Modu",
"Translate": "Çevir",
@@ -33,6 +33,7 @@
"Enter your custom CSS here...": "Nhập CSS tùy chỉnh của bạn vào đây...",
"Excerpts": "Trích dẫn",
"Failed to import book(s): {{filenames}}": "Không thể nhập sách: {{filenames}}",
"Fast": "Nhanh",
"Font": "Phông chữ",
"Font & Layout": "Phông chữ & Bố cục",
"Font Face": "Kiểu chữ",
@@ -108,10 +109,12 @@
"Sign In": "Đăng nhập",
"Sign Out": "Đăng xuất",
"Sky": "Xanh trời",
"Slow": "Chậm",
"Solarized": "Solarized",
"Speak": "Đọc",
"Subjects:": "Chủ đề:",
"System Fonts": "Phông chữ hệ thống",
"TTS not supported in this device": "TTS không được hỗ trợ trên thiết bị này",
"Theme Color": "Màu chủ đề",
"Theme Mode": "Chế độ chủ đề",
"Translate": "Dịch",
@@ -33,6 +33,7 @@
"Enter your custom CSS here...": "在此处输入您的自定义 CSS...",
"Excerpts": "摘录",
"Failed to import book(s): {{filenames}}": "导入书籍失败:{{filenames}}",
"Fast": "快",
"Font": "字体",
"Font & Layout": "字体和布局",
"Font Face": "字型",
@@ -108,10 +109,12 @@
"Sign In": "登录",
"Sign Out": "登出",
"Sky": "天青",
"Slow": "慢",
"Solarized": "日晖",
"Speak": "朗读",
"Subjects:": "主题",
"System Fonts": "系统字体",
"TTS not supported in this device": "此设备不支持 TTS",
"Theme Color": "主题颜色",
"Theme Mode": "主题模式",
"Translate": "翻译",
@@ -33,6 +33,7 @@
"Enter your custom CSS here...": "在此處輸入您的自定義 CSS...",
"Excerpts": "摘錄",
"Failed to import book(s): {{filenames}}": "導入書籍失敗:{{filenames}}",
"Fast": "快",
"Font": "字體",
"Font & Layout": "字體和版面",
"Font Face": "字型",
@@ -108,10 +109,12 @@
"Sign In": "登入",
"Sign Out": "登出",
"Sky": "天青",
"Slow": "慢",
"Solarized": "日暉",
"Speak": "朗讀",
"Subjects:": "主題",
"System Fonts": "系統字體",
"TTS not supported in this device": "此設備不支持 TTS",
"Theme Color": "主題顏色",
"Theme Mode": "主題模式",
"Translate": "翻譯",
@@ -11,7 +11,7 @@ import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
const POPUP_WIDTH = 260;
const POPUP_HEIGHT = 80;
const POPUP_HEIGHT = 160;
const POPUP_PADDING = 10;
const TTSControl = () => {
@@ -100,6 +100,14 @@ const TTSControl = () => {
}
};
// rate range: 0.5 - 3, 1.0 is normal speed
const handleSetRate = async (rate: number) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.setRate(rate);
}
};
const updatePanelPosition = () => {
if (iconRef.current) {
const rect = iconRef.current.getBoundingClientRect();
@@ -149,6 +157,7 @@ const TTSControl = () => {
onBackward={handleBackward}
onForward={handleForward}
onStop={handleStop}
onSetRate={handleSetRate}
/>
</Popup>
)}
@@ -1,4 +1,5 @@
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdStop } from 'react-icons/md';
type TTSPanelProps = {
@@ -7,11 +8,55 @@ type TTSPanelProps = {
onBackward: () => void;
onForward: () => void;
onStop: () => void;
onSetRate: (rate: number) => void;
};
const TTSPanel = ({ isPlaying, onTogglePlay, onBackward, onForward, onStop }: TTSPanelProps) => {
const TTSPanel = ({
isPlaying,
onTogglePlay,
onBackward,
onForward,
onStop,
onSetRate,
}: TTSPanelProps) => {
const _ = useTranslation();
const [rate, setRate] = useState(1.0);
return (
<div className='flex w-full items-center justify-center rounded-2xl p-4'>
<div className='flex w-full flex-col items-center justify-center gap-2 rounded-2xl p-4'>
<div className='flex w-full flex-col items-center gap-0.5'>
<input
type='range'
min={0.5}
max={3}
value={rate}
className='range'
step='0.1'
onChange={(e) => {
const newRate = parseFloat(e.target.value);
setRate(newRate);
onSetRate(newRate);
}}
/>
<div className='grid w-full grid-cols-7 text-xs'>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
</div>
<div className='grid w-full grid-cols-7 text-xs'>
<span className='text-center'>{_('Slow')}</span>
<span className='text-center'></span>
<span className='text-center'>1.0</span>
<span className='text-center'>1.5</span>
<span className='text-center'>2.0</span>
<span className='text-center'></span>
<span className='text-center'>{_('Fast')}</span>
</div>
</div>
<div className='flex items-center justify-between space-x-4'>
<button onClick={onBackward} className='hover:bg-base-200/75 rounded-full p-1'>
<MdFastRewind size={32} />
@@ -8,7 +8,7 @@ export interface TTSMessageEvent {
export interface TTSClient {
init(): Promise<void>;
speak(ssml: string): Promise<AsyncIterable<TTSMessageEvent>>;
speak(ssml: string): AsyncIterable<TTSMessageEvent>;
pause(): Promise<void>;
resume(): Promise<void>;
stop(): Promise<void>;
@@ -104,6 +104,10 @@ export class TTSController extends EventTarget {
}
}
async setRate(rate: number) {
this.ttsClient.setRate(rate);
}
error(e: unknown) {
console.error(e);
this.state = 'stopped';
@@ -12,7 +12,7 @@ interface TTSBoundaryEvent {
error?: string;
}
async function* speakWithBoundary(ssml: string) {
async function* speakWithBoundary(ssml: string, getRate: () => number, getPitch: () => number) {
const lang = parseSSMLLang(ssml);
const { plainText, marks } = parseSSMLMarks(ssml);
// console.log('ssml', ssml, marks);
@@ -24,10 +24,14 @@ async function* speakWithBoundary(ssml: string) {
if (lang) {
utterance.lang = lang;
}
utterance.rate = getRate();
utterance.pitch = getPitch();
const queue = new AsyncQueue<TTSBoundaryEvent>();
utterance.onboundary = (event: SpeechSynthesisEvent) => {
utterance.rate = getRate();
utterance.pitch = getPitch();
const mark = findSSMLMark(event.charIndex, marks);
// console.log('boundary', event.charIndex, mark);
queue.enqueue({
@@ -62,7 +66,8 @@ async function* speakWithBoundary(ssml: string) {
}
export class WebSpeechClient implements TTSClient {
#utterance: SpeechSynthesisUtterance | null = null;
#rate = 1.0;
#pitch = 1.0;
#synth = window.speechSynthesis;
async init() {
@@ -71,26 +76,24 @@ export class WebSpeechClient implements TTSClient {
}
}
async speak(ssml: string) {
await this.stop();
const generator = (async function* () {
for await (const ev of speakWithBoundary(ssml)) {
if (ev.type === 'boundary') {
yield {
code: 'boundary',
mark: ev.mark ?? '',
message: `${ev.name} ${ev.charIndex}/${ev.charLength}`,
} as TTSMessageEvent;
} else if (ev.type === 'error') {
yield { code: 'error', message: ev.error } as TTSMessageEvent;
} else if (ev.type === 'end') {
yield { code: 'end', message: 'Speech finished' } as TTSMessageEvent;
}
async *speak(ssml: string): AsyncGenerator<TTSMessageEvent> {
for await (const ev of speakWithBoundary(
ssml,
() => this.#rate,
() => this.#pitch,
)) {
if (ev.type === 'boundary') {
yield {
code: 'boundary',
mark: ev.mark ?? '',
message: `${ev.name ?? 'Unknown'} ${ev.charIndex ?? 0}/${ev.charLength ?? 0}`,
} as TTSMessageEvent;
} else if (ev.type === 'error') {
yield { code: 'error', message: ev.error ?? 'Unknown error' } as TTSMessageEvent;
} else if (ev.type === 'end') {
yield { code: 'end', message: 'Speech finished' } as TTSMessageEvent;
}
})();
return generator;
}
}
async pause() {
@@ -107,11 +110,11 @@ export class WebSpeechClient implements TTSClient {
async setRate(rate: number) {
// The Web Speech API uses utterance.rate in [0.1 .. 10],
if (this.#utterance) this.#utterance.rate = rate / 10;
this.#rate = rate;
}
async setPitch(pitch: number) {
// The Web Speech API uses pitch in [0 .. 2].
if (this.#utterance) this.#utterance.pitch = pitch / 50;
this.#pitch = pitch;
}
}