From 8d609292366a3c4c9ffaafa3d46477352723cc4d Mon Sep 17 00:00:00 2001 From: psiberfunk Date: Mon, 7 Sep 2026 21:43:58 -0400 Subject: [PATCH 01/32] fix(engines): hide redundant OmniVoice MPS sidecar --- CHANGELOG.md | 1 + README.md | 2 +- backend/api/routers/engines.py | 10 ++- backend/services/tts_backend.py | 17 +++++- backend/tests/test_omnivoice_subprocess.py | 61 +++++++++++++++++++ docs/engines/README.md | 2 +- docs/engines/omnivoice-subprocess.md | 10 +-- tests/backend/api/test_engines_route_shape.py | 40 +++++++++++- 8 files changed, 132 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21bd003e..ee468a43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ the frozen-backend fallback mirror it for their toolchains. ### Fixed +- Apple Silicon now shows one canonical OmniVoice choice in the engine picker while retaining its automatic crash-isolated sidecar runtime (#1913) - Install documentation help now prints correctly on Windows consoles using legacy encodings (#1815) — thanks @dajiaohuang! - Saved transcriptions with missing or invalid timestamps now remain readable (#1799) — thanks @yunaremaia and @tvbht! - Copying a saved transcription now uses the shared clipboard helper and reports failed copies accurately (#1803) — thanks @tvbht! diff --git a/README.md b/README.md index 78021085..db845ba3 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,7 @@ Engine support is capability-specific. Check cloning, language, platform, memory | [**Sherpa-ONNX**](docs/engines/sherpa-onnx.md) | 20+ | No | No | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 | | [**IndexTTS 2.5** ⚡](docs/engines/indextts.md) | ZH · EN · JA · ES · AR | Yes | No | CUDA/CPU | CPU | CUDA/CPU | Bilibili model license¹ | | [**OmniVoice GGUF** ⚡](docs/engines/omnivoice-gguf.md) | 600+ | Yes | Yes | CUDA/CPU | MPS/CPU | CUDA/CPU | [AGPL-3.0](LICENSE) app · [review the derivative model terms](https://huggingface.co/Serveurperso/OmniVoice-GGUF#license)³ | -| [**OmniVoice (subprocess)** ⚡](docs/engines/omnivoice-subprocess.md) | 600+ | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0 code, CC-BY-NC weights](https://huggingface.co/k2-fsa/OmniVoice#license)³ | +| [**OmniVoice (subprocess; opt-in off MPS)** ⚡](docs/engines/omnivoice-subprocess.md) | 600+ | Yes | Yes | CUDA/CPU | MPS via default OmniVoice | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0 code, CC-BY-NC weights](https://huggingface.co/k2-fsa/OmniVoice#license)³ | | [**PocketTTS** ⚡](docs/engines/pockettts.md) | EN · FR · DE · PT · IT · ES | Yes | No | CPU | CPU | CPU | CC-BY-4.0, gated² | | [**Supertonic 3** ⚡](docs/engines/supertonic3.md) | 31 | No | No | CPU | CPU | CPU | OpenRAIL-M | | [**MOSS-TTS-v1.5** ⚡](docs/engines/moss-tts-v15.md) | 31 | Yes | No | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 | diff --git a/backend/api/routers/engines.py b/backend/api/routers/engines.py index 3ee3c9fb..7d511962 100644 --- a/backend/api/routers/engines.py +++ b/backend/api/routers/engines.py @@ -589,7 +589,15 @@ def select_engine(req: SelectEngineRequest): if not family: raise HTTPException(400, f"Unknown family: {req.family}. Expected one of tts/asr/llm.") module, pref_key = family - available = {b["id"]: b for b in module.list_backends()} + # MPS intentionally hides the redundant explicit OmniVoice sidecar from + # the picker, but existing scripts and saved preferences may still submit + # that supported compatibility id directly. + rows = ( + module.list_backends(include_hidden=True) + if req.family == "tts" + else module.list_backends() + ) + available = {b["id"]: b for b in rows} if req.backend_id not in available: raise HTTPException(400, f"Unknown {req.family} backend: {req.backend_id!r}") entry = available[req.backend_id] diff --git a/backend/services/tts_backend.py b/backend/services/tts_backend.py index 7f0cecd7..5dfcbbe5 100644 --- a/backend/services/tts_backend.py +++ b/backend/services/tts_backend.py @@ -2391,8 +2391,15 @@ def _sidecar_installable_ids() -> frozenset[str]: return frozenset() -def list_backends() -> list[dict]: - """Enumerate every registered backend with its availability state. +def list_backends(*, include_hidden: bool = False) -> list[dict]: + """Enumerate the engine catalogue with each backend's availability state. + + On MPS, the canonical ``omnivoice`` id already resolves to the killable + OmniVoice sidecar. The explicit ``omnivoice-subprocess`` compatibility id + is therefore omitted from the normal catalogue so the picker does not + advertise two choices with the same runtime behavior. Internal callers + that must validate or preserve a stored compatibility id can pass + ``include_hidden=True``. Per-entry shape (ENGINE-05 + ENGINE-06): @@ -2445,6 +2452,12 @@ def list_backends() -> list[dict]: out: list[dict] = [] for bid, cls in _REGISTRY.items(): + if ( + not include_hidden + and caps.family == "mps" + and bid == "omnivoice-subprocess" + ): + continue cls = _effective_backend_class(bid, cls, caps.family) try: ok, msg = cls.is_available() diff --git a/backend/tests/test_omnivoice_subprocess.py b/backend/tests/test_omnivoice_subprocess.py index 3461ccc4..287cde92 100644 --- a/backend/tests/test_omnivoice_subprocess.py +++ b/backend/tests/test_omnivoice_subprocess.py @@ -160,6 +160,67 @@ def test_engine_catalogue_reports_effective_mps_isolation(monkeypatch): assert row["isolation_mode"] == "subprocess" +def test_mps_catalogue_hides_redundant_explicit_omnivoice_sidecar(monkeypatch): + """The picker advertises the canonical id, while legacy callers retain both.""" + from core.device_caps import HostCaps + from services import tts_backend + + monkeypatch.setattr( + tts_backend, + "_REGISTRY", + { + "omnivoice": OmniVoiceBackend, + "omnivoice-subprocess": OmniVoiceSubprocessBackend, + }, + ) + monkeypatch.setattr( + "core.device_caps.detect_host_caps", + lambda: HostCaps(family="mps", available_families=("mps", "cpu")), + ) + monkeypatch.setattr( + OmniVoiceSubprocessBackend, + "is_available", + classmethod(lambda cls: (True, "ready")), + ) + + picker_ids = {item["id"] for item in list_backends()} + assert picker_ids == {"omnivoice"} + assert get_backend_class("omnivoice") is OmniVoiceMPSSubprocessBackend + + all_ids = {item["id"] for item in list_backends(include_hidden=True)} + assert all_ids == {"omnivoice", "omnivoice-subprocess"} + assert get_backend_class("omnivoice-subprocess") is OmniVoiceSubprocessBackend + + +@pytest.mark.parametrize("family", ("cuda", "cpu")) +def test_non_mps_catalogue_keeps_explicit_omnivoice_sidecar(monkeypatch, family): + from core.device_caps import HostCaps + from services import tts_backend + + monkeypatch.setattr( + tts_backend, + "_REGISTRY", + { + "omnivoice": OmniVoiceBackend, + "omnivoice-subprocess": OmniVoiceSubprocessBackend, + }, + ) + monkeypatch.setattr( + "core.device_caps.detect_host_caps", + lambda: HostCaps(family=family, available_families=(family, "cpu")), + ) + monkeypatch.setattr( + OmniVoiceSubprocessBackend, + "is_available", + classmethod(lambda cls: (True, "ready")), + ) + + assert {item["id"] for item in list_backends()} == { + "omnivoice", + "omnivoice-subprocess", + } + + def test_mps_startup_does_not_preload_native_model(monkeypatch): from core.device_caps import HostCaps from services import model_manager diff --git a/docs/engines/README.md b/docs/engines/README.md index 57175b2d..9863f36e 100644 --- a/docs/engines/README.md +++ b/docs/engines/README.md @@ -36,7 +36,7 @@ approval), [Windows](../install/windows.md), [Linux](../install/linux.md), | Supertonic-3 | [supertonic3](supertonic3.md) | CPU | — (7 preset voices) | `uv sync --extra supertonic` + license | | MOSS-TTS-v1.5 (8B) | [moss-tts-v15](moss-tts-v15.md) | CUDA · CPU | ✅ | clone + env var | | dots.tts (2B) | [dots-tts](dots-tts.md) | CUDA · CPU (not Windows) | ✅ | clone + env var | -| OmniVoice (subprocess) | [omnivoice-subprocess](omnivoice-subprocess.md) | CUDA · MPS · CPU | ✅ | opt-in pick, no install | +| OmniVoice (subprocess) | [omnivoice-subprocess](omnivoice-subprocess.md) | CUDA · MPS · CPU | ✅ | opt-in pick off MPS; automatic via default OmniVoice on MPS | | PocketTTS (Kyutai) | [pockettts](pockettts.md) | CPU (not Intel Mac) | ✅ | `uv sync --extra pockettts` + license | | Confucius4-TTS | [confucius4-tts](confucius4-tts.md) | CUDA · CPU | ✅ | clone + env var | diff --git a/docs/engines/omnivoice-subprocess.md b/docs/engines/omnivoice-subprocess.md index 2356ef88..e08b28a3 100644 --- a/docs/engines/omnivoice-subprocess.md +++ b/docs/engines/omnivoice-subprocess.md @@ -32,12 +32,14 @@ lower call overhead. ## Selecting it -- **Model Catalogue → Engines**, or +- **Model Catalogue → Engines** on CUDA, ROCm, or CPU, or - `OMNIVOICE_TTS_BACKEND=omnivoice-subprocess` -The explicit engine is opt-in on CUDA, ROCm, and CPU. Apple Silicon gets the -same isolation automatically while keeping the default `omnivoice` id in APIs, -Settings, and saved projects. +The explicit engine is opt-in on CUDA, ROCm, and CPU. On Apple Silicon it is +not listed separately: the canonical `omnivoice` choice automatically uses the +same isolation while keeping that default id in APIs, Settings, and saved +projects. Existing explicit `omnivoice-subprocess` configuration remains +accepted for compatibility. ## Platform support diff --git a/tests/backend/api/test_engines_route_shape.py b/tests/backend/api/test_engines_route_shape.py index 2bd74fb9..aa7d12d9 100644 --- a/tests/backend/api/test_engines_route_shape.py +++ b/tests/backend/api/test_engines_route_shape.py @@ -228,7 +228,8 @@ def test_indextts2_entry_has_subprocess_isolation_mode(fresh_app): assert by_id["indextts2"]["isolation_mode"] == "subprocess" -def test_omnivoice_entry_has_in_process_isolation_mode(fresh_app): +def test_omnivoice_entry_has_in_process_isolation_mode(fresh_app, monkeypatch): + _force_cpu_host(monkeypatch) client = _client(fresh_app) r = client.get("/engines") assert r.status_code == 200 @@ -237,8 +238,43 @@ def test_omnivoice_entry_has_in_process_isolation_mode(fresh_app): assert by_id["omnivoice"]["isolation_mode"] == "in-process" -def test_gpu_compat_omnivoice_variants_include_rocm(fresh_app): +def test_mps_picker_hides_redundant_omnivoice_sidecar_but_api_accepts_it( + fresh_app, monkeypatch +): + """Keep stored/direct compatibility ids valid without advertising a duplicate.""" + from engines.omnivoice_subprocess import OmniVoiceSubprocessBackend + from services import tts_backend + + _force_host(monkeypatch, "mps") + monkeypatch.delenv("OMNIVOICE_TTS_BACKEND", raising=False) + monkeypatch.setattr( + OmniVoiceSubprocessBackend, + "is_available", + classmethod(lambda cls: (True, "ready")), + ) + + client = _client(fresh_app) + rows = {row["id"] for row in client.get("/engines/tts").json()["backends"]} + assert "omnivoice" in rows + assert "omnivoice-subprocess" not in rows + + canonical = next( + row for row in client.get("/engines/tts").json()["backends"] if row["id"] == "omnivoice" + ) + assert canonical["isolation_mode"] == "subprocess" + + response = client.post( + "/engines/select", + json={"family": "tts", "backend_id": "omnivoice-subprocess"}, + ) + assert response.status_code == 200, response.text + assert response.json()["active"] == "omnivoice-subprocess" + assert tts_backend.get_backend_class("omnivoice-subprocess") is OmniVoiceSubprocessBackend + + +def test_gpu_compat_omnivoice_variants_include_rocm(fresh_app, monkeypatch): """Both OmniVoice paths use torch's HIP-backed CUDA device on ROCm.""" + _force_host(monkeypatch, "rocm") client = _client(fresh_app) r = client.get("/engines") by_id = {b["id"]: b for b in r.json()["tts"]["backends"]} From a935c08d6a0f26ab11e2b344e2ecf63bc2c7bc75 Mon Sep 17 00:00:00 2001 From: psiberfunk Date: Mon, 7 Sep 2026 21:26:27 -0400 Subject: [PATCH 02/32] fix(audiobook): scale chapter timeouts --- CHANGELOG.md | 2 ++ backend/api/routers/audiobook.py | 18 ++++++++++- tests/test_audiobook_remote.py | 51 +++++++++++++++++++++++++++++++- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21bd003e..e76ab441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,8 @@ the frozen-backend fallback mirror it for their toolchains. ### Fixed +- Long audiobook chapters now use the same device- and text-length-aware synthesis timeout as other TTS routes (#1910) — thanks @psiberfunk! + - Install documentation help now prints correctly on Windows consoles using legacy encodings (#1815) — thanks @dajiaohuang! - Saved transcriptions with missing or invalid timestamps now remain readable (#1799) — thanks @yunaremaia and @tvbht! - Copying a saved transcription now uses the shared clipboard helper and reports failed copies accurately (#1803) — thanks @tvbht! diff --git a/backend/api/routers/audiobook.py b/backend/api/routers/audiobook.py index d7fb968f..752ad1cb 100644 --- a/backend/api/routers/audiobook.py +++ b/backend/api/routers/audiobook.py @@ -718,6 +718,10 @@ def _remote_chapter_call(chapter, *, engine_id, default_voice, voice_map, "expressive": opts.to_manifest(), "watermark": bool(watermark_enabled()), } signature = hashlib.sha256(json.dumps(params, sort_keys=True, default=str).encode()).hexdigest() + # The worker synthesizes from ``spans``, but the gateway and scheduler read + # top-level ``text`` to scale the remote execution deadline. Add this after + # the signature so existing content-addressed remote cache keys still hit. + params["text"] = "\n".join(row["text"] for row in rows) wav_path = os.path.join(cache_dir, f"remote-{signature}.wav") def decode(result): @@ -739,7 +743,7 @@ async def _run_chapter(chapter, *, operation="audiobook", decision, job, default voice_map, lexicon, cache_dir): """Run one chapter through the gateway; local preparation stays lazy.""" from services import gpu_gateway - from services.tts_backend import active_backend_id + from services.tts_backend import active_backend_id, get_backend_class engine_id = active_backend_id() remote, remote_cache = _remote_chapter_call( @@ -753,15 +757,27 @@ async def _run_chapter(chapter, *, operation="audiobook", decision, job, default return remote_cache, float(info.duration), True, None async def prepare_local(): + from services.model_manager import generate_timeout_s + synth, sr, resolve, local_engine = await _prepare_synth( default_voice, language=language, opts=opts, voice_map=voice_map ) + try: + timeout_engine = get_backend_class(local_engine) + except ValueError: + # Tests and third-party integrations may inject a synth under a + # non-catalogue id. Keep the canonical host/text policy available; + # registered production engines still add their routing metadata. + timeout_engine = None return gpu_gateway.LocalCall( fn=lambda: _render_chapter_cached( chapter, synth, sr, local_engine, resolve, cache_dir, lexicon, language, opts, voice_map, ), what="Audiobook chapter", + timeout=generate_timeout_s( + remote.params["text"], engine=timeout_engine + ), ) return await gpu_gateway.run( diff --git a/tests/test_audiobook_remote.py b/tests/test_audiobook_remote.py index 2c72506b..abef40b6 100644 --- a/tests/test_audiobook_remote.py +++ b/tests/test_audiobook_remote.py @@ -1,5 +1,4 @@ import asyncio -import os def test_remote_chapter_does_not_prepare_local_model(tmp_path, monkeypatch): @@ -20,6 +19,7 @@ def test_remote_chapter_does_not_prepare_local_model(tmp_path, monkeypatch): async def fake_run(op, *, local, remote, decision, job): assert op == remote.operation == "audiobook" assert local.prepare is not None + assert remote.params["text"] == "hello" out = tmp_path / "remote.wav" out.write_bytes(b"wav") return str(out), 1.0, False, None @@ -34,6 +34,55 @@ def test_remote_chapter_does_not_prepare_local_model(tmp_path, monkeypatch): assert result[0].endswith("remote.wav") +def test_local_chapter_uses_canonical_text_scaled_timeout(tmp_path, monkeypatch): + from api.routers import audiobook + from services import gpu_gateway, model_manager + from services.audiobook import Chapter, ExpressiveOptions, Span + from worker.routing import Decision + + class Backend: + gpu_compat = ("mps", "cpu") + + chapter = Chapter("One", [Span(None, "a" * 900), Span(None, "b" * 901)]) + expected_text = f"{'a' * 900}\n{'b' * 901}" + calls = [] + + monkeypatch.setattr(audiobook, "_resolve_voice", lambda _id: { + "ref_audio": None, "ref_text": None, "instruct": None, "seed": None, + }) + monkeypatch.setattr(audiobook, "_voice_profile_exists", lambda _id: False) + monkeypatch.setattr("services.tts_backend.active_backend_id", lambda: "test") + monkeypatch.setattr("services.tts_backend.get_backend_class", lambda _id: Backend) + + async def fake_prepare(*_args, **_kwargs): + return lambda *_args, **_kwargs: None, 24_000, lambda _id: {}, "test" + + def fake_timeout(text, *, engine=None, **_kwargs): + calls.append((text, engine)) + return 315.05 + + async def fake_run(op, *, local, remote, decision, job): + prepared = await local.prepare() + assert op == "audiobook" + assert prepared.timeout == 315.05 + assert remote.params["text"] == expected_text + return "local.wav", 1.0, False, None + + monkeypatch.setattr(audiobook, "_prepare_synth", fake_prepare) + monkeypatch.setattr(model_manager, "generate_timeout_s", fake_timeout) + monkeypatch.setattr(gpu_gateway, "run", fake_run) + + result = asyncio.run(audiobook._run_chapter( + chapter, + decision=Decision(False, "local"), job=gpu_gateway.JobRun("audiobook"), + default_voice=None, language=None, opts=ExpressiveOptions(), voice_map=None, + lexicon=None, cache_dir=str(tmp_path), + )) + + assert result[0] == "local.wav" + assert calls == [(expected_text, Backend)] + + def test_audiobook_worker_marks_and_encodes_chapter(monkeypatch): import numpy as np from worker.executor import TaskExecutor From fb98bbfaf601b951b28791a738ff996afe19eba7 Mon Sep 17 00:00:00 2001 From: psiberfunk Date: Mon, 7 Sep 2026 21:55:40 -0400 Subject: [PATCH 03/32] fix(audiobook): surface interrupted renders --- CHANGELOG.md | 1 + docs/expressive-speech.md | 4 + frontend/src/api/audiobook.ts | 27 ++++ frontend/src/components/Header.jsx | 6 +- .../audiobook/AudiobookRecovery.jsx | 98 ++++++++++++ frontend/src/i18n/locales/ar.json | 10 +- frontend/src/i18n/locales/de.json | 10 +- frontend/src/i18n/locales/en.json | 10 +- frontend/src/i18n/locales/es.json | 10 +- frontend/src/i18n/locales/fr.json | 10 +- frontend/src/i18n/locales/hi.json | 10 +- frontend/src/i18n/locales/id.json | 10 +- frontend/src/i18n/locales/it.json | 10 +- frontend/src/i18n/locales/ja.json | 10 +- frontend/src/i18n/locales/ko.json | 10 +- frontend/src/i18n/locales/nl.json | 10 +- frontend/src/i18n/locales/pl.json | 10 +- frontend/src/i18n/locales/pt.json | 10 +- frontend/src/i18n/locales/ru.json | 10 +- frontend/src/i18n/locales/sv.json | 10 +- frontend/src/i18n/locales/th.json | 10 +- frontend/src/i18n/locales/tr.json | 10 +- frontend/src/i18n/locales/uk.json | 10 +- frontend/src/i18n/locales/vi.json | 10 +- frontend/src/i18n/locales/zh-CN.json | 10 +- frontend/src/i18n/locales/zh-TW.json | 10 +- frontend/src/pages/AudiobookTab.jsx | 136 ++++++++++------ frontend/src/test/AudiobookRecovery.test.jsx | 146 ++++++++++++++++++ .../src/test/AudiobookStopCancel.test.jsx | 2 + frontend/src/test/AudiobookTabLayout.test.jsx | 2 + frontend/src/test/HeaderWaveBars.test.jsx | 30 ++++ 31 files changed, 593 insertions(+), 69 deletions(-) create mode 100644 frontend/src/components/audiobook/AudiobookRecovery.jsx create mode 100644 frontend/src/test/AudiobookRecovery.test.jsx create mode 100644 frontend/src/test/HeaderWaveBars.test.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 21bd003e..215ed2f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ the frozen-backend fallback mirror it for their toolchains. ### Fixed +- Interrupted audiobook renders can resume cached chapters after tab navigation, and their chapter cache is available from the recovery card (#1911) — thanks @psiberfunk! - Install documentation help now prints correctly on Windows consoles using legacy encodings (#1815) — thanks @dajiaohuang! - Saved transcriptions with missing or invalid timestamps now remain readable (#1799) — thanks @yunaremaia and @tvbht! - Copying a saved transcription now uses the shared clipboard helper and reports failed copies accurately (#1803) — thanks @tvbht! diff --git a/docs/expressive-speech.md b/docs/expressive-speech.md index fd690522..a9d839db 100644 --- a/docs/expressive-speech.md +++ b/docs/expressive-speech.md @@ -18,6 +18,10 @@ ignores. | Emotion ("excited", "sad", graded intensity) | IndexTTS2's emotion controls — Audiobook tab's Production Overrides, or the `/ws/tts` API — or CosyVoice 3 instruct | Opt-in engines only | | The same take again | Pin the seed / lock the profile | Default engine | +## Recovering an interrupted audiobook + +Switching away from the Audiobook tab explicitly interrupts synthesis at a chapter boundary. The Audiobook recovery card lets you resume with its cached chapters, and **Open chapter cache** reveals the chapter audio cache and each interrupted job's resume manifest. + ## Why bracket tags work at all (and when they don't) Everything you type in the text box reaches the active engine **verbatim** — diff --git a/frontend/src/api/audiobook.ts b/frontend/src/api/audiobook.ts index 2c2f3b06..652f37a1 100644 --- a/frontend/src/api/audiobook.ts +++ b/frontend/src/api/audiobook.ts @@ -125,6 +125,33 @@ export async function audiobookGenerate( }); } +export interface ResumableAudiobookJob { + job_id: string; + type: 'audiobook' | 'longform'; + status: string; + title: string; + total_chapters: number; + chapters_done: number; + created_at: number | null; +} + +/** List server-owned longform manifests left by interrupted renders. */ +export async function audiobookListJobs(): Promise<{ jobs: ResumableAudiobookJob[] }> { + const res = await apiFetch('/audiobook/jobs'); + return res.json(); +} + +/** Resume one trusted server-side manifest and stream the normal audiobook events. */ +export async function audiobookResume( + jobId: string, + opts: { signal?: AbortSignal } = {}, +): Promise { + return apiFetch(`/audiobook/resume/${encodeURIComponent(jobId)}`, { + method: 'POST', + signal: opts.signal, + }); +} + /** Upload a cover image; returns the server-side path to pass as `cover_path`. */ export async function audiobookUploadCover(file: File): Promise<{ path: string }> { const form = new FormData(); diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx index 114c6dc6..4c9072f7 100644 --- a/frontend/src/components/Header.jsx +++ b/frontend/src/components/Header.jsx @@ -108,6 +108,7 @@ function WaveBars({ color = '#f3a5b6', active }) { const heights = [4, 9, 5, 11, 6, 10, 5, 8]; return (