Compare commits

..
2 Commits
Author SHA1 Message Date
prosolis 24c3533e18 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.
2026-07-27 13:21:45 -07:00
prosolis ccb43e5a4d Phase 21: Petal learns to be an English+Portuguese pair
The plan said "Hunspell pt-PT vendored like en-US". Measuring that first is
what saved it: nspell expands affixes eagerly on construction, and European
Portuguese's 1,340 rules over 44,257 stems want over a gigabyte of browser
heap — ~340 MB for the first 12,000 entries, and no return at all after three
minutes on the whole file. So the expansion runs once at build time instead:
1,039,058 forms, 2.66 MB gzipped, read by the same nspell in 842 ms.

The obvious npm package would also have shipped the wrong language. Both
dictionary-pt and dictionary-pt-br carry VERO, the Brazilian word list, so
vendoring by name puts pt-BR spellings behind a pt-PT label — the drift
SUGGESTIONS §3 warns about, arriving through the packaging where no reviewer
can see it. The source is Projecto Natura's, and the build script now asserts
the fault lines (receção in, recepção out) before writing anything.

Spellcheck consults both dictionaries and flags only what both reject, which
is the no-detector answer to a pair with no script boundary. The word card
does the same in the other direction: "data" is a word in both languages, so
Petal shows both readings rather than guessing which she meant.

Writing the tests caught the one real bug — extendedAlphabet was a snapshot
while correct/suggest read live, and her dictionary arrives after English, so
every lookup would have resolved "cora" while the underlines were already
right.

