Phase 25. Spanish was never built — the groundwork was all [x] (DreamDict data, the prompt language, the L1 rule gating, TTS env-discovery), which is why the plan read as though it had shipped. shippedPairs was the honest answer: the server had been refusing es on purpose. The langpack is neutral Latin American, chosen with the user: tú, ustedes, no vosotros, and the pan-American half of every vocabulary split. A vitest greps for the peninsular twins the way fr is greped for québécismes — including coger, which is not merely regional but obscene through most of Latin America. The dictionary is the story. Debian's hunspell-es symlinks twenty country codes to one file, which reads as pan-Hispanic; RLA publishes twenty-four builds per release, one per country plus a generic es that is the union, and Debian ships peninsular es_ES. The 58,622-form gap is essentially voseo, so the first version of this commit underlined vení and tenés as misspellings and called it a considered gap. The MUST_ACCEPT list was written to catch exactly that and structurally could not: it asserted the pan-Hispanic vocabulary, and every RLA variant carries the full pan-Hispanic vocabulary — only the paradigms are localised. The REP table cited as the second witness is shared by all builds too. Two independent-looking proofs, neither able to distinguish anything, agreeing with each other. The profile now demands what discriminates, each verified against the build it targets: voseo rejects es_ES and Debian, vosotros rejects es_MX, and arepa/chévere/bacán reject es_AR, which has both paradigms and would otherwise pass. 717,640 forms, 1.74 MB gzipped, 762 ms / 97 MB in a real nspell. fr and pt-PT rebuild byte-identical from their own upstream debs, so the shared script still means what it meant. Shipping the union is fr's call arrived at from the other side: coût and cout are both correct French, tienes and tenés are both correct Spanish. The dictionary holds every variety because underlining is all it can do; the copy picks a register because speaking requires one. Reviewed by four models at the usual >=2-of-4 threshold, 5 of 27 findings applied — one catching the bedtime proverb as fr's Qui dort dîne calqued into Spanish, gloss and all, which is the rule the fr header states. One below-threshold finding (a missing ¡, seen by 1 of 4 because an absent opening mark has no closing ! to look wrong against) was applied and turned into an assertion instead: the suite now rejects any native line that closes ? or ! without opening one. piper-es on es_MX-ald-medium, not the es_ES-davefx-medium the plan named — six of Piper's nine Spanish voices are peninsular, so the obvious pick was the pt-PT trap through a different door. go build/vet/test, tsc, vite, vitest 251/251. Not deployed, not seen in a browser, not read by a native speaker, and no es account exists.
212 lines
8.8 KiB
YAML
212 lines
8.8 KiB
YAML
# Petal on the parodia.dev VPS.
|
|
#
|
|
# docker compose up -d --build
|
|
#
|
|
# Fronted by the host's existing Traefik (external `traefik` network, the
|
|
# `web-secure` entrypoint and the `default` cert resolver — same convention the
|
|
# other services on this box use). Petal itself never binds a host port; the
|
|
# only way in is through Traefik over HTTPS.
|
|
#
|
|
# Read-aloud runs as two sibling containers rather than host systemd services:
|
|
# each Piper HTTP server loads exactly one voice, the host has no lingering
|
|
# user session to keep systemd units alive, and keeping them on the internal
|
|
# network means the TTS ports are unreachable from anywhere but Petal.
|
|
#
|
|
# Copy deploy/petal.env.example to .env before the first `up`.
|
|
|
|
name: petal
|
|
|
|
services:
|
|
petal:
|
|
build:
|
|
context: .
|
|
dockerfile: Dockerfile
|
|
image: petal:local
|
|
container_name: petal
|
|
restart: unless-stopped
|
|
# ./data is a bind mount, so the image's own `petal` user (uid 10001) has no
|
|
# claim on it — the host's ownership wins and the container can't open
|
|
# petal.db. Run as whoever owns the stack directory instead. Keeping it the
|
|
# host user (rather than chowning ./data to 10001) is deliberate: the backup
|
|
# script gzips snapshots in place from the host, so the host account needs
|
|
# write access to the same directory. Still never root.
|
|
user: "${PETAL_UID:-1001}:${PETAL_GID:-1001}"
|
|
env_file: .env
|
|
environment:
|
|
# Fixed by the image layout; kept here so they're visible at a glance.
|
|
PORT: "8080"
|
|
DATABASE_PATH: /data/petal.db
|
|
IMAGE_DIR: /data/images
|
|
TTS_CACHE_DIR: /data/tts
|
|
# DreamDict's built dictionary, read-only, deployed into the data volume
|
|
# (see deploy/README.md). Absent it, word lookups fall back to the
|
|
# embedded English/Chinese datasets rather than failing.
|
|
DICT_PATH: /data/dict.db
|
|
# Piper sidecars. Each server loads one voice, so English and Chinese are
|
|
# 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
|
|
TTS_ENDPOINT_FR: http://piper-fr:5000
|
|
TTS_ENDPOINT_ES: http://piper-es:5000
|
|
# The sidecars run piper-tts 1.6.0, which serves synthesis on
|
|
# /synthesize; millenia's older server keeps the default "/".
|
|
TTS_PATH: /synthesize
|
|
# The companion's bedtime nag and night mode read the local clock.
|
|
TZ: ${TZ:-Europe/Lisbon}
|
|
volumes:
|
|
# A bind mount, not a named volume: petal.db must be trivially reachable
|
|
# from the host for the nightly backup and for a restore.
|
|
- ./data:/data
|
|
# Mount-liveness guard. On the VPS ./data is an encrypted LUKS volume, and
|
|
# the mountpoint directory still exists when that volume is NOT mounted —
|
|
# so without this, a boot where the unlock failed would start Petal
|
|
# against an empty unencrypted directory and quietly serve a blank
|
|
# database. .volume-ok lives on the encrypted filesystem, and
|
|
# create_host_path: false turns its absence into a container start
|
|
# failure instead. Harmless elsewhere: create the file once and it is a
|
|
# no-op. See deploy/README.md §6.
|
|
- type: bind
|
|
source: ./data/.volume-ok
|
|
target: /data/.volume-ok
|
|
read_only: true
|
|
bind:
|
|
create_host_path: false
|
|
networks:
|
|
- traefik
|
|
- internal
|
|
depends_on:
|
|
- piper-en
|
|
- piper-zh
|
|
- piper-pt
|
|
labels:
|
|
traefik.enable: "true"
|
|
traefik.docker.network: traefik
|
|
traefik.http.routers.petal.rule: Host(`${PETAL_HOST:-petal.parodia.dev}`)
|
|
traefik.http.routers.petal.entrypoints: web-secure
|
|
traefik.http.routers.petal.tls: "true"
|
|
traefik.http.routers.petal.tls.certResolver: default
|
|
traefik.http.routers.petal.service: petal
|
|
# No edge gate: Petal authenticates for itself now (Authentik OIDC), so
|
|
# every /api route answers 401 without a session and the only thing served
|
|
# to an anonymous visitor is the app shell and its sign-in redirect. The
|
|
# basic-auth middleware that stood here until Phase 16 — plus the separate
|
|
# unauthenticated router /api/health needed to escape it — is gone; a
|
|
# second password in front of a real login is just one more thing to lose.
|
|
traefik.http.routers.petal.middlewares: compression@file,petal-headers
|
|
traefik.http.services.petal.loadbalancer.server.port: "8080"
|
|
# HSTS is the edge's business — it is a statement about the TLS
|
|
# termination, which happens here and not in the container.
|
|
#
|
|
# The Content-Security-Policy that used to sit alongside it has moved into
|
|
# the app (see securityHeaders in cmd/server/main.go). customresponseheaders
|
|
# *overwrites*, so a policy set here would silently replace the stricter
|
|
# one an individual route chooses for itself — which is exactly what the
|
|
# image store does to keep an uploaded SVG from running as a page. A rule
|
|
# the edge can quietly undo is not a rule. X-Content-Type-Options and
|
|
# Referrer-Policy moved with it for the same reason: one place to read,
|
|
# and no dependence on this file being deployed alongside the binary.
|
|
traefik.http.middlewares.petal-headers.headers.customresponseheaders.Strict-Transport-Security: max-age=31536000; includeSubDomains
|
|
|
|
piper-en:
|
|
build:
|
|
context: deploy/piper
|
|
image: petal-piper:local
|
|
container_name: petal-piper-en
|
|
restart: unless-stopped
|
|
environment:
|
|
PIPER_VOICE: ${TTS_VOICE_EN:-en_US-amy-medium}
|
|
volumes:
|
|
- piper-voices:/voices
|
|
networks:
|
|
- internal
|
|
|
|
piper-zh:
|
|
build:
|
|
context: deploy/piper
|
|
image: petal-piper:local
|
|
container_name: petal-piper-zh
|
|
restart: unless-stopped
|
|
environment:
|
|
PIPER_VOICE: ${TTS_VOICE_ZH:-zh_CN-huayan-medium}
|
|
volumes:
|
|
- piper-voices:/voices
|
|
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
|
|
|
|
# French, for the fr pair. The opposite situation to Portuguese: every French
|
|
# voice Piper ships is fr_FR, so there is no wrong country to land on by
|
|
# default, and the name is plain ASCII so the entrypoint's percent-encoded
|
|
# fallback (added for tugão) never has to fire. siwis-medium to match the
|
|
# register of the other three.
|
|
piper-fr:
|
|
build:
|
|
context: deploy/piper
|
|
image: petal-piper:local
|
|
container_name: petal-piper-fr
|
|
restart: unless-stopped
|
|
environment:
|
|
PIPER_VOICE: ${TTS_VOICE_FR:-fr_FR-siwis-medium}
|
|
volumes:
|
|
- piper-voices:/voices
|
|
networks:
|
|
- internal
|
|
|
|
# Spanish, for the es pair — and the Portuguese trap rather than the French
|
|
# one. Piper's catalogue has nine Spanish voices, six of them es_ES, and the
|
|
# obvious pick (es_ES-davefx-medium, which the build plan itself named) is
|
|
# peninsular. The es pack is written in neutral Latin American Spanish, so a
|
|
# Castilian voice would read it aloud in the accent the copy was written to
|
|
# avoid — the same wrong-country default that pt-PT hit through packaging,
|
|
# arriving here through the voice list. Only two Latin American voices exist,
|
|
# es_AR-daniela-high and es_MX; Mexican is the neutral broadcast standard and
|
|
# ald-medium matches the register of the other four. ASCII, so the
|
|
# percent-encoded download fallback added for tugão never has to fire.
|
|
piper-es:
|
|
build:
|
|
context: deploy/piper
|
|
image: petal-piper:local
|
|
container_name: petal-piper-es
|
|
restart: unless-stopped
|
|
environment:
|
|
PIPER_VOICE: ${TTS_VOICE_ES:-es_MX-ald-medium}
|
|
volumes:
|
|
- piper-voices:/voices
|
|
networks:
|
|
- internal
|
|
|
|
networks:
|
|
# Created and owned by the host's Traefik stack.
|
|
traefik:
|
|
external: true
|
|
# Petal ↔ Piper only. Not reachable from the internet or the other stacks.
|
|
internal:
|
|
driver: bridge
|
|
|
|
volumes:
|
|
# Downloaded voice models, shared read-mostly by both Piper instances so the
|
|
# same model is never fetched twice.
|
|
piper-voices:
|