Commit Graph
4 Commits
Author SHA1 Message Date
8520b84b68 fix(clone): voice-design panel no longer crashes on a partial vd_states shape (#983) (#995)
Crash: DesignMethodPanel's optLabel() called val.replace(...) on an
undefined category value — a regression from f678e33, which swapped a
safe plain template literal for an i18n lookup that assumes vdStates[key]
is always a string. Both occurrences (the label kicker and the chip
list) are now guarded, falling back to 'Auto' the same way the rest of
the component treats an unset category.

Root cause: vdStates could actually go partial in real usage. Selecting
a design profile (useProfiles.js) or restoring legacy localStorage state
(useAppData.js) applied the backend/stored vd_states object as-is, with
no check that all 6 CATEGORIES keys were present — so an older client,
hand-edited payload, or partial API write reproduced the crash on
selection. Both call sites now run the restored object through
mergeDescribedAttrs() (voiceInstruct.js), the existing completion helper
already used for the "describe your voice" path, which fills any
missing/unknown category with 'Auto'. useAppData.js also gained the
typeof === 'object' guard useProfiles.js already had.

Closes the class at the source: POST /profiles now completes vd_states
against CATEGORY_ORDER (core/describe_voice.py, the same list the
frontend's CATEGORIES mirrors) before persisting, so a design profile
can never be *saved* with an incomplete shape regardless of which
client wrote it — updated two existing tests whose fixtures asserted
the old (partial) persisted shape.

Regression tests: DesignMethodPanel render test with a partial vdStates
input, a mergeDescribedAttrs unit test for the exact partial shape from
the issue, and a backend test asserting POST /profiles fills all 6 keys.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 04:34:14 +05:30
1575baca36 fix(design): heal validator-rejecting instruct on design voices (#594/#571/#596) (#600)
* fix(design): heal validator-rejecting instruct on design voices (#594/#571/#596)

A designed voice could persist an `instruct` the engine validator rejects —
either the literal "[object Object]" from a pre-fix build (#550) or freeform
prose typed into the style field — so every Generate/Dub that used the voice
failed with `Unsupported instruct items found in …` (400/500, and "Can't reach
the local backend" when it tore down mid-render). Migration 0006 only *blanked*
"[object Object]", which silently discarded the design — an Indonesian female
voice then rendered male (#594).

Fix the whole class by healing at every seam and rebuilding from the
authoritative source (the design's saved `vd_states` category picks):

- omnivoice/utils/voice_design.py: add sanitize_instruct / instruct_from_vd_states
  / heal_design_instruct — forgiving (never raise), drop poison/prose to valid
  tags, and rebuild tags from vd_states when the stored value is unusable.
- profiles.py: sanitize + rebuild at save (POST) and sanitize at edit (PUT), so
  no poisoned instruct can ever be persisted again.
- generation.py + dub_generate.py: heal whenever a profile drives synthesis, so
  legacy poisoned rows resolve to valid tags instead of 400-ing.
- migration 0007: heal existing profiles in place (recovers gender/age/pitch
  from vd_states), self-contained (frozen vocab snapshot) so it never drags
  torch into startup; supersedes 0006's blanking. Backward-compatible.

Tests: unit coverage for the healer, a migration test driving 0006->0007 on the
real schema, a parity guard so the frozen snapshot can't drift, and two API
guards. Corrected one existing test that had encoded the #594 behaviour.

Resolves #571, #594, #596; removes a major driver of the "Can't reach backend"
reports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cjk): allowlist migration 0007's frozen dialect-tag snapshot (#564)

The 0007 instruct-heal migration carries a frozen copy of the design-tag
whitelist (incl. Chinese dialect tags) so it stays self-contained; add it to
the hardcoded-CJK allowlist like omnivoice/utils/voice_design.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 05:21:19 +05:30
3656f0a4ef fix(profiles): decouple design-profile save from TTS render (#476) (#488)
* fix(profiles): decouple design-profile save from TTS render (#476)

Saving a design voice profile forced a full TTS model load + inference to
render a deterministic identity sample. On a fresh model-less image (Docker
first-run) that 503'd, so the save failed. A secondary guard also rejected an
all-Auto design (empty instruct) with a 422.

Saving a design profile is now a pure persistence operation:
- The seed-42 identity sample render is attempted opportunistically but is
  non-fatal — if the engine isn't ready the row is persisted with
  ref_audio_path=NULL (sample pending). The row's vd_states + instruct already
  make the voice fully usable (generation.py falls back to instruct-only
  conditioning for design profiles with no ref audio).
- The sample is rendered lazily + cached on the first GET /profiles/{id}/audio
  request; if the engine is still unavailable that path returns a precise
  "model not ready — finish setup / download a model" 503.
- The all-Auto (empty-instruct) design is now saveable (vd_states still
  required).

Adds tests/test_profile_design_save_decouple.py (top-level tests/, asyncio.run
per test) covering: design save with model unavailable creates the row instead
of 503-ing; all-Auto design is saveable; the pending sample materializes on
first /audio request. Updates the unification spec (docs-sync).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(profiles): contain profile-audio paths under VOICES_DIR (CodeQL CWE-22)

The lazy design-sample path was built as `os.path.join(VOICES_DIR,
f"{profile_id}.wav")` / `os.path.join(VOICES_DIR, audio_file)` where profile_id
is the request path param — CodeQL flagged 5 high-severity path-injection alerts
(profiles.py + the taint flowing into archetypes.py's torchaudio save). Add
`_safe_voice_path()` (basename + safe-char sanitise + realpath containment,
mirroring core.config.dub_seg_path) and route both the read and lazy-render
sites through it; a traversal id now 404s instead of escaping VOICES_DIR.
Regression test covers the containment guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(profiles): use CodeQL-recognized path-injection guards (CWE-22)

The previous `_safe_voice_path()` helper was correct (basename + realpath
containment) but CodeQL's taint tracking didn't propagate the barrier through
the function return, so the 5 path-injection alerts persisted. Switch to guards
CodeQL recognizes, inline at each file-op site:
- validate `profile_id` against the generated-id charset (`[A-Za-z0-9_-]{1,64}`)
  with `re.fullmatch` and 404 on mismatch (covers the `f"{profile_id}.wav"`
  render path);
- read only `os.path.join(VOICES_DIR, os.path.basename(name))` so a stored/derived
  filename is always a direct child of VOICES_DIR (covers the read + the taint
  flowing into archetypes.py's torchaudio save).
Drop the helper. Test now asserts a traversal/separator/NUL profile_id 404s at
the guard. Same security property, recognized by CodeQL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(profiles): inline realpath+commonpath containment for CodeQL (CWE-22)

CodeQL didn't recognize the earlier sanitizers — neither the helper (barrier
hidden behind a function return) nor os.path.basename / a cross-function regex
guard cleared the 5 path-injection alerts. Use the canonical, CodeQL-recognized
form INLINE at each file-op site: resolve the path with os.path.realpath (which
collapses any `..`) and confirm os.path.commonpath((base, path)) == base before
the read / the render, returning 404 / raising on escape. Same property the
helper had, now in a shape CodeQL's taint tracking follows. Keeps the profile_id
charset guard as defense-in-depth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(profiles): route design-sample path through shared _voices_path guard (#476)

The inline realpath+commonpath containment in get_profile_audio and
_materialize_design_sample wasn't recognized by CodeQL as a path-injection
sanitizer (5 new high-severity py/path-injection alerts at the file-op sites,
incl. archetypes.py mkdir via the rendered Path). Both now reuse the existing
_voices_path() helper, which applies the os.path.basename() barrier plus
symlink-resolved containment — the same guard the consent endpoint uses and
that CodeQL already accepts. Behavior is unchanged: the DB columns only ever
hold bare {profile_id}.wav filenames, so basename() is a no-op here.

Tests: tests/test_profile_design_save_decouple, test_profile_unification,
test_profile_consent, test_archetype_blank_guard — 25 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:58:17 +05:30
66f2ea7e50 feat(profiles): unified profile model — kind discriminator + stored design params (spec P3) (#376)
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>
2026-06-12 14:29:18 +05:30