diff --git a/README.md b/README.md index 4425a134..1b03a76f 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ | Clone a voice or design your own | Dub videos with timed speech | Local API & MCP for agents | | Dictate with a floating widget | Stories, audiobooks & batch jobs | Optional remote workers | -Start with **VoiceStudio** (default, powered by k2-fsa/OmniVoice), or choose another engine. +Start with **VoiceStudio** (default, powered by k2-fsa/OmniVoice), or choose another engine. [Features & engine catalog](docs/feature-catalog.md). Local workflows run on your hardware. Remote services are optional; usage analytics requires consent. diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index c6018e7f..4a6934f5 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -39,7 +39,7 @@ VoiceStudio/ │ │ └── setup/ first-run wizard, model download │ ├── core/ config, db, job queue, event bus, auth/CSRF, path security, │ │ opt-in analytics, version, diagnostics -│ ├── services/ 85 modules of business logic — TTS, dubbing pipeline, +│ ├── services/ 86 modules of business logic — TTS, dubbing pipeline, │ │ audio DSP, GPU gateway, engine routing, model lifecycle │ ├── engines/ per-engine adapters: indextts, supertonic3, confucius4, │ │ dots_tts, moss_tts_v15, pockettts, audiocpp, diff --git a/docs/feature-catalog.md b/docs/feature-catalog.md new file mode 100644 index 00000000..120d6e7f --- /dev/null +++ b/docs/feature-catalog.md @@ -0,0 +1,52 @@ +# Features and engines + +Engine availability depends on installed models, hardware, and configured providers. + +## Features + +- **Voice Cloning** +- **Voice Design** +- **Video Dubbing** +- **Dictation Widget** +- **Vocal Isolation** +- **Speaker Diarization** +- **Batch Queue** +- **MCP Server** +- **AI Watermark** +- **Local-first** +- **GPU Auto-Detect** +- **Remote Model Downloads** +- **Extensible** + +## Speech generation + +- **VoiceStudio** (default, powered by k2-fsa/OmniVoice) +- omnivoice-subprocess — [Guide](engines/omnivoice-subprocess.md) +- CosyVoice 3 — [Guide](engines/cosyvoice.md) +- KittenTTS +- MLX-Audio +- VoxCPM2 +- MOSS-TTS-Nano +- gpt-sovits +- sherpa-onnx +- **IndexTTS 2.5** ⚡ — [Guide](engines/indextts.md) +- omnivoice-gguf +- supertonic3 +- **MOSS-TTS-v1.5** — [Guide](engines/moss-tts-v15.md) +- **dots.tts** — [Guide](engines/dots-tts.md) +- **Confucius4-TTS** — [Guide](engines/confucius4-tts.md) +- pockettts +- audiocpp — [Guide](engines/audio-cpp.md) + +## Transcription + +- **WhisperX** (default) +- Faster-Whisper +- MLX Whisper +- PyTorch Whisper +- Parakeet TDT +- Parakeet TDT v3 (MLX) +- Moonshine +- FunASR +- **sherpa-onnx** (live dictation) +- **OpenAI-compatible** ⚠️ configured server diff --git a/docs/features.yaml b/docs/features.yaml index dcf36b13..0b0209b7 100644 --- a/docs/features.yaml +++ b/docs/features.yaml @@ -1,3 +1,4 @@ +catalog: docs/feature-catalog.md # Canonical feature inventory — the single source of truth that the daily # docs-drift job (.github/workflows/docs-drift.yml) diffs against README.md, # docs/, and the engine registries via scripts/check-docs-drift.py. diff --git a/electron/tests/live-route-health.mjs b/electron/tests/live-route-health.mjs index 076f23c6..6fe1bdd1 100644 --- a/electron/tests/live-route-health.mjs +++ b/electron/tests/live-route-health.mjs @@ -18,6 +18,7 @@ const routes = [ '/audiobook', '/projects', '/tools', + '/integrations', '/settings/general', '/settings/appearance', '/settings/models', diff --git a/electron/tests/route-layout-smoke.mjs b/electron/tests/route-layout-smoke.mjs index a6950ff6..d0533f75 100644 --- a/electron/tests/route-layout-smoke.mjs +++ b/electron/tests/route-layout-smoke.mjs @@ -15,6 +15,7 @@ const routes = [ '/batch', '/projects', '/tools', + '/integrations', '/settings', '/settings/general', '/settings/appearance', diff --git a/scripts/check-docs-drift.py b/scripts/check-docs-drift.py index 31e08a04..e3b7357a 100644 --- a/scripts/check-docs-drift.py +++ b/scripts/check-docs-drift.py @@ -74,6 +74,17 @@ def _check(root: Path) -> list[str]: inv = yaml.safe_load(inv_path.read_text(encoding="utf-8")) or {} readme = (root / "README.md").read_text(encoding="utf-8") + # Keep the complete inventory in a linked catalog when the README is compact. + catalog = inv.get("catalog") + if catalog: + if f"]({catalog})" not in readme: + drifts.append(f"README.md must link to catalog `{catalog}`") + catalog_path = root / catalog + if catalog_path.is_file(): + readme += "\n" + catalog_path.read_text(encoding="utf-8") + else: + drifts.append(f"catalog `{catalog}` does not exist") + # 1. Features present in README. for name in inv.get("features", []): diff --git a/tests/test_dub_batch_engine_selection.py b/tests/test_dub_batch_engine_selection.py index 12d60a70..981e463c 100644 --- a/tests/test_dub_batch_engine_selection.py +++ b/tests/test_dub_batch_engine_selection.py @@ -71,7 +71,7 @@ def _make_fake_engine(engine_id, *, supports_cloning=True, available=True, def generate(self, text, **kw) -> torch.Tensor: type(self).calls.append((text, kw)) - return torch.zeros(1, 24000) + return torch.full((1, 24000), 0.25) return _FakeEngine diff --git a/tests/test_dub_duration_plan_api.py b/tests/test_dub_duration_plan_api.py index 1b05aede..61586d72 100644 --- a/tests/test_dub_duration_plan_api.py +++ b/tests/test_dub_duration_plan_api.py @@ -302,7 +302,7 @@ def test_generate_records_natural_durations_for_calibration(patched_generate): run(_body( [ {"start": 0.0, "end": 2.0, "text": "1.5:hola"}, - {"start": 3.0, "end": 4.0, "text": "2.0:adios"}, + {"start": 3.0, "end": 5.0, "text": "2.0:adios"}, ], timing_strategy="concise", )) @@ -315,8 +315,8 @@ def test_generate_records_natural_durations_for_calibration(patched_generate): run(_body( [ {"start": 0.0, "end": 2.0, "text": "1.5:hola"}, - {"start": 3.0, "end": 4.0, "text": "2.0:adios"}, - {"start": 4.5, "end": 5.0, "text": "1.0:si"}, + {"start": 3.0, "end": 5.0, "text": "2.0:adios"}, + {"start": 5.0, "end": 6.0, "text": "1.0:si"}, ], timing_strategy="smart_fit", )) @@ -324,12 +324,11 @@ def test_generate_records_natural_durations_for_calibration(patched_generate): assert calibration_from_job(job, "es") is not None -def test_strict_slot_never_records_calibration(patched_generate): - """strict_slot pads/trims the audio to the slot — recording those - durations would poison the observed chars-per-second.""" +def test_strict_slot_records_duration_before_assembly_fitting(patched_generate): + """Calibration uses the complete generated waveform, not the fitted slot.""" run, job = patched_generate run(_body( [{"start": 0.0, "end": 2.0, "text": "1.5:hola"}], timing_strategy="strict_slot", )) - assert "seg_natural_durs_by_lang" not in job + assert job["seg_natural_durs_by_lang"]["es"]["0"]["dur"] == 1.5 diff --git a/tests/test_smart_fit_generate.py b/tests/test_smart_fit_generate.py index 5663e273..98c8f6ad 100644 --- a/tests/test_smart_fit_generate.py +++ b/tests/test_smart_fit_generate.py @@ -251,7 +251,7 @@ def test_zero_and_negative_duration_segments_dont_crash(patched_generate): {"start": 0.0, "end": 1.0, "text": "0.5:hola"}, # normal → seg_es_0.wav {"start": 1.0, "end": 1.0, "text": ""}, # zero duration {"start": 2.0, "end": 2.8, "text": " "}, # positive silence → mix temp - {"start": 4.0, "end": 3.5, "text": "boom"}, # negative duration + {"start": 4.0, "end": 3.5, "text": ""}, # negative duration ] parsed = run(_body(segs, timing_strategy="concise")) @@ -349,23 +349,13 @@ def test_smart_fit_fit_options_override(patched_generate): run, model, job, job_dir = patched_generate job["duration"] = 1.0 segs = [{"start": 0.0, "end": 1.0, "text": "2.0:texto"}] - done = _done(run(_body( - segs, - timing_strategy="smart_fit", + parsed = run(_body( + segs, timing_strategy="smart_fit", fit_options={"allow_video_retime": False}, - ))) - fs = done["fit_status"][0] - # Audio-only mode: 1.8× hard cap, residual trimmed; timeline unchanged. - assert fs["status"] == "overflow_trimmed" - assert fs["audio_rate"] == pytest.approx(1.8, abs=1e-3) - assert "video_ratio" not in fs - assert fs["overflow_s"] == pytest.approx(2.0 / 1.8 - 1.0, abs=1e-2) - n, sr = _track_samples(job_dir) - assert n == int(1.0 * sr) - assert job["fit_plans"]["es"]["params"]["allow_video_retime"] is False - # Different knobs → different fit fingerprint than the defaults. - from services.incremental import fit_fingerprint - assert job["fit_plans"]["es"]["fit_fp"] != fit_fingerprint({}) + )) + assert any(e.get("error_code") == "dub_timing_overflow" for e in parsed) + assert not any(e.get("type") == "done" for e in parsed) + assert not (job_dir / "dubbed_es.wav").exists() # ── Strategy-transition guard + fit-only re-mix ──────────────────────── @@ -380,10 +370,12 @@ def test_strict_slot_to_smart_fit_forces_one_full_regen(patched_generate): # strict_slot run → slot-squeezed WAVs on disk. run(_body(segs, timing_strategy="strict_slot")) - assert job["seg_wav_kind"] == "slotted" + assert job["seg_wav_kind"] == "natural" + job["seg_wav_kind"] = "slotted" + job["seg_wav_kind_by_lang"]["es"] = "slotted" import torchaudio wav, _ = torchaudio.load(str(job_dir / "seg_es_0.wav")) - assert wav.shape[-1] == int(1.0 * SR) # squeezed to the 1s slot + assert wav.shape[-1] == int(1.5 * SR) # current caches retain full speech # smart_fit "re-mix only" request — but the disk WAVs are slotted, so # the guard must force a full re-TTS instead of double-compressing. @@ -414,18 +406,20 @@ def test_strict_slot_to_natural_mode_forces_one_full_regen( ): """Every natural-rate timing mode needs the slotted-cache guard. - A strict-slot render destructively trims its durable segment WAVs. A later + A legacy strict-slot render destructively trimmed its durable segment WAVs. A later natural-rate re-mix cannot recover the missing tails from those files, and must synthesize once before it may label the cache ``natural``. """ run, model, job, job_dir = patched_generate segs = [ - {"start": 0.0, "end": 1.0, "text": "1.5:uno"}, - {"start": 2.0, "end": 3.0, "text": "1.5:dos"}, + {"start": 0.0, "end": 2.0, "text": "1.5:uno"}, + {"start": 2.0, "end": 4.0, "text": "1.5:dos"}, ] run(_body(segs, timing_strategy="strict_slot")) - assert job["seg_wav_kind"] == "slotted" + assert job["seg_wav_kind"] == "natural" + job["seg_wav_kind"] = "slotted" + job["seg_wav_kind_by_lang"]["es"] = "slotted" model.calls.clear() run(_body(segs, timing_strategy=natural_strategy, regen_only=[])) @@ -527,7 +521,7 @@ def test_foreign_rate_natural_cache_keeps_resample_scratch_fallback( assert not (job_dir / "seg_mix_0.wav").exists() -def test_natural_cache_decode_failure_degrades_to_silence( +def test_natural_cache_decode_failure_blocks_export( patched_generate, monkeypatch, ): """A valid WAV header must not move a corrupt payload outside recovery.""" @@ -551,8 +545,8 @@ def test_natural_cache_decode_failure_degrades_to_silence( parsed = run(_body(segs, timing_strategy="concise", regen_only=[])) assert model.calls == [] - assert any(event.get("type") == "warning" for event in parsed) - _done(parsed) + assert any(event.get("error_code") == "dub_speech_missing" for event in parsed) + assert not any(event.get("type") == "done" for event in parsed) def test_rvc_keeps_natural_rate_audio_outside_strict_slot( @@ -567,7 +561,7 @@ def test_rvc_keeps_natural_rate_audio_outside_strict_slot( monkeypatch.setattr(dg, "apply_rvc", lambda _path: None) run(_body( - [{"start": 0.0, "end": 1.0, "text": "1.5:uno"}], + [{"start": 0.0, "end": 2.0, "text": "1.5:uno"}], timing_strategy="concise", )) diff --git a/tests/test_task_stream_failure.py b/tests/test_task_stream_failure.py index 64158754..36c2afa5 100644 --- a/tests/test_task_stream_failure.py +++ b/tests/test_task_stream_failure.py @@ -7,8 +7,9 @@ from core.tasks import TaskManager, _stream_failure 'event: error\ndata: {"detail":"segment failed"}\n\n', ]) def test_error_stream_is_terminal(event, monkeypatch): - from core import job_store - from core import run_sentinel + # Patch the modules actually held by TaskManager despite suite reloads. + job_store = TaskManager.worker.__globals__["job_store"] + run_sentinel = TaskManager.worker.__globals__["run_sentinel"] states=[] for name in ['create','mark_running','append_event']: monkeypatch.setattr(job_store,name,lambda *a,**kw: None)