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:
prosolis
2026-07-27 13:21:45 -07:00
parent ccb43e5a4d
commit 24c3533e18
18 changed files with 595 additions and 66 deletions
+76
View File
@@ -0,0 +1,76 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { nativeLang, speak, stopSpeech } from './speech'
import { resetPackForTests, setPackLang } from '../i18n'
// Read-aloud has two jobs beyond "make a sound": ask for the right pace, and ask
// in the right language. Both are decided at the call site and travel in the
// request body, so this checks the body — the part a component author can get
// wrong without anything failing loudly.
let bodies: Array<Record<string, unknown>>
beforeEach(() => {
bodies = []
vi.stubGlobal(
'fetch',
vi.fn((_url: string, init: RequestInit) => {
bodies.push(JSON.parse(String(init.body)))
// Never resolves to audio: the fallback path needs no window.Audio here,
// and rejecting would run the Web Speech branch instead of the server one.
return new Promise(() => {})
}),
)
})
afterEach(() => {
stopSpeech()
vi.unstubAllGlobals()
resetPackForTests()
})
describe('speak', () => {
it('asks for the normal pace by default', () => {
speak('reception')
expect(bodies).toHaveLength(1)
expect(bodies[0]).toMatchObject({ text: 'reception', lang: 'en-US', slow: false })
})
it('asks for the slow replay when the slow control is used', () => {
speak('reception', undefined, true)
expect(bodies[0]).toMatchObject({ text: 'reception', slow: true })
})
it('still detects Chinese by script, so a zh selection is never read in English', () => {
speak('你好世界')
expect(bodies[0]).toMatchObject({ lang: 'zh-CN' })
})
it('sends nothing for empty text', () => {
speak(' ')
expect(bodies).toHaveLength(0)
})
})
describe('nativeLang', () => {
// The voice for her own language comes from the pack, not from the letters.
// "comum" is spelled the same in both halves of the pt pair, so a detector
// would have to guess; the component that knows it is rendering her language
// says so instead.
it('follows the pair language', () => {
setPackLang('zh')
expect(nativeLang()).toBe('zh-CN')
setPackLang('pt-PT')
expect(nativeLang()).toBe('pt-PT')
})
it('names a European Portuguese voice, never a Brazilian one', () => {
setPackLang('pt-PT')
expect(nativeLang()).not.toBe('pt-BR')
})
it('is what a Latin-pair lookup speaks the other reading in', () => {
setPackLang('pt-PT')
speak('comum', nativeLang())
expect(bodies[0]).toMatchObject({ text: 'comum', lang: 'pt-PT' })
})
})
+26 -9
View File
@@ -6,6 +6,8 @@
// (TTS disabled) or unreachable, we fall back to the browser's Web Speech API so
// the buttons still do something. No model or network is strictly required.
import { pack } from '../i18n'
// speechSupported reports whether read-aloud can do anything at all. Audio
// playback is universal, so as long as we can construct an Audio element OR the
// Web Speech API exists, the buttons should show. The server path is tried at
@@ -51,8 +53,10 @@ function pickVoice(lang: string): SpeechSynthesisVoice | undefined {
}
// speakWebSpeech is the fallback: the browser's built-in synthesizer. A touch
// slower than default so learners can follow along.
function speakWebSpeech(text: string, lang: string): void {
// slower than default so learners can follow along, and slower still when the
// slow replay was asked for — the fallback should degrade in voice quality, not
// in what the button does.
function speakWebSpeech(text: string, lang: string, slow: boolean): void {
if (!webSpeechSupported()) return
const synth = window.speechSynthesis
synth.cancel()
@@ -60,7 +64,7 @@ function speakWebSpeech(text: string, lang: string): void {
utterance.lang = lang
const voice = pickVoice(lang)
if (voice) utterance.voice = voice
utterance.rate = 0.95
utterance.rate = slow ? 0.7 : 0.95
synth.speak(utterance)
}
@@ -74,13 +78,26 @@ export function detectLang(text: string): string {
return CJK.test(text) ? 'zh-CN' : 'en-US'
}
// nativeLang is the locale of the writer's own language — the voice for the
// *other* reading of a word that exists in both halves of a Latin pair.
//
// It is asked for explicitly rather than detected, and that is the point. A
// script boundary can be detected (the CJK test above); "comum" cannot. So the
// component that knows it is rendering her language says so, and everything
// rendering English lets the default stand. No guess, therefore no wrong guess
// about her writing — the same rule the both-directions gloss follows.
export function nativeLang(): string {
return pack().locale
}
// speak reads `text` aloud, cancelling anything already in flight so rapid taps
// don't queue up. `lang` defaults to a guess from the text (Chinese vs English)
// so callers can just pass the selection; pass an explicit locale to override.
// It tries the server's neural voice first and silently falls back to the browser
// voice if that's unavailable (route off, network error, or a 404 for a language
// with no configured voice).
export function speak(text: string, lang = detectLang(text)): void {
// `slow` asks for the stretched replay (SUGGESTIONS §5e) — the second tap on a
// sentence that went by too fast. It tries the server's neural voice first and
// silently falls back to the browser voice if that's unavailable (route off,
// network error, or a 404 for a language with no configured voice).
export function speak(text: string, lang = detectLang(text), slow = false): void {
if (!text.trim()) return
stopSpeech()
const seq = ++requestSeq
@@ -88,7 +105,7 @@ export function speak(text: string, lang = detectLang(text)): void {
fetch('/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, lang }),
body: JSON.stringify({ text, lang, slow }),
})
.then((res) => {
if (!res.ok) throw new Error(`tts ${res.status}`)
@@ -115,6 +132,6 @@ export function speak(text: string, lang = detectLang(text)): void {
// Server TTS unavailable for this request — use the browser voice instead,
// unless a newer tap has already superseded this one.
if (seq !== requestSeq) return
speakWebSpeech(text, lang)
speakWebSpeech(text, lang, slow)
})
}
+1
View File
@@ -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 && (
+19 -1
View File
@@ -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) => (
+48 -14
View File
@@ -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 && (
+23 -9
View File
@@ -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 && (
+10
View File
@@ -118,6 +118,16 @@ describe('the zh pack', () => {
expect(empties).toEqual([])
})
// The voice read-aloud speaks this pair in. A pack that names a locale no
// Piper voice exists for degrades to Web Speech, which is survivable; a pack
// that names the *wrong region* does not announce itself at all — it just
// reads her language back to her in the accent the pair exists to avoid.
it.each(PACKS)('names a speakable locale for its own language ($code)', (p) => {
expect(p.locale, `${p.code} has no locale`).toMatch(/^[a-z]{2}(-[A-Za-z]{2,4})?$/)
expect(p.locale.split('-')[0]).toBe(p.code.split('-')[0])
if (p.code === 'pt-PT') expect(p.locale).toBe('pt-PT') // never pt-BR
})
it.each(PACKS)('labels every companion, tone and style ($code)', async (p) => {
const { COMPANIONS } = await import('../components/Companion/companions')
for (const c of COMPANIONS) {
+4
View File
@@ -31,6 +31,7 @@ import type { Pack } from '../types'
export const ptPT: Pack = {
code: 'pt-PT',
nativeName: 'Português',
locale: 'pt-PT',
app: {
duplicateTitle: (title) => `${title} (cópia)`,
@@ -184,6 +185,8 @@ export const ptPT: Pack = {
inGarden: 'Já está no jardim · In your garden (tap to remove)',
saveToGarden: 'Guardar no jardim · Save to garden',
readAloud: 'Ler em voz alta · Read aloud',
readSlowly: 'Ler devagar · Read slowly',
readAloudNative: 'Ler em português · Read in Portuguese',
lookingUp: 'A procurar… · Looking up…',
definition: 'Definição · Definition',
synonyms: 'Sinónimos · Synonyms',
@@ -242,6 +245,7 @@ export const ptPT: Pack = {
due: 'a rever · due',
seen: (reps, intervalDays) => `${reps}× revista · seen ${reps}× · intervalo ${intervalDays}d`,
readAloud: '🔊 Ler',
readSlowly: '🐢 Devagar',
source: '📄 Origem · Source',
remove: '🗑 Remover',
growing: (n) => `🐱💤 ${n} flor${n === 1 ? '' : 'es'} no jardim · ${n} blossom${n > 1 ? 's' : ''} growing`,
+4
View File
@@ -13,6 +13,7 @@ import type { Pack } from '../types'
export const zh: Pack = {
code: 'zh',
nativeName: '中文',
locale: 'zh-CN',
app: {
duplicateTitle: (title) => `${title} (副本)`,
@@ -169,6 +170,8 @@ export const zh: Pack = {
inGarden: '已在词汇花园 · In your garden (tap to remove)',
saveToGarden: '加入词汇花园 · Save to garden',
readAloud: '朗读 · Read aloud',
readSlowly: '慢速朗读 · Read slowly',
readAloudNative: '用中文朗读 · Read in Chinese',
lookingUp: '查找中… · Looking up…',
definition: '释义 · Definition',
synonyms: '近义词 · Synonyms',
@@ -228,6 +231,7 @@ export const zh: Pack = {
due: '待复习 · due',
seen: (reps, intervalDays) => `复习 ${reps} 次 · seen ${reps}× · 间隔 ${intervalDays}d`,
readAloud: '🔊 朗读',
readSlowly: '🐢 慢速',
source: '📄 出处 · Source',
remove: '🗑 移除',
growing: (n) => `🐱💤 ${n} 朵花在花园里 · ${n} blossom${n > 1 ? 's' : ''} growing`,
+17
View File
@@ -31,6 +31,13 @@ export interface Pack {
// for anywhere Petal has to say which pair this is.
code: PairLang
nativeName: string
// The BCP-47 locale to *speak* this language in — what read-aloud sends to
// Piper (and to the browser's Web Speech fallback). It is not derivable from
// `code`: zh is a pair language but zh-CN is a voice, and a pack is the only
// place that knows which regional voice its pair should be read in. pt-PT is
// spelled out for the same reason the prompts spell it out — the default
// Portuguese voice anyone reaches for is Brazilian.
locale: string
app: {
// A duplicated document's title. A function, not a suffix: where the marker
@@ -129,6 +136,13 @@ export interface Pack {
inGarden: string
saveToGarden: string
readAloud: string
// The same passage, said slowly (SUGGESTIONS §5e). Only ever offered for
// English: it is the language she is learning to hear.
readSlowly: string
// Read the *other* reading aloud — the one in her own language, in her own
// language's voice. Sits on the `alsoIn` block, so a pack whose pair has no
// collisions never sees it rendered.
readAloudNative: string
lookingUp: string
definition: string
synonyms: string
@@ -170,6 +184,9 @@ export interface Pack {
due: string
seen: (reps: number, intervalDays: number) => string
readAloud: string
// Short label for the slow replay on a flashcard, where a word she is
// trying to recall is exactly the word worth hearing stretched out.
readSlowly: string
source: string
remove: string
growing: (n: number) => string