Migration 0005_unified_profiles (0004 taken by mcp bindings):
- voice_profiles.kind TEXT DEFAULT 'clone' ('clone' | 'design'), backfilled
- voice_profiles.vd_states TEXT NULL — JSON of design category picks
- mirrored in _BASE_SCHEMA; idempotent _has_column guards; downgrade drops
POST /profiles:
- ref_audio now optional; kind + vd_states form fields with validation
(clone requires audio; design requires vd_states JSON object + instruct)
- design profiles render a deterministic identity sample (seed 42) through
the shared archetype renderer — one TTS code path
POST /generate:
- profile resolution branches on profile.kind (authoritative) instead of
the brittle is_locked/instruct inference; legacy pre-0005 rows keep the
old inference as fallback; history.mode records profile.kind
Frontend:
- 'Save design as profile' in the Design tab (vd_states + buildDesignInstruct)
- selecting a design profile restores its sliders (vd_states) for re-editing
Also unforks the alembic chain (0004_mcp + my 0004 both revised 0003 →
multiple heads broke alembic upgrade head and the 0003 migration tests).
Tests: tests/test_profile_unification.py — validation, design-create with
mocked renderer, migration up/backfill/downgrade. 18/18 profile tests,
312/312 frontend, related backend suite green.
Note: docs/specs/voice-studio-unification.md (on feat/studio-ux-overhaul)
still says 0004 — renumber to 0005 when branches meet.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
254 lines
8.6 KiB
Python
254 lines
8.6 KiB
Python
import re
|
|
import sqlite3
|
|
import logging
|
|
from contextlib import contextmanager
|
|
from core.config import DB_PATH
|
|
|
|
logger = logging.getLogger("omnivoice.db")
|
|
|
|
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
_TYPE_RE = re.compile(r"^[A-Za-z0-9_ '\"\(\)\-\.]+$")
|
|
|
|
|
|
def get_db():
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
return conn
|
|
|
|
|
|
@contextmanager
|
|
def db_conn():
|
|
"""Context-managed SQLite connection that commits on clean exit and always closes."""
|
|
conn = get_db()
|
|
try:
|
|
yield conn
|
|
conn.commit()
|
|
except Exception:
|
|
try:
|
|
conn.rollback()
|
|
except Exception:
|
|
pass
|
|
raise
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
_BASE_SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS voice_profiles (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
ref_audio_path TEXT,
|
|
ref_text TEXT DEFAULT '',
|
|
instruct TEXT DEFAULT '',
|
|
language TEXT DEFAULT 'Auto',
|
|
locked_audio_path TEXT DEFAULT '',
|
|
seed INTEGER DEFAULT NULL,
|
|
is_locked INTEGER DEFAULT 0,
|
|
personality TEXT DEFAULT '',
|
|
description TEXT DEFAULT '',
|
|
is_demo INTEGER DEFAULT 0,
|
|
verified_own_voice INTEGER DEFAULT 0,
|
|
consent_text TEXT DEFAULT '',
|
|
consent_audio_path TEXT DEFAULT '',
|
|
consent_recorded_at REAL DEFAULT NULL,
|
|
kind TEXT DEFAULT 'clone',
|
|
vd_states TEXT DEFAULT NULL,
|
|
created_at REAL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS generation_history (
|
|
id TEXT PRIMARY KEY,
|
|
text TEXT,
|
|
mode TEXT,
|
|
language TEXT,
|
|
instruct TEXT,
|
|
profile_id TEXT,
|
|
audio_path TEXT,
|
|
duration_seconds REAL,
|
|
generation_time REAL,
|
|
seed INTEGER DEFAULT NULL,
|
|
created_at REAL,
|
|
FOREIGN KEY (profile_id) REFERENCES voice_profiles(id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS dub_history (
|
|
id TEXT PRIMARY KEY,
|
|
filename TEXT,
|
|
duration REAL,
|
|
segments_count INTEGER,
|
|
language TEXT,
|
|
language_code TEXT,
|
|
tracks TEXT DEFAULT '[]',
|
|
job_data TEXT,
|
|
content_hash TEXT DEFAULT '',
|
|
created_at REAL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS studio_projects (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
video_path TEXT,
|
|
audio_path TEXT,
|
|
duration REAL,
|
|
state_json TEXT,
|
|
created_at REAL,
|
|
updated_at REAL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS export_history (
|
|
id TEXT PRIMARY KEY,
|
|
filename TEXT,
|
|
destination_path TEXT,
|
|
mode TEXT,
|
|
created_at REAL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS glossary_terms (
|
|
id TEXT PRIMARY KEY,
|
|
project_id TEXT NOT NULL,
|
|
source TEXT NOT NULL,
|
|
target TEXT NOT NULL,
|
|
note TEXT DEFAULT '',
|
|
auto INTEGER DEFAULT 0,
|
|
created_at REAL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_glossary_project ON glossary_terms(project_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS jobs (
|
|
id TEXT PRIMARY KEY,
|
|
type TEXT NOT NULL,
|
|
project_id TEXT,
|
|
status TEXT NOT NULL,
|
|
created_at REAL NOT NULL,
|
|
updated_at REAL NOT NULL,
|
|
finished_at REAL,
|
|
error TEXT,
|
|
meta_json TEXT DEFAULT '{}'
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
|
|
CREATE INDEX IF NOT EXISTS idx_jobs_project ON jobs(project_id);
|
|
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS job_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
job_id TEXT NOT NULL,
|
|
seq INTEGER NOT NULL,
|
|
created_at REAL NOT NULL,
|
|
payload TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_job_events_job_seq ON job_events(job_id, seq);
|
|
|
|
-- Phase 1 AUTH-02: encrypted per-install key/value store. Used today
|
|
-- for the HF token row + the per-install Fernet salt. Both fresh
|
|
-- installs (this CREATE) and v0.2.7 upgrades (alembic
|
|
-- 0001_phase1_settings) converge on the same schema.
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at REAL NOT NULL
|
|
);
|
|
|
|
-- Wave 2.2: per-agent MCP voice bindings. An MCP client (Claude Code,
|
|
-- Cursor, …) identified by the X-OmniVoice-Client-Id header it sends is
|
|
-- bound to a default voice profile / engine. Fresh installs create it
|
|
-- here; v0.3.x upgrades get it via alembic 0004.
|
|
CREATE TABLE IF NOT EXISTS mcp_client_bindings (
|
|
client_id TEXT PRIMARY KEY,
|
|
label TEXT NOT NULL DEFAULT '',
|
|
profile_id TEXT,
|
|
default_engine TEXT,
|
|
last_seen_at REAL,
|
|
created_at REAL
|
|
);
|
|
"""
|
|
|
|
# Only tables/columns this module is allowed to ALTER. Prevents SQL injection via
|
|
# the f-string ALTER below if these helpers ever get exposed to user input.
|
|
_ALLOWED_MIGRATIONS = {
|
|
("voice_profiles", "locked_audio_path"),
|
|
("voice_profiles", "seed"),
|
|
("voice_profiles", "is_locked"),
|
|
("voice_profiles", "personality"),
|
|
("generation_history", "seed"),
|
|
("dub_history", "content_hash"),
|
|
}
|
|
|
|
|
|
def _add_column_if_missing(conn, table: str, column: str, typedef: str):
|
|
if (table, column) not in _ALLOWED_MIGRATIONS:
|
|
raise ValueError(f"Migration not allowed: {table}.{column}")
|
|
if not _IDENT_RE.match(table) or not _IDENT_RE.match(column):
|
|
raise ValueError(f"Invalid identifier: {table}.{column}")
|
|
if not _TYPE_RE.match(typedef):
|
|
raise ValueError(f"Invalid typedef: {typedef!r}")
|
|
try:
|
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {typedef}")
|
|
except sqlite3.OperationalError as e:
|
|
if "duplicate column" not in str(e).lower():
|
|
logger.warning("ALTER %s.%s failed: %s", table, column, e)
|
|
|
|
|
|
def _migrate(conn, current: int) -> int:
|
|
"""Apply migrations sequentially. Return new version."""
|
|
if current < 1:
|
|
_add_column_if_missing(conn, "voice_profiles", "locked_audio_path", "TEXT DEFAULT ''")
|
|
_add_column_if_missing(conn, "voice_profiles", "seed", "INTEGER DEFAULT NULL")
|
|
_add_column_if_missing(conn, "voice_profiles", "is_locked", "INTEGER DEFAULT 0")
|
|
_add_column_if_missing(conn, "generation_history", "seed", "INTEGER DEFAULT NULL")
|
|
current = 1
|
|
if current < 2:
|
|
_add_column_if_missing(conn, "dub_history", "content_hash", "TEXT DEFAULT ''")
|
|
current = 2
|
|
# v3: glossary_terms table lives in _BASE_SCHEMA (IF NOT EXISTS), so an old
|
|
# DB simply picks it up on the next init — no ALTER needed.
|
|
if current < 3:
|
|
current = 3
|
|
if current < 4:
|
|
_add_column_if_missing(conn, "voice_profiles", "personality", "TEXT DEFAULT ''")
|
|
current = 4
|
|
return current
|
|
|
|
|
|
def init_db():
|
|
conn = get_db()
|
|
try:
|
|
conn.executescript(_BASE_SCHEMA)
|
|
version = conn.execute("PRAGMA user_version").fetchone()[0]
|
|
new_version = _migrate(conn, version)
|
|
if new_version != version:
|
|
conn.execute(f"PRAGMA user_version = {new_version}")
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
# Phase 1: also run any pending alembic migrations. Fresh installs land
|
|
# the schema via _BASE_SCHEMA above; v0.2.7 → v0.3.0 upgrades pick up
|
|
# the same end-state via the alembic versions/ chain. Both paths
|
|
# converge because every migration uses `CREATE TABLE IF NOT EXISTS`
|
|
# or explicit existence checks.
|
|
_run_alembic_upgrade()
|
|
|
|
|
|
def _run_alembic_upgrade() -> None:
|
|
"""Best-effort `alembic upgrade head` on startup. Non-fatal: if alembic
|
|
isn't reachable (e.g. user running a stripped-down install or migrations
|
|
were already applied out-of-band), log a warning and move on. The
|
|
_BASE_SCHEMA CREATE TABLE IF NOT EXISTS above guarantees the runtime
|
|
schema is correct regardless."""
|
|
try:
|
|
import os
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
|
|
# Walk up from backend/core/db.py to find the alembic.ini at the
|
|
# project root.
|
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
root = os.path.dirname(os.path.dirname(here))
|
|
ini = os.path.join(root, "alembic.ini")
|
|
if not os.path.isfile(ini):
|
|
logger.debug("alembic.ini not found at %s; skipping migrations", ini)
|
|
return
|
|
cfg = Config(ini)
|
|
cfg.set_main_option("sqlalchemy.url", f"sqlite:///{DB_PATH}")
|
|
command.upgrade(cfg, "head")
|
|
except Exception as exc:
|
|
# Don't block startup on a migration tooling problem. The runtime
|
|
# schema is already correct via _BASE_SCHEMA.
|
|
logger.warning("alembic upgrade head skipped: %s", exc)
|