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:
chris 2026-05-24 21:21:15 -04:00
parent c9126f718d
commit a8f93582bf
2 changed files with 121 additions and 1 deletions

View File

@ -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 }) {
const fileRef = useRef()
const [isReading, setIsReading] = useState(false)
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
const [typewriterMode, setTypewriterMode] = useState(false)
const typewriterRef = useRef(false)
@ -79,6 +115,8 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha
isReadingRef.current = true
setIsReading(true)
const chosenVoice = window.speechSynthesis.getVoices().find(v => v.voiceURI === voiceURIRef.current)
let idx = 0
function next() {
if (!isReadingRef.current || idx >= chunks.length) {
@ -87,6 +125,8 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha
return
}
const u = new SpeechSynthesisUtterance(chunks[idx++])
if (chosenVoice) u.voice = chosenVoice
u.rate = ttsRateRef.current
u.onend = next
u.onerror = () => { isReadingRef.current = false; setIsReading(false) }
speechSynthesis.speak(u)
@ -207,6 +247,38 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha
</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
className={`toolbar-btn read-aloud-btn${isReading ? ' reading-active' : ''}`}
onMouseDown={e => { e.preventDefault(); isReading ? stopReading() : startReading() }}

View File

@ -1486,6 +1486,54 @@ button { cursor: pointer; font-family: inherit; }
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 ───────────────────────────────────── */
.prompt-wrap { position: relative; }