* feat(tts): user pronunciation dictionary (expressive-tts slice 1) Per-term, per-language pronunciation overrides applied to text before synthesis, so names, brands, and acronyms come out right across generate, longform, and dub. Closes part of the #1 perceived-quality gap vs ElevenLabs (pronunciation dictionaries). First slice of docs/specs/01-expressive-tts.md. - Schema: additive `pronunciation_entries` table (alembic 0008, mirrored into _BASE_SCHEMA; tested upgrade — idempotent, downgrade, converge, back-compat). - Service: extend pronunciation.py to load enabled entries (cached) and apply longest-first, word-boundary-aware, per-language (global '*' + lang match, lang overrides global), reusing the existing ReDoS-safe matcher. - Inline one-off `[[term|replacement]]` overrides that don't persist and don't collide with [voice:]/[pause]/[Name]/SSML-lite (resolved pre-chunking). - API: /pronunciation CRUD + /test dry-run + import/export (loopback-guarded). - Apply point: generation.py after language resolves, before chunking — covers native + pluggable engines. - UI: PronunciationPanel in Settings → General; all strings via i18n. - Tests: migration lifecycle, CRUD, per-language, precedence, inline override, apply-at-synth. Route snapshot regenerated (+7). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(security): bound inline-override regex (ReDoS) + annotate parameterized UPDATE CodeQL flagged py/polynomial-redos on the [[...]] inline-override regex: [^\]] also matches [, so an unterminated run of [ allowed O(n) rescans from O(n) positions. Bound the inner class to {0,256} (linear; an inline override is a short respelling). Annotate the dynamic UPDATE (B608) — its column fragments are fixed literals and every value is a bound parameter; not an injection vector. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
68 lines
2.8 KiB
Python
68 lines
2.8 KiB
Python
"""Expressive-TTS Spec 01 Phase 1: user pronunciation dictionary
|
|
|
|
Revision ID: 0008_pronunciation_dictionary
|
|
Revises: 0007_rebuild_poisoned_design_instruct
|
|
Create Date: 2026-06-25 00:00:00.000000
|
|
|
|
Adds the ``pronunciation_entries`` table backing the user-editable, per-language
|
|
pronunciation dictionary (Settings → Pronunciation). Each row maps a ``term`` to
|
|
a ``replacement`` the engine pronounces correctly, scoped global (``language='*'``)
|
|
or to a 2-letter language. Applied as pure text substitution before synthesis, so
|
|
every engine honors it.
|
|
|
|
* ``id`` TEXT PRIMARY KEY — stable row id.
|
|
* ``term`` TEXT — the word/phrase to match (whole-word, case-insensitive).
|
|
* ``replacement`` TEXT — the respelling (or, for phoneme rows, the markup).
|
|
* ``type`` TEXT — 'respelling' | 'ipa' | 'cmu'.
|
|
* ``language`` TEXT — '*' = global, else a language code (e.g. 'en', 'de').
|
|
* ``enabled`` INTEGER — 1 = applied, 0 = parked.
|
|
* ``created_at`` REAL.
|
|
|
|
Additive + idempotent (guarded by sqlite_master), matching 0002/0003/0004, so
|
|
re-running on a fresh-install DB where ``_BASE_SCHEMA`` already created the table
|
|
is a no-op (Backward-compatible project data constraint). The same table is
|
|
mirrored into ``core/db.py::_BASE_SCHEMA`` so fresh installs and migrated DBs
|
|
converge on an identical end-state (the dual-path discipline).
|
|
"""
|
|
from typing import Sequence, Union
|
|
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
|
|
revision: str = "0008_pronunciation_dictionary"
|
|
down_revision: Union[str, None] = "0007_rebuild_poisoned_design_instruct"
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def _has_table(name: str) -> bool:
|
|
bind = op.get_bind()
|
|
row = bind.execute(
|
|
sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:n"),
|
|
{"n": name},
|
|
).fetchone()
|
|
return row is not None
|
|
|
|
|
|
def upgrade() -> None:
|
|
if _has_table("pronunciation_entries"):
|
|
return
|
|
op.create_table(
|
|
"pronunciation_entries",
|
|
sa.Column("id", sa.Text(), primary_key=True),
|
|
sa.Column("term", sa.Text(), nullable=False),
|
|
sa.Column("replacement", sa.Text(), nullable=False, server_default=""),
|
|
sa.Column("type", sa.Text(), nullable=False, server_default="respelling"),
|
|
sa.Column("language", sa.Text(), nullable=False, server_default="*"),
|
|
sa.Column("enabled", sa.Integer(), nullable=False, server_default="1"),
|
|
sa.Column("created_at", sa.Float(), nullable=True),
|
|
)
|
|
op.create_index("idx_pron_lang", "pronunciation_entries", ["language"])
|
|
|
|
|
|
def downgrade() -> None:
|
|
if _has_table("pronunciation_entries"):
|
|
op.drop_index("idx_pron_lang", table_name="pronunciation_entries")
|
|
op.drop_table("pronunciation_entries")
|