feat(voices): optional vocal isolation for URL imports

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-04 16:50:14 +02:00
parent e0daafd4e1
commit 91843e95e6
15 changed files with 391 additions and 5 deletions

View File

@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Added
- **Optional vocal isolation for URL/YouTube imports (default OFF).** `POST /api/isolate-vocals {id}` runs the Demucs CLI (`htdemucs`, two stems) on a registered temp audio and registers the vocals as a new id; the original stays valid. Needs `VOCAL_ISOLATION=1`, a `demucs` binary (or `VOCAL_ISOLATION_CMD`) and the weights already on disk; the app never downloads anything and nothing is added to the main image. Limits: 15 min input, 15 min runtime (`VOCAL_ISOLATION_MAX_SECONDS`/`_TIMEOUT`), one job at a time, cancelled on client disconnect, temp files always removed. `GET /api/version` reports `features.vocal_isolation`; the "Remove background music" button (clone page URL card and voice library add-URL) appears only then, with an aria-live status and a play original / play without music toggle. Tests: `tests/test_vocal_isolation.py`, `tests/vocal-isolation.spec.js`. See `architecture/vocal-isolation.md`.
- **Voice export/import bundles.** `GET /api/voices/{id}/export` streams a `<id>.voice.zip` (audio, `reference.txt`, `meta.json` with seed, design prompt, rating/tags, `provenance`, `audio_problems`; optional picture and consent recording; `manifest.json` with format version, app version and sha256 per file; no temp files). `POST /api/voices/import` validates at the boundary: 150 MB upload cap, fixed flat member names only (zip-slip/path traversal rejected), per-file, total and compression-ratio caps, hashes verified, audio decoded, meta and provenance re-validated; a taken name gets a `_2` suffix, an identical voice is a no-op, nothing is overwritten. Voice library toolbar: "Export selected" and "Import bundle" (44px). Tests: `tests/test_voice_bundle.py`, `tests/voice-bundle.spec.js`. See `architecture/voice-bundle.md`.
- **Consent/provenance field per voice.** Each voice gets `provenance` in its `.meta.json` (`basis`: own_voice, consented, licensed, public_domain or unknown; `source`, `licence`, `note`, optional `recorded_consent`, `set_at`). Existing voices count as `unknown`; `quick-import-voice` prefills the source URL (basis stays `unknown`). New `PUT /api/voices/{id}/provenance` (enum, length caps, control characters stripped, http(s) URLs only, no credentials) and `POST`/`GET /api/voices/{id}/consent-recording` (audio only, 10 MB cap, stored as `<voice>.consent`, moves/deletes with the voice). The voice inspector has a "Source & consent" section (typed note or MediaRecorder recording) and voices without a recorded basis show a "Source unknown" badge. Generation is not blocked. Data stays local. Tests: `tests/test_voice_provenance.py`, `tests/voice-provenance.spec.js`.
- **Seed Finder: Auto-pick best.** `POST /api/seed-autopick {voice, text, seeds|count}` (default 8, max 16) generates candidates sequentially through the existing temporary-voice path (voices.json is always restored), scores each 0-100 (ASR word accuracy, loudness/clipping, `audio_problems`) and returns all sub-scores plus the winner. Degrades to loudness/problems when STT is unavailable. The UI highlights the winner; saving stays a click on "Use seed". Tests: `tests/test_seed_autopick.py`, `tests/seed-autopick.spec.js`.

View File

