Read-aloud: voice picker + speed control, auto-avoids eSpeak
- ⚙ button opens a settings popover above the toolbar with:
- Voice dropdown: lists all voices the browser exposes, with ☁
suffix for remote/cloud voices vs local ones
- Speed slider: 0.5x–2.0x in 0.1 steps, labelled live
- On first load, auto-selects the first English non-eSpeak voice
(helps Linux/Firefox users who only have eSpeak installed by default
and haven't yet set a preference)
- Voice URI and rate persist in localStorage (sw-tts-voice, sw-tts-rate)
- Voice and rate applied per-utterance via voiceURIRef / ttsRateRef
so changes take effect on the next sentence without restarting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c9126f718d
commit
a8f93582bf
@ -1,10 +1,46 @@
|
|||||||
import { useRef, useState, useEffect } from 'react'
|
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeChange }) {
|
export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeChange }) {
|
||||||
const fileRef = useRef()
|
const fileRef = useRef()
|
||||||
const [isReading, setIsReading] = useState(false)
|
const [isReading, setIsReading] = useState(false)
|
||||||
const isReadingRef = useRef(false) // ref so closure in next() always sees current value
|
const isReadingRef = useRef(false) // ref so closure in next() always sees current value
|
||||||
|
|
||||||
|
// ── TTS voice & speed ─────────────────────────────────────────────────────
|
||||||
|
const [voices, setVoices] = useState([])
|
||||||
|
const [voiceURI, setVoiceURI] = useState(() => localStorage.getItem('sw-tts-voice') || '')
|
||||||
|
const [ttsRate, setTtsRate] = useState(() => parseFloat(localStorage.getItem('sw-tts-rate') || '1'))
|
||||||
|
const [showTtsMenu, setShowTtsMenu] = useState(false)
|
||||||
|
|
||||||
|
const loadVoices = useCallback(() => {
|
||||||
|
const all = window.speechSynthesis?.getVoices() ?? []
|
||||||
|
if (!all.length) return
|
||||||
|
setVoices(all)
|
||||||
|
// If no preference saved yet, auto-pick first English non-eSpeak voice
|
||||||
|
setVoiceURI(prev => {
|
||||||
|
if (prev && all.find(v => v.voiceURI === prev)) return prev
|
||||||
|
const pick = all.find(v => !v.name.toLowerCase().includes('espeak') && v.lang.startsWith('en'))
|
||||||
|
|| all.find(v => v.lang.startsWith('en'))
|
||||||
|
|| all[0]
|
||||||
|
return pick ? pick.voiceURI : ''
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadVoices()
|
||||||
|
window.speechSynthesis.addEventListener('voiceschanged', loadVoices)
|
||||||
|
return () => window.speechSynthesis.removeEventListener('voiceschanged', loadVoices)
|
||||||
|
}, [loadVoices])
|
||||||
|
|
||||||
|
useEffect(() => { localStorage.setItem('sw-tts-voice', voiceURI) }, [voiceURI])
|
||||||
|
useEffect(() => { localStorage.setItem('sw-tts-rate', ttsRate) }, [ttsRate])
|
||||||
|
|
||||||
|
// Keep latest voice/rate in refs so the reading closure always gets current values
|
||||||
|
const voiceURIRef = useRef(voiceURI)
|
||||||
|
const ttsRateRef = useRef(ttsRate)
|
||||||
|
useEffect(() => { voiceURIRef.current = voiceURI }, [voiceURI])
|
||||||
|
useEffect(() => { ttsRateRef.current = ttsRate }, [ttsRate])
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ── Typewriter mode ───────────────────────────────────────────────────────
|
// ── Typewriter mode ───────────────────────────────────────────────────────
|
||||||
const [typewriterMode, setTypewriterMode] = useState(false)
|
const [typewriterMode, setTypewriterMode] = useState(false)
|
||||||
const typewriterRef = useRef(false)
|
const typewriterRef = useRef(false)
|
||||||
@ -79,6 +115,8 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha
|
|||||||
isReadingRef.current = true
|
isReadingRef.current = true
|
||||||
setIsReading(true)
|
setIsReading(true)
|
||||||
|
|
||||||
|
const chosenVoice = window.speechSynthesis.getVoices().find(v => v.voiceURI === voiceURIRef.current)
|
||||||
|
|
||||||
let idx = 0
|
let idx = 0
|
||||||
function next() {
|
function next() {
|
||||||
if (!isReadingRef.current || idx >= chunks.length) {
|
if (!isReadingRef.current || idx >= chunks.length) {
|
||||||
@ -87,6 +125,8 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const u = new SpeechSynthesisUtterance(chunks[idx++])
|
const u = new SpeechSynthesisUtterance(chunks[idx++])
|
||||||
|
if (chosenVoice) u.voice = chosenVoice
|
||||||
|
u.rate = ttsRateRef.current
|
||||||
u.onend = next
|
u.onend = next
|
||||||
u.onerror = () => { isReadingRef.current = false; setIsReading(false) }
|
u.onerror = () => { isReadingRef.current = false; setIsReading(false) }
|
||||||
speechSynthesis.speak(u)
|
speechSynthesis.speak(u)
|
||||||
@ -207,6 +247,38 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha
|
|||||||
⌨
|
⌨
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className="tts-settings-wrap">
|
||||||
|
<button
|
||||||
|
className={`toolbar-btn${showTtsMenu ? ' active' : ''}`}
|
||||||
|
onMouseDown={e => { e.preventDefault(); setShowTtsMenu(o => !o) }}
|
||||||
|
title="Read-aloud settings (voice & speed)"
|
||||||
|
>⚙</button>
|
||||||
|
{showTtsMenu && (
|
||||||
|
<div className="tts-menu">
|
||||||
|
<label className="tts-label">Voice</label>
|
||||||
|
<select
|
||||||
|
className="tts-select"
|
||||||
|
value={voiceURI}
|
||||||
|
onChange={e => setVoiceURI(e.target.value)}
|
||||||
|
>
|
||||||
|
{voices.length === 0 && <option value="">Loading…</option>}
|
||||||
|
{voices.map(v => (
|
||||||
|
<option key={v.voiceURI} value={v.voiceURI}>
|
||||||
|
{v.name}{v.localService ? '' : ' ☁'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label className="tts-label">Speed — {ttsRate.toFixed(1)}×</label>
|
||||||
|
<input
|
||||||
|
type="range" min="0.5" max="2" step="0.1"
|
||||||
|
value={ttsRate}
|
||||||
|
onChange={e => setTtsRate(parseFloat(e.target.value))}
|
||||||
|
className="tts-slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`toolbar-btn read-aloud-btn${isReading ? ' reading-active' : ''}`}
|
className={`toolbar-btn read-aloud-btn${isReading ? ' reading-active' : ''}`}
|
||||||
onMouseDown={e => { e.preventDefault(); isReading ? stopReading() : startReading() }}
|
onMouseDown={e => { e.preventDefault(); isReading ? stopReading() : startReading() }}
|
||||||
|
|||||||
@ -1486,6 +1486,54 @@ button { cursor: pointer; font-family: inherit; }
|
|||||||
padding-bottom: 50vh;
|
padding-bottom: 50vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── TTS Settings ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.tts-settings-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-menu {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
width: 240px;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.75rem;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
||||||
|
z-index: 300;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-head);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-select {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: calc(var(--radius) - 1px);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tts-select:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
|
.tts-slider {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: var(--accent-hi);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Writing Prompt ───────────────────────────────────── */
|
/* ── Writing Prompt ───────────────────────────────────── */
|
||||||
|
|
||||||
.prompt-wrap { position: relative; }
|
.prompt-wrap { position: relative; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user