Not done, and not claimed: the pack has not been read by a pt-PT speaker, and
the Piper voice is deferred with the deploy.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 12:43:02 -07:00
34 changed files with 2050 additions and 170 deletions
+16 -6
View File
@@ -235,12 +235,21 @@ Option 3 ratified (import package, read-only `dict.db`).
### Phase 21 — The pt-PT pair (first Latin pair, proves the model)
SUGGESTIONS §1/§3/§3a. French and **Spanish** follow the same groove afterwards — es is no longer gated now that DreamDict has Spanish data (2026-07-26). pt-PT still goes first: it's the pair with a real user behind it, and it's the one that proves the langpack + both-dictionaries model.
Phase 20 left this ready: `dict.db` on the VPS now holds all five languages, and pt-PT gloss coverage of common English words is 62%.
- [ ] Hunspell pt-PT vendored like en-US; **both-dictionaries spellcheck** (flag only if wrong in both; pills from both) — the no-detector stance, Q1 settled
- [ ] Gloss/WordCard both directions; on en/pt collisions show both compactly, never hide either
- [ ] Prompts pinned to **European Portuguese, never pt-BR** (explicit in every prompt); pt-PT langpack copy written and **reviewed by a pt-PT speaker before trusted**
- [ ] Piper pt-PT voice instance on parodia; read-aloud + L1 voice wired; slow toggle (`length_scale`) while in there (SUGGESTIONS §5e)
- [ ] Companion tips/cheers/bedtime lines in the pt-PT pack (the kitten speaks pt+en to this user)
- [ ] Acceptance: a pt-PT-pair user gets the full experience end-to-end with the VPN down except LLM passes; zh-pair user sees zero change
**Code half built 2026-07-27** (user: "let's continue the build plan"; scope confirmed: code only, no VPS work; the pack written but flagged unreviewed). Not deployed — no migration, so it is a rebuild whenever the user wants it.
- [x] **pt-PT spelling dictionary — and it could not be "vendored like en-US".** nspell expands affixes *eagerly on construction*: English's ~50k stems and small rule set are fine, European Portuguese's **1,340 affix rules over 44,257 stems** are not. Measured here: ~340 MB of heap for the first 12,000 entries and no return at all after three minutes on the whole file, i.e. comfortably over a gigabyte for a browser to load a spellchecker. So `scripts/build_ptpt_dictionary.py` runs Hunspell's expansion **once at build time** — 1,039,058 surface forms, 15 MB of text, **2.66 MB gzipped**, which nspell then reads with no affix machinery at all in **842 ms / ~120 MB**. The runtime path is byte-for-byte the English one, which is the real prize. The shipped `.aff` keeps only upstream's TRY/KEY/REP/MAP, which shape *corrections* rather than membership — so "telemovel" still corrects to "telemóvel" and "cao" still knows about "ção".
- [x] **npm's `dictionary-pt` is not European Portuguese.** Both it and `dictionary-pt-br` package VERO (*Verificador Ortográfico Livre*, Brasil) — the obvious vendoring step would have shipped Brazilian spellings under a pt-PT label, which is the §3 drift risk arriving through the packaging rather than through the model. The real source is Projecto Natura's (Universidade do Minho), which LibreOffice ships and Debian packages as `hunspell-pt-pt`; its aff declares `LANG pt_PT`. The build script **asserts the fault lines before it writes anything**: accepts `receção`, `húmido`, `telemóvel`, `autocarro`, `comboio`, `ótimo`, `pensámos`, `escrevêssemos`; rejects `recepção`, `úmido`, `ônibus`, `óptimo`. A source that fails those is not the dictionary it is for.
- [x] **Both-dictionaries spellcheck** (`useSpellChecker`): English always loads; her language loads when a pair ships one; a token is flagged only when **every** loaded dictionary rejects it. Correction pills **interleave** the two rather than concatenating — otherwise English fills all five and a misspelt Portuguese word gets no Portuguese suggestion, which is the one case the second dictionary was loaded for. No dictionary at all accepts everything: a failed fetch must not underline the whole document.
- [x] **The tokenizer had to become a property of the checker, not a constant.** `[A-Za-z]` cuts "coração" into "cora" and "o", both short enough that `isCheckable` discards them — so the word was silently never checked *and* a right-click would have offered a definition of "cora". The wide alphabet is `[A-Za-zÀ-ÖØ-öø-ÿ]`, deliberately skipping × and ÷, which hide inside that Latin-1 range. It stays **off** for a writer with no Latin second language: widening it there can only find new words to underline (the *café* and *naïve* she borrows) and no mistake she actually made. `wordAt` takes the same flag, so the underline and every lookup agree.
- [x] **Gloss/WordCard both directions** — new `lexicon.Reverse` on the lookup and a `reverse` line on the hover tip. A Latin pair has no script boundary: *data*, *sale*, *comum*, *tarde* and *ali* are real words on both sides, and there is no honest way to look at one in a mixed document and know which was meant. Petal asks both directions and shows whatever answers — no detector, so it cannot be wrong about her writing, and for a learner the collision is the interesting part. The English de-inflection walk is deliberately **not** applied in reverse: `candidates` knows -s/-ed/-ing, and running it over Portuguese would be right by accident and wrong by rule.
- [x] Prompts pinned to **European Portuguese, never pt-BR** — already done in Phase 19 (`internal/llm/lang.go` spells it out inside the prompt, with *porquê* carried alongside so the tutor recognises her question).
- [x] pt-PT langpack written (`web/src/i18n/packs/pt-PT.ts`), and pt-PT is now a real switch rather than a fallback. Post-Acordo spellings with the European lexicon (*ficheiro*, *ecrã*, *guardar*, *sinónimo*, *académico*, *Iniciar sessão*), *estás a escrever* rather than the gerund, and second-person *tu* — a companion in a private notebook, not a form. A test greps the built pack for Brazilian forms, because that is exactly the error nobody reviewing the diff can see.
- [ ] ⚠️ **The pack is NOT reviewed by a pt-PT speaker** — SUGGESTIONS §3's own bar, and the one item here I cannot meet. Flagged at the top of the file and left unchecked deliberately; expect a speaker to change the register before the vocabulary.
- [x] Companion tips/cheers/bedtime lines in the pt-PT pack. Not a translation of the zh pack: the bedtime proverbs are Portuguese ones and there is a false-friends tip the Mandarin pair had no use for. The English wit in the bedtime lines is the user's own and is kept word for word across packs.
- [ ] Piper pt-PT voice instance on parodia; read-aloud + L1 voice wired; slow toggle (`length_scale`) while in there (SUGGESTIONS §5e) — the infra half, deferred with the deploy
- [ ] Acceptance: a pt-PT-pair user gets the full experience end-to-end with the VPN down except LLM passes; zh-pair user sees zero change — waits on the voice above and on a real pt-PT account
- Tests: `internal/lexicon/dreamdict_test.go` gains a real collision in the fixture (*data*: English facts, Portuguese date) — both readings on a collision, **no** reverse block for an English-only word, the tooltip carrying only the reverse gloss, and the embedded/glossless providers staying silent (a Chinese reading of an English word is worse than none). Frontend: `spellchecker.test.ts` (either-accepts, flag-only-if-both-reject, a Portuguese word never flagged for being unknown to English, no-dictionary-accepts-everything, a dictionary arriving *after* the checker was built, interleaved pills) and `SpellCheck.test.ts` (the narrow alphabet still cutting "coração", the wide one not, CJK never tokenized under either, × and ÷ excluded). The i18n suite now runs its shape assertions over *every* pack — a shape only the first author's pack satisfies is a coincidence, not a shape.
- **A bug the test found, not the code review**: `extendedAlphabet` was a value computed when the checker was built while `correct`/`suggest` read live. Her dictionary arrives *after* English, so the underlines would have been right while every lookup was still resolving "cora". It is a getter now.
- Verified: go build/vet/test, tsc, vite build, vitest 116/116 clean. The shipped asset loaded in a real nspell (842 ms, 139 MB, pt-PT variants correct both ways). Live smoke on a throwaway DB (:8091): both dictionary files served (577 B aff, 2,661,813 B gz), the gz inflating to 1,039,058 forms with `receção` present, and the zh word lookup unchanged. **Not verified against real data**: this laptop has no `dict.db`, so the reverse-lookup path is exercised by the fixture only — the first real pt-PT collision lookup happens on the VPS.
### Phase 22 — Learning loop + code-first layers
Each item independent and small; order within is free (SUGGESTIONS §5–§6).
@@ -265,6 +274,7 @@ Each item independent and small; order within is free (SUGGESTIONS §5–§6).
- [x] **Phase 14 — companion warmth + bedtime nag + night mode**: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. ✅ (see Phase 14 above)
## Session log
- 2026-07-27: **Phase 21 (code half) — the pt-PT pair, and the plan's one-line assumption about the dictionary** (user: "let's continue the build plan"; scope confirmed: code only, the Piper voice and the deploy deferred, the pack written but flagged unreviewed). The plan said "Hunspell pt-PT vendored like en-US", and that turned out to be the load-bearing sentence. **nspell expands affixes eagerly on construction** — it materialises every surface form the moment you build it. English survives that; European Portuguese's 1,340 affix rules over 44,257 stems do not. Measured before deciding anything: ~340 MB of heap for the first 12,000 entries, and no return at all after three minutes on the whole file — over a gigabyte, in a browser, on a tablet. So the expansion moved to build time: `scripts/build_ptpt_dictionary.py` writes 1,039,058 forms, 2.66 MB gzipped, which the *same* nspell then reads in 842 ms using ~120 MB, and the runtime path stays byte-for-byte the English one. The `.aff` keeps only TRY/KEY/REP/MAP, which shape corrections rather than membership, so "telemovel" still corrects to "telemóvel". **A second thing the obvious route would have got wrong quietly**: npm's `dictionary-pt` is not European Portuguese — both it and `dictionary-pt-br` package VERO (Brasil), so vendoring the obvious package name ships Brazilian spellings under a pt-PT label. That is §3's pt-BR drift arriving through the *packaging* rather than through the model, and nobody reviewing the diff would see it. The real source is Projecto Natura's, packaged as `hunspell-pt-pt`; the build script now asserts the fault lines (`receção`/`húmido`/`pensámos` in, `recepção`/`úmido`/`ônibus`/`óptimo` out) before it writes a byte, and a vitest greps the built langpack for *sinônimo*, *arquivo*, *tela*, *você*. **Both-dictionaries spellcheck** landed as §3a specifies — flag only what every loaded dictionary rejects, interleave the correction pills so English can't fill all five — and dragged a smaller thing with it: the tokenizer had to become a property of the checker rather than a constant, because `[A-Za-z]` cuts "coração" into "cora", which is both silently unchecked *and* what a right-click would have looked up. The wide alphabet stays off for a writer with no Latin second language, where it could only earn her new squiggles. **Gloss both directions**: a Latin pair has no script boundary, so *data*, *sale* and *comum* are words on both sides and there is no honest way to know which she meant — Petal asks both and shows what answers, which needs no detector and therefore cannot be wrong about her writing. The reverse direction deliberately skips the English de-inflection walk, which over Portuguese would be right by accident and wrong by rule. **Writing the tests found the bug**: `extendedAlphabet` was a snapshot taken when the checker was built while `correct`/`suggest` read live — and her dictionary arrives *after* English, so the underlines would have been right while every lookup still resolved "cora". go build/vet/test, tsc, vite, vitest 116/116 clean; the shipped asset loaded in a real nspell; live smoke on a throwaway DB served both files and left the zh lookup untouched. **Two things outstanding and both said plainly**: the pack has *not* been read by a pt-PT speaker (SUGGESTIONS §3's own bar, and not one I can meet), and this laptop has no `dict.db`, so the reverse-lookup path is covered by a fixture rather than by a real collision — the first of those happens on the VPS.
- 2026-07-27: **dict.db rebuilt with Spanish, and a log line caught lying** (user: "if we need to redeploy DreamDict to add Spanish support, then do so"). Millenia's dreamdict checkout held ~490 lines of uncommitted work; rather than pull over it, comparing file contents showed an earlier draft of the regional-variant work already committed upstream — nothing unique, but not mine to discard, so it was left alone and the rebuild ran from a clean clone pushed over from the laptop (millenia has no GitHub SSH). Import took 6m15s and added **es: 102,971 words**, leaving en/fr/pt-PT/zh byte-identical — the check that distinguishes "added a language" from "quietly changed everything". Coverage measured before shipping: **es 68.6%**, the best of the four; **zh re-measured at 53.2%**, so the ECDICT decision stands on fresh evidence rather than on the earlier number. Shipped direct millenia→parodia over headscale, hashed both ends, kept the April file for rollback. **The rebuild's real find was in Petal, not DreamDict**: the startup line reported `dictionary.Langs()`, a compile-time constant of *supported* languages, so it had been printing a cheerful `[en fr pt-PT es zh]` over a database with no Spanish in it — the exact failure it existed to catch, reported as success, and something I had already claimed as proof the deploy was good. It now counts rows. Chasing a failed SUBTLEX-US download (benign — the loader falls back to `.txt`) also confirmed English "frequency" is mostly SCOWL's commonness bucket, which independently vindicates the band chip reading `difficulty` instead.
- 2026-07-27: **Phase 20 — DreamDict becomes the dictionary for every pair but Chinese** (user: "let's continue the build plan"; scope confirmed: build the seam against the existing April `dict.db`, rebuild it later, code + local verification only). The prerequisite was bigger than the plan recorded: renaming DreamDict's module path was necessary but useless on its own, because the query layer lived in `internal/dictionary` and no module may import another's `internal`. Both fixed upstream — the package is now `dictionary`, with a comment saying why *reading* a built database is public API while the loaders that build one stay internal. In Petal, `Provider` is the two questions the popover already asked, so the embedded `*Lexicon` satisfied it with no changes at all, and `Set.For(lang)` is the one place the choice is made. **The measurement is the story of the phase.** `MULTIUSER_PLAN.md` mapped `Gloss ← Translate(word, "en", L1)` 1:1; against the real 452 MB database that table answers for **17%** of the 2,000 commonest English words into pt-PT. Wiktionary's translation sections are thin in that direction — "ephemeral", "think" and "quickly" have no en→pt-PT row at all. The shared-synset path answers for **61%**, so a new upstream `Equivalents` queries that and falls back to translations for 62% combined. Then the *ordering* was wrong in an instructive way: sorting by target frequency glosses "think" as *lembrar* — "remember" — because lembrar is commoner in Portuguese, even though pensar shares six of think's synsets to lembrar's one. Counting sense agreement first fixes it (think → pensar; write → escrever; garden → jardim). The same measurement is what kept **zh on ECDICT**: DreamDict reaches a Chinese gloss for 53% of those words where ECDICT reaches nearly all — the plan said converge only if quality holds, and it didn't. Two other decisions worth keeping: a missing `dict.db` is **not an error** (a laptop has never had one) but a present-and-unimported one is; and a pt-PT writer without a dictionary falls back to the embedded datasets **with the gloss suppressed**, keeping the English half rather than blanking the popover — an empty field reads as "not found", the wrong language reads as broken. The new fields surface as **three** bands, not five, because the difficulty score can separate "everyday" from "you'll have to explain this" but cannot rank *obfuscate* against *serendipity*, and a finer scale would be a confident-looking lie. Writing the tests found two bugs first: `trimEtymology` sliced by byte, which would have emitted invalid UTF-8 for precisely the Greek and Latin etymologies the feature exists for, and its ellipsis path overran its own cap. go build/vet/test, tsc, vite, vitest 96/96 clean in both repos; live smoke on a throwaway DB against the real dictionary, one instance flipped from zh to pt-PT mid-run. **Then deployed, with Phase 19** (user: "do it"): dreamdict pushed to GitHub, the `replace` swapped for a real pseudo-version, encrypted off-box backup first, `dict.db` copied into the LUKS volume and SHA-256-verified, then a rebuild — no migration in either phase, so `schema_migrations` stayed at 11 and her writing came through untouched (8 documents, 33 versions, 103 suggestions, FTS matching, integrity ok). Both accounts are on the zh pair, so **nothing she sees changed today**; what shipped is the capacity for the next pair. Outstanding: the deployed `dict.db` predates DreamDict's Spanish data and needs rebuilding before the es pair ships.
- 2026-07-27: **Phase 18 deployed, and Phase 19 — the copy stops being hardcoded Mandarin** (user: "let's continue the build plan"; sequencing confirmed: rehearse + deploy 18, then start 19). The rehearsal the previous session was blocked from running went first: a `VACUUM INTO` snapshot of the live VPS database, migrated locally by the Phase-18 binary, every count unchanged and FTS/integrity/foreign keys clean, `personal_words` created empty — then the deploy itself (off-box encrypted backup, rebuild, all three containers healthy, `0011` applied to the live DB with her writing untouched, `/api/spell/words` 401 without a session over public HTTPS). **One check was refused and not worked around**: minting a probe session row to see the endpoint answer 200 for a real cookie reads as credential fabrication to this session's classifier; the endpoint's lifecycle is covered by tests and the shared middleware governs that last step for every other route. **Phase 19** then lifted every `中文 · English` literal out of ~29 files into `web/src/i18n` — one `Pack` type, a verbatim `zh` pack, and two access paths chosen by *when* copy is built: `usePack()` for components, `pack()` for the companion and prose checker, which compose a line when something happens rather than when something renders. The interesting decisions were about what a pack must be allowed to control: **every string with a value in it is a function** (`reviewDue(n)`, `daysAgo(n)`, even English pluralisation) because word order isn't universal; the roster constants keep only value + emoji so a label can never drift from its key; and `gradeBand` returns a band *name* rather than a label. On the server, `internal/llm/lang.go` replaces "Simplified Chinese" in the three prompts that name her language — with pt-PT spelled **"European Portuguese (pt-PT, never Brazilian Portuguese)"** in the prompt itself, and her word for "why" carried alongside so the tutor still recognises the question. `pair_lang` is read **in the row-scoped query each handler already ran**, not a second lookup that could disagree with it — and the test for that was checked by breaking the join and watching it fail. go build/vet/test, tsc, vite, vitest 90/90 clean; live smoke on a throwaway DB. **Phase 19 is not deployed** — no migration, so it's a rebuild whenever the user wants it.
+5 -1
View File
@@ -200,7 +200,11 @@ func main() {
// back to the browser's Web Speech API on its own.
if ttsHandler, ok := tts.New(cfg); ok {
pr.Mount("/tts", ttsHandler.Routes())
log.Printf("read-aloud enabled (TTS endpoint=%s)", cfg.TTSEndpoint)
// Name the languages, not just the English endpoint: which
// voices a deployment actually reached is the thing worth
// seeing at boot, and a missing sidecar is silent otherwise
// (a 404 the client answers by quietly using Web Speech).
log.Printf("read-aloud enabled (voices: %s)", strings.Join(ttsHandler.Languages(), ", "))
}
})
})
+29
View File
@@ -579,6 +579,35 @@ Petal's env then carries `TTS_ENDPOINT=http://127.0.0.1:5005`,
maps language → instance from config, so another language is another instance
plus an env pair, no code change.
**Adding a language (Phase 21 made this literal).** Petal discovers its Piper
instances from the environment: English is the unsuffixed
`TTS_ENDPOINT`/`TTS_VOICE_EN`, and every other language is a
`TTS_ENDPOINT_<LANG>`/`TTS_VOICE_<LANG>` pair. `<LANG>` is the *base* tag —
`PT`, not `PT_PT`, because an environment variable name cannot hold a hyphen and
only one Portuguese model is loaded regardless. Both halves must be set: an
endpoint with no voice is dropped, so a half-finished language reads to the
browser as "no voice here, use Web Speech" instead of erroring on every tap. The
startup line names what it actually resolved:
```
read-aloud enabled (voices: en=en_US-amy-medium, pt=pt_PT-tugão-medium, zh=zh_CN-huayan-medium)
```
**Portuguese: `pt_PT-tugão-medium` is the only European voice Piper ships.** The
other five `pt_*` models in the catalogue are all Brazilian, so the voice has to
be named explicitly for the same reason the Hunspell dictionary did (Phase 21):
the obvious default is the wrong country. Check what exists before assuming:
```bash
docker exec petal-piper-en python -c "import urllib.request,json; \
d=json.load(urllib.request.urlopen('https://huggingface.co/rhasspy/piper-voices/resolve/main/voices.json')); \
print([k for k in d if k.startswith('pt')])"
```
**Slow replay.** `POST /api/tts` takes `slow: true`, which raises Piper's
`length_scale` to about 4/3 (≈0.75× pace). It is a separate cache entry, not a
playback-rate trick, so the slow clip is synthesized once and then instant.
**Piper version note:** piper-tts moved synthesis from `POST /` to
`POST /synthesize` in 1.6.0, with an identical request body. `TTS_PATH` selects
which — it defaults to `/`, and both the VPS compose and millenia's `start.sh`
+9
View File
@@ -49,8 +49,17 @@ LLM_TIMEOUT=90s
# --- Read-aloud (Piper sidecars) ---------------------------------------------
# Endpoints are wired in docker-compose.yml; these pick the voice each sidecar
# loads. Changing one means recreating that container so it downloads the model.
#
# A language is routable only when both halves are set — a TTS_ENDPOINT_XX with
# no TTS_VOICE_XX reads as "no voice for this language" and the browser's own
# synthesizer takes over, rather than as an instance that errors on every
# request. Adding fr or es is a compose service plus a pair of lines here.
#
# pt_PT-tugão-medium is the only European Portuguese voice Piper ships; every
# other pt model in the catalogue is Brazilian.
TTS_VOICE_EN=en_US-amy-medium
TTS_VOICE_ZH=zh_CN-huayan-medium
TTS_VOICE_PT=pt_PT-tugão-medium
TTS_AUDIO_FORMAT=mp3
TTS_TIMEOUT=15s
+24
View File
@@ -46,6 +46,11 @@ services:
# separate containers; the handler maps language → instance from config.
TTS_ENDPOINT: http://piper-en:5000
TTS_ENDPOINT_ZH: http://piper-zh:5000
# A language is discovered from the TTS_ENDPOINT_<LANG>/TTS_VOICE_<LANG>
# pair, so fr and es cost a service and two lines rather than a code
# change. <LANG> is the base tag — an env var name can't hold pt-PT's
# hyphen, and there is one Portuguese voice loaded either way.
TTS_ENDPOINT_PT: http://piper-pt:5000
# The sidecars run piper-tts 1.6.0, which serves synthesis on
# /synthesize; millenia's older server keeps the default "/".
TTS_PATH: /synthesize
@@ -75,6 +80,7 @@ services:
depends_on:
- piper-en
- piper-zh
- piper-pt
labels:
traefik.enable: "true"
traefik.docker.network: traefik
@@ -123,6 +129,24 @@ services:
networks:
- internal
# European Portuguese, for the pt-PT pair. pt_PT-tugão-medium is the *only*
# European voice in Piper's catalogue — the other five Portuguese models are
# all pt_BR — so the default anyone reaches for is the Brazilian one, exactly
# as it was with the Hunspell dictionary in Phase 21. Named here rather than
# left to the image default for that reason.
piper-pt:
build:
context: deploy/piper
image: petal-piper:local
container_name: petal-piper-pt
restart: unless-stopped
environment:
PIPER_VOICE: ${TTS_VOICE_PT:-pt_PT-tugão-medium}
volumes:
- piper-voices:/voices
networks:
- internal
networks:
# Created and owned by the host's Traefik stack.
traefik:
+76 -7
View File
@@ -2,6 +2,7 @@ package config
import (
"os"
"strings"
"time"
)
@@ -29,10 +30,16 @@ type Config struct {
// TTS (read-aloud). Off unless TTSEndpoint is set — when empty, the /api/tts
// route isn't mounted and the frontend falls back to the browser's Web Speech
// API. Endpoint points at a local Piper HTTP server.
TTSEndpoint string // Piper instance serving the English voice
TTSEndpointZH string // Piper instance serving the Chinese voice; empty = zh falls back to Web Speech
TTSVoiceEN string // Piper voice id for English (e.g. en_US-amy-medium)
TTSVoiceZH string // Piper voice id for Chinese (e.g. zh_CN-huayan-medium)
TTSEndpoint string // Piper instance serving the English voice; also the on/off switch
// TTSVoices is every language Petal can read aloud, keyed by base language
// tag ("en", "zh", "pt", …). Each Piper server loads exactly one model, so
// a language *is* an instance — and the instances are discovered from the
// environment rather than named in this struct: one
// TTS_ENDPOINT_<LANG>/TTS_VOICE_<LANG> pair per language, so the fr and es
// pairs cost a compose service and two lines of .env rather than a code
// change. English keeps the unsuffixed TTS_ENDPOINT/TTS_VOICE_EN it has
// always had.
TTSVoices map[string]TTSVoice
// TTSPath is the path Piper serves synthesis on. Piper moved it from "/" to
// "/synthesize" in 1.6.0 with an unchanged request body, so this is a
// version knob, not a feature: millenia's older server keeps the default,
@@ -56,6 +63,12 @@ type Config struct {
AllowedSubs string
}
// TTSVoice is one Piper instance and the single voice it has loaded.
type TTSVoice struct {
Endpoint string
Voice string
}
// AuthEnabled reports whether real logins are configured. When false, Petal
// resolves every request to the local user.
func (c *Config) AuthEnabled() bool {
@@ -78,9 +91,7 @@ func Load() *Config {
LLMTimeout: envDuration("LLM_TIMEOUT", 30*time.Second),
TTSEndpoint: env("TTS_ENDPOINT", ""),
TTSEndpointZH: env("TTS_ENDPOINT_ZH", ""),
TTSVoiceEN: env("TTS_VOICE_EN", "en_US-amy-medium"),
TTSVoiceZH: env("TTS_VOICE_ZH", "zh_CN-huayan-medium"),
TTSVoices: ttsVoices(os.Environ()),
TTSPath: env("TTS_PATH", "/"),
TTSCacheDir: env("TTS_CACHE_DIR", "./data/tts"),
TTSTimeout: envDuration("TTS_TIMEOUT", 15*time.Second),
@@ -93,6 +104,64 @@ func Load() *Config {
}
}
// ttsVoices reads the Piper instances out of an environment slice (as returned
// by os.Environ) into a map keyed by base language tag.
//
// English is the unsuffixed pair, TTS_ENDPOINT + TTS_VOICE_EN, because that is
// what every deployment already sets and read-aloud has always been English
// first. Every other language is a TTS_ENDPOINT_<LANG>/TTS_VOICE_<LANG> pair,
// discovered rather than enumerated — TTS_ENDPOINT_ZH is what millenia and the
// VPS already use, and TTS_ENDPOINT_PT is all the Portuguese pair needs.
//
// <LANG> is the *base* tag: an environment variable name cannot hold the hyphen
// in "pt-PT", and the handler routes on the base tag anyway (a request for
// pt-PT, pt-BR or bare pt reaches the same instance, because there is only one
// Portuguese voice loaded). A pair is ignored unless both halves are set: half
// a configuration should read as "no voice for this language" and fall back to
// the browser, not as an instance that answers every request with an error.
func ttsVoices(environ []string) map[string]TTSVoice {
vals := make(map[string]string, len(environ))
for _, kv := range environ {
if k, v, ok := strings.Cut(kv, "="); ok {
vals[k] = v
}
}
voices := map[string]TTSVoice{}
add := func(lang, endpoint, voice string) {
endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
voice = strings.TrimSpace(voice)
if endpoint == "" || voice == "" {
return
}
voices[lang] = TTSVoice{Endpoint: endpoint, Voice: voice}
}
// The two languages that shipped before this was a map keep their voice
// defaults, so an existing deployment that names only the endpoints (as
// millenia's unit does) sounds exactly as it did.
voiceOr := func(key, fallback string) string {
if v := strings.TrimSpace(vals[key]); v != "" {
return v
}
return fallback
}
add("en", vals["TTS_ENDPOINT"], voiceOr("TTS_VOICE_EN", "en_US-amy-medium"))
for k, endpoint := range vals {
suffix, ok := strings.CutPrefix(k, "TTS_ENDPOINT_")
if !ok || suffix == "" {
continue
}
voice := vals["TTS_VOICE_"+suffix]
if suffix == "ZH" {
voice = voiceOr("TTS_VOICE_ZH", "zh_CN-huayan-medium")
}
add(strings.ToLower(suffix), endpoint, voice)
}
return voices
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
+83
View File
@@ -0,0 +1,83 @@
package config
import "testing"
// The Piper instances are discovered from the environment rather than named in
// code, so that a new pair costs a compose service and two .env lines. These
// assert the discovery rule, including the two shapes that already exist in the
// wild (millenia's systemd unit and the VPS compose file).
func TestTTSVoicesDiscovery(t *testing.T) {
voices := ttsVoices([]string{
"TTS_ENDPOINT=http://piper-en:5000",
"TTS_VOICE_EN=en_US-amy-medium",
"TTS_ENDPOINT_ZH=http://piper-zh:5000/",
"TTS_VOICE_ZH=zh_CN-huayan-medium",
"TTS_ENDPOINT_PT=http://piper-pt:5000",
"TTS_VOICE_PT=pt_PT-tugão-medium",
// Noise that must not become a language.
"TTS_PATH=/synthesize",
"PATH=/usr/bin",
})
want := map[string]TTSVoice{
"en": {"http://piper-en:5000", "en_US-amy-medium"},
// The trailing slash is trimmed here so the synthesis path concatenates
// cleanly rather than producing a double slash at every call site.
"zh": {"http://piper-zh:5000", "zh_CN-huayan-medium"},
"pt": {"http://piper-pt:5000", "pt_PT-tugão-medium"},
}
if len(voices) != len(want) {
t.Fatalf("discovered %v, want %v", voices, want)
}
for lang, w := range want {
if voices[lang] != w {
t.Errorf("%s = %+v, want %+v", lang, voices[lang], w)
}
}
}
// Half a configuration is not a language. An endpoint with no voice (or the
// reverse) must read as "no voice for this language" — a 404 the client answers
// by falling back to Web Speech — rather than as an instance that exists and
// errors on every request.
func TestTTSVoicesIgnoresHalfConfiguredLanguages(t *testing.T) {
voices := ttsVoices([]string{
"TTS_ENDPOINT=http://piper-en:5000",
"TTS_VOICE_EN=en_US-amy-medium",
"TTS_ENDPOINT_FR=http://piper-fr:5000", // no TTS_VOICE_FR
"TTS_VOICE_ES=es_ES-davefx-medium", // no TTS_ENDPOINT_ES
})
if _, ok := voices["fr"]; ok {
t.Errorf("fr routed with no voice configured")
}
if _, ok := voices["es"]; ok {
t.Errorf("es routed with no endpoint configured")
}
if len(voices) != 1 {
t.Errorf("discovered %v, want English only", voices)
}
}
// A deployment that predates the map names only the endpoints and relies on the
// voice defaults; it must sound exactly as it did.
func TestTTSVoicesKeepsTheOriginalDefaults(t *testing.T) {
voices := ttsVoices([]string{
"TTS_ENDPOINT=http://127.0.0.1:5005",
"TTS_ENDPOINT_ZH=http://127.0.0.1:5006",
})
if got := voices["en"].Voice; got != "en_US-amy-medium" {
t.Errorf("en voice = %q, want the default", got)
}
if got := voices["zh"].Voice; got != "zh_CN-huayan-medium" {
t.Errorf("zh voice = %q, want the default", got)
}
}
// Read-aloud is off when no English instance is configured; nothing else may
// switch it on. (tts.New gates on TTSEndpoint, so a stray TTS_ENDPOINT_PT with
// no English sibling must not produce a routable map that outlives that gate.)
func TestTTSVoicesEmptyWithoutEndpoints(t *testing.T) {
if voices := ttsVoices([]string{"TTS_VOICE_EN=en_US-amy-medium"}); len(voices) != 0 {
t.Errorf("discovered %v, want none", voices)
}
}
+69 -1
View File
@@ -174,9 +174,63 @@ func (p dreamProvider) Lookup(word string) (Result, error) {
}
res.Etymology = trimEtymology(ety)
if res.Reverse, err = p.reverse(norm); err != nil {
return Result{}, err
}
return res, nil
}
// reverse reads the token as a word of the writer's own language, and returns
// nil when it isn't one — which is the answer for almost every word she looks
// up, since she is writing English.
//
// The English de-inflection walk is deliberately *not* applied here. [candidates]
// knows about -s, -ed and -ing; running it over Portuguese would turn "vinhas"
// into "vinha" by an English rule that happens to be right and "cantava" into
// nothing by rules that are simply irrelevant. dict.db stores headwords, so an
// inflected Portuguese form finds nothing and the card shows only the English
// reading — the same outcome as today, rather than a confidently wrong one.
func (p dreamProvider) reverse(norm string) (*Reverse, error) {
back, err := p.dict.d.Equivalents(norm, p.native, langEN)
if err != nil {
return nil, err
}
defs, err := p.dict.d.Define(norm, p.native)
if err != nil {
return nil, err
}
if len(back) == 0 && len(defs) == 0 {
return nil, nil
}
rev := &Reverse{Lang: p.native}
if len(back) > maxGlossSenses {
back = back[:maxGlossSenses]
}
rev.Gloss = strings.Join(back, "; ")
for _, d := range defs {
rev.Definitions = append(rev.Definitions, Meaning{PartOfSpeech: d.POS, Definition: d.Gloss})
if len(rev.Definitions) >= maxReverseDefinitions {
break
}
}
prons, err := p.dict.d.Pronunciation(norm, p.native)
if err != nil {
return nil, err
}
rev.Phonetic = pickIPA(prons)
return rev, nil
}
// maxReverseDefinitions is smaller than [maxDefinitions]: the reverse reading is
// the second half of a card that already has an English one, and it is there to
// say "this is also a Portuguese word, and here is what it means" rather than to
// be a dictionary entry in its own right.
const maxReverseDefinitions = 2
// Gloss returns the writer's-language translation alone — the hover tooltip's
// fast path, one indexed query per candidate form and nothing else.
func (p dreamProvider) Gloss(word string) (GlossResult, error) {
@@ -188,7 +242,21 @@ func (p dreamProvider) Gloss(word string) (GlossResult, error) {
if err != nil {
return GlossResult{}, err
}
return GlossResult{Word: word, Gloss: gloss}, nil
res := GlossResult{Word: word, Gloss: gloss}
// The tooltip carries only the reverse *gloss*, not the whole reading: it is
// a one-line bubble under a resting pointer, and the popover is one click
// away for anyone who wants the rest.
back, err := p.dict.d.Equivalents(norm, p.native, langEN)
if err != nil {
return GlossResult{}, err
}
if len(back) > maxGlossSenses {
back = back[:maxGlossSenses]
}
res.Reverse = strings.Join(back, "; ")
return res, nil
}
// maxGlossSenses caps how many translations are strung together. One is often
+121
View File
@@ -96,6 +96,24 @@ func writeFixture(t *testing.T, seeded bool) string {
exec(`INSERT INTO word_synsets (word_id, synset_id, source) VALUES
(5, 1, 'wordnet'), (6, 1, 'omw'), (7, 1, 'omw')`)
// "data" is the collision the Latin pairs create and the zh pair never did:
// a real English word and a real Portuguese one, spelled identically and
// meaning different things. There is no honest way to look at it in a mixed
// document and know which was meant, so Petal shows both readings.
exec(`INSERT INTO words (id, word, lang, pos, frequency) VALUES
(8, 'data', 'en', 'noun', 800),
(9, 'data', 'pt-PT', 'noun', 700),
(10, 'date', 'en', 'noun', 750)`)
exec(`INSERT INTO definitions (word_id, pos, gloss, source, priority) VALUES
(8, 'noun', 'facts collected for reference', 'wordnet', 10),
(9, 'noun', 'dia do mês', 'wiktionary', 20),
(9, 'noun', 'momento no tempo', 'wiktionary', 30),
(9, 'noun', 'um terceiro sentido', 'wiktionary', 40)`)
exec(`INSERT INTO translations (word_id, translation, target_lang, source) VALUES
(9, 'date', 'en', 'kaikki')`)
exec(`INSERT INTO pronunciations (word_id, format, value, source) VALUES
(9, 'ipa', '/ˈdatɐ/', 'wiktionary')`)
exec(`INSERT INTO etymology (word_id, text, source) VALUES
(1, 'From Medieval Latin ephemerus, from Ancient Greek ἐφήμερος (ephḗmeros, "lasting only a day"), from ἐπί (epí, "upon") and ἡμέρα (hēméra, "day"). The sense of transience is attested in English from the late sixteenth century onwards.', 'wiktionary')`)
@@ -541,3 +559,106 @@ func TestContentsCountsRowsNotSupportedLanguages(t *testing.T) {
t.Error("Contents with no dictionary must still say something")
}
}
// The Latin+Latin wrinkle (SUGGESTIONS.md §3a). An English+Chinese pair never
// had to decide which language a word was in — the script decided. An
// English+Portuguese pair has no script boundary, and "data", "sale", "comum"
// and "tarde" are real words on both sides of it. Petal asks both directions
// and shows whatever answers, which needs no language detector and therefore
// cannot be wrong about somebody's writing.
func TestDreamLookupShowsBothReadingsOnACollision(t *testing.T) {
p := dreamProvider{dict: openFixture(t), native: "pt-PT"}
res, err := p.Lookup("data")
if err != nil {
t.Fatalf("Lookup: %v", err)
}
// The English reading is unchanged and still leads.
if len(res.Definitions) == 0 || res.Definitions[0].Definition != "facts collected for reference" {
t.Fatalf("Definitions = %v, want the English sense first", res.Definitions)
}
if res.Reverse == nil {
t.Fatal("Reverse = nil; a word that exists in both languages must carry both readings")
}
if res.Reverse.Lang != "pt-PT" {
t.Errorf("Reverse.Lang = %q, want the writer's language", res.Reverse.Lang)
}
if res.Reverse.Gloss != "date" {
t.Errorf("Reverse.Gloss = %q, want the English meaning of the Portuguese word", res.Reverse.Gloss)
}
if res.Reverse.Phonetic != "ˈdatɐ" {
t.Errorf("Reverse.Phonetic = %q, want the Portuguese IPA without slashes", res.Reverse.Phonetic)
}
// The reverse reading is a footnote on a card that already has an English
// half, so it is capped harder than the main entry.
if len(res.Reverse.Definitions) != maxReverseDefinitions {
t.Fatalf("Reverse.Definitions = %d, want %d", len(res.Reverse.Definitions), maxReverseDefinitions)
}
if res.Reverse.Definitions[0].Definition != "dia do mês" {
t.Errorf("Reverse.Definitions[0] = %q, want the Portuguese sense",
res.Reverse.Definitions[0].Definition)
}
}
func TestDreamLookupHasNoReverseForAnEnglishOnlyWord(t *testing.T) {
// Which is almost every word she looks up: she is writing English. A
// second block under every card would make the collision case invisible.
p := dreamProvider{dict: openFixture(t), native: "pt-PT"}
res, err := p.Lookup("ephemeral")
if err != nil {
t.Fatalf("Lookup: %v", err)
}
if res.Reverse != nil {
t.Fatalf("Reverse = %+v, want none for a word that is only English", res.Reverse)
}
}
func TestDreamGlossCarriesTheReverseReading(t *testing.T) {
// The hover tooltip takes the same both-directions rule in one line less
// space: the reverse *gloss* only, never the definitions.
p := dreamProvider{dict: openFixture(t), native: "pt-PT"}
g, err := p.Gloss("data")
if err != nil {
t.Fatalf("Gloss: %v", err)
}
if g.Reverse != "date" {
t.Errorf("Gloss.Reverse = %q, want the English meaning of the Portuguese word", g.Reverse)
}
g, err = p.Gloss("ephemeral")
if err != nil {
t.Fatalf("Gloss: %v", err)
}
if g.Reverse != "" {
t.Errorf("Gloss.Reverse = %q, want none for an English-only word", g.Reverse)
}
if g.Gloss != "efémero; passageiro" {
t.Errorf("Gloss = %q, want the forward gloss untouched", g.Gloss)
}
}
func TestReverseIsSilentForTheEmbeddedProviders(t *testing.T) {
// The zh pair has no collisions and no DreamDict, and a writer with no
// dict.db at all falls through to `glossless`. Neither may start emitting a
// reverse block: the card would then claim a Chinese reading of an English
// word, which is worse than saying nothing.
set := NewSet(nil)
res, err := set.For(LangZh).Lookup("river")
if err != nil {
t.Fatalf("Lookup: %v", err)
}
if res.Reverse != nil {
t.Errorf("embedded Reverse = %+v, want none", res.Reverse)
}
res, err = set.For("pt-PT").Lookup("river")
if err != nil {
t.Fatalf("Lookup: %v", err)
}
if res.Reverse != nil {
t.Errorf("glossless Reverse = %+v, want none", res.Reverse)
}
}
+33
View File
@@ -47,6 +47,35 @@ type Result struct {
// Latin roots with English — "ephemeral" is much easier to keep once you
// have seen efémero next to it.
Etymology string `json:"etymology"`
// Reverse is the same token read as a word of the writer's own language,
// present only when it is one. Absent for every writer whose pair is not
// Latin-script, and for the overwhelming majority of words in one that is.
Reverse *Reverse `json:"reverse,omitempty"`
}
// Reverse is a lookup in the other direction: the token treated as a word of the
// writer's language, translated into English.
//
// It exists because a Latin-script pair has no script boundary to tell the two
// halves apart. In English+Chinese, "which language is this word?" answers
// itself. In English+Portuguese it does not: *sale*, *casa*, *comum*, *tarde*
// and *ali* are all real words on both sides, and *chat* and *pain* are the
// French versions of the same trap.
//
// Petal does not guess. It asks both directions and shows whatever comes back,
// which needs no detector, cannot be wrong about someone's writing, and — for a
// learner — is more interesting than a correct guess would have been.
type Reverse struct {
// Lang is the language this reading is in, so the card can label it.
Lang string `json:"lang"`
// Gloss is the English meaning of the native-language word.
Gloss string `json:"gloss"`
// Definitions are the word's senses as written in the writer's own
// language — the monolingual half, for when the English gloss isn't enough.
Definitions []Meaning `json:"definitions,omitempty"`
// Phonetic is IPA for the native-language pronunciation; "" when absent.
Phonetic string `json:"phonetic,omitempty"`
}
// unknownDifficulty is the [Result.Difficulty] value meaning "no score",
@@ -59,6 +88,10 @@ const unknownDifficulty = -1
type GlossResult struct {
Word string `json:"word"`
Gloss string `json:"gloss"`
// Reverse is the English meaning of the word read as one of the writer's
// own language — the tooltip's half of the both-directions rule (see
// [Reverse]). Empty unless the token is a word in her language too.
Reverse string `json:"reverse,omitempty"`
}
// maxSynonyms caps how many synonyms we hand the popover, even though the dataset
+15 -4
View File
@@ -8,8 +8,18 @@
// so it belongs to her account rather than to a browser profile.
//
// Everything here is scoped by `lang` as well as by user. That is the language
// of the *dictionary* that flagged the word, not the writer's own language: an
// en-US personal word must not silence a pt-PT flag once the second pair ships.
// of the *dictionary* the word was accepted against, not the writer's own.
//
// Phase 18 justified that key by saying an en-US personal word must not silence
// a pt-PT flag once the second pair shipped. Phase 21 shipped it and the
// justification did not survive: under the both-dictionaries rule
// (SUGGESTIONS.md §3a) a word is only ever flagged when *every* loaded
// dictionary rejected it, so there is no such thing as a pt-PT flag an English
// exception could silence. What the key is actually good for is narrower and
// still worth having — the rows say which dictionary each acceptance was made
// against, so a pair that later loses or gains a dictionary keeps a truthful
// record instead of one merged list of unknown provenance. The browser writes a
// row per loaded dictionary when she accepts a word; see useSpellChecker.
package spell
import (
@@ -24,8 +34,9 @@ import (
"gitea.parodia.dev/drwily/petal/internal/httputil"
)
// DefaultLang is the dictionary assumed when a caller doesn't name one. Only
// en-US ships today; pt-PT arrives with the first Latin pair.
// DefaultLang is the dictionary assumed when a caller doesn't name one. English
// is in every pair, so it is the safe assumption; pt-PT is named explicitly by
// the pt-PT pair's second dictionary.
const DefaultLang = "en"
// MaxWordLen bounds a single entry. A personal dictionary holds words, and a
+45 -17
View File
@@ -19,6 +19,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"unicode/utf8"
@@ -36,6 +37,12 @@ const maxTextBytes = 4000
// with the words, mirroring the old utterance.rate = 0.95. Higher = slower.
const lengthScale = 1.1
// slowLengthScale is the "say it slower" replay (SUGGESTIONS §5e): roughly 0.75×
// the normal pace, which is the speed listening drills have used for decades.
// Piper stretches durations rather than resampling, so the voice keeps its pitch
// instead of turning into a slowed tape.
const slowLengthScale = lengthScale / 0.75
// audioFormat describes one output encoding: the cache-file extension, the
// response Content-Type, and the ffmpeg args that turn Piper's WAV (on stdin)
// into this format (on stdout). A nil ffmpegArgs means "serve the WAV as-is".
@@ -105,16 +112,14 @@ func New(cfg *config.Config) (*Handler, bool) {
format = formats["mp3"]
}
// Map by base language so en-US, en-GB, etc. all resolve to the English
// instance (the client sends BCP-47 tags like the old Web Speech path did).
// A language is only routable when both its endpoint and voice are set;
// otherwise the client falls back to Web Speech for that language.
// Keyed by base language so en-US, en-GB — and pt-PT, pt-BR, bare pt —
// resolve to the one instance that has that language's model loaded (the
// client sends BCP-47 tags, as the old Web Speech path did). Config has
// already dropped any language configured by halves, so an unroutable
// language reaches the client as a 404 and falls back to Web Speech.
routes := map[string]route{}
if cfg.TTSVoiceEN != "" {
routes["en"] = route{strings.TrimRight(cfg.TTSEndpoint, "/"), cfg.TTSVoiceEN}
}
if cfg.TTSEndpointZH != "" && cfg.TTSVoiceZH != "" {
routes["zh"] = route{strings.TrimRight(cfg.TTSEndpointZH, "/"), cfg.TTSVoiceZH}
for lang, v := range cfg.TTSVoices {
routes[lang] = route{endpoint: v.Endpoint, voice: v.Voice}
}
if err := os.MkdirAll(cfg.TTSCacheDir, 0o755); err != nil {
@@ -132,6 +137,19 @@ func New(cfg *config.Config) (*Handler, bool) {
}, true
}
// Languages lists the base language tags this handler can synthesize, sorted,
// each with the voice serving it — for the startup line, so a deployment says
// which sidecars it actually reached rather than which ones it was configured
// to want.
func (h *Handler) Languages() []string {
out := make([]string, 0, len(h.routes))
for lang, rt := range h.routes {
out = append(out, lang+"="+rt.voice)
}
sort.Strings(out)
return out
}
// Routes mounts the synthesis endpoint. Mount under "/tts" so the full path is
// POST /api/tts.
func (h *Handler) Routes() chi.Router {
@@ -140,11 +158,13 @@ func (h *Handler) Routes() chi.Router {
return r
}
// synthRequest is the body the editor posts: a passage and the BCP-47 language
// tag it's written in (e.g. "en-US", "zh-CN").
// synthRequest is the body the editor posts: a passage, the BCP-47 language tag
// it's written in (e.g. "en-US", "zh-CN", "pt-PT"), and whether to say it slowly
// — the replay a learner reaches for when the sentence went past too fast.
type synthRequest struct {
Text string `json:"text"`
Lang string `json:"lang"`
Slow bool `json:"slow"`
}
// synth resolves a voice for the requested language, returns cached audio when
@@ -180,9 +200,17 @@ func (h *Handler) synth(w http.ResponseWriter, r *http.Request) {
return
}
// Content-addressed: identical (voice, text) → identical clip. The format
// extension keeps encodings from colliding in the same dir.
sum := sha256.Sum256([]byte(rt.voice + "\n" + text))
scale := lengthScale
if req.Slow {
scale = slowLengthScale
}
// Content-addressed: identical (voice, pace, text) → identical clip. The pace
// belongs in the key — without it the slow replay of a word already heard at
// normal speed would be served from cache at normal speed, which is the one
// request where the difference is the whole point. The format extension keeps
// encodings from colliding in the same dir.
sum := sha256.Sum256([]byte(fmt.Sprintf("%s\n%.3f\n%s", rt.voice, scale, text)))
name := hex.EncodeToString(sum[:])[:32] + h.format.ext
path := filepath.Join(h.cacheDir, name)
@@ -191,7 +219,7 @@ func (h *Handler) synth(w http.ResponseWriter, r *http.Request) {
return
}
audio, err := h.synthesize(r.Context(), rt, text)
audio, err := h.synthesize(r.Context(), rt, text, scale)
if err != nil {
http.Error(w, "synthesis failed", http.StatusBadGateway)
fmt.Fprintf(os.Stderr, "tts: synthesize: %v\n", err)
@@ -222,11 +250,11 @@ func (h *Handler) serve(w http.ResponseWriter, r *http.Request, path string) {
// synthesize POSTs to the route's Piper instance, then transcodes the returned
// WAV when the configured format calls for it.
func (h *Handler) synthesize(ctx context.Context, rt route, text string) ([]byte, error) {
func (h *Handler) synthesize(ctx context.Context, rt route, text string, scale float64) ([]byte, error) {
body, _ := json.Marshal(map[string]any{
"text": text,
"voice": rt.voice,
"length_scale": lengthScale,
"length_scale": scale,
})
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, rt.endpoint+h.synthURI, bytes.NewReader(body))
if err != nil {
+91 -3
View File
@@ -22,6 +22,7 @@ func newStubPiper(t *testing.T, body []byte) (*httptest.Server, *int32, *synthEc
_ = json.NewDecoder(r.Body).Decode(&req)
last.voice, _ = req["voice"].(string)
last.text, _ = req["text"].(string)
last.scale, _ = req["length_scale"].(float64)
w.Header().Set("Content-Type", "audio/wav")
_, _ = w.Write(body)
}))
@@ -29,7 +30,10 @@ func newStubPiper(t *testing.T, body []byte) (*httptest.Server, *int32, *synthEc
return srv, &calls, last
}
type synthEcho struct{ voice, text string }
type synthEcho struct {
voice, text string
scale float64
}
// newHandler builds a wav-format handler (no ffmpeg) pointed at a stub server.
func newHandler(t *testing.T, endpoint string) *Handler {
@@ -88,7 +92,12 @@ func TestSynthPathNormalisation(t *testing.T) {
func post(t *testing.T, h *Handler, text, lang string) *httptest.ResponseRecorder {
t.Helper()
b, _ := json.Marshal(synthRequest{Text: text, Lang: lang})
return postReq(t, h, synthRequest{Text: text, Lang: lang})
}
func postReq(t *testing.T, h *Handler, body synthRequest) *httptest.ResponseRecorder {
t.Helper()
b, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(b))
rr := httptest.NewRecorder()
h.synth(rr, req)
@@ -192,8 +201,87 @@ func TestTextIsCapped(t *testing.T) {
}
}
// The slow replay is the whole of SUGGESTIONS §5e: same text, same voice, more
// time per phoneme.
func TestSlowRequestStretchesTheVoice(t *testing.T) {
srv, _, last := newStubPiper(t, []byte("RIFF....fake-wav"))
h := newHandler(t, srv.URL)
if rr := postReq(t, h, synthRequest{Text: "reception", Lang: "en-US"}); rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if last.scale != lengthScale {
t.Fatalf("normal length_scale = %v, want %v", last.scale, lengthScale)
}
if rr := postReq(t, h, synthRequest{Text: "reception", Lang: "en-US", Slow: true}); rr.Code != http.StatusOK {
t.Fatalf("slow status = %d, want 200", rr.Code)
}
if last.scale != slowLengthScale {
t.Fatalf("slow length_scale = %v, want %v", last.scale, slowLengthScale)
}
if slowLengthScale <= lengthScale {
t.Fatalf("slowLengthScale %v is not slower than %v", slowLengthScale, lengthScale)
}
}
// The pace has to be part of the cache key. Without it, asking for the slow
// replay of a word already heard at normal speed serves the normal clip — the
// one request where hearing the difference is the entire point.
func TestSlowClipIsNotServedFromTheNormalCache(t *testing.T) {
srv, calls, last := newStubPiper(t, []byte("RIFF....fake-wav"))
h := newHandler(t, srv.URL)
postReq(t, h, synthRequest{Text: "reception", Lang: "en-US"})
postReq(t, h, synthRequest{Text: "reception", Lang: "en-US", Slow: true})
if *calls != 2 {
t.Fatalf("piper calls = %d, want 2 (the slow clip is a different clip)", *calls)
}
if last.scale != slowLengthScale {
t.Fatalf("second call length_scale = %v, want the slow one", last.scale)
}
// …and each pace still caches on its own.
postReq(t, h, synthRequest{Text: "reception", Lang: "en-US", Slow: true})
postReq(t, h, synthRequest{Text: "reception", Lang: "en-US"})
if *calls != 2 {
t.Fatalf("piper calls = %d, want 2 (both paces now cached)", *calls)
}
}
// A Portuguese request must reach the Portuguese instance on the base tag alone:
// env var names cannot hold the hyphen in pt-PT, so config keys the map on "pt"
// and the handler has to meet it there. pt-BR resolves to the same instance
// because there is only one Portuguese voice loaded — and it is the European one.
func TestPortugueseRoutesOnTheBaseTag(t *testing.T) {
enSrv, enCalls, _ := newStubPiper(t, []byte("EN-wav"))
ptSrv, ptCalls, ptLast := newStubPiper(t, []byte("PT-wav"))
h := &Handler{
routes: map[string]route{
"en": {strings.TrimRight(enSrv.URL, "/"), "en_US-amy-medium"},
"pt": {strings.TrimRight(ptSrv.URL, "/"), "pt_PT-tugão-medium"},
},
cacheDir: t.TempDir(),
format: formats["wav"],
client: http.DefaultClient,
}
// Distinct text per tag, so a cache hit can't stand in for a route.
for i, tag := range []string{"pt-PT", "pt", "pt-BR"} {
if rr := post(t, h, strings.Repeat("receção ", i+1), tag); rr.Code != http.StatusOK {
t.Fatalf("%s status = %d, want 200", tag, rr.Code)
}
}
if *ptCalls != 3 || *enCalls != 0 {
t.Fatalf("calls en=%d pt=%d, want en=0 pt=3", *enCalls, *ptCalls)
}
if ptLast.voice != "pt_PT-tugão-medium" {
t.Fatalf("pt voice = %q, want the European Portuguese voice", ptLast.voice)
}
}
func TestBaseLang(t *testing.T) {
cases := map[string]string{"en-US": "en", "EN_gb": "en", "zh-CN": "zh", "en": "en", "": ""}
cases := map[string]string{"en-US": "en", "EN_gb": "en", "zh-CN": "zh", "pt-PT": "pt", "en": "en", "": ""}
for in, want := range cases {
if got := baseLang(in); got != want {
t.Errorf("baseLang(%q) = %q, want %q", in, got, want)
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""Build Petal's browser pt-PT spelling dictionary from the LibreOffice Hunspell one.
Why this script exists at all
----------------------------
English is vendored the obvious way: `dictionary-en`'s `en.aff` + `en.dic` go
into web/public/dictionaries/en and nspell reads them in the browser. The plan
for Phase 21 said "Hunspell pt-PT vendored like en-US", and that turns out not to
work, for a measured reason.
nspell expands affixes **eagerly at load time** — it materialises every surface
form into a hash the moment you construct it. English gets away with this: ~50k
stems and a small rule set. European Portuguese does not. `pt_PT.aff` carries
1,340 affix rules (the full verb paradigm: six persons x a dozen tenses, plus
diminutives, plus productive prefixes) over 44,257 stems. Measured on this
machine, nspell needed ~340 MB of heap for the first 12,000 entries alone and had
not returned after three minutes on the whole file; extrapolated, it wants well
over a gigabyte. That is not something to hand a browser, still less a tablet.
So the expansion happens **here**, once, at build time, and the browser gets a
flat word list it can load with no affix machinery at all: 1,039,058 forms, 15 MB
of text, 2.7 MB gzipped, which nspell reads in ~0.8s using ~120 MB. The runtime
code path is then *identical* to English — same nspell, same interface — which is
the real prize. The aff we ship alongside keeps only the suggestion-shaping
directives (TRY/KEY/REP/MAP), so corrections still know that "cao" wants "ção"
and that a missing acute accent is a near miss.
Choosing the source
-------------------
npm's `dictionary-pt` is **not** European Portuguese. Both it and
`dictionary-pt-br` package VERO ("Verificador Ortográfico Livre", Brasil), so
vendoring the obvious npm name would have shipped Brazilian spellings under a
pt-PT label — exactly the pt-BR drift SUGGESTIONS.md §3 warns about, arriving
through the packaging rather than through the model.
The authentic dictionary is the Projecto Natura one (Universidade do Minho) that
LibreOffice ships and Debian packages as `hunspell-pt-pt`. Its aff declares
`LANG pt_PT`. Spot-checked against the built list, it accepts `receção`,
`húmido`, `telemóvel`, `autocarro`, `comboio`, `ótimo` and `pensámos`, and
rejects `recepção`, `úmido`, `ônibus` and `óptimo` — post-Acordo European
Portuguese, which is what a pt-PT writer should be held to.
Licensing: GPL-2 or LGPL-2.1 or MPL-1.1, (c) José João de Almeida, Rui Vilela,
Alberto Simões. The upstream copyright file is vendored beside the output.
Usage
-----
apt-get download hunspell-pt-pt # or take pt_PT.aff/.dic from LibreOffice
dpkg-deb -x hunspell-pt-pt_*.deb ptpt
python3 scripts/build_ptpt_dictionary.py \
ptpt/usr/share/hunspell/pt_PT.aff \
ptpt/usr/share/hunspell/pt_PT.dic \
web/public/dictionaries/pt-PT
Only the directives pt_PT.aff actually uses are implemented: PFX/SFX with
single-character flags, strip/append/condition, and cross-product. It carries no
compounding, no flag aliases and no NEEDAFFIX, so there is nothing else to
honour — the script asserts that rather than assuming it.
"""
import gzip
import os
import re
import sys
# Directives worth keeping in the shipped aff. These shape *suggestions*, not
# membership: TRY orders the alphabet the corrector tries, KEY knows which keys
# are adjacent, REP holds Portuguese-specific confusions (cao/ção, ss/ç), and MAP
# says an accented vowel and its bare form are the same letter for scoring —
# which is most of what an ESL writer gets wrong in Portuguese.
KEEP_DIRECTIVES = ("SET", "TRY", "KEY", "REP", "MAP", "WORDCHARS")
# Directives that would change which words are *accepted*. If a future upstream
# release starts using one, this script's output would silently disagree with
# the real dictionary, so it stops instead.
UNSUPPORTED = (
"COMPOUNDFLAG", "COMPOUNDMIN", "COMPOUNDRULE", "COMPOUNDBEGIN",
"ONLYINCOMPOUND", "NEEDAFFIX", "PSEUDOROOT", "CIRCUMFIX",
"FORBIDDENWORD", "AF", "AM", "FLAG",
)
def parse_aff(path):
"""Return (prefix rules, suffix rules, cross-product flags) keyed by flag."""
with open(path, encoding="utf-8") as fh:
lines = fh.read().splitlines()
for line in lines:
head = line.split()[0] if line.split() else ""
if head in UNSUPPORTED:
raise SystemExit(
f"{path}: unsupported directive {head!r} — this expander only "
"handles plain PFX/SFX affixation, and honouring it would change "
"which words are accepted. Extend the script before shipping."
)
pfx, sfx, cross = {}, {}, {}
i = 0
while i < len(lines):
parts = lines[i].split()
if parts and parts[0] in ("PFX", "SFX"):
kind, flag, cross_flag, count = parts[0], parts[1], parts[2], int(parts[3])
table = pfx if kind == "PFX" else sfx
cross[flag] = cross_flag == "Y"
rules = table.setdefault(flag, [])
for j in range(1, count + 1):
p = lines[i + j].split()
strip = "" if p[2] == "0" else p[2]
# The appended text may carry its own continuation flags after a
# slash (append/FLAGS). We drop them: honouring them would mean
# affixing an affixed form, which pt_PT.aff does not do.
append = "" if p[3] == "0" else p[3].split("/")[0]
cond = p[4] if len(p) > 4 else "."
anchored = ("^" + cond) if kind == "PFX" else (cond + "$")
rules.append((strip, append, re.compile(anchored)))
i += count + 1
continue
i += 1
return pfx, sfx, cross
def apply_suffix(word, rules):
out = []
for strip, append, cond in rules:
if strip and not word.endswith(strip):
continue
if not cond.search(word):
continue
stem = word[: len(word) - len(strip)] if strip else word
out.append(stem + append)
return out
def apply_prefix(word, rules):
out = []
for strip, append, cond in rules:
if strip and not word.startswith(strip):
continue
if not cond.search(word):
continue
stem = word[len(strip):] if strip else word
out.append(append + stem)
return out
def expand(aff_path, dic_path):
pfx, sfx, cross = parse_aff(aff_path)
forms = set()
with open(dic_path, encoding="utf-8") as fh:
fh.readline() # leading entry count, not a word
for raw in fh:
entry = raw.strip().split("\t")[0] # drop morphological fields
if not entry:
continue
word, _, flagstr = entry.partition("/")
word = word.strip()
if not word:
continue
flags = set(flagstr.strip())
forms.add(word)
for f in flags:
if f in sfx:
forms.update(apply_suffix(word, sfx[f]))
prefixed = []
for f in flags:
if f in pfx:
prefixed.extend(apply_prefix(word, pfx[f]))
forms.update(prefixed)
# Cross-product: a prefixed form may also take a suffix, but only
# when both rules are declared cross-product ("Y").
for f in flags:
if f in pfx and cross.get(f):
for base in apply_prefix(word, pfx[f]):
for g in flags:
if g in sfx and cross.get(g):
forms.update(apply_suffix(base, sfx[g]))
return forms
def shipped_aff(aff_path):
keep = []
for line in open(aff_path, encoding="utf-8").read().splitlines():
head = line.split()[0] if line.split() else ""
if head in KEEP_DIRECTIVES:
keep.append(line)
return "\n".join(keep) + "\n"
# Words the built list must accept, and must reject, before it is written. These
# are the pt-PT/pt-BR fault lines: post-Acordo spellings, the European lexicon,
# and the first-person-plural preterite accent that only pt-PT writes. A source
# dictionary that fails these is not the one this script is for.
MUST_ACCEPT = ("receção", "húmido", "telemóvel", "autocarro", "comboio",
"ótimo", "pensámos", "escrevêssemos", "jardim")
MUST_REJECT = ("recepção", "úmido", "ônibus", "óptimo")
def main(aff_path, dic_path, out_dir):
forms = expand(aff_path, dic_path)
missing = [w for w in MUST_ACCEPT if w not in forms]
present = [w for w in MUST_REJECT if w in forms]
if missing or present:
raise SystemExit(
"this does not look like European Portuguese: "
f"missing {missing}, unexpectedly present {present}"
)
os.makedirs(out_dir, exist_ok=True)
ordered = sorted(forms)
body = f"{len(ordered)}\n" + "\n".join(ordered) + "\n"
dic_out = os.path.join(out_dir, "pt-PT.dic.gz")
# mtime=0 so rebuilding identical input produces an identical file — a
# vendored asset that changes on every build is noise in the diff.
with gzip.GzipFile(dic_out, "wb", compresslevel=9, mtime=0) as fh:
fh.write(body.encode("utf-8"))
aff_out = os.path.join(out_dir, "pt-PT.aff")
with open(aff_out, "w", encoding="utf-8") as fh:
fh.write(shipped_aff(aff_path))
print(f"{len(ordered)} forms -> {dic_out} "
f"({os.path.getsize(dic_out) / 1e6:.2f} MB gzipped)", file=sys.stderr)
if __name__ == "__main__":
if len(sys.argv) != 4:
raise SystemExit(__doc__.strip().splitlines()[-1])
main(*sys.argv[1:4])
+32
View File
@@ -0,0 +1,32 @@
European Portuguese spelling dictionary
=======================================
The word list in `pt-PT.dic.gz` and the suggestion directives in `pt-PT.aff` are
derived from the LibreOffice/Projecto Natura Hunspell dictionary for European
Portuguese (`pt_PT.aff` / `pt_PT.dic`), as packaged by Debian/Ubuntu in
`hunspell-pt-pt`.
Copyright (C) 2006-2012 José João de Almeida <jj@di.uminho.pt>
Rui Vilela <ruivilela@di.uminho.pt>
Alberto Simões <ambs@di.uminho.pt>
Universidade do Minho — Projecto Natura
License: GPL-2 or LGPL-2.1 or MPL-1.1
(Petal redistributes it under the MPL-1.1 option.)
Upstream: https://natura.di.uminho.pt/ — via
https://git.libreoffice.org/dictionaries/+/refs/heads/master/pt_PT
What Petal changed
------------------
Nothing about which words are correct. `scripts/build_ptpt_dictionary.py`
applies the upstream affix rules ahead of time — Hunspell's PFX/SFX expansion
run once at build time instead of once per browser — and writes the resulting
1,039,058 surface forms as a flat word list. The shipped `.aff` keeps only
upstream's TRY/KEY/REP/MAP/WORDCHARS lines, which shape *corrections* rather
than membership. See that script's header for why the dictionary could not be
vendored in its original form.
Note that npm's `dictionary-pt` is *not* this dictionary: both it and
`dictionary-pt-br` package the Brazilian VERO word list.
+42
View File
@@ -0,0 +1,42 @@
SET UTF-8
TRY aerisontcdmlupvgbfzáhçqjíxãóéêâúõACMPSBTELGRIFVDkHJONôywUKXZWQÁYÍÉàÓèÂÚ
KEY qwertyuiop|asdfghjkl|zxcvbnm
WORDCHARS -
REP 25
REP por pro
REP pre per
REP damente mente
REP mente damente
REP iz íz
REP cao ção
REP ç ss
REP ss ç
REP c ss
REP ss c
REP ch x
REP x ch
REP cç x
REP x cç
REP k qu
REP íti ití
REP ití íti
REP issí íssi
REP ilí íli
REP íli ilí
REP ífi ifí
REP ifí ífi
REP nume mune
REP coen quen
REP concerteza com_certeza
MAP 11
MAP aá
MAP aã
MAP aâ
MAP eé
MAP eê
MAP ií
MAP cç
MAP oó
MAP oô
MAP oõ
MAP uú
Binary file not shown.
+15
View File
@@ -78,12 +78,27 @@ export interface WordInfo {
frequency: number
difficulty: number
etymology: string // free-form, already trimmed to a line by the server; '' when absent
// The same token read as a word of the writer's own language, when it is one.
// Absent for a zh-pair writer and for almost every word in a Latin pair — see
// lexicon.Reverse for why Petal asks both directions instead of guessing.
reverse?: WordReverse
}
// A word looked up in the other direction: the writer's language -> English.
export interface WordReverse {
lang: string
gloss: string
definitions?: WordMeaning[]
phonetic?: string
}
// The lightweight Chinese-only gloss behind the inline hover/select tooltip.
export interface Gloss {
word: string
gloss: string
// The English meaning of the token read as a word of her own language.
// Present only on a collision (Portuguese *sale*, French *chat*).
reverse?: string
}
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice' | 'collocation' | 'mechanics'
+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)
})
}
+32 -9
View File
@@ -32,6 +32,7 @@ import { RewritePreview, type RewriteStatus } from './RewritePreview'
import { api, type Suggestion, type WordInfo } from '../../api/client'
import { speak, speechSupported } from '../../audio/speech'
import type { SpellChecker } from '../../hooks/useSpellChecker'
import { usePack } from '../../i18n'
export interface EditorChange {
content: string // Tiptap JSON, stringified
@@ -176,6 +177,8 @@ interface HoverState {
interface GlossState {
word: string
gloss: string
// The other reading, when the token is a word in her language too.
reverse?: string
from: number
to: number
top: number
@@ -222,6 +225,9 @@ export function EditorCore({
spellChecker,
onAddWord,
}: Props) {
// Her pair's copy — the hover tip labels the second reading with the language's
// own name, so it says "português" rather than "pt-PT".
const pack = usePack()
const wrapperRef = useRef<HTMLDivElement>(null)
const [hover, setHover] = useState<HoverState | null>(null)
// The open spelling popover (click a red-underlined word), or null.
@@ -368,6 +374,13 @@ export function EditorCore({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [docId, editor])
// Which letters count as part of a word. The spell checker owns the answer,
// because it depends on which dictionaries this writer's pair loaded — see
// SpellCheck's wordRe. Every surface that resolves "the word under here"
// (lookup, gloss, right-click) has to agree with the underline, or the
// popover would offer a definition of "cora".
const wordAlphabet = spellChecker?.extendedAlphabet ?? false
// Push the spell checker into its decoration plugin once the dictionary loads
// (and again whenever the personal dictionary changes its identity).
useEffect(() => {
@@ -590,7 +603,7 @@ export function EditorCore({
const openMisspellAt = useCallback(
(pos: number): boolean => {
if (!editor || !spellChecker) return false
const range = wordAt(editor.state.doc, pos)
const range = wordAt(editor.state.doc, pos, spellChecker.extendedAlphabet)
if (!range || spellChecker.correct(range.word)) return false
const wrapper = wrapperRef.current
if (!wrapper) return false
@@ -679,7 +692,7 @@ export function EditorCore({
const openWordLookup = useCallback(
(pos: number) => {
if (!editor) return
const range = wordAt(editor.state.doc, pos)
const range = wordAt(editor.state.doc, pos, wordAlphabet)
if (!range) return
const wrapper = wrapperRef.current
if (!wrapper) return
@@ -784,13 +797,13 @@ export function EditorCore({
if (!editor) return
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
if (!coords) return
if (!wordAt(editor.state.doc, coords.pos)) return
if (!wordAt(editor.state.doc, coords.pos, wordAlphabet)) return
e.preventDefault()
// A misspelled word offers corrections first; otherwise look it up.
if (openMisspellAt(coords.pos)) return
openWordLookup(coords.pos)
},
[editor, openMisspellAt, openWordLookup],
[editor, wordAlphabet, openMisspellAt, openWordLookup],
)
// Touch has no hover or right-click, so a long-press (~500ms without moving)
@@ -844,7 +857,7 @@ export function EditorCore({
clear()
return
}
const range = wordAt(editor.state.doc, coords.pos)
const range = wordAt(editor.state.doc, coords.pos, wordAlphabet)
if (!range) {
clear()
return
@@ -859,7 +872,9 @@ export function EditorCore({
.then((g) => {
if (token !== glossReqRef.current) return
const wrapper = wrapperRef.current
if (!g.gloss || !wrapper) {
// A token can have only the reverse reading — a Portuguese word she
// hovers in her own sentence — and that is still worth a tooltip.
if ((!g.gloss && !g.reverse) || !wrapper) {
setGloss(null)
return
}
@@ -868,14 +883,14 @@ export function EditorCore({
const wrapRect = wrapper.getBoundingClientRect()
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 280))
const top = end.bottom - wrapRect.top + 6
setGloss({ word: range.word, gloss: g.gloss, from: range.from, to: range.to, top, left })
setGloss({ word: range.word, gloss: g.gloss, reverse: g.reverse, from: range.from, to: range.to, top, left })
})
.catch(() => {
if (token === glossReqRef.current) setGloss(null)
})
}, 350)
},
[editor, selection, rewrite, misspell, wordInfo, pinned, gloss],
[editor, wordAlphabet, selection, rewrite, misspell, wordInfo, pinned, gloss],
)
// Leaving the editor surface drops any pending/shown gloss.
@@ -1081,12 +1096,20 @@ export function EditorCore({
<EditorContent editor={editor} className="h-full" />
{findOpen && editor && <FindReplace editor={editor} onClose={() => setFindOpen(false)} />}
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
{gloss && <GlossTip gloss={gloss.gloss} style={{ top: gloss.top, left: gloss.left }} />}
{gloss && (
<GlossTip
gloss={gloss.gloss}
reverse={gloss.reverse}
reverseLang={pack.nativeName}
style={{ top: gloss.top, left: gloss.left }}
/>
)}
{selection && !rewrite && !dragging && (
<SelectionBubble
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 && (
+14 -1
View File
@@ -7,10 +7,17 @@
interface Props {
gloss: string
// The English meaning of the same token read as a word of the writer's own
// language, when it is one. On a Latin-script pair "sale" is both, and the
// bubble shows the two readings stacked rather than picking one — the same
// both-directions rule the word card follows, in one line less space.
reverse?: string
// How the writer's language names itself, to label the second line.
reverseLang?: string
style: React.CSSProperties
}
export function GlossTip({ gloss, style }: Props) {
export function GlossTip({ gloss, reverse, reverseLang, style }: Props) {
return (
<div
className="petal-gloss-tip pointer-events-none absolute z-20 px-2.5 py-1.5 text-sm"
@@ -27,6 +34,12 @@ export function GlossTip({ gloss, style }: Props) {
}}
>
{gloss}
{reverse && (
<span className="mt-0.5 block" style={{ opacity: 0.72, fontSize: '0.85em' }}>
{reverseLang ? `${reverseLang}: ` : ''}
{reverse}
</span>
)}
</div>
)
}
+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) => (
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import { Schema, type Node as PMNode } from '@tiptap/pm/model'
import { wordAt } from './SpellCheck'
// Same minimal schema the suggestion-anchoring tests use: enough of a document
// to walk textblocks, none of the editor.
const schema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { group: 'block', content: 'inline*', toDOM: () => ['p', 0] },
text: { group: 'inline' },
},
})
const para = (text: string): PMNode =>
schema.node('doc', null, [schema.node('paragraph', null, text ? [schema.text(text)] : [])])
// posOf turns a plain-text offset into a ProseMirror position inside the single
// paragraph (+1 for the paragraph's opening token).
const posOf = (offset: number) => offset + 1
describe('wordAt and the pair alphabet', () => {
it('keeps English-only tokenizing when no accented dictionary is loaded', () => {
const doc = para('the river runs')
expect(wordAt(doc, posOf(5))?.word).toBe('river')
})
it('cuts an accented word into fragments on the narrow alphabet', () => {
// Not a hypothetical: this is what every surface did before the pt-PT pair,
// and it is why the alphabet had to become a property of the checker rather
// than a constant. "coração" tokenized as A-Z yields "cora" — a definition
// of which would be worse than no popover.
const doc = para('o coração dela')
expect(wordAt(doc, posOf(3))?.word).toBe('cora')
})
it('resolves the whole word once the pair widens the alphabet', () => {
const doc = para('o coração dela')
expect(wordAt(doc, posOf(3), true)?.word).toBe('coração')
// …from either side of the accented letters, not just before them.
expect(wordAt(doc, posOf(9), true)?.word).toBe('coração')
})
it('never tokenizes CJK, whichever alphabet is in force', () => {
// The zh pair's guarantee, and it must survive a change made for another
// pair entirely: Chinese is the source language, not something to spellcheck.
const doc = para('我在写作 today')
expect(wordAt(doc, posOf(1))).toBeNull()
expect(wordAt(doc, posOf(1), true)).toBeNull()
expect(wordAt(doc, posOf(6), true)?.word).toBe('today')
})
it('stops the wide alphabet at the maths symbols hiding in Latin-1', () => {
// × (U+00D7) and ÷ (U+00F7) sit inside the accented-letter block. A range
// written À-ÿ would swallow them and glue "3×4" into one token.
const doc = para('3×4 é isso')
expect(wordAt(doc, posOf(4), true)?.word).toBe('é')
})
})
+33 -5
View File
@@ -22,7 +22,25 @@ interface PluginState {
// A word is a run of Latin letters with optional internal/edge apostrophes
// (don't, O'Brien). Anything else — digits, punctuation, CJK — terminates a run.
//
// Two alphabets, because the writer's pair decides which is right. English needs
// only A-Z. European Portuguese needs ç and the accented vowels, and tokenizing
// "ação" without them yields "a" and "o" — two fragments short enough that
// isCheckable throws them away, so the word is silently never checked at all.
//
// The narrow alphabet stays the default rather than always widening: for a
// writer with no Latin second language, adding accented letters can only find
// new words to underline (the "café" and "naïve" she borrows), and finds no
// mistakes she has actually made.
const WORD_RE = /[A-Za-z][A-Za-z']*/g
const WORD_RE_LATIN = /[A-Za-zÀ-ÖØ-öø-ÿ][A-Za-zÀ-ÖØ-öø-ÿ']*/g
// wordRe returns a fresh matcher for the alphabet in force. Fresh because these
// are /g regexes carrying lastIndex, and two scans sharing one would interleave.
function wordRe(extended: boolean): RegExp {
const src = extended ? WORD_RE_LATIN : WORD_RE
return new RegExp(src.source, 'g')
}
// isCheckable filters tokens we shouldn't flag: single letters and all-caps
// acronyms (NASA, USA), which dictionaries reliably miss and which read as noise
@@ -47,12 +65,13 @@ function eachMisspelling(
checker: SpellChecker,
visit: (from: number, to: number, word: string) => void,
) {
const re = wordRe(checker.extendedAlphabet)
doc.descendants((node, pos) => {
if (!node.isTextblock) return true
const text = node.textContent
WORD_RE.lastIndex = 0
re.lastIndex = 0
let m: RegExpExecArray | null
while ((m = WORD_RE.exec(text)) !== null) {
while ((m = re.exec(text)) !== null) {
const { core, lead } = coreOf(m[0])
if (!isCheckable(core) || checker.correct(core)) continue
const from = mapOffset(node, pos, m.index + lead)
@@ -78,16 +97,25 @@ function buildDecorations(doc: PMNode, checker: SpellChecker, cursor: number): D
// click), returning its range + text so the card can offer corrections and the
// replacement can target the exact span — robust to duplicate words anywhere
// else in the document. Returns null if the position isn't inside a Latin word.
export function wordAt(doc: PMNode, pos: number): { from: number; to: number; word: string } | null {
//
// `extended` widens the alphabet the same way the decoration pass does, so that
// right-clicking "coração" looks up the whole word rather than "cora". Callers
// pass the live checker's flag; the default keeps English-only behaviour.
export function wordAt(
doc: PMNode,
pos: number,
extended = false,
): { from: number; to: number; word: string } | null {
let found: { from: number; to: number; word: string } | null = null
const re = wordRe(extended)
doc.descendants((node, nodePos) => {
if (found) return false
if (!node.isTextblock) return true
if (pos <= nodePos || pos >= nodePos + node.nodeSize) return false
const text = node.textContent
WORD_RE.lastIndex = 0
re.lastIndex = 0
let m: RegExpExecArray | null
while ((m = WORD_RE.exec(text)) !== null) {
while ((m = re.exec(text)) !== null) {
const { core, lead } = coreOf(m[0])
if (!core) continue
const from = mapOffset(node, nodePos, m.index + lead)
+69 -2
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'
@@ -28,9 +28,11 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
const gloss = info?.gloss ?? ''
const phonetic = info?.phonetic ?? ''
const etymology = info?.etymology ?? ''
// Present only when the token is also a word in her own language.
const reverse = info?.reverse ?? null
// Null whenever the dictionary has no opinion — the chip then doesn't render.
const band = info ? wordBand(info.frequency ?? 0, info.difficulty ?? -1) : null
const empty = !loading && !gloss && definitions.length === 0 && synonyms.length === 0
const empty = !loading && !gloss && !reverse && definitions.length === 0 && synonyms.length === 0
return (
<div
@@ -75,6 +77,7 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
</button>
)}
{speechSupported() && (
<>
<button
type="button"
onClick={() => speak(word)}
@@ -85,6 +88,21 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
>
🔊
</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>
@@ -184,6 +202,55 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
</div>
)}
{/* The same word read as one of hers. Only a Latin-script pair ever sees
this — "sale" is English and Portuguese, "chat" is English and French —
and Petal shows both readings rather than deciding which she meant. A
detector would be right most of the time and wrong about her writing
the rest; two lines are right always, and for a learner the collision
is the interesting part. */}
{reverse && (
<div
className="mt-3 rounded-xl px-2.5 py-2"
style={{ background: 'var(--color-surface-alt)' }}
>
<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 && (
<span className="ml-1.5 text-xs" style={{ color: 'var(--color-muted)' }}>
/{reverse.phonetic}/
</span>
)}
</p>
{(reverse.definitions ?? []).map((m, i) => (
<p key={i} className="mt-1 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
{m.part_of_speech && <span className="mr-1 italic">{m.part_of_speech}</span>}
{m.definition}
</p>
))}
</div>
)}
{/* Where the word came from. Last, and in small muted type, because it is
the one thing here that is interesting rather than useful — and for a
writer whose own language shares Latin roots with English, "efémero"
+14
View File
@@ -445,6 +445,7 @@ 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)}
@@ -454,6 +455,19 @@ function ReviewSession({
>
🔊
</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 && (
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest'
import { combine, interleave, type Loaded } from './useSpellChecker'
// The both-dictionaries rule (SUGGESTIONS.md §3a), separated from the fetching
// so it can be checked without a 15 MB word list. What matters here is not
// "does nspell work" but which way the combination is allowed to be wrong.
// A dictionary that accepts exactly the words it was given.
function dict(lang: string, words: string[], corrections: string[] = [], extendedAlphabet = false): Loaded {
const set = new Set(words)
return {
lang,
extendedAlphabet,
spell: {
correct: (w: string) => set.has(w),
suggest: () => corrections,
add: (w: string) => set.add(w),
},
}
}
const en = dict('en', ['sale', 'the', 'river'], ['sailed', 'salt'])
const pt = dict('pt-PT', ['sale', 'coração', 'jardim'], ['salte', 'sala'], true)
describe('the both-dictionaries rule', () => {
it('accepts a word either dictionary knows', () => {
const c = combine(() => [en, pt])
expect(c.correct('river')).toBe(true) // English only
expect(c.correct('jardim')).toBe(true) // Portuguese only
expect(c.correct('sale')).toBe(true) // both — the collision case
})
it('flags only what every dictionary rejects', () => {
expect(combine(() => [en, pt]).correct('qqzzx')).toBe(false)
})
it('never flags a Portuguese word just because English has not heard of it', () => {
// The property the whole design exists for. With English alone, "coração"
// is a misspelling; with her own dictionary loaded it is a word she wrote.
expect(combine(() => [en]).correct('coração')).toBe(false)
expect(combine(() => [en, pt]).correct('coração')).toBe(true)
})
it('accepts everything when no dictionary loaded', () => {
// A failed fetch must not underline every word in the document. Silence is
// the safe failure; a page of red is not.
expect(combine(() => []).correct('qqzzx')).toBe(true)
})
it('sees a dictionary that arrives after the checker was built', () => {
// English loads immediately; hers lands a moment later, once /api/me has
// named her pair. The checker reads through a getter for exactly this.
let loaded: Loaded[] = [en]
const c = combine(() => loaded)
expect(c.correct('jardim')).toBe(false)
expect(c.extendedAlphabet).toBe(false)
loaded = [en, pt]
expect(c.correct('jardim')).toBe(true)
expect(c.extendedAlphabet).toBe(true)
})
})
describe('correction pills', () => {
it('interleaves the two dictionaries rather than letting one fill the list', () => {
// Five pills fit. Concatenating would spend all of them on English and
// leave a misspelt Portuguese word with no Portuguese correction — the one
// case the second dictionary was loaded for.
expect(combine(() => [en, pt]).suggest('salle')).toEqual(['sailed', 'salte', 'salt', 'sala'])
})
it('drops duplicates, keeping the first dictionary to offer one', () => {
expect(interleave([['a', 'b'], ['a', 'c']])).toEqual(['a', 'b', 'c'])
})
it('keeps going when one dictionary runs out of ideas', () => {
expect(interleave([['a'], ['x', 'y', 'z']])).toEqual(['a', 'x', 'y', 'z'])
expect(interleave([[], []])).toEqual([])
expect(interleave([])).toEqual([])
})
})
+232 -59
View File
@@ -1,37 +1,85 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import nspell, { type NSpell } from 'nspell'
import nspell from 'nspell'
import { api } from '../api/client'
import { usePack } from '../i18n'
import type { PairLang } from '../i18n'
// useSpellChecker loads the vendored en-US Hunspell dictionary (served from
// /dictionaries/en, embedded in the Go binary via web/dist) and builds an
// in-browser nspell instance — zero backend round-trips, per spec. The
// dictionary is ~550KB, so it's fetched as a static asset (kept out of the JS
// bundle) once per app session, not per document.
// useSpellChecker builds the in-browser spell checker — zero backend round-trips
// per keystroke, per spec.
//
// English is always loaded, because English is always the target half of the
// pair. The writer's own language is loaded too when Petal ships a dictionary
// for it, and then the two are consulted together: **a token is flagged only if
// both dictionaries reject it.**
//
// That rule is the answer to the one genuinely new problem a Latin-script pair
// creates (SUGGESTIONS.md §3a). The zh pair never had to decide which language a
// word was in — the script answers it, and CJK is simply never tokenized. In an
// English+Portuguese document both halves are Latin letters, and there is no
// honest way to look at "sale" and know which language it is. Asking both
// dictionaries needs no detector and no guess. It can miss a misspelling that
// happens to be a real word in the other language; it can never squiggle
// correct writing. That is the gentle direction to be wrong in.
//
// The personal word list — the words she's told Petal to stop flagging — lives
// on the server, keyed by her account and by the dictionary's language. It used
// to be one localStorage key, which meant two people sharing a device shared a
// word list built from one person's private writing, and one person on a laptop
// and a tablet had two lists that never met. It is replayed into nspell on load;
// adding a word bumps a `version` so consumers re-run their decorations and the
// word stops flagging.
// on the server, keyed by her account and by dictionary language. It is replayed
// into each nspell instance on load; adding a word bumps a `version` so consumers
// re-run their decorations and the word stops flagging.
// SpellChecker is the minimal surface the editor decoration layer consumes.
export interface SpellChecker {
correct(word: string): boolean
suggest(word: string): string[]
// Whether the loaded dictionaries need letters beyond A-Z. Portuguese words
// carry ç and five accented vowels; tokenizing without them would cut "ação"
// into fragments. It is a property of the checker rather than a constant
// because widening the alphabet for an English-only writer would only earn
// her new squiggles under the French and Portuguese words she borrows.
extendedAlphabet: boolean
}
// The dictionary this hook loads. Only en-US ships today; pt-PT arrives with the
// first Latin pair, and its personal words are a separate list by design — an
// English exception must not silence a Portuguese flag.
const DICT_LANG = 'en'
// A dictionary Petal can load into the browser.
interface DictSpec {
// The key the personal word list is stored under. It is the dictionary's
// language, not the writer's.
lang: string
aff: string
dic: string
// Whether `dic` is gzipped. pt-PT's word list is 15 MB of text, so it ships
// compressed and is inflated here; en's 550 KB does not need it.
gzipped?: boolean
extendedAlphabet?: boolean
}
const EN: DictSpec = {
lang: 'en',
aff: '/dictionaries/en/en.aff',
dic: '/dictionaries/en/en.dic',
}
// The writer's-language dictionary, by pair. zh has no Hunspell dictionary and
// needs none: Chinese is not tokenized, so it is never flagged.
//
// pt-PT's word list is pre-expanded (see scripts/build_ptpt_dictionary.py) —
// nspell expands affixes eagerly on load, and doing that to European
// Portuguese's 1,340 rules in a browser wants over a gigabyte of heap. The
// forms are computed at build time instead, so this is the same nspell reading
// a bigger, simpler file.
const PAIR_DICTS: Partial<Record<PairLang, DictSpec>> = {
'pt-PT': {
lang: 'pt-PT',
aff: '/dictionaries/pt-PT/pt-PT.aff',
dic: '/dictionaries/pt-PT/pt-PT.dic.gz',
gzipped: true,
extendedAlphabet: true,
},
}
// Where the list lived before it had an owner (Phase 7). Read once, handed to
// the account, and then removed — see takeLegacyWords.
// the account, and then removed — see readLegacyWords.
const LEGACY_KEY = 'petal.spell.personal'
// takeLegacyWords reads the pre-account list without deleting it: the words are
// readLegacyWords reads the pre-account list without deleting it: the words are
// only dropped from the browser once the server has actually accepted them, so
// a failed request costs nothing.
function readLegacyWords(): string[] {
@@ -52,72 +100,197 @@ function clearLegacyWords() {
}
}
export function useSpellChecker() {
const spellRef = useRef<NSpell | null>(null)
const [ready, setReady] = useState(false)
// Bumped whenever the personal dictionary changes, to force re-decoration.
const [version, setVersion] = useState(0)
// fetchText retrieves a dictionary file, inflating it when it ships gzipped.
//
// DecompressionStream is used rather than a bundled inflate because it costs no
// bytes and has been in every browser since 2023. A browser without it gets an
// exception, which the caller treats as "this dictionary didn't load" — the same
// outcome as a failed fetch.
async function fetchText(url: string, gzipped: boolean | undefined): Promise<string> {
const res = await fetch(url)
if (!res.ok) throw new Error(`${url}: ${res.status}`)
if (!gzipped) return res.text()
if (!res.body) throw new Error(`${url}: no body to inflate`)
const stream = res.body.pipeThrough(new DecompressionStream('gzip'))
return new Response(stream).text()
}
useEffect(() => {
let cancelled = false
;(async () => {
try {
// Served from web/dist root (and embedded in the Go binary), same as /api.
// Dictionary is the three methods Petal asks of nspell. Naming them rather than
// referring to NSpell is what lets the decision logic below be exercised without
// a 15 MB word list behind it; a real nspell instance satisfies this as-is.
export interface Dictionary {
correct(word: string): boolean
suggest(word: string): string[]
add(word: string): unknown
}
// A dictionary that finished loading, with the language its personal words
// belong to.
export interface Loaded {
lang: string
spell: Dictionary
extendedAlphabet: boolean
}
// load builds one nspell instance and replays this writer's personal words into
// it. `adoptLegacy` is passed only for English, and only on first load.
async function load(spec: DictSpec, adoptLegacy: boolean): Promise<Loaded> {
const [aff, dic] = await Promise.all([
fetch('/dictionaries/en/en.aff').then((r) => r.text()),
fetch('/dictionaries/en/en.dic').then((r) => r.text()),
fetchText(spec.aff, false),
fetchText(spec.dic, spec.gzipped),
])
if (cancelled) return
const sp = nspell(aff, dic)
spellRef.current = sp
setReady(true)
const spell = nspell(aff, dic)
// Her own words come from her account. A browser holding a list from
// before accounts existed hands it over on the way — but only lets go of
// it once the server has taken it.
const legacy = readLegacyWords()
// Her own words come from her account. A browser holding a list from before
// accounts existed hands it over on the way — but only lets go of it once the
// server has taken it.
const legacy = adoptLegacy ? readLegacyWords() : []
const stored = legacy.length
? await api.addPersonalWords(DICT_LANG, legacy).then((res) => {
? await api.addPersonalWords(spec.lang, legacy).then((res) => {
clearLegacyWords()
return res
})
: await api.listPersonalWords(DICT_LANG)
if (cancelled) return
for (const w of stored.words) sp.add(w)
setVersion((v) => v + 1)
} catch (err) {
// A failure here costs correct words being flagged, not writing. The
// checker itself stays usable if only the word list failed to arrive.
console.error('spell checker failed to load', err)
: await api.listPersonalWords(spec.lang)
for (const w of stored.words) spell.add(w)
return { lang: spec.lang, spell, extendedAlphabet: spec.extendedAlphabet ?? false }
}
})()
// interleave merges each dictionary's corrections round-robin. Concatenating
// would let English fill all five pills and leave a misspelt Portuguese word
// with no Portuguese suggestion, which is the case the second dictionary exists
// for.
export function interleave(lists: string[][]): string[] {
const out: string[] = []
const seen = new Set<string>()
for (let i = 0; i < Math.max(...lists.map((l) => l.length), 0); i++) {
for (const list of lists) {
const word = list[i]
if (word && !seen.has(word)) {
seen.add(word)
out.push(word)
}
}
}
return out
}
// combine is the both-dictionaries rule itself, separated from the loading so it
// can be reasoned about (and tested) on its own. `dicts` is a getter because the
// writer's dictionary lands after English and the checker must see it when it
// does, without being rebuilt around a stale array.
export function combine(dicts: () => Loaded[]): SpellChecker {
return {
// Flag only what *every* loaded dictionary rejects. With one dictionary this
// is exactly the pre-Phase-21 behaviour; with two it is the rule from
// SUGGESTIONS.md §3a. No dictionary at all accepts everything — an editor
// that underlines every word because a fetch failed is worse than one that
// underlines nothing.
correct: (w) => {
const loaded = dicts()
if (loaded.length === 0) return true
return loaded.some((d) => d.spell.correct(w))
},
suggest: (w) => interleave(dicts().map((d) => d.spell.suggest(w))),
// A getter, not a snapshot. The alphabet is read by every surface that
// resolves a word, and if it were captured when the checker was built it
// would still say A-Z after her dictionary arrived — so a pt-PT writer's
// first lookups would silently be of "cora" while the underlines were
// already right.
get extendedAlphabet() {
return dicts().some((d) => d.extendedAlphabet)
},
}
}
export function useSpellChecker() {
const pack = usePack()
const pairSpec = PAIR_DICTS[pack.code]
const loadedRef = useRef<Loaded[]>([])
// Bumped whenever a dictionary arrives or the personal list changes, to force
// the editor to re-decorate.
const [version, setVersion] = useState(0)
const [ready, setReady] = useState(false)
// English first, once per session — it is never reloaded, whatever the pair.
useEffect(() => {
let cancelled = false
load(EN, true)
.then((loaded) => {
if (cancelled) return
loadedRef.current = [...loadedRef.current.filter((l) => l.lang !== EN.lang), loaded]
setReady(true)
setVersion((v) => v + 1)
})
.catch((err) => {
// A failure here costs correct words being flagged, not writing.
console.error('spell checker failed to load', err)
})
return () => {
cancelled = true
}
}, [])
// Recreate the checker's identity on load and on every personal-dict change so
// the editor's effect re-pushes it and rebuilds decorations.
// The writer's own dictionary, once her pair is known. `pack.code` is 'zh'
// until /api/me answers, so a pt-PT writer loads this a moment after the
// editor is already usable — which is the right order: the English half works
// immediately and hers fills in.
useEffect(() => {
if (!pairSpec) {
// Switching away from a pair (only tests do this today) must not leave the
// old language's words still being accepted.
loadedRef.current = loadedRef.current.filter((l) => l.lang === EN.lang)
setVersion((v) => v + 1)
return
}
let cancelled = false
load(pairSpec, false)
.then((loaded) => {
if (cancelled) return
loadedRef.current = [...loadedRef.current.filter((l) => l.lang !== loaded.lang), loaded]
setVersion((v) => v + 1)
})
.catch((err) => {
// Only her half failed. English still checks, and the consequence is
// that correct Portuguese gets underlined — worth a console line, not
// worth blocking the editor.
console.error(`spell checker failed to load ${pairSpec.lang}`, err)
})
return () => {
cancelled = true
}
}, [pairSpec])
// The checker's identity changes on every load and every added word, so the
// editor's effect re-pushes it and rebuilds decorations.
const checker = useMemo<SpellChecker | null>(() => {
if (!ready) return null
return {
correct: (w) => spellRef.current?.correct(w) ?? true,
suggest: (w) => spellRef.current?.suggest(w) ?? [],
}
// Read through the ref rather than closing over a snapshot: the pair
// dictionary arrives after English, and `version` is what re-runs this.
return combine(() => loadedRef.current)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, version])
// Adding a word takes effect in the editor immediately and is persisted in the
// background: the word stops being underlined the instant she asks, whatever
// the network is doing.
//
// It is written to every loaded dictionary's list. Under the both-dictionaries
// rule a word is only ever flagged when *all* of them rejected it, so
// accepting it is a statement about her pair rather than about one language —
// and recording it against only one would silently unaccept it if the other
// dictionary is the one still loaded next time.
const addWord = useCallback((word: string) => {
const sp = spellRef.current
if (!sp) return
sp.add(word)
setVersion((v) => v + 1)
api.addPersonalWords(DICT_LANG, [word]).catch((err) => {
const dicts = loadedRef.current
if (dicts.length === 0) return
for (const d of dicts) {
d.spell.add(word)
api.addPersonalWords(d.lang, [word]).catch((err) => {
console.error('could not save personal word', err)
})
}
setVersion((v) => v + 1)
}, [])
return { checker, ready, addWord }
+78 -15
View File
@@ -2,6 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { onPackChange, pack, resetPackForTests, setPackLang } from './index'
import { zh } from './packs/zh'
import { ptPT } from './packs/pt-PT'
import type { Pack } from './types'
// Every pack that ships. Shape assertions run over all of them, because the
// point of Phase 19 was that a language is data — and data that only the first
// author's pack satisfies isn't a shape, it's a coincidence.
const PACKS: Pack[] = [zh, ptPT]
beforeEach(() => {
resetPackForTests()
@@ -15,10 +22,19 @@ describe('pack selection', () => {
expect(pack().code).toBe('zh')
})
it('switches to a shipped pack when the session names one', () => {
setPackLang('pt-PT')
expect(pack()).toBe(ptPT)
expect(pack().code).toBe('pt-PT')
// And back — a writer moving pairs must not strand the app on the old copy.
setPackLang('zh')
expect(pack()).toBe(zh)
})
it('falls back rather than blanking on a pair with no pack yet', () => {
// A pair_lang the deployment has no copy for is a deployment that got ahead
// of its translation. She should still get a working editor.
setPackLang('pt-PT')
setPackLang('fr')
expect(pack()).toBe(zh)
setPackLang('klingon')
expect(pack()).toBe(zh)
@@ -30,20 +46,19 @@ describe('pack selection', () => {
expect(pack()).toBe(zh)
})
// Only one pack ships today, so a *real* switch can't be exercised yet; what
// can be is the other half of that contract — that a no-op never wakes every
// reader in the app. The switching path gets its test when pt-PT lands.
it('never notifies readers when nothing actually changed', () => {
it('notifies readers on a real switch, and only on a real one', () => {
const seen = vi.fn()
onPackChange(seen)
setPackLang('zh') // already the current pack — nothing changed
expect(seen).not.toHaveBeenCalled()
// Standing in for a second pack until one ships: switching to something
// unshipped resolves back to zh, which is also not a change.
// An unshipped pair resolves back to zh, which is also not a change.
setPackLang('fr')
expect(seen).not.toHaveBeenCalled()
setPackLang('pt-PT')
expect(seen).toHaveBeenCalledTimes(1)
})
it('stops notifying after unsubscribe', () => {
@@ -87,7 +102,7 @@ describe('the zh pack', () => {
// A pack with a hole in it renders an empty label rather than failing, which
// is exactly the kind of thing that reaches production. Types catch a missing
// *key*; only this catches an empty *value*.
it('has no empty strings anywhere', () => {
it.each(PACKS)('has no empty strings anywhere ($code)', (p) => {
const empties: string[] = []
const walk = (node: unknown, path: string) => {
if (typeof node === 'string') {
@@ -99,28 +114,38 @@ describe('the zh pack', () => {
for (const [k, v] of Object.entries(node)) walk(v, path ? `${path}.${k}` : k)
}
}
walk(zh, '')
walk(p, '')
expect(empties).toEqual([])
})
it('labels every companion in the roster and every tone the editor offers', async () => {
// 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) {
expect(zh.companion.names[c.id], `no name for companion ${c.id}`).toBeTruthy()
expect(p.companion.names[c.id], `no name for companion ${c.id}`).toBeTruthy()
}
const { TONES } = await import('../components/Editor/ToneSelect')
for (const tone of TONES) {
expect(zh.tones[tone.value], `no label for tone ${tone.value}`).toBeTruthy()
expect(p.tones[tone.value], `no label for tone ${tone.value}`).toBeTruthy()
}
const { REWRITE_STYLES } = await import('../components/Editor/SelectionBubble')
for (const style of REWRITE_STYLES) {
expect(zh.styles[style.value], `no label for style ${style.value}`).toBeTruthy()
expect(p.styles[style.value], `no label for style ${style.value}`).toBeTruthy()
}
})
it('labels every word band the popover can show', async () => {
it.each(PACKS)('labels every word band the popover can show ($code)', async (p) => {
// wordBand returns a band name, never a label — an unlabelled band would
// render as an empty chip, which reads as a bug rather than as no data.
const { wordBand } = await import('../components/Editor/wordband')
@@ -136,7 +161,45 @@ describe('the zh pack', () => {
)
expect(bands.size).toBe(3)
for (const band of bands) {
expect(zh.editor.wordBands[band], `no label for word band ${band}`).toBeTruthy()
expect(p.editor.wordBands[band], `no label for word band ${band}`).toBeTruthy()
}
})
})
describe('the pt-PT pack', () => {
// The pack is European Portuguese or it is nothing: a Brazilian form in the
// chrome is exactly the drift SUGGESTIONS.md §3 says to guard against, and it
// is invisible to anyone who doesn't read Portuguese — including whoever
// reviews this diff.
it('is European Portuguese, not Brazilian', () => {
const text = JSON.stringify(ptPT, (_k, v) => (typeof v === 'function' ? v(1, 'x') : v))
// Brazilian spellings and vocabulary that would give the pack away.
for (const bad of ['sinônimo', 'acadêmico', 'arquivo', 'tela', 'salvar', 'deletar', 'usuário', 'você']) {
expect(text, `pt-BR form "${bad}" in the pt-PT pack`).not.toContain(bad)
}
// And the European forms that should be there instead.
expect(ptPT.editor.synonyms).toContain('Sinónimos')
expect(ptPT.styles.academic.native).toBe('Académico')
expect(ptPT.auth.signIn).toContain('Iniciar sessão')
})
it('renders its interpolated lines with the value in place', () => {
expect(ptPT.app.duplicateTitle('Primavera')).toBe('Primavera (cópia)')
expect(ptPT.companion.milestone(300).native).toContain('300 palavras')
// Portuguese agreement is the pack's business, the same way English
// pluralisation is — the call site only ever passes a number.
expect(ptPT.garden.reviewDue(1)).toContain('1 palavra ·')
expect(ptPT.garden.reviewDue(4)).toContain('4 palavras ·')
expect(ptPT.garden.growing(1)).toContain('1 flor no jardim')
expect(ptPT.garden.growing(3)).toContain('3 flores no jardim')
})
it('says the collision line the zh pair never needed', () => {
// "sale", "comum" and "tarde" are words on both sides of this pair, so the
// word card's second reading is reachable copy here — unlike in zh.
expect(ptPT.editor.alsoIn).toBeTruthy()
expect(ptPT.editor.alsoIn).not.toBe(zh.editor.alsoIn)
})
})
+4 -3
View File
@@ -16,12 +16,13 @@ import { useSyncExternalStore } from 'react'
import type { Pack, PairLang } from './types'
import { zh } from './packs/zh'
import { ptPT } from './packs/pt-PT'
export type { Pack, PairLang, Line } from './types'
// Every pack Petal ships. pt-PT, fr and es land here in Phase 21 — adding one
// is this line plus the file, and TypeScript then names every string it owes.
const PACKS: Partial<Record<PairLang, Pack>> = { zh }
// Every pack Petal ships. fr and es are the same two lines apiece when their
// copy is written — TypeScript names every string a new pack still owes.
const PACKS: Partial<Record<PairLang, Pack>> = { zh, 'pt-PT': ptPT }
const DEFAULT_LANG: PairLang = 'zh'
+318
View File
@@ -0,0 +1,318 @@
// The European Portuguese pack — the first pair that is not Chinese, and the
// one that proves a language really is data.
//
// ⚠️ WRITTEN BUT NOT YET REVIEWED BY A NATIVE SPEAKER.
// SUGGESTIONS.md §3 sets the bar: "the pt-PT pack should be reviewed by a pt-PT
// speaker before it's trusted — same standard the zh copy got by being written
// for a real reader." That review has not happened. Until it does, treat every
// line here as a good-faith draft rather than as shipped copy, and expect a
// speaker to change the register long before they change the vocabulary.
//
// European Portuguese, not Brazilian. That is the single most likely way for
// this file to go quietly wrong, so the choices are deliberate throughout:
//
// * Post-Acordo spellings (ação, direção, ótimo), but the pt-PT lexicon —
// ficheiro not arquivo, ecrã not tela, guardar not salvar, eliminar not
// deletar, telemóvel not celular, sinónimo not sinônimo, académico not
// acadêmico.
// * "Estás a escrever", not "está escrevendo". The progressive with *a* +
// infinitive is the European construction, and the gerund is the single
// clearest tell of a Brazilian text.
// * Second person singular *tu*, because Petal is a companion in someone's
// private notebook and *você* would put a desk between them.
// * "Iniciar sessão" / "Terminar sessão", not "fazer login" / "sair".
//
// Portuguese first, English underneath — same shape as the zh pack, for the
// same reason: she reads her own language faster, and the English half is what
// she is here to learn.
import type { Pack } from '../types'
export const ptPT: Pack = {
code: 'pt-PT',
nativeName: 'Português',
locale: 'pt-PT',
app: {
duplicateTitle: (title) => `${title} (cópia)`,
garden: 'Jardim de palavras',
history: 'Histórico',
},
auth: {
title: 'Inicia sessão outra vez',
titleEn: 'Please sign in again',
bodyWithDraft:
'O que acabaste de escrever está guardado neste dispositivo — volta a entrar e guarda-se sozinho.',
bodyWithDraftEn: "What you just wrote is safe on this device — it'll save itself once you're back in.",
bodyPlain: 'A tua sessão expirou. Tudo o que escreveste já está guardado.',
bodyPlainEn: 'Your session expired. Everything you wrote is already saved.',
signIn: 'Iniciar sessão · Sign in',
},
companion: {
choose: 'Escolhe um companheiro · Choose a companion',
encouragements: [
{ native: 'Que bom! Esta frase ficou bem mais fluida 🌸', en: 'Lovely — that reads so much smoother now.' },
{ native: 'Estás a escrever cada vez melhor ✨', en: "You're getting better and better." },
{ native: 'Gosto muito desta mudança 💕', en: 'I really like that change.' },
{ native: 'Continua assim — tu consegues!', en: 'Keep going — youve got this!' },
{ native: 'Pois é, assim ficou muito mais claro 👍', en: 'Mm, thats much clearer.' },
{ native: 'Que boa escolha de palavra 🌷', en: 'Thats such a good word choice.' },
{ native: 'Ui, este parágrafo lê-se tão bem ☁️', en: 'Ooh, that paragraph flows so nicely.' },
{ native: 'Adoro ver-te escrever com mais confiança 💛', en: 'I love watching you write with more confidence.' },
{ native: 'Cada bocadinho de progresso conta 🌱', en: 'Every little bit of progress counts.' },
{ native: 'Hoje as tuas palavras estão a brilhar ✨', en: 'Your words are sparkling today.' },
],
tips: [
{ native: 'Dica: em inglês, frases mais curtas leem-se melhor.', en: 'Tip: shorter English sentences often read clearer.' },
{ native: 'Não te esqueças dos artigos “the” e “a”.', en: "Don't forget articles like “the” and “a”." },
{ native: 'Para o passado, usa o past tense: go → went.', en: 'For the past, use past tense: go → went.' },
{ native: 'Ler em voz alta ajuda a apanhar o que soa estranho.', en: 'Reading aloud helps you catch awkward spots.' },
{ native: 'Uma ideia por parágrafo e fica tudo arrumado.', en: 'One idea per paragraph keeps it tidy.' },
{ native: 'Se tiveres dúvidas, pergunta-me ✨', en: 'Not sure about something? Just ask me. ✨' },
{ native: 'O plural leva “s”: two apples 🍎', en: 'Plurals take an “s”: two apples 🍎' },
// One tip the zh pack has no use for: the false friends between
// Portuguese and English are a daily hazard for this writer and a
// non-problem for the last one.
{ native: 'Atenção aos falsos amigos: “pretender” não é *to pretend*.', en: 'Careful with false friends — “pretender” means to intend.' },
],
breaks: [
{ native: 'Já escreves há um bom bocado — levanta-te e descansa os olhos 🍵', en: "You've been writing a while — stretch and rest your eyes. 🍵" },
{ native: 'Bebe um copo de água e para cinco minutos?', en: 'Sip some water and take five?' },
{ native: 'Olha ao longe um momento, dá uma folga aos olhos 🌿', en: 'Look into the distance for a moment — give your eyes a break. 🌿' },
],
// Late-night nudges. The English wit is the user's own and is kept word for
// word across every pack; the Portuguese line leads gently into it, exactly
// as the Mandarin one does.
bedtime: [
{ native: 'A tua cama deve estar com saudades 🛏️', en: 'I bet your bed is missing you right now.' },
{ native: 'Cansada não se escreve bem — vai descansar 🌙', en: 'A tired writer is a bad writer — get some rest.' },
{ native: 'Dorme sobre o assunto, que as ideias vêm sozinhas ✨', en: 'Sleep is a wondrous enabler.' },
{ native: 'Ouves? Pois não… está toda a gente a dormir, e tu também devias 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
// Portuguese proverbs on sleep and haste, in place of the Chinese ones —
// a pack is not a translation of another pack.
{ native: 'Deitar cedo e cedo erguer dá saúde e faz crescer.', en: 'Early to bed and early to rise makes you healthy and helps you grow.' },
{ native: 'Quem dorme sobre o problema, acorda com a solução.', en: 'Sleep on the problem and you wake with the answer.' },
{ native: 'Depressa e bem, há pouco quem.', en: 'Fast and good together — few manage that.' },
],
greeting: { native: 'Olá! Estou aqui a fazer-te companhia 🐱', en: "Hi! I'm right here keeping you company. 🐱" },
welcomeBack: { native: 'Bem-vinda de volta ✨ vamos continuar!', en: 'Welcome back ✨ lets keep going!' },
errors: [
{ native: 'Ups — houve um percalço, mas as tuas palavras estão a salvo.', en: 'Oops — a little hiccup, but your words are safe.' },
{ native: 'Bolas, encravei por um segundo — já volto.', en: 'Haiya, I got stuck for a sec — back in a moment.' },
{ native: 'Não te preocupes, tentamos outra vez daqui a pouco 🍵', en: "Don't worry — let's try again in a bit. 🍵" },
],
milestone: (words: number) => ({
native: `Uau! Já vais em ${words} palavras 🎉`,
en: `Wow — ${words} words already! Amazing. 🎉`,
}),
names: {
cat: 'Gato dorminhoco',
dog: 'Cão contente',
'wiggle-dog': 'Cão abanão',
butterfly: 'Borboleta',
parrot: 'Papagaio',
},
},
prose: {
longSentence: 'Esta frase está um bocadinho longa — dividi-la em duas ou três fica mais claro 🌸',
commaSplice: 'Aqui há duas frases ligadas só por uma vírgula. Podes usar um ponto final, ou juntar “and / but”.',
vagueThis: (word) => `Não se percebe bem a que “${word}” se refere — vale a pena dizê-lo (por exemplo, “${word} idea / change…”).`,
oxfordComma: 'Numa lista de três ou mais, uma vírgula antes de “and / or” também ajuda a ler (a vírgula de Oxford).',
transitionComma: (word) => `Depois de uma palavra de ligação no início, põe uma vírgula: “${word}, …”.`,
capitalizeSentence: 'Começa cada frase com letra maiúscula.',
repeatedWord: (word) => `${word}” parece estar escrito duas vezes — dá uma vista de olhos.`,
capitalizeI: 'Em inglês, o “I” (eu) escreve-se sempre com maiúscula.',
spaceBeforePunct: 'Em inglês não se põe espaço antes da pontuação: a vírgula e o ponto vêm colados à palavra.',
spaceAfterPunct: 'Depois da vírgula ou do ponto, deixa um espaço antes da palavra seguinte.',
articleAn: (word) => `Antes de som de vogal usa-se “an”: “an ${word}”.`,
articleA: (word) => `Antes de som de consoante usa-se “a”: “a ${word}”.`,
uncountable: (word, singular) => `${word}” é incontável em inglês — não leva s, basta “${singular}”.`,
capitalizeProper: (fixed) => `Em inglês, línguas, nacionalidades, dias da semana e meses levam maiúscula: “${fixed}”.`,
thirdPersonS: (subject, verb) => `Com he/she/it, o verbo leva -s: “${subject} ${verb}”.`,
pluralAfter: (determiner, noun) => `Depois de “${determiner}” o nome vai no plural: “${determiner} ${noun}s”.`,
doubleDeterminer: (first, second) => `${first} ${second}” tem dois determinantes — fica só com um (por exemplo, tira “${first}”).`,
thereArePlural: (noun) => `Com plural usa-se “there are”: “there are ${noun}…”.`,
itsOwn: '“its” = “it is”. Para dizer “o seu / dele”, é “its” — portanto “its own”.',
itsIs: (rest) => `Aqui é “its ${rest}” (it is); “its” é o possessivo.`,
thanNotThen: (word) => `Nas comparações usa-se “than”, não “then”: “${word} than”.`,
},
docs: {
sortRecent: 'Recentes · Recent',
sortTitle: 'Título · Title',
sortLongest: 'Mais longos · Longest',
backUpAll: 'Cópia de segurança · Back up all:',
signOut: 'Terminar sessão · Sign out',
duplicate: 'Duplicar · Duplicate',
searchPlaceholder: 'Procurar · Search',
searching: 'A procurar… · Searching…',
noMatches: 'Sem resultados · No matches',
tags: 'Etiquetas · Tags',
newTagPlaceholder: 'Nova etiqueta · New tag',
},
editor: {
askPlaceholder: 'Ask why… / Pergunta porquê…',
findPlaceholder: 'Localizar · Find',
findNone: 'Nada · 0',
matchCase: 'Match case · Maiúsculas/minúsculas',
close: 'Close · Fechar',
replacePlaceholder: 'Substituir por · Replace',
replace: 'Substituir',
replaceAll: 'Tudo',
spelling: 'Ortografia · Spelling',
noSuggestions: 'Sem sugestões · No suggestions',
addToDictionary: 'Adicionar ao dicionário · Add to dictionary',
readSelection: 'Ler a seleção em voz alta · Read selection aloud',
rewrite: 'Reescrever · Rewrite',
rewriting: 'A reescrever… · Rewriting…',
rewriteFailed: 'Não deu para reescrever — tenta outra vez · Couldnt rewrite',
cancel: 'Cancelar · Cancel',
retry: 'Tentar de novo · Retry',
useThis: 'Usar esta · Use this',
word: 'Palavra · Word',
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',
tapToSwap: 'toca para trocar · tap to swap',
nothingFound: 'Não encontrei esta palavra · Nothing found for this word',
origin: 'Origem · Origin',
// This one the pt-PT pair actually sees: sale, comum, tarde, ali, data and
// dozens more are words on both sides of the pair.
alsoIn: 'Também é palavra em português · Also a word in Portuguese',
wordBands: {
simple: { native: 'Do dia a dia', en: 'Everyday word' },
standard: { native: 'Normal', en: 'Standard' },
advanced: { native: 'Avançada', en: 'Advanced' },
},
},
styles: {
natural: { native: 'Mais natural', en: 'Natural' },
academic: { native: 'Académico', en: 'Academic' },
professional: { native: 'Profissional', en: 'Professional' },
casual: { native: 'Descontraído', en: 'Casual' },
humorous: { native: 'Bem-humorado', en: 'Humorous' },
creative: { native: 'Criativo', en: 'Creative' },
persuasive: { native: 'Persuasivo', en: 'Persuasive' },
},
tones: {
general: { native: 'Geral', en: 'General' },
academic: { native: 'Académico', en: 'Academic' },
professional: { native: 'Profissional', en: 'Professional' },
casual: { native: 'Descontraído', en: 'Casual' },
humorous: { native: 'Bem-humorado', en: 'Humorous' },
creative: { native: 'Criativo', en: 'Creative' },
persuasive: { native: 'Persuasivo', en: 'Persuasive' },
},
exports: {
label: 'Exportar',
print: 'Imprimir / PDF',
formats: {
md: { native: 'Markdown', en: 'Markdown (.md)' },
docx: { native: 'Documento Word', en: 'Word (.docx)' },
html: { native: 'Página web', en: 'Web page (.html)' },
txt: { native: 'Texto simples', en: 'Plain text (.txt)' },
},
},
garden: {
title: 'Jardim de palavras · Vocabulary Garden',
titleWithFlower: '🌷 Jardim de palavras · Vocabulary Garden',
reviewing: 'A rever · Reviewing — recall, then grade yourself',
subtitle: 'Words you looked up, blooming as you learn them',
reviewDue: (n) => `Rever ${n} palavra${n === 1 ? '' : 's'} · Review ${n} due 🌸`,
emptyLead: 'O teu jardim ainda está vazio.',
emptyHint: 'Clica com o botão direito numa palavra inglesa para a procurar — e ela germina aqui.',
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`,
end: 'Terminar · End',
promptProduction: 'Qual é a palavra inglesa? · Which English word?',
promptRecognition: 'O que significa? · What does this mean?',
showAnswer: 'Ver a resposta · Show answer',
gradeAgain: { native: 'Outra vez', en: 'Again' },
gradeGood: { native: 'Lembro-me', en: 'Good' },
gradeEasy: { native: 'Fácil', en: 'Easy' },
},
history: {
title: 'Histórico · History',
kinds: {
manual: { native: 'Ponto guardado', en: 'Saved point' },
auto: { native: 'Automático', en: 'Auto' },
pre_restore: { native: 'Antes de restaurar', en: 'Before restore' },
},
justNow: 'just now · agora mesmo',
minutesAgo: (n) => `${n} min ago · há ${n} min`,
hoursAgo: (n) => `${n} hr ago · há ${n} h`,
daysAgo: (n) => `${n} day${n > 1 ? 's' : ''} ago · há ${n} dia${n > 1 ? 's' : ''}`,
preview: 'Pré-visualizar · Preview',
restoring: 'Restoring…',
restoreThis: 'Restaurar esta versão · Restore this version',
passport: '📜 Certificado de escrita · Writing passport',
keepFullHistory: 'Guardar o histórico completo · Keep full history',
},
status: {
savedLocally: 'Guardado neste dispositivo · Kept on this device',
helperRestingNative: 'O ajudante está a descansar',
helperRestingEn: "· Petal's helper is resting · o teu texto está guardado",
soundsOn: 'Som ligado · Sounds on',
soundsOff: 'Som desligado · Sounds off',
petalsOn: 'Pétalas ligadas · Petals on',
petalsOff: 'Pétalas desligadas · Petals off',
statsTitle: 'Estatísticas · Writing stats',
stats: {
words: { native: 'Palavras', en: 'Words' },
characters: { native: 'Caracteres', en: 'Characters' },
sentences: { native: 'Frases', en: 'Sentences' },
paragraphs: { native: 'Parágrafos', en: 'Paragraphs' },
pages: { native: 'Páginas', en: 'Pages' },
readingTime: { native: 'Tempo de leitura', en: 'Reading time' },
avgWordLength: { native: 'Comprimento médio', en: 'Avg word length' },
variety: { native: 'Variedade vocabular', en: 'Word variety' },
readability: { native: 'Nível de leitura', en: 'Reading level' },
},
readability: {
easy: { native: 'Fácil', en: 'Easy' },
standard: { native: 'Normal', en: 'Standard' },
fairlyHard: { native: 'Algo difícil', en: 'Fairly hard' },
advanced: { native: 'Avançado', en: 'Advanced' },
},
},
toolbar: {
untitledHeading: '(sem título)',
outline: 'Estrutura · Outline',
outlineHint: 'Usa H1/H2/H3 para criar títulos e a navegação aparece aqui.',
},
update: {
available: 'Há uma versão nova',
refresh: 'Atualizar · Refresh',
dismiss: 'Mais tarde · Dismiss',
},
}
+8
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,12 +170,18 @@ 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',
tapToSwap: '点击替换 · tap to swap',
nothingFound: '没有找到这个词 · Nothing found for this word',
origin: '词源 · Origin',
// Never rendered for this pair — English and Chinese share no spellings, so
// a word is never both. Written out anyway because the type demands it and
// because "unreachable" is a claim about today's data, not a guarantee.
alsoIn: '这个词在中文里也有 · Also a word in Chinese',
wordBands: {
simple: { native: '常用词', en: 'Everyday word' },
standard: { native: '一般难度', en: 'Standard' },
@@ -224,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`,
+24
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
@@ -137,6 +151,13 @@ export interface Pack {
// Where the word came from — a real hook for a writer whose own language
// shares roots with English.
origin: string
// Heading for the other reading of a word that exists in both languages —
// Portuguese *sale*, French *chat*. Petal shows both rather than picking
// one, so this labels the half that is in her language. The pack names its
// own language here rather than being handed a code: only it knows whether
// that reads as "em português" or as "葡萄牙语". A pack whose pair has no
// such collisions (zh) never sees this rendered.
alsoIn: string
// How hard the word is, keyed by the band wordBand() returns.
wordBands: Record<string, Line>
}
@@ -163,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