@ -14,6 +14,16 @@ Living manual. Every feature updates this file in the same commit as the code.
---
## Remove background music from URL imports (optional)
- **Purpose:** strip music and background sound from a downloaded clip before cloning, so the clone does not learn the music.
- **Prerequisites:** off by default. The server owner sets `VOCAL_ISOLATION=1`, installs Demucs (`pip install demucs` in a venv or sidecar, ARM64 supported; set `VOCAL_ISOLATION_CMD` if it is not on `PATH`) and downloads the `htdemucs` weights once (`955717e8-8726e21a.th`, about 80 MB, into `$TORCH_HOME/hub/checkpoints`). The app never downloads weights itself. Without all of this the button does not appear.
- **Steps:** download a YouTube/URL clip, press **Remove background music**, wait (minutes on CPU). The cleaned audio loads in the player or waveform; press **Play original** / **Play without music** to compare. Then trim and save as usual.
- **Result:** a new temporary audio; the original is kept until it expires.
- **Errors:** clips over 15 minutes are refused (trim first), only one job runs at a time, a timeout or failure shows a message under the button.
- **Mobile/accessibility:** buttons are normal buttons; progress and errors are announced through a polite live region.
- **Offline/privacy:** runs fully locally; no audio leaves the machine.
## Voice export and import bundles
Purpose: back up a voice or move it between machines (DGX and NUC) as one `.voice.zip` file containing the reference audio, transcript, settings (seed, design prompt, rating, tags, source & consent, audio problems), and optionally the picture and the consent recording.

View File

@ -0,0 +1,42 @@
# Vocal isolation for URL/YouTube imports
Status: implemented behind a flag, default OFF. Goal: remove music/background from a downloaded
clip before trimming and cloning (`/api/download-yt` -> `/api/isolate-vocals` -> trim -> `quick-import-voice`).
## Findings (read-only inspection, 2026-10-04)
- App image: `python:3.11-slim-bookworm`, `requirements.txt` has no torch/onnx/demucs. The live app
container has no `torch` (`import torch` fails). It is `read_only: true`, `cap_drop: ALL`, 8 GB mem
limit, no GPU reservation. Source dirs are bind-mounted read-only.
- GPU/torch live in other service containers (tts-faster-qwen3-*, aligner, ...), not reusable: different
images, no shared API for source separation.
- There is no VAD, denoise or separation anywhere in the app today.
## Options
| Option | Footprint | Verdict |
|---|---|---|
| Demucs `htdemucs` (MIT code; weights MIT, ~80 MB, from dl.fbaipublicfiles.com via torch hub) in the main image | torch + torchaudio on ARM64 ~ 2-3 GB image growth | rejected: heavy, unconditional |
| Demucs in a separate sidecar container | no growth of main image, GPU optional | best long-term, but needs a new image, compose service and network contract |
| **Optional external Demucs CLI**, called as a subprocess, only when the flag is on and the binary + weights exist | zero deps in the main image; the owner picks where Demucs lives (venv on a bind mount, or a derived image) | **chosen** |
| Lighter: RNNoise/DeepFilterNet/spectral gating | small, but removes noise only, not music/singing | does not meet the goal |
Why the CLI: `demucs --two-stems=vocals -n htdemucs` is the stable public interface; no import-time cost,
no behaviour change when absent. ARM64: `torch`/`torchaudio` publish aarch64 wheels (CUDA wheels exist for
DGX Spark / GB10; CPU wheels work everywhere), `demucs` itself is pure Python (`pip install demucs`).
A sidecar can later expose the same CLI via `VOCAL_ISOLATION_CMD` (e.g. `docker exec ...` wrapper) without code changes.
## Safety rules
- `VOCAL_ISOLATION=1` required; otherwise the feature reports unavailable and the API answers 503.
- Available only if the command resolves AND the htdemucs weights file (`955717e8-*.th`) is already in
`$TORCH_HOME/hub/checkpoints` (default `~/.cache/torch`). The app never downloads weights: the owner
installs them once (documented in Manual.md). This prevents Demucs' silent first-run download.
- Input bounded to `VOCAL_ISOLATION_MAX_SECONDS` (default 900), runtime to `VOCAL_ISOLATION_TIMEOUT`
(default 900 s), one job at a time (409 when busy), client disconnect cancels and kills the process.
- Work happens in a private temp dir that is always removed; the result is converted to 24 kHz mono WAV
and registered as a new temp id. The original id is untouched (compare / undo in the UI).
- Subprocess started with an argument list (no shell); the path comes from the registry, never from the client.
## Contract
- `GET /api/version` gains `features.vocal_isolation: bool`.
- `POST /api/isolate-vocals {id}` -> `{id, duration}`. Errors: 400 bad id, 404 unknown id, 413 too long,
409 busy, 503 disabled/unavailable, 504 timeout, 500 failed (message without internals).
- Offline behaviour: fully local; no network access.

