* feat: onboarding demos, opt-in bug reporting, error-docs deeplinks + issue triage Working-tree snapshot bundling several in-flight workstreams (v0.3.0): - Onboarding/demo system: DemoPresetGrid, DictationDemo, DubbingDemo components + tests, render scripts (render_demos_omnivoice.py, build_demos.sh, build_dub_demo.sh), personalities preview URLs, alembic 0002 voice-profile demo fields. - Opt-in bug reporting: ReportBugButton (prefilled GitHub-issue URL path). - Error transparency UX: errorDocsMap deeplinks + BootstrapSplash/error wiring. - Dub workspace: DubSegmentRow/Table, WaveformTimeline, dubSlice tweaks. - Issue triage: .planning/issue-clusters/ (plan-01..05 root-cause masters, GH #128-#132). - CLAUDE.md: hard rule — everything ships on v0.3.0, no version bumps. KNOWN GAP (why this is a draft): the generated demo audio assets are NOT in this tree, and backend/assets/samples/demo_voice.wav is deleted. onboarding.py guards the missing file (skips seeding the demo profile with a warning), so no crash — but first-run Launchpad will be empty and /demo_audio/ preview URLs 404 until assets are regenerated via scripts/build_demos.sh. Do not merge before regenerating + committing the demo assets. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(dub): timing strategies — kill audio compression, add Concise + Stretch Video Replaces the current audio time-compression default (atempo squeeze to fit slot) that produced chipmunk/alien output on high-density target languages like Bengali. Two new user-selectable modes; legacy behaviour kept behind an explicit "Strict slot" choice. New `DubRequest.timing_strategy` enum (default "concise"): - "concise" Translator trims text to fit at natural rate; if it still overflows, hard-trim at slot with a fade so we never overlap the next speaker. Surface overflow_s per segment so the user can shorten the text. - "stretch_video" Audio plays at natural 1.0× rate. Backend computes a per-segment new timeline; persists a video_stretch_plan on the job. Mux step (dub_export) builds an ffmpeg trim+setpts+concat filter graph that stretches each segment's video portion to match the natural-rate dub audio. Gaps/pre-roll/tail pass through at 1.0×. Sub burn under stretch_video is skipped in one pass (cues would drift). - "strict_slot" Legacy atempo squeeze. Retained for back-compat. Director rate-bias side-effect (seg_speed *= bias) now gated on strict_slot only, so "urgent"/"slow" direction tokens keep their instruct effect in the new modes without chipmunking. Per-segment fit_status emitted in the SSE done event: {status: "fits" | "overflows" | "video_stretched", overflow_s?, stretch_ratio?} DubSegmentRow's "Sync: 100%" badge (which was lying — sync_ratio was always ~1.0 because the TTS loop pre-trimmed to slot) is replaced with a truthful "Fits / Overflows +Ns / Video 1.18×" label. Frontend: - prefsSlice.timingStrategy (persisted, store v3→v4 with safe migrate). - DubTab footer Segmented control: "Concise · Stretch Video · Strict slot". - useDubWorkflow passes timing_strategy on /dub/generate; consumes fit_status. Tests: tests/test_dub_timing_strategy.py — 13 cases covering schema defaults/validation, _build_video_stretch_filter_graph (pre-roll, gap, tail, empty-plan early return, post-subtitle chain-in), and _video_stretch_plan_for guards. 30/30 existing dub tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(waveform): surface missing source as "Source media missing" instead of code-4 black box When a project's underlying media file is gone (moved or deleted between save and reload) the <video> element fires MediaError code 4 and the companion audio fetch returns HTTP 404 — both were silently warned to the console while the user stared at an unresponsive black panel and an empty waveform. - WaveformTimeline now flips loadError when the video element rejects code 3 (decode) or 4 (src not supported), and tracks `sourceMissing` separately so the error UI can name the actual problem. - The audio decode fallback chain catches HTTP 404 specifically and treats it as source-missing instead of loading silent empty peaks — an empty waveform on a deleted source is more confusing than a clear "Re-upload the video to continue" message. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tray): "Show OmniVoice" reloads when the webview is blank When the dev Vite server restarts (or the main window is created before the backend is ready), the webview load fails and the window is left with `<body></body>` plus a "Could not connect to the server" console error. Clicking "Show OmniVoice" from the tray menu just re-showed the broken window — there was no recovery path short of quit+relaunch. Now the show handler runs a tiny eval after `show()`/`set_focus()` that calls `location.reload()` only when `document.body.childElementCount === 0`. A healthy window doesn't blink (body is non-empty); a blank one self-recovers as soon as the user clicks Show. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#133): bug-report diagnostics field mapping + drop unused imports Address PR #133 review: - ReportBugButton: /system/info exposes `platform` + `device`, not `os`/`torch_device`/`gpu` — those reads silently dropped OS/GPU from every bug report. Map to the real fields (CodeRabbit). Also remove the dead `home` local in stripHome (CodeQL unused-variable). - DictationDemo: drop unused `Loader` import (CodeQL unused-import). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
235 lines
7.9 KiB
Python
235 lines
7.9 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,
|
|
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
|
|
);
|
|
"""
|
|
|
|
# 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)
|