6 Commits
Author SHA1 Message Date
Palash DebnathandClaude Opus 5 8da377a1cb fix(audiobook): say why a chapter failed, and don't hang when an engine stops (#1321) (#1325)
The report arrived as a raw traceback pasted out of a log file — because that is the only place the reason existed. A failed chapter was a red row and the word "failed"; the SSE event carried the literal string "chapter failed to render", the symptom the user could already see.

- Both the per-chapter and the terminal all-failed events are now built with the shared `core.failure` builder: sanitized text, a guaranteed non-empty reason, error class, docs deeplink and hint. `error` keeps mirroring `reason`, so older frontends and the Stories exporter are unaffected. The chapter list shows the reason inline, full text in the row tooltip.
- **A silent infinite hang on the same path.** asyncio refuses to put `StopIteration` into a Future — `_copy_future_state` raises TypeError inside the event loop's own callback, so the `run_in_executor` future is never completed and the caller waits forever, with no error, event or timeout. Reachable from ordinary input: VoxCPM's `next_and_close` is a bare `next(gen)`, so a generator ending without yielding raises it straight into a GPU-pool worker. Fixed at the pool boundary (`_ResilientGpuPool.submit` plus a matching `_cpu_pool` subclass) as `WorkerStopIteration(RuntimeError)`, keeping the original as `__cause__`.
- An all-failed render now marks the job failed. It previously returned without touching job history, so the row stayed `running` and the next startup's orphan sweep read a hopeless render as interrupted and offered it as resumable (Greptile P1).

Tests: 6 pool-guard tests (two hang without the fix, bounded so a hang fails rather than stalls CI), longform e2e extended for both event shapes, the empty-`str(exc)` floor, and both sides of the job-status branch, plus the chapter-list component.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 04:22:50 -07:00
debpalashandClaude Opus 4.8 9d027b32c9 feat(audiobook): cast/voice mapping + markup toolbar + live stats + validation (#1217)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 01:54:13 +05:30
debpalashandClaude Opus 4.8 a12a7e7ee1 feat(audiobook): expressive maturity — overrides, emotion, cache opt-out, discoverability (#1210)
Audiobook renders were locked to the model's most deterministic preset (32
steps / 2.0 guidance / model-default temps) with no way to change it, which is
why books sounded flatter than the same voice on the Voice page. Open that up
without changing any default byte-for-byte.

- Production Overrides in the Audiobook tab: position_temperature,
  class_temperature, num_step, guidance_scale, postprocess_output (+ seed),
  reusing the Voice page's panel. Unset reproduces today exactly.
- IndexTTS2 graded emotion (emo_vector / emo_text / emo_alpha) reaches the
  longform path via a typed engine-options object; engines that don't
  understand an option ignore it (no crash across the ~14 backends).
- Cache opt-out ("vary repeated lines") so identical lines can get distinct
  takes; default off keeps the content-addressed replay.
- Markup reference now lists the reaction tags that already work in audiobooks;
  docs/expressive-speech.md corrected so no recipe it names is unreachable.
- Fix AudiobookGenerateBody dropping `language`, so audiobook language
  selection actually reaches the backend.

Every new param is folded into BOTH cache layers (chapter + segment) and the
per-chapter preview, so changing a knob re-renders instead of replaying stale
audio — and an all-default request keeps its old cache key, so existing books
don't re-render. Regression tests: tests/test_audiobook_expressive.py (backward
compat, cache-signature loop, preview/render parity, engine-ignores-unknown,
cache opt-out, emotion reaches engine) + audiobookOverrides.test.jsx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:45:04 +05:30
8ed76b40a6 fix(lang): propagate profile/request language into generation + longform (#533/#505/#502) (#565)
Non-English voices drifted to English/wrong-language because the request's or
profile's language wasn't reaching the model:

- #533: generate_speech() read instruct/ref_text/seed from a resolved profile row
  but never row['language'] (and collapsed Auto→None), so a German archetype
  previewed in German yet generated in English on the user's own call (and via
  Docker/API). Fall back to the profile's stored language when the request didn't
  pin one; an explicit non-Auto request language still wins. (Frontend already
  sets the dropdown on profile-select; this is the authoritative backend fix.)
- #505 (B2): the audiobook/longform synth hardcoded language=None, so the engine
  re-autodetected per chunk and a non-English clone flipped language mid-render.
  Add _resolve_default_language (request → profile → autodetect) and thread the
  resolved language through _build_synth/_prepare_synth/_render_longform_sse, the
  three longform request models, the preview path, and the resume manifest.
  Genuine Auto/unset behavior is unchanged.
