Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
25 changes: 25 additions & 0 deletions backend/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,31 @@ 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.



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

Expand Down
71 changes: 71 additions & 0 deletions backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,77 @@ class Config:
from_attributes = True


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

term: str = Field(..., min_length=1, max_length=200)
replacement: str = Field(..., min_length=1, max_length=500)
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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated


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

term: Optional[str] = Field(None, min_length=1, max_length=200)
replacement: Optional[str] = Field(None, min_length=1, max_length=500)
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
143 changes: 143 additions & 0 deletions backend/routes/pronunciation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Pronunciation dictionary endpoints."""

import logging

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session

from .. import models
from ..database import PronunciationEntry, VoiceProfile as DBVoiceProfile, get_db
from ..services import pronunciation

logger = logging.getLogger(__name__)

router = APIRouter()


def _validate_profile(profile_id: str | None, db: Session) -> None:
if profile_id is None:
return
if db.query(DBVoiceProfile).filter_by(id=profile_id).first() is None:
raise HTTPException(status_code=404, detail=f"Profile '{profile_id}' not found")


@router.get("/pronunciations", response_model=list[models.PronunciationEntryResponse])
async def list_pronunciations(
language: str | None = Query(None, description="Filter to entries that apply to this language"),
profile_id: str | None = Query(None, description="Filter to entries that apply to this voice"),
include_disabled: bool = Query(True),
db: Session = Depends(get_db),
):
"""List dictionary entries.

With no filters this returns everything, which is what a management screen
wants. Passing ``language`` or ``profile_id`` narrows it to what would
actually apply to a generation with those settings.
"""
if language is None and profile_id is None:
q = db.query(PronunciationEntry)
if not include_disabled:
q = q.filter(PronunciationEntry.enabled.is_(True))
return q.order_by(PronunciationEntry.term).all()

return pronunciation.get_entries(
db, language=language, profile_id=profile_id, include_disabled=include_disabled
)


@router.post("/pronunciations", response_model=models.PronunciationEntryResponse)
async def create_pronunciation(
data: models.PronunciationEntryCreate,
db: Session = Depends(get_db),
):
"""Add a term and how to say it."""
_validate_profile(data.profile_id, db)

existing = pronunciation.find_duplicate(db, data.term, data.language, data.profile_id)
if existing is not None:
raise HTTPException(
status_code=409,
detail=(
f"An entry for '{data.term}' already exists in this scope "
f"(id {existing.id}). Update it instead."
),
)

entry = PronunciationEntry(
term=data.term.strip(),
replacement=data.replacement.strip(),
language=data.language,
profile_id=data.profile_id,
enabled=data.enabled,
notes=data.notes,
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry


@router.put("/pronunciations/{entry_id}", response_model=models.PronunciationEntryResponse)
async def update_pronunciation(
entry_id: str,
data: models.PronunciationEntryUpdate,
db: Session = Depends(get_db),
):
"""Update an entry. Omitted fields are left as they are."""
entry = db.query(PronunciationEntry).filter_by(id=entry_id).first()
if entry is None:
raise HTTPException(status_code=404, detail="Pronunciation entry not found")

fields = data.model_dump(exclude_unset=True)
if "profile_id" in fields:
_validate_profile(fields["profile_id"], db)

# Re-check the scope only when something that defines it moved.
if {"term", "language", "profile_id"} & fields.keys():
clash = pronunciation.find_duplicate(
db,
fields.get("term", entry.term),
fields.get("language", entry.language),
fields.get("profile_id", entry.profile_id),
exclude_id=entry_id,
)
if clash is not None:
raise HTTPException(
status_code=409,
detail=f"That scope already has an entry for this term (id {clash.id}).",
)

for key, value in fields.items():
setattr(entry, key, value.strip() if key in {"term", "replacement"} and value else value)

db.commit()
db.refresh(entry)
return entry


@router.delete("/pronunciations/{entry_id}")
async def delete_pronunciation(entry_id: str, db: Session = Depends(get_db)):
"""Delete an entry."""
entry = db.query(PronunciationEntry).filter_by(id=entry_id).first()
if entry is None:
raise HTTPException(status_code=404, detail="Pronunciation entry not found")
db.delete(entry)
db.commit()
return {"message": "Pronunciation entry deleted"}


@router.post("/pronunciations/preview", response_model=models.PronunciationPreviewResponse)
async def preview_pronunciations(
data: models.PronunciationPreviewRequest,
db: Session = Depends(get_db),
):
"""Show what the engine would be given for this text.

The dictionary runs at generation time and the rewritten text is never
stored, so without this there is no way to see what a rule actually does
short of listening to the output.
"""
result, applied = pronunciation.apply_pronunciations(
data.text, data.language, db, profile_id=data.profile_id
)
return {"original": data.text, "result": result, "applied": applied}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
10 changes: 9 additions & 1 deletion backend/services/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from typing import Literal, Optional

from .. import config
from . import history, profiles
from . import history, profiles, pronunciation
from ..database import get_db
from ..utils.tasks import get_task_manager

Expand Down Expand Up @@ -76,6 +76,14 @@ async def run_generation(
)

await history.update_generation_status(generation_id, "generating", bg_db)

# Respell dictionary terms on the way into the engine only. The row in
# `generations` keeps what the author wrote, so History stays readable
# and editing an entry changes future audio without rewriting the past.
text, _applied = pronunciation.apply_pronunciations(
text, language, bg_db, profile_id=profile_id
)

trim_fn = trim_tts_output if engine_needs_trim(engine) else None
runaway_detector = has_tts_runaway if engine_retries_runaway(engine) else None

Expand Down
Loading