chore: import upstream snapshot with attribution
CI / frontend-quality (push) Failing after 1s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:09:02 +08:00
commit b4eee4aa71
666 changed files with 124744 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
"""Database package — ORM models, session management, and migrations.
Re-exports all public symbols so that ``from .database import get_db``
and ``from .database import Generation as DBGeneration`` continue to work
without changing any importers.
"""
from .models import (
Base,
AudioChannel,
Capture,
CaptureSettings,
ChannelDeviceMapping,
CloudSettings,
EffectPreset,
Generation,
GenerationSettings,
GenerationVersion,
MCPClientBinding,
ProfileChannelMapping,
ProfileSample,
Project,
Story,
StoryItem,
VoiceProfile,
)
from .session import engine, SessionLocal, _db_path, init_db, get_db
__all__ = [
# Models
"Base",
"AudioChannel",
"Capture",
"CaptureSettings",
"ChannelDeviceMapping",
"CloudSettings",
"EffectPreset",
"Generation",
"GenerationSettings",
"GenerationVersion",
"MCPClientBinding",
"ProfileChannelMapping",
"ProfileSample",
"Project",
"Story",
"StoryItem",
"VoiceProfile",
# Session
"engine",
"SessionLocal",
"_db_path",
"init_db",
"get_db",
]
+336
View File
@@ -0,0 +1,336 @@
"""Column-level migrations for the voicebox SQLite database.
Why not Alembic? voicebox is a single-user desktop app shipping as a
PyInstaller binary. Every user has exactly one SQLite file. Alembic's
strengths -- migration tracking across environments, rollback, team
coordination -- don't apply here and would add bundling complexity
(alembic.ini, env.py, versions/ directory all need to survive
PyInstaller). The column-existence checks below are idempotent, run in
<50 ms on startup, and have worked reliably across 12 schema changes.
If the project ever moves to a server-based deployment or Postgres, this
decision should be revisited.
Adding a new migration:
1. Append a new ``_migrate_*`` helper at the bottom of this file.
2. Call it from ``run_migrations()`` in the appropriate spot.
3. The helper should check column/table existence before acting
(idempotent) and print a short message when it does real work.
"""
import json
import logging
import sqlite3
from sqlalchemy import inspect, text
from ..utils.capture_chords import (
default_push_to_talk_chord,
default_toggle_to_talk_chord,
)
logger = logging.getLogger(__name__)
def run_migrations(engine) -> None:
"""Run all schema migrations. Safe to call on every startup."""
inspector = inspect(engine)
tables = set(inspector.get_table_names())
_migrate_story_items(engine, inspector, tables)
_migrate_profiles(engine, inspector, tables)
_migrate_generations(engine, inspector, tables)
_migrate_effect_presets(engine, inspector, tables)
_migrate_generation_versions(engine, inspector, tables)
_migrate_capture_settings(engine, inspector, tables)
_migrate_mcp_bindings(engine, inspector, tables)
_normalize_storage_paths(engine, tables)
# -- helpers ---------------------------------------------------------------
def _get_columns(inspector, table: str) -> set[str]:
return {col["name"] for col in inspector.get_columns(table)}
def _add_column(engine, table: str, column_sql: str, label: str) -> None:
"""Add a column if it doesn't already exist."""
with engine.connect() as conn:
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column_sql}"))
conn.commit()
logger.info("Added %s column to %s", label, table)
# -- per-table migrations --------------------------------------------------
def _migrate_story_items(engine, inspector, tables: set[str]) -> None:
if "story_items" not in tables:
return
columns = _get_columns(inspector, "story_items")
# Replace position-based ordering with absolute timecodes
if "position" in columns:
logger.info("Migrating story_items: removing position column, using start_time_ms")
with engine.connect() as conn:
if "start_time_ms" not in columns:
conn.execute(text(
"ALTER TABLE story_items ADD COLUMN start_time_ms INTEGER DEFAULT 0"
))
result = conn.execute(text("""
SELECT si.id, si.story_id, si.position, g.duration
FROM story_items si
JOIN generations g ON si.generation_id = g.id
ORDER BY si.story_id, si.position
"""))
current_story_id = None
current_time_ms = 0
for item_id, story_id, _position, duration in result.fetchall():
if story_id != current_story_id:
current_story_id = story_id
current_time_ms = 0
conn.execute(
text("UPDATE story_items SET start_time_ms = :time WHERE id = :id"),
{"time": current_time_ms, "id": item_id},
)
current_time_ms += int((duration or 0) * 1000) + 200
conn.commit()
# Recreate table without the position column (SQLite lacks DROP COLUMN)
conn.execute(text("""
CREATE TABLE story_items_new (
id VARCHAR PRIMARY KEY,
story_id VARCHAR NOT NULL,
generation_id VARCHAR NOT NULL,
start_time_ms INTEGER NOT NULL DEFAULT 0,
track INTEGER NOT NULL DEFAULT 0,
trim_start_ms INTEGER NOT NULL DEFAULT 0,
trim_end_ms INTEGER NOT NULL DEFAULT 0,
version_id VARCHAR,
created_at DATETIME,
FOREIGN KEY (story_id) REFERENCES stories(id),
FOREIGN KEY (generation_id) REFERENCES generations(id)
)
"""))
conn.execute(text("""
INSERT INTO story_items_new (id, story_id, generation_id, start_time_ms, track, trim_start_ms, trim_end_ms, version_id, created_at)
SELECT id, story_id, generation_id, start_time_ms,
COALESCE(track, 0), COALESCE(trim_start_ms, 0), COALESCE(trim_end_ms, 0), version_id, created_at
FROM story_items
"""))
conn.execute(text("DROP TABLE story_items"))
conn.execute(text("ALTER TABLE story_items_new RENAME TO story_items"))
conn.commit()
# Re-read after table recreation
columns = _get_columns(inspector, "story_items")
if "track" not in columns:
_add_column(engine, "story_items", "track INTEGER NOT NULL DEFAULT 0", "track")
# Re-read so subsequent checks see new columns
columns = _get_columns(inspector, "story_items")
if "trim_start_ms" not in columns:
_add_column(engine, "story_items", "trim_start_ms INTEGER NOT NULL DEFAULT 0", "trim_start_ms")
if "trim_end_ms" not in columns:
_add_column(engine, "story_items", "trim_end_ms INTEGER NOT NULL DEFAULT 0", "trim_end_ms")
if "version_id" not in columns:
_add_column(engine, "story_items", "version_id VARCHAR", "version_id")
if "volume" not in columns:
_add_column(engine, "story_items", "volume FLOAT NOT NULL DEFAULT 1.0", "volume")
def _migrate_profiles(engine, inspector, tables: set[str]) -> None:
if "profiles" not in tables:
return
columns = _get_columns(inspector, "profiles")
if "avatar_path" not in columns:
_add_column(engine, "profiles", "avatar_path VARCHAR", "avatar_path")
if "effects_chain" not in columns:
_add_column(engine, "profiles", "effects_chain TEXT", "effects_chain")
# Voice type system — v0.3.x
if "voice_type" not in columns:
_add_column(engine, "profiles", "voice_type VARCHAR DEFAULT 'cloned'", "voice_type")
if "preset_engine" not in columns:
_add_column(engine, "profiles", "preset_engine VARCHAR", "preset_engine")
if "preset_voice_id" not in columns:
_add_column(engine, "profiles", "preset_voice_id VARCHAR", "preset_voice_id")
if "design_prompt" not in columns:
_add_column(engine, "profiles", "design_prompt TEXT", "design_prompt")
if "default_engine" not in columns:
_add_column(engine, "profiles", "default_engine VARCHAR", "default_engine")
if "personality" not in columns:
_add_column(engine, "profiles", "personality TEXT", "personality")
def _migrate_generations(engine, inspector, tables: set[str]) -> None:
if "generations" not in tables:
return
columns = _get_columns(inspector, "generations")
if "status" not in columns:
_add_column(engine, "generations", "status VARCHAR DEFAULT 'completed'", "status")
if "error" not in columns:
_add_column(engine, "generations", "error TEXT", "error")
if "engine" not in columns:
_add_column(engine, "generations", "engine VARCHAR DEFAULT 'qwen'", "engine")
# Re-read after engine column (variable name shadows outer scope in old code)
columns = _get_columns(inspector, "generations")
if "model_size" not in columns:
_add_column(engine, "generations", "model_size VARCHAR", "model_size")
if "is_favorited" not in columns:
_add_column(engine, "generations", "is_favorited BOOLEAN DEFAULT 0", "is_favorited")
if "source" not in columns:
_add_column(
engine,
"generations",
"source VARCHAR NOT NULL DEFAULT 'manual'",
"source",
)
def _migrate_effect_presets(engine, inspector, tables: set[str]) -> None:
if "effect_presets" not in tables:
return
columns = _get_columns(inspector, "effect_presets")
if "sort_order" not in columns:
_add_column(engine, "effect_presets", "sort_order INTEGER DEFAULT 100", "sort_order")
def _migrate_generation_versions(engine, inspector, tables: set[str]) -> None:
if "generation_versions" not in tables:
return
columns = _get_columns(inspector, "generation_versions")
if "source_version_id" not in columns:
_add_column(engine, "generation_versions", "source_version_id VARCHAR", "source_version_id")
def _migrate_capture_settings(engine, inspector, tables: set[str]) -> None:
if "capture_settings" not in tables:
return
columns = _get_columns(inspector, "capture_settings")
push_default = json.dumps(default_push_to_talk_chord())
toggle_default = json.dumps(default_toggle_to_talk_chord())
if "allow_auto_paste" not in columns:
_add_column(
engine,
"capture_settings",
"allow_auto_paste BOOLEAN NOT NULL DEFAULT 1",
"allow_auto_paste",
)
if "default_playback_voice_id" not in columns:
_add_column(
engine,
"capture_settings",
"default_playback_voice_id VARCHAR",
"default_playback_voice_id",
)
if "chord_push_to_talk_keys" not in columns:
_add_column(
engine,
"capture_settings",
f"chord_push_to_talk_keys TEXT NOT NULL DEFAULT '{push_default}'",
"chord_push_to_talk_keys",
)
if "chord_toggle_to_talk_keys" not in columns:
_add_column(
engine,
"capture_settings",
f"chord_toggle_to_talk_keys TEXT NOT NULL DEFAULT '{toggle_default}'",
"chord_toggle_to_talk_keys",
)
if "hotkey_enabled" not in columns:
_add_column(
engine,
"capture_settings",
"hotkey_enabled BOOLEAN NOT NULL DEFAULT 0",
"hotkey_enabled",
)
def _migrate_mcp_bindings(engine, inspector, tables: set[str]) -> None:
"""Drop the legacy ``default_intent`` column and add ``default_personality``.
The intent tri-state (respond / rewrite / compose) has been collapsed
to a boolean: when true, ``voicebox.speak`` rewrites input through the
profile's personality LLM before TTS.
"""
if "mcp_client_bindings" not in tables:
return
columns = _get_columns(inspector, "mcp_client_bindings")
if "default_personality" not in columns:
_add_column(
engine,
"mcp_client_bindings",
"default_personality BOOLEAN NOT NULL DEFAULT 0",
"default_personality",
)
if "default_intent" in columns:
if _supports_drop_column(engine):
with engine.connect() as conn:
conn.execute(text("ALTER TABLE mcp_client_bindings DROP COLUMN default_intent"))
conn.commit()
logger.info("Dropped legacy default_intent column from mcp_client_bindings")
else:
# ALTER TABLE … DROP COLUMN on SQLite requires 3.35+ (Mar
# 2021). Production PyInstaller builds bundle Python 3.12
# which links to SQLite 3.40+; this branch only fires for
# dev environments running the backend directly against an
# old system SQLite (Ubuntu 20.04 = 3.31, Debian 11 = 3.34).
# Leaving the unused column in place is harmless — the ORM
# only maps declared columns, so a stray one does no work
# and gets no reads or writes.
logger.warning(
"SQLite %s too old to DROP COLUMN (need 3.35+); leaving unused default_intent column on mcp_client_bindings in place.",
sqlite3.sqlite_version,
)
def _supports_drop_column(engine) -> bool:
"""Whether ``ALTER TABLE … DROP COLUMN`` is supported by the dialect +
runtime. Non-SQLite dialects (Postgres, MySQL) have supported it for
decades; SQLite only gained the feature in 3.35."""
if engine.dialect.name != "sqlite":
return True
return tuple(int(p) for p in sqlite3.sqlite_version.split(".")[:3]) >= (3, 35, 0)
def _normalize_storage_paths(engine, tables: set[str]) -> None:
"""Normalize stored file paths to be relative to the configured data dir."""
from pathlib import Path
from ..config import get_data_dir, to_storage_path, resolve_storage_path
data_dir = get_data_dir()
path_columns = [
("generations", "audio_path"),
("generation_versions", "audio_path"),
("profile_samples", "audio_path"),
("profiles", "avatar_path"),
]
total_fixed = 0
with engine.connect() as conn:
for table, column in path_columns:
if table not in tables:
continue
rows = conn.execute(
text(f"SELECT id, {column} FROM {table} WHERE {column} IS NOT NULL")
).fetchall()
for row_id, path_val in rows:
if not path_val:
continue
p = Path(path_val)
resolved = resolve_storage_path(p)
if resolved is None:
continue
normalized = to_storage_path(resolved)
if normalized != path_val:
conn.execute(
text(f"UPDATE {table} SET {column} = :path WHERE id = :id"),
{"path": normalized, "id": row_id},
)
total_fixed += 1
if total_fixed > 0:
conn.commit()
logger.info("Normalized %d stored file paths", total_fixed)
+303
View File
@@ -0,0 +1,303 @@
"""ORM model definitions for the voicebox SQLite database."""
from datetime import datetime
import uuid
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean, JSON
from sqlalchemy.ext.declarative import declarative_base
from ..utils.capture_chords import (
default_push_to_talk_chord,
default_toggle_to_talk_chord,
)
Base = declarative_base()
class VoiceProfile(Base):
"""Voice profile.
voice_type discriminates three flavours:
- "cloned" — traditional reference-audio profiles (all cloning engines)
- "preset" — engine-specific pre-built voice (e.g. Kokoro voices)
- "designed" — text-described voice (e.g. Qwen CustomVoice, future)
"""
__tablename__ = "profiles"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, unique=True, nullable=False)
description = Column(Text)
language = Column(String, default="en")
avatar_path = Column(String, nullable=True)
effects_chain = Column(Text, nullable=True)
# Voice type system — added v0.3.x
voice_type = Column(String, default="cloned") # "cloned" | "preset" | "designed"
preset_engine = Column(String, nullable=True) # e.g. "kokoro" — only for preset
preset_voice_id = Column(String, nullable=True) # e.g. "am_adam" — only for preset
design_prompt = Column(Text, nullable=True) # text description — only for designed
default_engine = Column(String, nullable=True) # auto-selected engine, locked for preset
# Free-form character prompt used by the compose button and the
# personality-rewrite path on /generate. Describes *what* this voice
# says and how, orthogonal to how it sounds (handled by the preset /
# cloning metadata above).
personality = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ProfileSample(Base):
"""Audio sample attached to a voice profile."""
__tablename__ = "profile_samples"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
audio_path = Column(String, nullable=False)
reference_text = Column(Text, nullable=False)
class Generation(Base):
"""A single TTS generation."""
__tablename__ = "generations"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
text = Column(Text, nullable=False)
language = Column(String, default="en")
audio_path = Column(String, nullable=True)
duration = Column(Float, nullable=True)
seed = Column(Integer)
instruct = Column(Text)
engine = Column(String, default="qwen")
model_size = Column(String, nullable=True)
status = Column(String, default="completed")
error = Column(Text, nullable=True)
is_favorited = Column(Boolean, default=False)
# Origin of this generation — "manual" for plain /generate calls,
# "personality_speak" for rows whose text was rewritten through the
# profile's personality LLM before TTS. Future sources (bulk import,
# agent replies, etc.) can extend this.
source = Column(String, nullable=False, default="manual")
created_at = Column(DateTime, default=datetime.utcnow)
class Story(Base):
"""A story that sequences multiple generations."""
__tablename__ = "stories"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, nullable=False)
description = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class StoryItem(Base):
"""Links a generation to a story at a specific timecode."""
__tablename__ = "story_items"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
story_id = Column(String, ForeignKey("stories.id"), nullable=False)
generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
version_id = Column(String, ForeignKey("generation_versions.id"), nullable=True)
start_time_ms = Column(Integer, nullable=False, default=0)
track = Column(Integer, nullable=False, default=0)
trim_start_ms = Column(Integer, nullable=False, default=0)
trim_end_ms = Column(Integer, nullable=False, default=0)
volume = Column(Float, nullable=False, default=1.0)
created_at = Column(DateTime, default=datetime.utcnow)
class Project(Base):
"""Audio studio project (JSON blob)."""
__tablename__ = "projects"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, nullable=False)
data = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class GenerationVersion(Base):
"""A version of a generation's audio (original, processed, alternate takes)."""
__tablename__ = "generation_versions"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
label = Column(String, nullable=False)
audio_path = Column(String, nullable=False)
effects_chain = Column(Text, nullable=True)
source_version_id = Column(String, ForeignKey("generation_versions.id"), nullable=True)
is_default = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
class EffectPreset(Base):
"""Saved effect chain preset."""
__tablename__ = "effect_presets"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, unique=True, nullable=False)
description = Column(Text, nullable=True)
effects_chain = Column(Text, nullable=False)
is_builtin = Column(Boolean, default=False)
sort_order = Column(Integer, default=100)
created_at = Column(DateTime, default=datetime.utcnow)
class AudioChannel(Base):
"""Audio output channel (bus)."""
__tablename__ = "audio_channels"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, nullable=False)
is_default = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
class ChannelDeviceMapping(Base):
"""Mapping between a channel and an OS audio device."""
__tablename__ = "channel_device_mappings"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
channel_id = Column(String, ForeignKey("audio_channels.id"), nullable=False)
device_id = Column(String, nullable=False)
class ProfileChannelMapping(Base):
"""Many-to-many mapping between voice profiles and audio channels."""
__tablename__ = "profile_channel_mappings"
profile_id = Column(String, ForeignKey("profiles.id"), primary_key=True)
channel_id = Column(String, ForeignKey("audio_channels.id"), primary_key=True)
class CaptureSettings(Base):
"""Singleton row holding user defaults for the capture/refine flow.
Kept server-side so every window, CLI client, and API consumer reads the
same preferences. The ``id`` column is always 1.
"""
__tablename__ = "capture_settings"
id = Column(Integer, primary_key=True, default=1)
stt_model = Column(String, nullable=False, default="turbo")
language = Column(String, nullable=False, default="auto")
auto_refine = Column(Boolean, nullable=False, default=True)
llm_model = Column(String, nullable=False, default="0.6B")
smart_cleanup = Column(Boolean, nullable=False, default=True)
self_correction = Column(Boolean, nullable=False, default=True)
preserve_technical = Column(Boolean, nullable=False, default=True)
allow_auto_paste = Column(Boolean, nullable=False, default=True)
default_playback_voice_id = Column(String, nullable=True)
# Default OFF — opting in is what triggers the macOS Input Monitoring TCC
# prompt. We deliberately don't spawn the global keyboard tap until the
# user flips this on so a fresh-install user doesn't see a scary
# "Voicebox would like to receive keystrokes from any application" dialog
# before they've even opened the Captures tab.
hotkey_enabled = Column(Boolean, nullable=False, default=False)
# Lists of keytap key names (e.g. "MetaRight", "ControlRight"). Right-hand
# modifiers by default so they don't collide with left-hand shortcuts.
chord_push_to_talk_keys = Column(
JSON, nullable=False, default=default_push_to_talk_chord
)
chord_toggle_to_talk_keys = Column(
JSON, nullable=False, default=default_toggle_to_talk_chord
)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class GenerationSettings(Base):
"""Singleton row for long-form TTS generation preferences."""
__tablename__ = "generation_settings"
id = Column(Integer, primary_key=True, default=1)
max_chunk_chars = Column(Integer, nullable=False, default=800)
crossfade_ms = Column(Integer, nullable=False, default=50)
normalize_audio = Column(Boolean, nullable=False, default=True)
autoplay_on_generate = Column(Boolean, nullable=False, default=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class CloudSettings(Base):
"""Singleton row holding the link to a Voicebox Cloud account.
Populated by the "Log in with browser" pairing flow (see services/cloud.py):
the browser hands back a one-time code, which the backend exchanges for an
``api_key`` it stores here. The key is a bearer credential for
api.voicebox.sh — auth only, never an encryption key (E2E key material lives
elsewhere). Stored in the local app database alongside the user's other data;
moving it to the OS keychain is a future hardening step. The ``id`` is
always 1; a null ``api_key`` means "not connected".
"""
__tablename__ = "cloud_settings"
id = Column(Integer, primary_key=True, default=1)
api_key = Column(String, nullable=True)
device_name = Column(String, nullable=True)
account_user_id = Column(String, nullable=True)
connected_at = Column(DateTime, nullable=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class MCPClientBinding(Base):
"""Per-MCP-client settings (voice profile, engine, personality default).
Lets users bind distinct voices to distinct agents — e.g. Claude Code
speaks in "Morgan," Cursor in "Scarlett." The MCP client identifies
itself via the ``X-Voicebox-Client-Id`` HTTP header; direct-HTTP
clients set it in their MCP config's ``headers`` block, the stdio
shim forwards it from the ``VOICEBOX_CLIENT_ID`` env var.
"""
__tablename__ = "mcp_client_bindings"
client_id = Column(String, primary_key=True)
label = Column(String, nullable=True) # display name
profile_id = Column(String, ForeignKey("profiles.id"), nullable=True)
default_engine = Column(String, nullable=True)
# When true, voicebox.speak routes through the profile's personality LLM
# (rewrite) before TTS by default. Callers can still override per call.
default_personality = Column(Boolean, nullable=False, default=False)
last_seen_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Capture(Base):
"""A single voice input capture (dictation, recording, or uploaded file).
Stores the original audio alongside the raw transcript and, optionally, a
refined version produced by the LLM. Refinement flags are serialized as
JSON so we can reproduce the prompt that generated the refined text.
"""
__tablename__ = "captures"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
audio_path = Column(String, nullable=False)
source = Column(String, nullable=False, default="file") # dictation | recording | file
language = Column(String, nullable=True)
duration_ms = Column(Integer, nullable=True)
transcript_raw = Column(Text, nullable=False, default="")
transcript_refined = Column(Text, nullable=True)
stt_model = Column(String, nullable=True)
llm_model = Column(String, nullable=True)
refinement_flags = Column(Text, nullable=True) # JSON blob
created_at = Column(DateTime, default=datetime.utcnow)
+73
View File
@@ -0,0 +1,73 @@
"""Post-migration data seeding and backfills."""
import json
import logging
import uuid
from .. import config
logger = logging.getLogger(__name__)
def backfill_generation_versions(SessionLocal, Generation, GenerationVersion) -> None:
"""Create 'clean' version entries for generations that predate the versions feature."""
db = SessionLocal()
try:
existing_version_gen_ids = {
row[0] for row in db.query(GenerationVersion.generation_id).all()
}
generations = db.query(Generation).filter(
Generation.status == "completed",
Generation.audio_path.isnot(None),
Generation.audio_path != "",
).all()
count = 0
for gen in generations:
if gen.id in existing_version_gen_ids:
continue
resolved_audio_path = config.resolve_storage_path(gen.audio_path)
if resolved_audio_path is None or not resolved_audio_path.exists():
continue
version = GenerationVersion(
id=str(uuid.uuid4()),
generation_id=gen.id,
label="clean",
audio_path=gen.audio_path,
effects_chain=None,
is_default=True,
)
db.add(version)
count += 1
if count > 0:
db.commit()
logger.info("Backfilled %d generation version entries", count)
finally:
db.close()
def seed_builtin_presets(SessionLocal, EffectPreset) -> None:
"""Ensure built-in effect presets exist in the database."""
from ..utils.effects import BUILTIN_PRESETS
db = SessionLocal()
try:
for idx, (_key, preset_data) in enumerate(BUILTIN_PRESETS.items()):
sort_order = preset_data.get("sort_order", idx)
existing = db.query(EffectPreset).filter_by(name=preset_data["name"]).first()
if not existing:
preset = EffectPreset(
id=str(uuid.uuid4()),
name=preset_data["name"],
description=preset_data.get("description"),
effects_chain=json.dumps(preset_data["effects_chain"]),
is_builtin=True,
sort_order=sort_order,
)
db.add(preset)
elif existing.sort_order != sort_order:
existing.sort_order = sort_order
db.commit()
finally:
db.close()
+78
View File
@@ -0,0 +1,78 @@
"""Engine creation, initialization, and session management."""
import logging
import uuid
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from .. import config
from .models import (
Base,
AudioChannel,
EffectPreset,
Generation,
GenerationVersion,
ProfileChannelMapping,
VoiceProfile,
)
from .migrations import run_migrations
from .seed import backfill_generation_versions, seed_builtin_presets
logger = logging.getLogger(__name__)
# Initialized by init_db()
engine = None
SessionLocal = None
_db_path = None
def init_db() -> None:
"""Initialize the database engine, run migrations, create tables, and seed data."""
global engine, SessionLocal, _db_path
_db_path = config.get_db_path()
_db_path.parent.mkdir(parents=True, exist_ok=True)
engine = create_engine(
f"sqlite:///{_db_path}",
connect_args={"check_same_thread": False},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
run_migrations(engine)
Base.metadata.create_all(bind=engine)
# Create default audio channel if it doesn't exist
db = SessionLocal()
try:
default_channel = db.query(AudioChannel).filter(AudioChannel.is_default == True).first()
if not default_channel:
default_channel = AudioChannel(
id=str(uuid.uuid4()),
name="Default",
is_default=True,
)
db.add(default_channel)
for profile in db.query(VoiceProfile).all():
db.add(ProfileChannelMapping(
profile_id=profile.id,
channel_id=default_channel.id,
))
db.commit()
finally:
db.close()
backfill_generation_versions(SessionLocal, Generation, GenerationVersion)
seed_builtin_presets(SessionLocal, EffectPreset)
def get_db():
"""Yield a database session (FastAPI dependency)."""
db = SessionLocal()
try:
yield db
finally:
db.close()