Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/database/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
ProfileChannelMapping,
ProfileSample,
Project,
PronunciationEntry,
Story,
StoryItem,
VoiceProfile,
Expand All @@ -41,6 +42,7 @@
"MCPClientBinding",
"ProfileChannelMapping",
"ProfileSample",
"PronunciationEntry",
"Project",
"Story",
"StoryItem",
Expand Down
81 changes: 81 additions & 0 deletions backend/database/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,90 @@ def run_migrations(engine) -> None:
_migrate_generation_versions(engine, inspector, tables)
_migrate_capture_settings(engine, inspector, tables)
_migrate_mcp_bindings(engine, inspector, tables)
_migrate_pronunciation_entries(engine, inspector, tables)
_normalize_storage_paths(engine, tables)


def _migrate_pronunciation_entries(engine, inspector, tables: set[str]) -> None:
"""Add the scope-uniqueness index to an existing pronunciation_entries table.

``create_all`` builds the index with the table, so a fresh install needs
nothing here. A database created by an earlier build of this feature has the
table but not the index, and ``create_all`` will not add one to a table that
already exists.

Duplicates that predate the index would make CREATE UNIQUE INDEX fail, so
they are collapsed first, keeping the oldest row in each scope.

"Oldest" has to mean ``created_at``, not ``MIN(id)``: ids are random UUIDs,
so ordering by id would keep an arbitrary row and pick a different one on a
different machine. The concatenated key sorts by timestamp first and falls
back to id, which keeps the choice deterministic when two rows share a
timestamp -- ISO-8601 text sorts chronologically, so this needs no window
function and stays portable across SQLite builds.

``IF NOT EXISTS`` rather than relying on the inspector check alone: the
inspector reflects a snapshot, and a migration that raises takes startup
down with it. The check stays as the fast path that skips the dedup scan.
"""
if "pronunciation_entries" not in tables:
return

# Strategy columns. Existing rows are all respellings, which is the
# default, so no backfill is needed.
columns = _get_columns(inspector, "pronunciation_entries")
for column, ddl in (
("strategy", "strategy VARCHAR NOT NULL DEFAULT 'respell'"),
("spoken_language", "spoken_language VARCHAR"),
("phonemes", "phonemes VARCHAR"),
):
if column not in columns:
_add_column(engine, "pronunciation_entries", ddl, column)

# sqlite_master rather than the inspector: SQLAlchemy cannot reflect an
# expression-based index and skips it with a warning, so the inspector
# never reports this one and the guard would never fire -- leaving the
# dedup scan to run on every startup for nothing.
with engine.connect() as conn:
already = conn.execute(
text(
"SELECT 1 FROM sqlite_master "
"WHERE type = 'index' AND name = 'uq_pronunciation_scope'"
)
).first()
if already:
return

with engine.connect() as conn:
removed = conn.execute(
text(
"""
DELETE FROM pronunciation_entries
WHERE COALESCE(created_at, '') || '|' || id NOT IN (
SELECT MIN(COALESCE(created_at, '') || '|' || id)
FROM pronunciation_entries
GROUP BY lower(term), COALESCE(language, ''), COALESCE(profile_id, '')
)
"""
)
).rowcount
conn.execute(
text(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_pronunciation_scope
ON pronunciation_entries (
lower(term), COALESCE(language, ''), COALESCE(profile_id, '')
)
"""
)
)
conn.commit()

if removed:
logger.info("Collapsed %d duplicate pronunciation entries before indexing", removed)
logger.info("Added uq_pronunciation_scope index to pronunciation_entries")


# -- helpers ---------------------------------------------------------------

def _get_columns(inspector, table: str) -> set[str]:
Expand Down
77 changes: 75 additions & 2 deletions backend/database/models.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
"""ORM model definitions for the voicebox SQLite database."""

from datetime import datetime
import uuid
from datetime import datetime

from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean, JSON
from sqlalchemy import (
JSON,
Boolean,
Column,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
func,
)
from sqlalchemy.ext.declarative import declarative_base

from ..utils.capture_chords import (
Expand Down Expand Up @@ -141,6 +153,67 @@ class GenerationVersion(Base):
created_at = Column(DateTime, default=datetime.utcnow)


class PronunciationEntry(Base):
"""A term the engine says wrong, and how to spell it so it says it right.

Respelling rather than phonemes: every engine reads plain text, so
``bandeja -> ban-DEH-ha`` works everywhere, where a phoneme string only
works on the engines that accept one.
"""

__tablename__ = "pronunciation_entries"

id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
term = Column(String, nullable=False)

# How to say it. Always populated, and always something every engine can
# read, so it doubles as the fallback when a richer strategy is not
# available on the target engine. For a `language` entry it is the term
# itself, since the text does not change -- only which language reads it.
replacement = Column(String, nullable=False)

# Which realisation to prefer: "respell" | "language" | "phoneme".
# The compiler drops to `replacement` whenever the engine cannot do the
# preferred one, which is the same native-or-structural rule the
# directives follow.
strategy = Column(String, nullable=False, default="respell")
# For strategy="language": the language this term should be read in.
# Distinct from `language` below, which is about *when* the entry applies.
spoken_language = Column(String, nullable=True)
# For strategy="phoneme": engine-specific phoneme string.
phonemes = Column(String, nullable=True)

# NULL applies in every generation language. A code restricts the entry to
# that language -- a Spanish word needs respelling when the engine is
# reading English, but not when it is already reading Spanish.
language = Column(String, nullable=True)
# NULL is a global entry; set to scope it to one voice.
profile_id = Column(String, ForeignKey("profiles.id"), nullable=True)
enabled = Column(Boolean, default=True, nullable=False)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

# One entry per (term, language, profile) scope, enforced by the database
# rather than only by a pre-insert check, which two concurrent creates can
# both pass.
#
# A plain UniqueConstraint would not do it: SQL treats NULLs as distinct,
# so two global entries for the same term would both be accepted -- exactly
# the case worth catching. COALESCE maps the wildcard scopes onto a real
# value so they compare equal, and lower() makes the term case-insensitive
# to match how it is looked up.
__table_args__ = (
Index(
"uq_pronunciation_scope",
func.lower(term),
func.coalesce(language, ""),
func.coalesce(profile_id, ""),
unique=True,
),
)


class EffectPreset(Base):
"""Saved effect chain preset."""

Expand Down
Loading