forked from akai/readest
Add speech speed slider in the TTS panel (#128)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user