- #502 (partial): the duration estimator weights combining marks (U+0300–036F)
  at 0.0, so NFD/decomposed text under-allocated frames → rushed audio. NFC-
  normalize text at the estimator entry — fixes the whole diacritic-script class
  (no-op for precomposed text). (The residual "distorted" core still needs the
  reporter's sample; tracked separately.)

Tests (fail-before/pass-after): profile language reaches the engine (German→de;
explicit/Auto override semantics); longform synth gets the resolved language
(→ja), not None; NFD vs NFC duration parity (Korean Hangul diverges ~3x pre-fix).
Full suite: tests/ 1740 passed, backend/tests/ 114 passed.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:45:58 +05:30
Palash DebnathandClaude Opus 4.8 d20c24e1e1 feat(longform): two-pass loudnorm measure orchestrator + wiring (#28 slice 2) (#455)
* feat(longform): two-pass loudnorm measure orchestrator + wiring (#28 slice 2)

Completes accurate ACX/podcast mastering end-to-end (builds on the pure builders
from #28 slice 1).

- `services/loudness.py` — `measure_loudness(ffmpeg, concat, preset, *, job_id)`:
  runs ffmpeg's measure pass, parses the loudnorm JSON → MeasuredLoudness.
  **Never raises** — skip / non-zero rc / rc None / asyncio.TimeoutError / spawn
  OSError / empty or unparseable stderr / silent program all WARN + return None
  → single-pass fallback (a slow/broken measure degrades the master, never
  aborts the render). Logs rc + a static message only, never the raw stderr
  (path-safe / local-first). UTF-8 decode with replacement (Windows-cp safe).
- `_render_longform_sse` (audiobook.py): between the concat write and the mux,
  when `loudness` is a known preset (acx/podcast; same `.lower()`/no-strip gate
  as the builders) → emit a `mastering` event, measure, and pass `measured` into
  `build_render_cmd` (two-pass apply; `None` → single-pass). `done` gains a
  `loudness` block {preset, target_i, target_tp, two_pass, measured_i} ONLY for
  a requested preset — off/None paths keep the byte-identical legacy `done`
  shape. Both front doors (/audiobook + /longform/render) get it via the shared
  generator. Chapter cache key is deliberately untouched (loudness-agnostic →
  acx/off reuse the same cached WAVs; no re-render, no cache-layout break).

Tests: `test_loudness.py` (14 — happy fixture, skip-without-spawn for off/
unknown/whitespace/None, non-zero/None rc, timeout-not-propagated, OSError,
empty/unparseable stderr, non-UTF-8 stderr, job_id+argv forwarding) + 2 e2e
cases (mastering event + done.loudness present for acx; absent for off). Orch
tests run locally (stubbed run_ffmpeg, no torch); e2e on CI.

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

* fix(loudness): lazy-import run_ffmpeg so the measure stub survives sys.modules purges

test_loudness monkeypatched services.loudness.run_ffmpeg, but the route-shape
fresh_app fixture purges services.* from sys.modules, so under the full-suite
ordering the patch missed the re-imported module → real ffmpeg ran → 3 failures.
Lazy-import run_ffmpeg inside measure_loudness and patch it at its source
(services.ffmpeg_utils.run_ffmpeg) so the stub is always picked up at call time.
Verified by running the purging suite + test_loudness together (31 pass).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:03:28 +05:30
Palash DebnathandClaude Opus 4.8 63a4b897ee test(longform): real-ffmpeg + stub-TTS e2e for the chapterized renderer (#34) (#441)
#34 runtime-verify Layer 1 — the cheap regression net over the audiobook /
stories convergence. Drives the REAL `_render_longform_sse` generator + REAL
ffmpeg with a stub CPU-tone synth (no GPU/model), and ffprobes the muxed output.

Covers happy m4b (full SSE sequence + 2 tagged chapters), mp3 container,
per-chapter partial failure (chapter_error isolates ch.0, surviving chapter
still muxes), total failure (error + NO file), empty plan, and the no-ffmpeg
branch. Gated on ffmpeg present (skip otherwise; runs in CI).

Like the other endpoint tests it imports the app+torch stack, so it's validated
on CI (local pytest segfaults on the pre-existing torch/Triton import).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:07:04 +05:30