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
59 changes: 59 additions & 0 deletions backend/database/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,68 @@ 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
existing = {ix["name"] for ix in inspector.get_indexes("pronunciation_entries")}
if "uq_pronunciation_scope" in existing:
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
60 changes: 58 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,50 @@ 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)
replacement = Column(String, nullable=False)
# 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)
Comment on lines +164 to +178

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/sh
set -eu

# Identify the deployed database dialect and the repository migration mechanism.
rg -n -C 3 'create_engine\(|DATABASE_URL|sqlite|postgres|mysql|Base\.metadata\.create_all|alembic' \
  backend pyproject.toml setup.cfg setup.py 2>/dev/null || true
fd -HI -t f | rg '(^|/)(alembic\.ini|env\.py|.*migration.*)$' || true

Repository: jamiepine/voicebox

Length of output: 5689


🏁 Script executed:

#!/bin/sh
set -eu

echo "== models/PronunciationEntry =="
sed -n '130,180p' backend/database/models.py 2>/dev/null || true

echo "== pronunciation routes/service duplicate logic =="
fd -HI -t f 'pronunciation|pronounce' backend | sed -n '1,20p'
for f in $(fd -HI -t f 'pronunciation|pronounce' backend); do
  echo "--- $f"
  rg -n -C 4 'find_duplicate|duplicate|PronunciationEntry|normalized|normalize|insert|create\(|409|Conflict' "$f" || true
done

echo "== migrations checks around pronunciation_entries =="
rg -n -C 6 'pronunciation_entries|find_duplicate|duplicate|created_if_empty|create_table|unique|uniqueConstraint' backend/database/migrations.py backend/database/models.py 2>/dev/null || true

Repository: jamiepine/voicebox

Length of output: 13642


Add a NULL-aware uniqueness constraint for pronunciation scopes.

find_duplicate() is a pre-insert check, so concurrent creates can insert the same normalized term, language, and global/profile scope into pronunciation_entries. Add/patch a unique constraint or expression index that enforces this scope across SQLite before adding the row, and map its integrity error to HTTP 409 on insert/update.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/database/models.py` around lines 152 - 166, Add database-enforced
uniqueness for the normalized pronunciation term across each language and
profile scope, treating NULL language and NULL profile_id values consistently on
SQLite; define the constraint or expression index on the pronunciation_entries
model. Update the pronunciation insert and update handlers to catch violations
of this constraint and return HTTP 409, while preserving find_duplicate() as an
early check.


# 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
84 changes: 83 additions & 1 deletion backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
Pydantic models for request/response validation.
"""

from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, StringConstraints
from typing_extensions import Annotated
from typing import Optional, List
from datetime import datetime

Expand Down Expand Up @@ -127,6 +128,87 @@ class Config:
from_attributes = True


# A value of " " passes a raw min_length check and then stores as empty once
# the route strips it. Strip first, then length-check the result.
TrimmedTerm = Annotated[
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=200)
]
TrimmedReplacement = Annotated[
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=500)
]


class PronunciationEntryCreate(BaseModel):
"""Request model for creating a pronunciation entry."""

term: TrimmedTerm
replacement: TrimmedReplacement
language: Optional[str] = Field(
None,
pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$",
description="Apply only when generating in this language. Omit for all languages.",
)
profile_id: Optional[str] = Field(
None, description="Scope to one voice. Omit for a global entry."
)
enabled: bool = True
notes: Optional[str] = Field(None, max_length=1000)


class PronunciationEntryUpdate(BaseModel):
"""Request model for updating a pronunciation entry. Omitted fields are left alone."""

term: Optional[TrimmedTerm] = None
replacement: Optional[TrimmedReplacement] = None
language: Optional[str] = Field(
None, pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$"
)
profile_id: Optional[str] = None
enabled: Optional[bool] = None
notes: Optional[str] = Field(None, max_length=1000)


class PronunciationEntryResponse(BaseModel):
"""Response model for a pronunciation entry."""

id: str
term: str
replacement: str
language: Optional[str] = None
profile_id: Optional[str] = None
enabled: bool = True
notes: Optional[str] = None
created_at: datetime
updated_at: datetime

class Config:
from_attributes = True


class PronunciationPreviewRequest(BaseModel):
"""Request to see what the dictionary would do to a piece of text."""

text: str = Field(..., min_length=1, max_length=50000)
language: Optional[str] = None
profile_id: Optional[str] = None


class PronunciationSubstitution(BaseModel):
"""One replacement the dictionary made."""

term: str
replacement: str
entry_id: str


class PronunciationPreviewResponse(BaseModel):
"""What the engine would actually be given, and why it differs."""

original: str
result: str
applied: List[PronunciationSubstitution]


class HistoryQuery(BaseModel):
"""Query model for generation history."""

Expand Down
2 changes: 2 additions & 0 deletions backend/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def register_routers(app: FastAPI) -> None:
from .captures import router as captures_router
from .stories import router as stories_router
from .effects import router as effects_router
from .pronunciation import router as pronunciation_router
from .audio import router as audio_router
from .models import router as models_router
from .settings import router as settings_router
Expand All @@ -36,6 +37,7 @@ def register_routers(app: FastAPI) -> None:
app.include_router(captures_router)
app.include_router(stories_router)
app.include_router(effects_router)
app.include_router(pronunciation_router)
app.include_router(audio_router)
app.include_router(models_router)
app.include_router(settings_router)
Expand Down
10 changes: 8 additions & 2 deletions backend/routes/generations.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from sqlalchemy.orm import Session

from .. import config, models
from ..services import history, personality, profiles, tts
from ..services import history, personality, profiles, pronunciation, tts
from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
from ..services.generation import run_generation
from ..services.task_queue import cancel_generation as cancel_generation_job, enqueue_generation
Expand Down Expand Up @@ -363,9 +363,15 @@ async def stream_speech(

runaway_detector = has_tts_runaway

# Same respelling the persisted path does, so a streamed preview matches
# what /generate would produce.
stream_text, _applied = pronunciation.apply_pronunciations(
data.text, data.language, db, profile_id=data.profile_id
)

audio, sample_rate = await generate_chunked(
tts_model,
data.text,
stream_text,
voice_prompt,
language=data.language,
seed=data.seed,
Expand Down
Loading