Give read-aloud a Portuguese voice, and a slower one
Phase 21's infra half. Two things the pt-PT pair needs from TTS, and one thing every learner has wanted since Phase 11. **A language is no longer a code change.** The handler knew exactly two languages, named in the Config struct: English on TTS_ENDPOINT and Chinese on TTS_ENDPOINT_ZH. Petal now discovers its Piper instances from the environment — English keeps the unsuffixed pair it has always had, and every other language is a TTS_ENDPOINT_<LANG>/TTS_VOICE_<LANG> pair — so fr and es cost a compose service and two lines of .env. <LANG> is the base tag, because an environment variable name cannot hold pt-PT's hyphen and only one Portuguese model is loaded either way. A language configured by halves is dropped rather than routed: half a configuration should reach the client as "no voice here, use Web Speech", not as an instance that errors on every tap. The startup line now names the voices it actually resolved rather than the English endpoint it was handed — the same lesson the dictionary line learned last week. **pt_PT-tugão-medium is the only European voice Piper ships.** The other five pt models in the catalogue are Brazilian, so the default anyone reaches for is the wrong country — the same trap as `dictionary-pt` packaging VERO, arriving through the catalogue rather than through the model. Named explicitly in compose, with the query that checks it in the deploy README. **The slow replay** (SUGGESTIONS §5e) is `slow: true` on /api/tts, raising Piper's length_scale to ~4/3. Piper stretches durations rather than resampling, so it stays a voice instead of a groan. The pace is part of the cache key — without it the slow replay of a word already heard at normal speed would be served back at normal speed, which is the one request where the difference is the whole point. 🐢 sits beside 🔊 on the word card, the selection bubble and the garden flashcard; the Web Speech fallback slows too, so the button means the same thing when Piper is down. **And the other reading gets her own voice.** The `alsoIn` block — the Portuguese sense of a word that is also English — now speaks in the pair's locale, which the pack names (`locale`) rather than anything inferring it from the letters. "comum" is spelled identically in both halves; a detector would have to guess, and this is the same reason the gloss shows both directions instead of picking one. Tests: config discovery (both existing deployment shapes, half-configured languages dropped, the pre-map voice defaults preserved), the slow scale and its separate cache entry, pt routing on the base tag with pt-BR landing on the European instance, and speech.ts's request body. The i18n shape suite now asserts every pack names a speakable locale in its own language — and that pt-PT's is not pt-BR. Verified: go build/vet/test, tsc, vitest 125/125, vite build. Live smoke against two fake Piper servers: en/pt × normal/slow all reached the right instance at the right length_scale with four distinct cache entries, and an unconfigured language still 404s.
This commit is contained in:
@@ -1109,6 +1109,7 @@ export function EditorCore({
|
||||
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
|
||||
onRewrite={handleRewrite}
|
||||
onSpeak={speechSupported() ? () => speak(selection.text) : null}
|
||||
onSpeakSlow={speechSupported() ? () => speak(selection.text, undefined, true) : null}
|
||||
/>
|
||||
)}
|
||||
{rewrite && (
|
||||
|
||||
@@ -30,11 +30,15 @@ interface Props {
|
||||
// Read the selected text aloud (null when speech isn't available — the button
|
||||
// is then hidden).
|
||||
onSpeak: (() => void) | null
|
||||
// The same passage, said slowly. A whole sentence replayed at three-quarter
|
||||
// speed is the case SUGGESTIONS §5e is actually about — a word she can look
|
||||
// up, but a sentence only goes past once.
|
||||
onSpeakSlow: (() => void) | null
|
||||
}
|
||||
|
||||
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||
|
||||
export function SelectionBubble({ style, onRewrite, onSpeak }: Props) {
|
||||
export function SelectionBubble({ style, onRewrite, onSpeak, onSpeakSlow }: Props) {
|
||||
const pk = usePack()
|
||||
const [natural, ...tones] = REWRITE_STYLES
|
||||
|
||||
@@ -85,6 +89,20 @@ export function SelectionBubble({ style, onRewrite, onSpeak }: Props) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onSpeakSlow && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
|
||||
onClick={onSpeakSlow}
|
||||
className="inline-flex h-8 items-center justify-center px-2 text-sm"
|
||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)', pointerEvents: 'auto' }}
|
||||
title={pk.editor.readSlowly}
|
||||
aria-label="Read selection aloud slowly"
|
||||
>
|
||||
🐢
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="mx-0.5 h-5 w-px shrink-0" style={{ background: 'var(--color-border)' }} />
|
||||
|
||||
{tones.map((t) => (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WordInfo } from '../../api/client'
|
||||
import { speak, speechSupported } from '../../audio/speech'
|
||||
import { nativeLang, speak, speechSupported } from '../../audio/speech'
|
||||
import { usePack } from '../../i18n'
|
||||
import { wordBand } from './wordband'
|
||||
|
||||
@@ -77,16 +77,32 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
||||
</button>
|
||||
)}
|
||||
{speechSupported() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => speak(word)}
|
||||
aria-label={`Pronounce ${word}`}
|
||||
title={t.editor.readAloud}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full text-sm"
|
||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
🔊
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => speak(word)}
|
||||
aria-label={`Pronounce ${word}`}
|
||||
title={t.editor.readAloud}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full text-sm"
|
||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
🔊
|
||||
</button>
|
||||
{/* The same word, stretched out. A learner replaying a word at
|
||||
three-quarter speed is one of the oldest listening aids there
|
||||
is, and Piper does it by lengthening durations rather than
|
||||
slowing the tape, so it stays a voice rather than a groan. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => speak(word, undefined, true)}
|
||||
aria-label={`Pronounce ${word} slowly`}
|
||||
title={t.editor.readSlowly}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full text-sm"
|
||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
🐢
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,9 +213,27 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
||||
className="mt-3 rounded-xl px-2.5 py-2"
|
||||
style={{ background: 'var(--color-surface-alt)' }}
|
||||
>
|
||||
<p className="mb-1 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||
{t.editor.alsoIn}
|
||||
</p>
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<p className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||
{t.editor.alsoIn}
|
||||
</p>
|
||||
{/* Her language, in her language's voice. The pack names the locale
|
||||
(nativeLang) rather than anything guessing from the letters:
|
||||
"comum" is spelled the same either way, and an English voice
|
||||
reading it is the mistake this whole block exists to avoid. */}
|
||||
{speechSupported() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => speak(word, nativeLang())}
|
||||
aria-label={`Pronounce ${word} in ${t.nativeName}`}
|
||||
title={t.editor.readAloudNative}
|
||||
className="ml-auto flex h-6 w-6 items-center justify-center rounded-full text-xs"
|
||||
style={{ background: 'var(--color-surface)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
🔊
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="leading-snug" style={{ color: 'var(--color-plum)' }}>
|
||||
{reverse.gloss || word}
|
||||
{reverse.phonetic && (
|
||||
|
||||
@@ -445,15 +445,29 @@ function ReviewSession({
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="text-lg font-extrabold text-plum">{card.word}</span>
|
||||
{speechSupported() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => speak(card.word)}
|
||||
aria-label={`Pronounce ${card.word}`}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-xs"
|
||||
style={{ background: 'var(--color-surface)' }}
|
||||
>
|
||||
🔊
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => speak(card.word)}
|
||||
aria-label={`Pronounce ${card.word}`}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-xs"
|
||||
style={{ background: 'var(--color-surface)' }}
|
||||
>
|
||||
🔊
|
||||
</button>
|
||||
{/* A word she has just failed to recall is exactly the word
|
||||
worth hearing stretched out. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => speak(card.word, undefined, true)}
|
||||
aria-label={`Pronounce ${card.word} slowly`}
|
||||
title={t.garden.readSlowly}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-xs"
|
||||
style={{ background: 'var(--color-surface)' }}
|
||||
>
|
||||
🐢
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{card.phonetic && (
|
||||
|
||||
Reference in New Issue
Block a user