82
core/vocal_isolation.py Normal file
View File

@ -0,0 +1,82 @@
"""Optional vocal isolation (Demucs CLI). Off unless VOCAL_ISOLATION=1; never downloads weights."""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
import threading
import time
import uuid
from pathlib import Path
from core.audio import _duration, _to_wav_24k
from core.registry import TEMP_DIR
_WEIGHTS_GLOB = "955717e8*.th" # htdemucs
_busy = threading.Lock() # ponytail: one job at a time; a queue only if several users isolate concurrently
class VocalIsolationError(Exception):
def __init__(self, message: str, status: int = 500):
super().__init__(message)
self.status = status
def _cmd() -> str | None:
return os.environ.get("VOCAL_ISOLATION_CMD") or shutil.which("demucs")
def _weights_present() -> bool:
home = Path(os.environ.get("TORCH_HOME") or Path.home() / ".cache" / "torch")
return any((home / "hub" / "checkpoints").glob(_WEIGHTS_GLOB))
def enabled() -> bool:
return os.environ.get("VOCAL_ISOLATION", "").strip().lower() in {"1", "true", "yes", "on"}
def available() -> bool:
return enabled() and _cmd() is not None and _weights_present()
def isolate_vocals(path: Path, cancel: threading.Event | None = None) -> Path:
"""Return a new 24 kHz mono WAV with vocals only; `path` is left untouched."""
if not enabled():
raise VocalIsolationError("Vocal isolation is turned off (set VOCAL_ISOLATION=1).", 503)
cmd = _cmd()
if cmd is None or not _weights_present():
raise VocalIsolationError("Vocal isolation is not installed (Demucs or its weights are missing).", 503)
max_s = float(os.environ.get("VOCAL_ISOLATION_MAX_SECONDS", "900"))
timeout = float(os.environ.get("VOCAL_ISOLATION_TIMEOUT", "900"))
if _duration(path) > max_s:
raise VocalIsolationError(f"Audio is longer than {int(max_s // 60)} minutes; trim it first.", 413)
if not _busy.acquire(blocking=False):
raise VocalIsolationError("Another vocal isolation is running; try again shortly.", 409)
work = Path(tempfile.mkdtemp(prefix="vocal_", dir=TEMP_DIR))
try:
proc = subprocess.Popen(
[cmd, "--two-stems=vocals", "-n", "htdemucs", "-o", str(work), str(path)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
deadline = time.monotonic() + timeout
while proc.poll() is None:
if cancel is not None and cancel.is_set():
proc.kill(); proc.wait()
raise VocalIsolationError("Vocal isolation was cancelled.", 499)
if time.monotonic() > deadline:
proc.kill(); proc.wait()
raise VocalIsolationError("Vocal isolation timed out.", 504)
time.sleep(0.2)
found = next(work.glob("*/*/vocals.*"), None)
if proc.returncode != 0 or found is None:
raise VocalIsolationError("Vocal isolation failed.", 500)
staged = TEMP_DIR / f"{uuid.uuid4().hex}_vocals{found.suffix}"
shutil.move(str(found), staged)
try:
return _to_wav_24k(staged)
finally:
staged.unlink(missing_ok=True)
finally:
_busy.release()
shutil.rmtree(work, ignore_errors=True)

View File

@ -199,7 +199,8 @@ async def browse_dirs_mkdir(payload: dict = Body(...)):
@router.get("/api/version")
async def get_version():
return {"version": __version__}
from core.vocal_isolation import available as _vocal_isolation
return {"version": __version__, "features": {"vocal_isolation": _vocal_isolation()}}
@router.get("/api/changelog", response_class=PlainTextResponse)

View File

@ -6,6 +6,7 @@ import glob
import json
import os
import re
import threading
import uuid
from datetime import datetime, timezone
from html import unescape
@ -18,7 +19,7 @@ from fastapi.responses import StreamingResponse
from core.config import _load_settings
from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES, _ALLOW_PRIVATE_DOWNLOADS, CONFIG_DIR, _BENCHMARK_SENTENCES
from core.registry import _registry_put, TEMP_DIR
from core.registry import _registry_put, _registry_get, TEMP_DIR
from core.validation import _validate_http_url
from core.audio import _to_wav_24k, _duration
from core.voice import (
@ -722,6 +723,34 @@ async def download_yt(url: str):
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
@router.post("/api/isolate-vocals")
async def isolate_vocals_route(request: Request):
"""Remove background music from a registered temp audio id; the original id stays valid."""
from core import vocal_isolation as vi
data = await request.json()
fid = data.get("id") if isinstance(data, dict) else None
if not isinstance(fid, str) or not fid:
raise HTTPException(400, "id required")
src = _registry_get(fid)
if src is None or not src.exists():
raise HTTPException(404, "Source file not found")
cancel = threading.Event()
task = asyncio.create_task(asyncio.to_thread(vi.isolate_vocals, src, cancel))
try:
while not task.done():
await asyncio.wait({task}, timeout=1.0)
if not task.done() and await request.is_disconnected():
cancel.set()
wav = task.result()
except vi.VocalIsolationError as e:
raise HTTPException(e.status, str(e))
except Exception:
raise HTTPException(500, "Vocal isolation failed")
nid = uuid.uuid4().hex
_registry_put(nid, wav)
return {"id": nid, "duration": _duration(wav)}
@router.get("/api/elevenlabs/voices")
async def elevenlabs_shared_voices(request: Request):
settings = _load_settings()

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,34 @@
// Optional "Remove background music" for URL/YouTube imports. Button appears only when the
// server reports features.vocal_isolation. Original stays loadable via the toggle (before/after).
function setupVocalIsolation({ btn, toggle, msg, getId, getDur, apply }) {
const b = $(btn), t = $(toggle), m = $(msg);
let pair = null, showing = 'clean';
const say = text => { m.textContent = text; status(text); };
fetch('/api/version').then(r => r.json()).then(v => { if (v?.features?.vocal_isolation) b.hidden = false; }).catch(() => {});
const label = () => { t.textContent = showing === 'clean' ? 'Play original' : 'Play without music'; };
b.addEventListener('click', async () => {
const id = getId();
if (!id) { say('Load audio first, then remove the background music.'); return; }
b.disabled = true; t.hidden = true;
say('Removing background music. This can take a few minutes.');
try {
const r = await fetch('/api/isolate-vocals', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ id }) });
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || r.statusText);
pair = { orig:id, origDur:getDur(), clean:d.id, cleanDur:d.duration };
showing = 'clean'; apply(d.id, d.duration);
t.hidden = false; label();
say('Background music removed. Use the button next to it to compare with the original.');
} catch (e) {
say('Could not remove background music: ' + e.message);
toast('Vocal isolation failed: ' + e.message, 'error');
} finally { b.disabled = false; }
});
t.addEventListener('click', () => {
if (!pair) return;
showing = showing === 'clean' ? 'orig' : 'clean';
if (showing === 'clean') apply(pair.clean, pair.cleanDur); else apply(pair.orig, pair.origDur);
label();
say(showing === 'clean' ? 'Playing the version without background music.' : 'Playing the original audio.');
});
}

