From 464edbc4fd658728f06d3a7d98340aa28ff3e3ec Mon Sep 17 00:00:00 2001 From: historia Date: Sat, 12 Sep 2026 15:25:06 -0400 Subject: feat: slop web gui --- lib/project/tests/gui_state.test.mjs | 31 ++++ lib/project/tests/test_gui.py | 260 ++++++++++++++++++++++++++++ lib/project/tests/test_gui_assets.py | 46 +++++ lib/project/tests/test_gui_browser.py | 257 +++++++++++++++++++++++++++ lib/project/tests/test_gui_browser_races.py | 210 ++++++++++++++++++++++ lib/project/tests/test_gui_jobs.py | 124 +++++++++++++ lib/project/tests/test_gui_launcher.py | 113 ++++++++++++ lib/project/tests/test_pauses.py | 200 +++++++++++++++++++++ 8 files changed, 1241 insertions(+) create mode 100644 lib/project/tests/gui_state.test.mjs create mode 100644 lib/project/tests/test_gui.py create mode 100644 lib/project/tests/test_gui_assets.py create mode 100644 lib/project/tests/test_gui_browser.py create mode 100644 lib/project/tests/test_gui_browser_races.py create mode 100644 lib/project/tests/test_gui_jobs.py create mode 100644 lib/project/tests/test_gui_launcher.py create mode 100644 lib/project/tests/test_pauses.py (limited to 'lib/project/tests') diff --git a/lib/project/tests/gui_state.test.mjs b/lib/project/tests/gui_state.test.mjs new file mode 100644 index 0000000..10775eb --- /dev/null +++ b/lib/project/tests/gui_state.test.mjs @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {parseTime, formatTime, selection, stable, matches, monitoringGain, absolutePath} from '../src/producer/web/state.js'; + +test('exact excerpt times work beyond four hours without wrapping', () => { + assert.equal(parseTime('05:12:03.125'), 18723.125); + assert.equal(formatTime(18723.125), '05:12:03.125'); + assert.equal(parseTime('14400'), 14400); + assert.equal(formatTime(59.9999), '00:01:00'); + for (const value of ['1:60:00', '-1', 'Infinity', '1e3', '', '00:00:60']) assert.throws(() => parseTime(value)); + assert.deepEqual(selection('03:59:30', '04:00:00', 14400), {start: 14370, duration: 30}); + for (const [start, end] of [['0', '121'], ['2', '1'], ['0', '0'], ['14399', '14401']]) assert.throws(() => selection(start, end, 14400)); +}); + +test('preview identity includes source, exact selection and every requested setting', () => { + const snapshot = {path: 'take.wav', input_id: 'identity', start: 10, duration: 30, settings: {mp3: true, trim_silence: true, denoiser: 'none'}}; + assert.ok(matches(snapshot, structuredClone(snapshot))); + assert.ok(matches(snapshot, {...snapshot, settings: {denoiser: 'none', trim_silence: true, mp3: true}})); + for (const change of [{path: 'other.wav'}, {input_id: 'changed'}, {start: 11}, {duration: 29}, {settings: {...snapshot.settings, trim_silence: false}}]) assert.equal(matches(snapshot, {...snapshot, ...change}), false); + assert.equal(matches(snapshot, null), false); + assert.equal(stable({b: 2, a: 1}), stable({a: 1, b: 2})); +}); + +test('monitoring is attenuation only and independent of export settings', () => { + assert.equal(monitoringGain(-6, false), 1); + assert.equal(monitoringGain(6, true), 1); + assert.equal(monitoringGain(NaN, true), 1); + assert.ok(Math.abs(monitoringGain(-6, true) - 0.501187) < 0.000001); + assert.equal(absolutePath('/recordings', 'delivery/master.wav'), '/recordings/delivery/master.wav'); + assert.equal(absolutePath('/', './master.wav'), '/master.wav'); +}); diff --git a/lib/project/tests/test_gui.py b/lib/project/tests/test_gui.py new file mode 100644 index 0000000..c9e5285 --- /dev/null +++ b/lib/project/tests/test_gui.py @@ -0,0 +1,260 @@ +"""Optional GUI API, origin/root boundary, snapshots, and real sound previews.""" +from dataclasses import asdict +import hashlib +from pathlib import Path +import struct +import time + +import numpy as np +import pytest +import soundfile as sf + +pytest.importorskip("fastapi") +from fastapi.testclient import TestClient + +from producer.config import Settings +from producer.gui import create_app +from producer import gui_jobs + +TOKEN = "test-session-credential" +AUTH = {"Authorization": f"Bearer {TOKEN}"} +URL = "http://127.0.0.1:8765" + + +@pytest.fixture +def client(tmp_path): + with TestClient(create_app(tmp_path, 8765, TOKEN), base_url=URL, headers=AUTH) as client: + yield client + + +def wait(client, job_id): + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + job = client.get(f"/api/jobs/{job_id}").json() + if job["status"] in ("succeeded", "failed", "cancelled"): + return job + time.sleep(0.02) + pytest.fail("Job failed to terminate") + + +def submit(client, path, kind, **kwargs): + info = client.get("/api/input", params={"path": path.name}) + assert info.status_code == 200, info.text + response = client.post("/api/jobs", json={"kind": kind, "path": path.name, + "input_id": info.json()["input_id"], **kwargs}) + assert response.status_code == 202, response.text + return response.json() + + +def test_security_credentials_origins_hosts_and_headers(tmp_path): + with TestClient(create_app(tmp_path, 8765, TOKEN), base_url=URL) as client: + assert client.get("/").status_code == 200 + assert client.get("/api/schema").status_code == 401 + assert client.get(f"/api/schema?token={TOKEN}").status_code == 401 + assert client.get("/api/schema", headers={"Cookie": f"token={TOKEN}"}).status_code == 401 + for headers in [AUTH | {"Host": "evil.example:8765"}, + AUTH | {"Host": "localhost:8765"}, + AUTH | {"Origin": "https://evil.example"}, + AUTH | {"Origin": "null"}, + AUTH | {"Origin": "http://127.0.0.1:9999"}, + AUTH | {"Sec-Fetch-Site": "cross-site"}]: + assert client.get("/api/schema", headers=headers).status_code == 403 + assert client.post("/api/jobs", json={}, headers=headers).status_code == 403 + response = client.get("/api/schema", headers=AUTH | {"Origin": URL}) + assert response.status_code == 200 + assert response.headers["Cache-Control"] == "no-store" + assert response.headers["Referrer-Policy"] == "no-referrer" + assert "frame-ancestors 'none'" in response.headers["Content-Security-Policy"] + assert "Access-Control-Allow-Origin" not in response.headers + assert TOKEN not in response.text + assert client.get("/openapi.json").status_code == 404 + assert client.post("/api/resolve", content=b"x" * 65537, headers=AUTH).status_code == 413 + assert client.post("/api/resolve", content="{", headers=AUTH).status_code == 400 + + +def test_root_browser_symlinks_traversal_and_no_arbitrary_download(client, tmp_path, wav): + source = wav() + outside = tmp_path.parent / "outside.wav" + outside.write_bytes(b"not accessible") + (tmp_path / "escape.wav").symlink_to(outside) + (tmp_path / "escape-dir").symlink_to(tmp_path.parent, target_is_directory=True) + (tmp_path / "inside.wav").symlink_to(source) + (tmp_path / "note.txt").write_text("not exposed") + (tmp_path / "subdir").mkdir() + listing = client.get("/api/files").json() + assert listing["parent"] is None + assert {item["name"] for item in listing["entries"]} == {"input.wav", "inside.wav", "subdir"} + for path in ("../outside.wav", str(outside), "escape.wav", "escape-dir", "subdir/../../outside.wav"): + assert client.get("/api/input", params={"path": path}).status_code == 400 + assert client.get("/api/files", params={"path": ".."}).status_code == 400 + assert client.get("/api/files", params={"limit": 201}).status_code == 400 + assert len(client.get("/api/files", params={"limit": 1}).json()["entries"]) == 1 + assert client.get("/api/files", params={"limit": 1}).json()["next_offset"] == 1 + assert client.get("/assets/../gui.py").status_code == 404 + assert client.get("/assets/%2e%2e/gui.py").status_code == 404 + assert client.get("/api/results/missing/original.wav").status_code == 404 + + +def test_schema_complete_strict_resolution_and_preset_roundtrip(client, bypass): + schema = client.get("/api/schema").json() + assert {item["name"] for item in schema["fields"]} == set(asdict(Settings())) + assert all(item["group"] and item["help"] for item in schema["fields"]) + assert schema["settings"] == asdict(Settings()) + for value in ({"unknown": 1}, {"normalize": "false"}, {"target_lufs": -100}, + {"normalize": True, "limiter": False}, {"sample_rate": 1234}): + assert client.post("/api/resolve", json={"settings": value}).status_code == 400 + response = client.post("/api/presets/download", json={"settings": asdict(bypass)}) + assert response.status_code == 200 + assert "attachment" in response.headers["Content-Disposition"] + imported = client.post("/api/presets/import", json={"toml": response.text}) + assert imported.json()["settings"] == asdict(bypass) + assert client.post("/api/presets/import", json={"toml": "no_such_setting = 2"}).status_code == 400 + assert client.post("/api/presets/import", json={"toml": "["}).status_code == 400 + + +def test_config_precedence_and_startup_snapshot(tmp_path): + config = tmp_path / "config.toml" + config.write_text('profile = "narrator"\ntarget_lufs = -25\n') + app = create_app(tmp_path, 8765, TOKEN, config=config, profile="radio", overrides={"target_lufs": -22}) + config.write_text("this file changed after startup") + with TestClient(app, base_url=URL, headers=AUTH) as client: + settings = client.get("/api/schema").json()["settings"] + assert settings["profile"] == "radio" and settings["compressor_ratio"] == 3.5 + assert settings["target_lufs"] == -22 + resolved = client.post("/api/resolve", json={"settings": {"target_lufs": -18, "profile": "natural"}}) + assert resolved.json()["settings"]["target_lufs"] == -18 + assert resolved.json()["settings"]["compressor_ratio"] == 2 + + +def test_scan_fixed_bins_cache_and_stale_identity(client, wav, monkeypatch): + source = wav() + original = source.read_bytes() + first = wait(client, submit(client, source, "scan")["id"]) + assert first["status"] == "succeeded", first + result = first["result"] + assert len(result["min"]) == len(result["max"]) == 2048 + assert max(result["max"]) == pytest.approx(0.15, abs=1e-6) + monkeypatch.setattr(gui_jobs, "waveform", lambda *args: pytest.fail("Expected repeated-result cache")) + second = wait(client, submit(client, source, "scan")["id"]) + assert second["cached"] and second["result"] == result + assert source.read_bytes() == original + old_identity = first["snapshot"]["input_id"] + with source.open("ab") as handle: + handle.write(b"changed") + response = client.post("/api/jobs", json={"kind": "scan", "path": source.name, "input_id": old_identity}) + assert response.status_code == 400 + assert "changed" in response.text + + +def test_real_preview_context_exact_crop_bypass_and_export_trim(client, wav, bypass, ffmpeg): + t = np.arange(48000 * 8) / 48000 + samples = 0.15 * np.sin(2 * np.pi * 440 * t) + source = wav(samples) + before = hashlib.sha256(source.read_bytes()).hexdigest() + requested = asdict(bypass) | {"trim_silence": True, "mp3": True} + queued = submit(client, source, "preview", settings=requested, start=2, duration=3) + preview = wait(client, queued["id"]) + assert preview["status"] == "succeeded", preview + result = preview["result"] + assert preview["snapshot"]["settings"] == requested + assert result["requested_settings"] == requested + assert result["effective_settings"] == requested | {"trim_silence": False, "mp3": False} + assert result["normalization_mode"] == "disabled" + assert result["context_start"] == 0 and result["context_end"] == 8 + assert result["approximate"] is True + assert "Pacing edits are not previewed" in result["notice"] + import io + originals = client.get(result["audio"]["original"]) + processed = client.get(result["audio"]["processed"]) + for response in (originals, processed): + assert response.status_code == 200 + info = sf.info(io.BytesIO(response.content)) + assert info.frames == 3 * 48000 + assert info.subtype == "PCM_16" + original_audio, _ = sf.read(io.BytesIO(originals.content)) + processed_audio, _ = sf.read(io.BytesIO(processed.content)) + np.testing.assert_allclose(original_audio, processed_audio, atol=1 / 32768) + np.testing.assert_allclose(original_audio, samples[2 * 48000:5 * 48000], atol=1 / 32768) + assert result["monitoring"]["available"] + assert result["monitoring"]["original_gain_db"] <= 0 + assert result["monitoring"]["processed_gain_db"] <= 0 + repeated = wait(client, submit(client, source, "preview", settings=requested, start=2, duration=3)["id"]) + assert repeated["cached"] and repeated["result"]["result_id"] == result["result_id"] + changed = wait(client, submit(client, source, "preview", settings=requested | {"highpass": True}, start=2, duration=3)["id"]) + assert not changed["cached"] and changed["result"]["result_id"] != result["result_id"] + for _ in range(2): + exported = wait(client, submit(client, source, "export", settings=requested)["id"]) + assert exported["status"] == "succeeded", exported + assert exported["result"]["settings"] == requested + assert len(exported["result"]["paths"]) == 3 + assert "not auditioned" in exported["result"]["notice"] + for path in exported["result"]["paths"]: + assert (source.parent / path).is_file() + assert len(list(source.parent.glob("producer-export-*"))) == 2 + assert hashlib.sha256(source.read_bytes()).hexdigest() == before + # A cached audio URL cannot read stale results after the recording changes. + with source.open("ab") as handle: + handle.write(b"changed") + assert client.get(result["audio"]["original"]).status_code == 400 + + +def test_preview_bounds_settings_errors_and_unsupported_input(client, wav): + source = wav() + for start, duration in ((-1, 1), (0, 121), (0, 0), (2, 2), (True, 1)): + info = client.get("/api/input", params={"path": source.name}).json() + response = client.post("/api/jobs", json={"kind": "preview", "path": source.name, + "input_id": info["input_id"], "start": start, "duration": duration}) + assert response.status_code == 400 + stereo = wav(np.zeros((20, 2)), name="stereo.wav") + assert "Stereo" in client.get("/api/input", params={"path": stereo.name}).json()["detail"] + surround = wav(np.zeros((20, 3)), name="surround.wav") + assert client.get("/api/input", params={"path": surround.name}).status_code == 400 + source.write_bytes(b"not a wav") + response = client.get("/api/input", params={"path": source.name}) + assert response.status_code == 400 and "Unsupported" in response.text + + +def test_no_total_duration_cap_and_seek_to_end_of_four_hour_input(tmp_path, progress): + # Sparse mono PCM16 WAV. No real recordings touched and no huge allocation. + source = tmp_path / "four-hours.wav" + sr, seconds = 8000, 4 * 60 * 60 + data_bytes = sr * seconds * 2 + header = (b"RIFF" + struct.pack(" 100 + assert client.get("/api/schema").status_code == 401 diff --git a/lib/project/tests/test_gui_browser.py b/lib/project/tests/test_gui_browser.py new file mode 100644 index 0000000..1e38964 --- /dev/null +++ b/lib/project/tests/test_gui_browser.py @@ -0,0 +1,257 @@ +"""Opt-in real Chromium/loopback smoke; no AI models or recording uploads. + +Install Playwright externally, then run: +PRODUCER_BROWSER_TESTS=1 python -m pytest tests/test_gui_browser.py -q +Screenshots go to PRODUCER_BROWSER_ARTIFACTS (default /tmp/producer-browser-smoke). +The long overview case uses mocked fixed-size scan metadata, not 4-hour AI inference. +""" +from dataclasses import asdict +import hashlib +import json +import os +import re +from pathlib import Path +import socket +import threading +import time + +import numpy as np +import pytest + +pytestmark = pytest.mark.skipif(os.environ.get("PRODUCER_BROWSER_TESTS") != "1", reason="Opt-in Playwright browser smoke") + + +@pytest.fixture +def studio(tmp_path, wav, bypass, ffmpeg, monkeypatch): + pytest.importorskip("playwright.sync_api") + import uvicorn + from producer.gui import create_app + from producer import gui_jobs + + source = wav(0.15 * np.sin(2 * np.pi * 440 * np.arange(48000 * 8) / 48000), name="Narration.wav") + wav(np.zeros((100, 2)), name="Stereo.wav") + (tmp_path / "delivery").mkdir() + (tmp_path / "subfolder").mkdir() + control = {"hold": False, "started": threading.Event(), "release": threading.Event()} + actual = gui_jobs.render_preview + + def controlled_preview(*args): + if control["hold"]: + control["started"].set() + assert control["release"].wait(15), "Browser test did not release held preview" + control["hold"] = False + return actual(*args) + + monkeypatch.setattr(gui_jobs, "render_preview", controlled_preview) + token = "browser-test-private-credential" + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + sock.listen(128) + port = sock.getsockname()[1] + app = create_app(tmp_path, port, token, overrides=asdict(bypass)) + server = uvicorn.Server(uvicorn.Config(app, log_level="error", access_log=False, proxy_headers=False)) + thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True) + thread.start() + deadline = time.monotonic() + 10 + while not server.started and time.monotonic() < deadline: + time.sleep(0.02) + assert server.started + try: + yield {"url": f"http://127.0.0.1:{port}", "token": token, "root": tmp_path, "source": source, "control": control} + finally: + control["release"].set() + server.should_exit = True + thread.join(15) + sock.close() + assert not thread.is_alive() + + +def test_gui_browser_real_workflow(studio): + from playwright.sync_api import sync_playwright, expect + + screenshots = Path(os.environ.get("PRODUCER_BROWSER_ARTIFACTS", "/tmp/producer-browser-smoke")) + screenshots.mkdir(parents=True, exist_ok=True) + before = hashlib.sha256(studio["source"].read_bytes()).hexdigest() + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"]) + context = browser.new_context(viewport={"width": 1360, "height": 1000}, accept_downloads=True) + page = context.new_page() + errors, console_errors, media_requests = [], [], [] + page.on("pageerror", lambda exc: errors.append(str(exc))) + page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) + page.on("request", lambda req: media_requests.append(req.url) if "/api/results/" in req.url else None) + page.goto(studio["url"] + "/#token=" + studio["token"]) + expect(page.locator("#workspace")).not_to_have_attribute("disabled", "") + assert "#" not in page.url + assert studio["token"] not in page.locator("body").inner_text() + assert page.locator("[data-setting]").count() == 47 + expect(page.locator("#setting-denoiser")).to_have_value("none") + expect(page.locator("#folder-up")).to_be_disabled() + # Unsupported input errors are human-readable, not raw HTML. + page.get_by_role("button", name=re.compile(r"WAV\s*Stereo.wav")).click() + expect(page.locator("#error")).to_be_visible() + expect(page.locator("#error-text")).to_contain_text("Stereo") + page.locator("#dismiss-error").click() + page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=15000) + expect(page.locator("#input-info")).to_contain_text("00:00:08") + # Both drag selection and keyboard-accessible exact inputs. + page.locator("#waveform").scroll_into_view_if_needed() + box = page.locator("#waveform").bounding_box() + page.mouse.move(box["x"] + box["width"] * .25, box["y"] + 80) + page.mouse.down() + page.mouse.move(box["x"] + box["width"] * .65, box["y"] + 80, steps=5) + page.mouse.up() + assert page.locator("#selection-start").input_value() != "00:00:00" + page.locator("#selection-start").fill("00:00:02") + page.locator("#selection-end").fill("00:00:05") + page.locator("#zoom-selection").click() + page.locator("#zoom-full").click() + # Explicit render, requested pacing+MP3 preserved in snapshot but bypassed internally. + page.locator("#setting-trim_silence").check() + page.locator("#setting-mp3").check() + page.locator("#render-preview").click() + expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000) + assert len(media_requests) == 2 + page.locator("#compare").scroll_into_view_if_needed() + page.locator("#play").click() + expect(page.locator("#play")).to_have_text("Pause") + page.wait_for_timeout(350) + position = page.locator("#audio-original").evaluate("audio => audio.currentTime") + page.locator("#listen-processed").click() + expect(page.locator("#listen-processed")).to_have_attribute("aria-pressed", "true") + assert page.locator("#audio-processed").evaluate("audio => audio.currentTime") >= position - .15 + assert page.locator("#audio-original").evaluate("audio => audio.paused") + assert page.locator("#audio-processed").evaluate("audio => audio.loop") + page.locator("#match").check() + expect(page.locator("#monitoring-note")).to_contain_text("Monitoring only") + page.locator("#play").click() + expect(page.locator("#meters")).to_contain_text("not a full-export measurement") + page.locator("#comparison-name").fill("Gentle ") + page.locator("#save-comparison").click() + expect(page.locator("#comparisons")).to_contain_text("Gentle ") + assert page.locator("#comparisons snapshot").count() == 0 + page.locator("#presets-panel summary").click() + page.locator("#preset-name").fill("My sound") + page.locator("#save-preset").click() + expect(page.locator("#presets")).to_contain_text("My sound") + with page.expect_download() as downloaded: + page.locator("#download-preset").click() + toml_path = screenshots / "roundtrip-preset.toml" + downloaded.value.save_as(toml_path) + assert "trim_silence = true" in toml_path.read_text() + assert studio["token"] not in toml_path.read_text() + page.locator("#setting-denoise_strength").fill("0.4") + expect(page.locator("#comparison-status")).to_contain_text("changed since preview") + page.locator("#import-preset").set_input_files(toml_path) + expect(page.locator("#setting-denoise_strength")).to_have_value("0.85") + # The saved copy cannot change when controls are edited later. + page.locator("#setting-denoise_strength").fill("0.5") + saved = page.evaluate("JSON.parse(localStorage.getItem(Object.keys(localStorage).find(k => k.startsWith('producer-studio-v1:'))))") + assert saved["comparisons"][0]["snapshot"]["settings"]["denoise_strength"] == .85 + assert saved["presets"][0]["settings"]["denoise_strength"] == .85 + assert studio["token"] not in json.dumps(saved) + # Delay real preview completion. Editing after submission must never publish it as current. + studio["control"]["hold"] = True + page.locator("#render-preview").click() + assert studio["control"]["started"].wait(10) + old_source = page.locator("#comparison-source").inner_text() + page.locator("#setting-denoise_strength").fill("0.6") + studio["control"]["release"].set() + expect(page.locator("#jobs")).to_contain_text("Older snapshot; not loaded", timeout=30000) + assert page.locator("#comparison-source").inner_text() == old_source + assert len(media_requests) == 2 + # Restore is an explicit render and restores the original immutable requested settings. + page.locator("#comparisons button", has_text="Restore & render").click() + expect(page.locator("#setting-denoise_strength")).to_have_value("0.85") + expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000) + # Cross-field errors do not silently enable the limiter. + page.locator("#setting-normalize").check() + page.locator("#render-preview").click() + expect(page.locator("#error-text")).to_contain_text("limiter") + assert not page.locator("#setting-limiter").is_checked() + page.locator("#dismiss-error").click() + page.locator("#setting-normalize").uncheck() + page.locator("#output-dir").fill("delivery") + preview_requests = len(media_requests) + page.locator("#prepare-export").click() + expect(page.locator("#export-dialog")).to_be_visible() + expect(page.locator("#export-pacing")).to_contain_text("NOT auditioned") + expect(page.locator("#export-summary")).to_contain_text("whole original 00:00:08") + page.locator("#confirm-export").click() + expect(page.locator("#export-results")).to_contain_text("Full recording exported", timeout=30000) + outputs = list((studio["root"] / "delivery").glob("producer-export-*")) + assert len(outputs) == 1 + assert (outputs[0] / "master.wav").is_file() and (outputs[0] / "master.mp3").is_file() + assert (outputs[0] / "logs/master.report.json").is_file() + import soundfile as sf + assert sf.info(outputs[0] / "master.wav").duration == pytest.approx(8, abs=.01) + assert len(media_requests) == preview_requests, "Full exports must not be fetched into browser blobs" + page.locator("#prepare-export").click() + page.locator("#confirm-export").click() + expect(page.locator("#export-results .export-result")).to_have_count(2, timeout=30000) + assert len(list((studio["root"] / "delivery").glob("producer-export-*"))) == 2 + assert hashlib.sha256(studio["source"].read_bytes()).hexdigest() == before + page.locator("#compare").scroll_into_view_if_needed() + page.screenshot(path=str(screenshots / "desktop-comparison.png"), full_page=True) + page.locator("#compare").screenshot(path=str(screenshots / "comparison-panel.png")) + # Reload retains per-tab authorization and local snapshots, not misleading current audio. + page.reload() + expect(page.locator("#workspace")).not_to_have_attribute("disabled", "") + expect(page.locator("#comparisons")).to_contain_text("Gentle ") + expect(page.locator("#play")).to_be_disabled() + expect(page.locator("#export-results .export-result")).to_have_count(2, timeout=15000) + page.set_viewport_size({"width": 390, "height": 844}) + page.screenshot(path=str(screenshots / "mobile-studio.png"), full_page=True) + page.evaluate("window.scrollTo(0, 0)") + page.screenshot(path=str(screenshots / "mobile-entry.png")) + assert page.evaluate("document.documentElement.scrollWidth <= innerWidth") + # A fresh tab without the private fragment cannot read API data. + unauth = context.new_page() + unauth.goto(studio["url"]) + expect(unauth.locator("#error-text")).to_contain_text("private URL") + expect(unauth.locator("#workspace")).to_have_attribute("disabled", "") + unauth.close() + # Simulate server restart credential expiry and verify recovery guidance. + page.route("**/api/jobs", lambda route: route.fulfill(status=401, json={"detail": "expired"})) + expect(page.locator("#workspace")).to_have_attribute("disabled", "", timeout=10000) + expect(page.locator("#connection")).to_contain_text("Session expired") + assert errors == [] + # Expected HTTP errors for stereo, invalid constraint and the expiry test are allowed. + assert not [msg for msg in console_errors if "Content Security Policy" in msg or "Refused" in msg] + browser.close() + + +def test_gui_browser_long_overview(studio): + from playwright.sync_api import sync_playwright, expect + + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"]) + page = browser.new_page(viewport={"width": 1200, "height": 900}) + requests, errors = [], [] + page.on("request", lambda request: requests.append(request.url)) + page.on("pageerror", lambda exc: errors.append(str(exc))) + page.goto(studio["url"] + "/#token=" + studio["token"]) + expect(page.locator("#workspace")).not_to_have_attribute("disabled", "") + # Four-hour metadata plus fixed-size peaks: browser never receives full recording samples. + info = {"path": "Narration.wav", "input_id": "long-identity", "frames": 14400 * 8000, + "duration": 14400, "sample_rate": 8000, "channels": 1, "subtype": "PCM_16"} + result = {"input_id": "long-identity", "duration": 14400, "frames": info["frames"], "sample_rate": 8000, + "bins": 2048, "min": [-.3] * 2048, "max": [.4] * 2048} + job = {"id": "mock-scan", "kind": "scan", "snapshot": {"path": "Narration.wav", "input_id": "long-identity"}, + "status": "succeeded", "progress": {"stage": "Done", "completed": 1, "total": 1}, + "created": time.time(), "result": result, "error": None, "cancel_requested": False, "cached": False} + page.route("**/api/input?*", lambda route: route.fulfill(json=info)) + page.route("**/api/jobs", lambda route: route.fulfill(json=job if route.request.method == "POST" else {"jobs": [job]})) + page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) + expect(page.locator("#wave-end")).to_have_text("04:00:00") + page.locator("#selection-start").fill("03:59:30") + page.locator("#selection-end").fill("04:00:00") + expect(page.locator("#selection-note")).to_contain_text("30 seconds selected") + page.locator("#zoom-selection").click() + expect(page.locator("#wave-start")).to_have_text("03:59:15") + assert len(json.dumps(result)) < 50000 + assert not [url for url in requests if "/api/results/" in url] + assert errors == [] + browser.close() diff --git a/lib/project/tests/test_gui_browser_races.py b/lib/project/tests/test_gui_browser_races.py new file mode 100644 index 0000000..fab60ba --- /dev/null +++ b/lib/project/tests/test_gui_browser_races.py @@ -0,0 +1,210 @@ +"""Additional opt-in browser race/cancellation coverage; uses the real API fixture.""" +import os +import re + +import pytest + +from test_gui_browser import studio # noqa: F401 -- shared real loopback fixture + +pytestmark = pytest.mark.skipif(os.environ.get("PRODUCER_BROWSER_TESTS") != "1", reason="Opt-in Playwright browser smoke") + + +def test_gui_browser_cancellation_is_not_pretend(studio): + from playwright.sync_api import sync_playwright, expect + + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"]) + page = browser.new_page() + page.goto(studio["url"] + "/#token=" + studio["token"]) + page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) + studio["control"]["hold"] = True + page.locator("#render-preview").click() + assert studio["control"]["started"].wait(10) + expect(page.locator("#jobs .job").filter(has_text="running")).to_have_count(1, timeout=10000) + page.locator("#setting-denoise_strength").fill("0.4") + page.locator("#render-preview").click() + queued = page.locator("#jobs .job").filter(has_text="queued") + expect(queued).to_have_count(1) + queued.get_by_role("button", name="Cancel", exact=True).click() + expect(page.locator("#jobs .job").filter(has_text="cancelled")).to_have_count(1) + running = page.locator("#jobs .job").filter(has_text="running") + running.get_by_role("button", name="Cancel", exact=True).click() + expect(page.locator("#jobs")).to_contain_text("Cancelling…") + assert page.locator("#jobs .job").filter(has_text="cancelled").count() == 1 + studio["control"]["release"].set() + expect(page.locator("#jobs .job").filter(has_text="cancelled")).to_have_count(2, timeout=10000) + expect(page.locator("#play")).to_be_disabled() + browser.close() + + +def test_gui_browser_late_audio_is_aborted_and_urls_are_released(studio): + from playwright.sync_api import sync_playwright, expect + + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"]) + page = browser.new_page() + page.add_init_script("""window.objectURLs = {created: [], revoked: []}; + const create = URL.createObjectURL.bind(URL), revoke = URL.revokeObjectURL.bind(URL); + URL.createObjectURL = blob => { const url = create(blob); objectURLs.created.push(url); return url; }; + URL.revokeObjectURL = url => { objectURLs.revoked.push(url); revoke(url); };""") + page.goto(studio["url"] + "/#token=" + studio["token"]) + page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) + page.locator("#selection-start").fill("1") + page.locator("#selection-end").fill("4") + page.locator("#render-preview").click() + expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000) + first_source = page.locator("#comparison-source").inner_text() + first_urls = page.evaluate("objectURLs.created.slice()") + assert len(first_urls) == 2 + held = [] + + def hold(route): + response = route.fetch() + held.append((route, response)) + + page.route("**/api/results/**", hold) + page.locator("#selection-start").fill("2") + page.locator("#render-preview").click() + for _ in range(200): + if len(held) == 2: + break + page.wait_for_timeout(50) + assert len(held) == 2 + expect(page.locator("#comparison-status")).to_contain_text("Loading") + page.locator("#selection-start").fill("3") + for route, response in held: + route.fulfill(response=response) + page.wait_for_timeout(500) + assert page.locator("#comparison-source").inner_text() == first_source + expect(page.locator("#comparison-status")).to_contain_text("changed since preview") + assert page.evaluate("objectURLs.created.length") == 2 + page.unroute("**/api/results/**", hold) + page.locator("#render-preview").click() + expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000) + assert page.evaluate("objectURLs.created.length") == 4 + assert page.evaluate("objectURLs.revoked") == first_urls + browser.close() + + +def test_gui_browser_nonzero_monitoring_gain_is_playback_only(studio): + from playwright.sync_api import sync_playwright, expect + + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"]) + page = browser.new_page() + page.add_init_script("""window.monitorGains = []; + const createGain = AudioContext.prototype.createGain; + AudioContext.prototype.createGain = function() { + const gain = createGain.call(this); monitorGains.push(gain); return gain; + };""") + page.goto(studio["url"] + "/#token=" + studio["token"]) + page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) + page.locator("#advanced-settings summary").click() + # Real FFmpeg leveling makes a measurable difference without any AI runtime. + page.locator("#setting-leveling").check() + page.locator("#setting-level_target_db").fill("-12") + page.locator("#render-preview").click() + expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000) + requested = page.locator("#preview-settings").text_content() + meters = page.locator("#meters").inner_text() + page.locator("#match").check() + expect(page.locator("#monitoring-note")).to_contain_text("Monitoring only") + gains = page.evaluate("monitorGains.map(node => node.gain.value)") + assert len(gains) == 2 + assert all(0 < gain <= 1 for gain in gains) + assert min(gains) < .9, "A nonzero measured loudness difference should attenuate one playback side" + assert page.locator("#preview-settings").text_content() == requested + assert page.locator("#meters").inner_text() == meters + page.locator("#prepare-export").click() + expect(page.locator("#export-dialog")).to_be_visible() + frozen_export = page.locator("#export-settings").text_content() + assert '"level_target_db": -12' in frozen_export + assert "original_gain_db" not in frozen_export and "processed_gain_db" not in frozen_export + assert "monitoring" not in frozen_export + page.locator("#cancel-export").click() + page.locator("#match").uncheck() + assert page.evaluate("monitorGains.map(node => node.gain.value)") == [1, 1] + browser.close() + + +@pytest.mark.parametrize("kind", ["scan", "preview"]) +def test_delayed_job_list_preserves_new_submission_intent(studio, kind): + from playwright.sync_api import sync_playwright, expect + + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"]) + page = browser.new_page() + page.goto(studio["url"] + "/#token=" + studio["token"]) + if kind == "preview": + page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) + held = [] + + def hold_one_list(route): + if route.request.method == "GET" and not held: + held.append((route, route.fetch())) + else: + route.continue_() + + page.route("**/api/jobs", hold_one_list) + for _ in range(100): + if held: + break + page.wait_for_timeout(50) + assert held, "Expected an in-flight list captured before the new submission" + with page.expect_response(lambda response: response.request.method == "POST" + and response.url.endswith("/api/jobs")) as posted: + if kind == "scan": + page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + else: + page.locator("#render-preview").click() + assert posted.value.status == 202 + expect(page.locator("#jobs .job").filter(has_text=kind.upper())).to_have_count(1) + route, response = held[0] + route.fulfill(response=response) + if kind == "scan": + expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) + else: + expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000) + browser.close() + + +@pytest.mark.parametrize("interruption", ["switch", "edit", "expire"]) +def test_delayed_audio_resume_does_not_start_superseded_playback(studio, interruption): + from playwright.sync_api import sync_playwright, expect + + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"]) + page = browser.new_page() + page.add_init_script("""window.releaseAudioResume = null; + const resume = AudioContext.prototype.resume; + AudioContext.prototype.resume = function() { + return new Promise(resolve => { + window.releaseAudioResume = () => { resume.call(this).then(resolve); }; + }); + };""") + page.goto(studio["url"] + "/#token=" + studio["token"]) + page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) + page.locator("#render-preview").click() + expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000) + page.locator("#play").click() + page.wait_for_function("typeof releaseAudioResume === 'function'") + if interruption == "switch": + page.locator("#listen-processed").click() + expect(page.locator("#listen-processed")).to_have_attribute("aria-pressed", "true") + elif interruption == "edit": + page.locator("#setting-denoise_strength").fill("0.4") + else: + page.route("**/api/jobs", lambda route: route.fulfill( + status=401, content_type="application/json", body='{"detail":"Expired"}')) + expect(page.locator("#workspace")).to_have_attribute("disabled", "", timeout=10000) + page.evaluate("releaseAudioResume()") + # Let both the resume and any erroneously scheduled play promise settle. + page.wait_for_timeout(300) + assert page.locator("#audio-original").evaluate("audio => audio.paused") + assert page.locator("#audio-processed").evaluate("audio => audio.paused") + browser.close() diff --git a/lib/project/tests/test_gui_jobs.py b/lib/project/tests/test_gui_jobs.py new file mode 100644 index 0000000..6116a72 --- /dev/null +++ b/lib/project/tests/test_gui_jobs.py @@ -0,0 +1,124 @@ +"""Bounded single-worker scheduling and cancellation through real process cleanup.""" +import os +import sys +import threading +import time + +import pytest + +from producer import gui_jobs, setup +from producer.gui import rooted + + +def terminal(jobs, job_id): + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + job = jobs.get(job_id) + if job["status"] in ("succeeded", "failed", "cancelled"): + return job + time.sleep(0.01) + pytest.fail("Job failed to finish") + + +def test_single_worker_bounded_queue_and_running_process_cancellation(wav, tmp_path, monkeypatch): + source = wav() + started = threading.Event() + pid_file = tmp_path / "child.pid" + calls = [] + def slow_scan(path, progress): + calls.append(path) + if len(calls) == 1: + started.set() + setup.run_process([sys.executable, "-c", + f"import os,time;open({str(pid_file)!r},'w').write(str(os.getpid()));time.sleep(60)"], + "Waiting for worker", progress) + return {"bins": 2048} + monkeypatch.setattr(gui_jobs, "waveform", slow_scan) + jobs = gui_jobs.Jobs(tmp_path, lambda value: rooted(tmp_path, value)) + snapshot = {"path": source.name, "input_id": gui_jobs.identity(source)} + try: + first = jobs.submit("scan", snapshot) + assert started.wait(5) + # Public snapshots cannot mutate the immutable in-flight job. + first["snapshot"]["path"] = "missing.wav" + queued = [jobs.submit("scan", snapshot) for _ in range(3)] + with pytest.raises(gui_jobs.Busy): + jobs.submit("scan", snapshot) + assert len(calls) == 1 + assert jobs.cancel(queued[0]["id"])["status"] == "cancelled" + deadline = time.monotonic() + 5 + while not pid_file.exists() and time.monotonic() < deadline: + time.sleep(0.01) + pid = int(pid_file.read_text()) + jobs.cancel(first["id"]) + assert terminal(jobs, first["id"])["status"] == "cancelled" + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) + assert terminal(jobs, queued[1]["id"])["status"] == "succeeded" + assert terminal(jobs, queued[2]["id"])["cached"] + assert len(calls) == 2 + finally: + jobs.close() + assert not jobs.work.exists() + + +def test_history_and_result_cache_are_bounded_and_evicted_media_expires(wav, tmp_path, monkeypatch): + source = wav() + monkeypatch.setattr(gui_jobs, "MAX_HISTORY", 5) + monkeypatch.setattr(gui_jobs, "CACHE_ENTRIES", 2) + def preview(path, work, snapshot, progress): + (work / "original.wav").write_bytes(b"preview") + (work / "processed.wav").write_bytes(b"preview") + return {} + monkeypatch.setattr(gui_jobs, "render_preview", preview) + jobs = gui_jobs.Jobs(tmp_path, lambda value: rooted(tmp_path, value)) + snapshot = {"path": source.name, "input_id": gui_jobs.identity(source)} + try: + results = [] + for index in range(10): + job = jobs.submit("preview", snapshot | {"start": index}) + result = terminal(jobs, job["id"]) + assert result["status"] == "succeeded", result + results.append(result["result"]["result_id"]) + assert len(jobs.list()) == 5 + assert len(jobs.cache) == 2 + assert len(list(jobs.work.iterdir())) == 2 + with pytest.raises(KeyError): + jobs.media(results[0], "original") + assert jobs.media(results[-1], "original") == b"preview" + with pytest.raises(KeyError): + jobs.media(results[-1], "../original") + # Byte budget independently evicts even below the entry-count limit. + monkeypatch.setattr(gui_jobs, "CACHE_BYTES", 1) + with jobs.condition: + jobs._evict() + assert not jobs.cache + assert not list(jobs.work.iterdir()) + finally: + jobs.close() + + +def test_queued_input_replacement_is_rejected_and_shutdown_cancels_scan(wav, tmp_path, monkeypatch): + source = wav() + started = threading.Event() + def scan(path, progress): + started.set() + while True: + progress("Scanning", 1, 10) + time.sleep(0.01) + monkeypatch.setattr(gui_jobs, "waveform", scan) + jobs = gui_jobs.Jobs(tmp_path, lambda value: rooted(tmp_path, value)) + snapshot = {"path": source.name, "input_id": gui_jobs.identity(source)} + running = jobs.submit("scan", snapshot) + assert started.wait(5) + queued = jobs.submit("scan", snapshot) + with source.open("ab") as handle: + handle.write(b"replacement") + jobs.cancel(running["id"]) + assert terminal(jobs, queued["id"])["status"] == "failed" + assert "changed" in jobs.get(queued["id"])["error"] + # Submit with updated identity and exercise app shutdown cleanup. + last = jobs.submit("scan", snapshot | {"input_id": gui_jobs.identity(source)}) + jobs.close() + assert jobs.get(last["id"])["status"] == "cancelled" + assert not jobs.work.exists() diff --git a/lib/project/tests/test_gui_launcher.py b/lib/project/tests/test_gui_launcher.py new file mode 100644 index 0000000..8111c67 --- /dev/null +++ b/lib/project/tests/test_gui_launcher.py @@ -0,0 +1,113 @@ +"""Exercise bootstrap/config routing without downloading or changing the real runtime.""" +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from producer import cli + +LAUNCHER = Path(__file__).resolve().parents[3] / "producer.sh" + + +def test_cli_gui_dispatch_preserves_configuration_flags(tmp_path, monkeypatch): + from producer import gui + config = tmp_path / "config.toml" + config.write_text('profile = "narrator"\n') + received = [] + monkeypatch.setattr(gui, "serve", lambda *args, **kwargs: received.append((args, kwargs))) + monkeypatch.setattr(sys, "argv", ["producer", "gui", "--root", str(tmp_path), "--port", "9001", + "--config", str(config), "--profile", "radio", "--target-lufs", "-21", "--set", "target_lufs=-18"]) + cli.main() + assert received == [((tmp_path, 9001), {"config": config, "profile": "radio", "overrides": {"target_lufs": -18}})] + + +def test_launcher_only_gui_installs_extra_and_preserves_config_injection(tmp_path): + checkout = tmp_path / "checkout" + checkout.mkdir() + launcher = checkout / "producer.sh" + launcher.write_text(LAUNCHER.read_text()) + launcher.chmod(0o755) + (checkout / "config.toml").write_text('profile = "narrator"\n') + (checkout / "lib/project").mkdir(parents=True) + tools = tmp_path / "tools" + tools.mkdir() + log, forwarded = tmp_path / "install.jsonl", tmp_path / "forwarded.json" + fake_python = (f"#!{sys.executable}\nimport json,sys\nfrom pathlib import Path\n" + f"if '-m' in sys.argv: Path({str(forwarded)!r}).write_text(json.dumps(sys.argv[1:]))\n") + uv = tools / "uv" + uv.write_text(f"#!{sys.executable}\n" + "import json,sys\nfrom pathlib import Path\n" + f"with Path({str(log)!r}).open('a') as out: out.write(json.dumps(sys.argv[1:])+'\\n')\n" + "if sys.argv[1] == 'venv':\n" + " python = Path(sys.argv[-1]) / 'bin/python'\n" + " python.parent.mkdir(parents=True, exist_ok=True)\n" + f" python.write_text({fake_python!r})\n" + " python.chmod(0o755)\n") + uv.chmod(0o755) + env = os.environ.copy() | {"PATH": f"{tools}:{os.environ['PATH']}"} + def launch(*args): + result = subprocess.run([str(launcher), *args], cwd=checkout, env=env, + capture_output=True, text=True, timeout=15) + assert result.returncode == 0, result.stderr + return json.loads(forwarded.read_text()) + args = launch("config", "--json") + assert args == ["-I", "-m", "producer", "config", f"--config={checkout / 'config.toml'}", "--json"] + calls = [json.loads(line) for line in log.read_text().splitlines()] + installs = [call for call in calls if call[0] == "pip"] + assert len(installs) == 1 and installs[0][-1].endswith("[bundled-ffmpeg]") + args = launch("gui", "--root", str(checkout), "--port", "8766") + assert args[3:5] == ["gui", f"--config={checkout / 'config.toml'}"] + calls = [json.loads(line) for line in log.read_text().splitlines()] + installs = [call for call in calls if call[0] == "pip"] + assert len(installs) == 2 and installs[1][-1].endswith("[bundled-ffmpeg,gui]") + before = log.read_text() + args = launch("gui", "--config=custom.toml") + assert args[-2:] == ["gui", "--config=custom.toml"] + launch("config") + assert log.read_text() == before # GUI/core marker reuse, no extra installs. + + +def test_cli_commands_import_without_optional_web_dependencies(): + # A fresh interpreter explicitly denies optional imports, even if installed. + code = """ +import sys +class Deny: + def find_spec(self, fullname, *args): + if fullname.split('.')[0] in ('fastapi', 'uvicorn', 'starlette'): + raise AssertionError('CLI imported optional GUI dependency: ' + fullname) +sys.meta_path.insert(0, Deny()) +from producer.cli import parser +assert parser().parse_args(['config']).command == 'config' +""" + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + + +def test_server_binds_only_loopback_and_prints_fragment_token(tmp_path, monkeypatch, capsys): + pytest.importorskip("fastapi") + uvicorn = pytest.importorskip("uvicorn") + import socket + from producer import gui + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + port = reservation.getsockname()[1] + seen = [] + class Server: + def __init__(self, config): + assert config.host == "127.0.0.1" + assert config.proxy_headers is False + assert config.access_log is False + def run(self, sockets): + seen.append(sockets[0].getsockname()) + monkeypatch.setattr(uvicorn, "Server", Server) + gui.serve(tmp_path, port) + assert seen == [("127.0.0.1", port)] + output = capsys.readouterr().out + assert f"http://127.0.0.1:{port}/#token=" in output + assert len(output.split("#token=")[1].splitlines()[0]) >= 43 + for invalid in (0, -1, 65536): + with pytest.raises(ValueError): + gui.serve(tmp_path, invalid) diff --git a/lib/project/tests/test_pauses.py b/lib/project/tests/test_pauses.py new file mode 100644 index 0000000..b67f98d --- /dev/null +++ b/lib/project/tests/test_pauses.py @@ -0,0 +1,200 @@ +import hashlib +import io +import sys +from types import SimpleNamespace + +import numpy as np +import pytest +import soundfile as sf + +from producer import pauses, pipeline +from producer.config import resolve + + +def test_hysteresis_keeps_short_words_and_closes_at_eof(): + values = [0, 0.5, 0.25, 0.1, 0, 0.8] + regions = pauses.speech_regions(values, 6 * 1536 - 7, 48000, 0.35) + assert regions == [(1536, 4608), (7680, 6 * 1536 - 7)] + + +@pytest.mark.parametrize("values", [[0.5], [0.5, float("nan")], [0.5, 1.1], [0.5, "bad"]]) +def test_invalid_detector_output_is_not_silently_accepted(values): + with pytest.raises(RuntimeError): + pauses.speech_regions(values, 3072, 48000, 0.35) + + +def test_padding_merges_close_words_but_keeps_long_gaps(): + settings = resolve() + # One millisecond per sample makes these expected boundaries easy to read. + speech = [(500, 800), (1000, 1300), (2000, 2300)] + assert pauses.pause_regions(speech, 3000, 1000, settings) == [ + (0, 380), (1420, 1880), (2420, 3000)] + # No room for padding plus fade plus the requested zero core: leave alone. + assert pauses.pause_regions([(0, 100), (400, 1000)], 1000, 1000, settings) == [] + + +def test_streamed_envelope_preserves_onsets_and_duration(wav, tmp_path, progress): + sr = 48000 + audio = np.full(3 * sr + 7, 0.001, dtype="float32") + source = wav(audio) + target = tmp_path / "silenced.wav" + # Deliberately straddle the 65536-sample processing boundary. + a, b, fade = 60000, 100000, 480 + pauses.silence_pauses(source, target, [(a, b)], 10, progress) + result, rate = sf.read(target, dtype="float32") + assert rate == sr and result.shape == audio.shape + np.testing.assert_array_equal(result[:a + 1], audio[:a + 1]) + np.testing.assert_array_equal(result[b - 1:], audio[b - 1:]) + assert np.count_nonzero(result[a + fade:b - fade]) == 0 + assert np.all(np.diff(result[a:a + fade]) <= 0) + assert np.all(np.diff(result[b - fade:b]) >= 0) + assert np.max(np.abs(np.diff(result))) < 0.00001 + + +def test_file_edges_do_not_fade_noise_back_in(wav, tmp_path, progress): + source = wav(np.full(48000, 0.001)) + target = tmp_path / "edges.wav" + pauses.silence_pauses(source, target, [(0, 12000), (36000, 48000)], 10, progress) + data = sf.read(target)[0] + assert np.count_nonzero(data[:11520]) == 0 + assert np.count_nonzero(data[36480:]) == 0 + np.testing.assert_array_equal(data[12000:36000], sf.read(source)[0][12000:36000]) + + +def test_no_intervals_is_exact_bypass(wav, tmp_path, progress): + source = wav(np.random.default_rng(0).normal(0, 0.01, 200001)) + target = tmp_path / "out.wav" + pauses.silence_pauses(source, target, [], 10, progress) + np.testing.assert_array_equal(sf.read(source)[0], sf.read(target)[0]) + with pytest.raises(ValueError, match="distinct"): + pauses.silence_pauses(source, source, [], 10, progress) + + +@pytest.mark.parametrize("rate", [44100, 48000]) +def test_pipeline_detects_before_denoise_and_exports_zero_cores( + wav, tmp_path, progress, ffmpeg, monkeypatch, rate): + sr = 48000 + t = np.arange(2 * sr) / sr + speech = 0.15 * np.sin(2 * np.pi * 220 * t) + audio = np.concatenate([speech, np.full(sr, 0.001), speech]) + source = wav(audio) + original = source.read_bytes() + events = [] + + def detect(prepared, *args): + events.append("detect") + data = sf.read(prepared)[0] + assert np.max(np.abs(data)) > 0.14 # Not the attenuated denoiser output. + return [(2 * sr + 5760, 3 * sr - 5760)], {"speech_regions": 2, "pauses_silenced": 1} + + def denoise(prepared, target, *args, **kwargs): + assert events == ["detect"] + events.append("denoise") + data, sr = sf.read(prepared) + sf.write(target, data * 0.8, sr, subtype="FLOAT") + + monkeypatch.setattr(pauses, "detect_pauses", detect) + monkeypatch.setattr("producer.ai.denoise", denoise) + settings = resolve(overrides={"pause_silence": True, "sample_rate": rate}) + target = tmp_path / "master.wav" + report = pipeline.process(source, target, settings, progress) + data, sr = sf.read(target) + assert sr == rate and len(data) == 5 * rate + # Resampling and dynamics may have tails at edges; the interior is zero, + # even after full normalization and final 32-bit encoding. + assert np.count_nonzero(data[int(2.3 * rate):int(2.7 * rate)]) == 0 + assert report["pause_cleanup"]["pauses_silenced"] == 1 + assert report["silence_removed_seconds"] == 0 + assert abs(report["master_loudness"]["input_i"] - settings.target_lufs) <= 0.5 + assert report["master_loudness"]["input_tp"] <= settings.true_peak_db + 0.1 + assert source.read_bytes() == original + + +def test_no_speech_fails_open_and_skips_requested_trim( + wav, tmp_path, bypass, progress, ffmpeg, monkeypatch): + bypass.pause_silence = bypass.trim_silence = True + monkeypatch.setattr(pauses, "detect_pauses", lambda *args: ([], {"speech_regions": 0})) + source = wav() + report = pipeline.process(source, tmp_path / "out.wav", bypass, progress) + assert report["master"]["frames"] == report["input"]["frames"] + assert any("found no speech" in warning for warning in report["warnings"]) + assert report["gaps_trimmed"] == 0 + + +def test_detector_failure_does_not_publish_or_fallback( + wav, tmp_path, bypass, progress, ffmpeg, monkeypatch): + bypass.pause_silence = True + def fail(*args): + raise RuntimeError("injected detector failure") + monkeypatch.setattr(pauses, "detect_pauses", fail) + target = tmp_path / "master.wav" + target.write_bytes(b"previous") + with pytest.raises(RuntimeError, match="injected detector"): + pipeline.process(wav(), target, bypass, progress, overwrite=True) + assert target.read_bytes() == b"previous" + assert not list(tmp_path.glob(".producer-*")) + + +def test_vad_trimming_only_removes_zero_cores(wav, tmp_path, bypass, progress, ffmpeg, monkeypatch): + sr = 48000 + bypass.pause_silence = bypass.trim_silence = True + bypass.gap_threshold_db = -20 # Would eat this entire quiet recording in legacy mode. + speech = np.full(sr, 0.005) + source = wav(np.concatenate([speech, np.full(3 * sr, 0.001), speech])) + monkeypatch.setattr(pauses, "detect_pauses", lambda *args: + ([(sr + 5760, 4 * sr - 5760)], {"speech_regions": 2})) + target = tmp_path / "out.wav" + report = pipeline.process(source, target, bypass, progress) + data = sf.read(target)[0] + np.testing.assert_allclose(data[:sr], speech, atol=1e-8) + np.testing.assert_allclose(data[-sr:], speech, atol=1e-8) + assert report["gaps_trimmed"] == 1 + assert report["silence_removed_seconds"] > 1 + + +def test_worker_uses_state_context_and_pads_partial_last_frame(wav, tmp_path, monkeypatch): + from producer.vad_worker import probabilities + calls = [] + class Session: + def __init__(self, *args, **kwargs): + assert kwargs["providers"] == ["CPUExecutionProvider"] + def run(self, _, inputs): + calls.append(inputs) + return np.array([[0.75]]), inputs["state"] + 1 + monkeypatch.setitem(sys.modules, "onnxruntime", SimpleNamespace( + SessionOptions=SimpleNamespace, InferenceSession=Session)) + audio = np.arange(600, dtype="float32") / 600 + path = tmp_path / "vad.wav" + sf.write(path, audio, 16000, subtype="FLOAT") + assert probabilities(path, tmp_path / "model") == [0.75, 0.75] + assert calls[0]["input"].shape == (1, 576) + assert np.count_nonzero(calls[0]["input"][0, :64]) == 0 + np.testing.assert_array_equal(calls[1]["input"][0, :64], audio[448:512]) + assert np.count_nonzero(calls[1]["input"][0, 152:]) == 0 + assert np.all(calls[1]["state"] == 1) + + +def test_model_download_is_verified_and_cached(tmp_path, monkeypatch, progress): + root = tmp_path / "runtime" + monkeypatch.setattr(pauses, "data_dir", lambda: root) + environment = root / "vad-onnx-1.20.1-py311-v1" + (environment / "bin").mkdir(parents=True) + (environment / "bin/python").touch() + (environment / ".producer-ready").touch() + payload = b"test model" + monkeypatch.setattr(pauses, "MODEL_SHA256", hashlib.sha256(payload).hexdigest()) + downloads = [] + def download(*args, **kwargs): + downloads.append(args) + return io.BytesIO(payload) + monkeypatch.setattr(pauses.urllib.request, "urlopen", download) + python, model = pauses.ensure_vad(progress) + assert model.read_bytes() == payload + assert pauses.ensure_vad(progress) == (python, model) + assert len(downloads) == 1 + model.write_bytes(b"corrupt cached model") + monkeypatch.setattr(pauses.urllib.request, "urlopen", lambda *a, **k: io.BytesIO(b"bad")) + with pytest.raises(RuntimeError, match="checksum mismatch"): + pauses.ensure_vad(progress) + assert model.read_bytes() == b"corrupt cached model" + assert not list(model.parent.glob(".silero-*")) -- cgit v1.2.3