View File

@ -1133,3 +1133,6 @@ Object.assign(root.TTVC.voiceClone, {
design: { run: runVoiceDesign, buildPayload: voiceDesignPayload },
});
})(window);
setupVocalIsolation({ btn:'yt-iso-btn', toggle:'yt-iso-toggle', msg:'yt-iso-msg',
getId:() => currentFileId, getDur:() => parseFloat($('trim-end').value) || 0, apply:(id, dur) => loadAudioId(id, dur) });

View File

@ -1646,6 +1646,9 @@ $('lib-add-url-btn').addEventListener('click', () => {
};
});
setupVocalIsolation({ btn:'lib-iso-btn', toggle:'lib-iso-toggle', msg:'lib-iso-msg',
getId:() => libAddState.id, getDur:() => libAddState.duration, apply:(id, dur) => loadLibAddAudio(id, dur, 'Audio') });
$('lib-add-rec-start').addEventListener('click', async () => {
try {
await ensureLibAddMicMonitor();

View File

@ -11,6 +11,7 @@
"image-cache",
"integrations",
"routing",
"vocal-isolation",
"voice-clone",
"voice-library",
"tts-preview",

View File

@ -83,6 +83,11 @@
<button class="btn-primary" id="yt-btn">Download</button>
</div>
<div id="yt-progress"></div>
<div class="btn-row">
<button class="btn-secondary" id="yt-iso-btn" hidden>Remove background music</button>
<button class="btn-secondary" id="yt-iso-toggle" hidden></button>
</div>
<div class="note" id="yt-iso-msg" role="status" aria-live="polite"></div>
</div>
</div><!-- /clone-srcpanel -->

View File

@ -232,6 +232,11 @@
<input type="text" id="lib-add-url" placeholder="Paste a direct MP3/WAV, YouTube, or Aiartes URL">
<button class="btn-primary" id="lib-add-url-btn">Download URL</button>
</div>
<div class="btn-row">
<button class="btn-secondary" id="lib-iso-btn" hidden>Remove background music</button>
<button class="btn-secondary" id="lib-iso-toggle" hidden></button>
</div>
<div class="note" id="lib-iso-msg" role="status" aria-live="polite"></div>
</div>
</div>
<div class="lib-add-source-box lib-add-record-box">

View File

@ -0,0 +1,119 @@
"""vocal isolation: flag off, unavailable, success, timeout, cleanup (Demucs mocked)."""
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from pydub import AudioSegment
from pydub.generators import Sine
from core import vocal_isolation as vi
from core.registry import TEMP_DIR
def wav(path, ms=1000):
Sine(300).to_audio_segment(ms).export(str(path), format="wav")
return path
class FakeProc:
"""Stands in for demucs: writes <out>/htdemucs/<stem>/vocals.wav, then exits."""
def __init__(self, args, **kw):
self.never_ends = FakeProc.never_ends
out = Path(args[args.index("-o") + 1])
if not self.never_ends and FakeProc.write:
d = out / "htdemucs" / Path(args[-1]).stem
d.mkdir(parents=True)
wav(d / "vocals.wav")
self.returncode = None if self.never_ends else FakeProc.rc
never_ends = False
write = True
rc = 0
def poll(self):
return self.returncode
def kill(self):
self.returncode = -9
def wait(self):
return self.returncode
def env(**kw):
base = {"VOCAL_ISOLATION": "1", "VOCAL_ISOLATION_CMD": "/bin/true"}
base.update(kw)
return mock.patch.dict(os.environ, base)
class VocalIsolationTests(unittest.TestCase):
def setUp(self):
self.src = wav(Path(tempfile.mkdtemp()) / "in.wav")
FakeProc.never_ends, FakeProc.write, FakeProc.rc = False, True, 0
self.w = mock.patch.object(vi, "_weights_present", return_value=True)
self.w.start()
self.addCleanup(self.w.stop)
def test_flag_off(self):
with mock.patch.dict(os.environ, {"VOCAL_ISOLATION": ""}):
self.assertFalse(vi.available())
with self.assertRaises(vi.VocalIsolationError) as c:
vi.isolate_vocals(self.src)
self.assertEqual(c.exception.status, 503)
def test_unavailable_without_weights_or_binary(self):
with env(), mock.patch.object(vi, "_weights_present", return_value=False):
self.assertFalse(vi.available())
with self.assertRaises(vi.VocalIsolationError) as c:
vi.isolate_vocals(self.src)
self.assertEqual(c.exception.status, 503)
with env(VOCAL_ISOLATION_CMD=""), mock.patch("shutil.which", return_value=None):
self.assertFalse(vi.available())
def test_success_and_cleanup(self):
with env(), mock.patch("subprocess.Popen", FakeProc):
self.assertTrue(vi.available())
out = vi.isolate_vocals(self.src)
self.assertTrue(out.exists())
self.assertEqual(AudioSegment.from_file(str(out)).frame_rate, 24000)
self.assertTrue(self.src.exists()) # original untouched
self.assertEqual(list(TEMP_DIR.glob("vocal_*")), [])
self.assertEqual(list(TEMP_DIR.glob("*_vocals.*")), [])
def test_timeout_kills_and_cleans(self):
FakeProc.never_ends = True
with env(VOCAL_ISOLATION_TIMEOUT="0.3"), mock.patch("subprocess.Popen", FakeProc):
with self.assertRaises(vi.VocalIsolationError) as c:
vi.isolate_vocals(self.src)
self.assertEqual(c.exception.status, 504)
self.assertEqual(list(TEMP_DIR.glob("vocal_*")), [])
def test_cancel(self):
import threading
FakeProc.never_ends = True
ev = threading.Event(); ev.set()
with env(), mock.patch("subprocess.Popen", FakeProc):
with self.assertRaises(vi.VocalIsolationError) as c:
vi.isolate_vocals(self.src, ev)
self.assertEqual(c.exception.status, 499)
def test_too_long_and_failure_and_busy(self):
with env(VOCAL_ISOLATION_MAX_SECONDS="0.5"):
with self.assertRaises(vi.VocalIsolationError) as c:
vi.isolate_vocals(self.src)
self.assertEqual(c.exception.status, 413)
FakeProc.write, FakeProc.rc = False, 1
with env(), mock.patch("subprocess.Popen", FakeProc):
with self.assertRaises(vi.VocalIsolationError) as c:
vi.isolate_vocals(self.src)
self.assertEqual(c.exception.status, 500)
self.assertEqual(list(TEMP_DIR.glob("vocal_*")), [])
with env(), vi._busy:
with self.assertRaises(vi.VocalIsolationError) as c:
vi.isolate_vocals(self.src)
self.assertEqual(c.exception.status, 409)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,51 @@
// @ts-check
// "Remove background music" in the URL import card (all /api routes mocked).
const { test, expect } = require('@playwright/test');
async function open(page, available) {
await page.route('**/api/**', route => route.request().method() === 'GET' ? route.fulfill({ json: {} }) : route.fulfill({ json: { ok: true } }));
await page.route('**/api/voices*', route => route.fulfill({ json: [] }));
await page.route('**/api/version', route => route.fulfill({ json: { version: 'x', features: { vocal_isolation: available } } }));
await page.route('**/api/audio/*', route => route.fulfill({ status: 404, body: '' }));
await page.route('**/static/dist/main.min.js*', r => r.abort());
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
}
test('button hidden when the server cannot isolate', async ({ page }) => {
await open(page, false);
await expect(page.locator('#lib-iso-btn')).toHaveAttribute('hidden', '');
await expect(page.locator('#yt-iso-btn')).toHaveAttribute('hidden', '');
});
test('isolate, compare with original, and show errors', async ({ page }) => {
await open(page, true);
/** @type {any[]} */ const calls = [];
let fail = false;
await page.route('**/api/isolate-vocals', route => {
calls.push(route.request().postDataJSON());
return fail ? route.fulfill({ status: 503, json: { detail: 'Vocal isolation is turned off' } })
: route.fulfill({ json: { id: 'clean1', duration: 4 } });
});
await page.evaluate(() => window.navTo('s-voices'));
const btn = page.locator('#lib-iso-btn');
await expect(btn).not.toHaveAttribute('hidden', '');
const msg = page.locator('#lib-iso-msg');
await expect(msg).toHaveAttribute('aria-live', 'polite');
await btn.evaluate(el => el.click());
await expect(msg).toContainText('Load audio first');
await page.evaluate(() => { libAddState.id = 'orig1'; libAddState.duration = 6; });
await btn.evaluate(el => el.click());
await expect(msg).toContainText('Background music removed');
expect(calls).toEqual([{ id: 'orig1' }]);
expect(await page.evaluate(() => libAddState.id)).toBe('clean1');
const toggle = page.locator('#lib-iso-toggle');
await expect(toggle).toHaveText('Play original');
await toggle.evaluate(el => el.click());
expect(await page.evaluate(() => libAddState.id)).toBe('orig1');
await expect(toggle).toHaveText('Play without music');
fail = true;
await btn.evaluate(el => el.click());
await expect(msg).toContainText('turned off');
});