aboutsummaryrefslogtreecommitdiff
path: root/lib/project/tests
diff options
context:
space:
mode:
Diffstat (limited to 'lib/project/tests')
-rw-r--r--lib/project/tests/conftest.py25
-rw-r--r--lib/project/tests/test_cli.py52
-rw-r--r--lib/project/tests/test_gui.py87
-rw-r--r--lib/project/tests/test_gui_browser.py39
-rw-r--r--lib/project/tests/test_gui_browser_races.py31
-rw-r--r--lib/project/tests/test_pipeline.py88
6 files changed, 240 insertions, 82 deletions
diff --git a/lib/project/tests/conftest.py b/lib/project/tests/conftest.py
index 0155384..9cde05f 100644
--- a/lib/project/tests/conftest.py
+++ b/lib/project/tests/conftest.py
@@ -12,6 +12,31 @@ from producer.config import resolve
from producer.setup import ensure_ffmpeg
+@pytest.fixture(autouse=True)
+def producer_root(tmp_path, monkeypatch):
+ """Isolated shared logs directory for every test: report logs always go
+ under <PRODUCER_ROOT>/logs."""
+ root = tmp_path / "producer-root"
+ monkeypatch.setenv("PRODUCER_ROOT", str(root))
+ return root
+
+
+@pytest.fixture
+def logs(producer_root):
+ return producer_root / "logs"
+
+
+@pytest.fixture
+def report_of(logs):
+ """Locate the one published report for an audio destination; report names
+ are timestamped, so tests glob by stem."""
+ def find(target):
+ matches = sorted(logs.glob(f"{target.stem}.*.report.json"))
+ assert len(matches) == 1, matches
+ return matches[0]
+ return find
+
+
@pytest.fixture
def progress():
return lambda *_: None
diff --git a/lib/project/tests/test_cli.py b/lib/project/tests/test_cli.py
index 05c8292..31eadf9 100644
--- a/lib/project/tests/test_cli.py
+++ b/lib/project/tests/test_cli.py
@@ -19,7 +19,7 @@ def invoke(monkeypatch):
def test_preview_real_audio_preserves_original_and_reports_provenance(
- wav, tmp_path, ffmpeg, invoke, capsys, monkeypatch
+ wav, tmp_path, ffmpeg, invoke, capsys, monkeypatch, report_of
):
time = np.arange(48000 * 7) / 48000
source = wav(0.15 * np.sin(2 * np.pi * 440 * time))
@@ -44,13 +44,15 @@ def test_preview_real_audio_preserves_original_and_reports_provenance(
assert "Preview:" in captured.err
assert source.read_bytes() == original
assert len(modes) == 2
+ # --no-normalize --no-limiter differ from every built-in profile: the
+ # outputs are named 'custom', with the compared profile kept for clarity.
assert {path.name for path in output_dir.iterdir()} == {
- f"input.{profile}.preview.wav"
+ f"input.custom.{profile}.preview.wav"
for profile in ("natural", "radio")
- } | {"logs"}
+ }
for profile, mode in zip(("natural", "radio"), modes):
- target = output_dir / f"input.{profile}.preview.wav"
- report = json.loads(pipeline.report_for(target).read_text())
+ target = output_dir / f"input.custom.{profile}.preview.wav"
+ report = json.loads(report_of(target).read_text())
info = sf.info(target)
assert info.duration == pytest.approx(5, abs=0.02)
assert info.channels == 1
@@ -123,7 +125,7 @@ def test_missing_input_fails_before_tool_setup(tmp_path, invoke, capsys, monkeyp
assert not target.exists()
-def test_existing_output_is_not_overwritten(wav, tmp_path, invoke, capsys, monkeypatch):
+def test_existing_output_is_not_overwritten(wav, tmp_path, invoke, capsys, monkeypatch, logs):
source = wav()
original = source.read_bytes()
target = tmp_path / "master.wav"
@@ -135,7 +137,7 @@ def test_existing_output_is_not_overwritten(wav, tmp_path, invoke, capsys, monke
assert "Output exists" in capsys.readouterr().err
assert target.read_bytes() == b"previous master"
assert source.read_bytes() == original
- assert not pipeline.report_for(target).exists()
+ assert not list(logs.glob("*.report.json"))
def test_overwrite_cannot_replace_input(wav, invoke, capsys, monkeypatch):
@@ -169,19 +171,23 @@ def test_default_outputs_are_written_beside_each_input(wav, tmp_path, ffmpeg, in
sources = [wav(name="a/take.wav"), wav(name="b/take.wav")]
invoke("process", *sources, "--no-denoise")
for source in sources:
+ # --no-denoise differs from the built-in profile, so outputs are named
+ # 'custom'. Report logs never appear beside the outputs.
assert {path.name for path in source.parent.iterdir()} == {
- "take.wav", "take.natural.wav", "logs"}
+ "take.wav", "take.custom.wav"}
captured = capsys.readouterr()
assert captured.out == ""
assert captured.err.count("Written:") == 2
-def test_process_keyword_is_optional(wav, tmp_path, ffmpeg, invoke, capsys):
+def test_process_keyword_is_optional(wav, tmp_path, ffmpeg, invoke, capsys, report_of):
source = wav()
original = source.read_bytes()
invoke(source, "--no-denoise")
assert {path.name for path in tmp_path.iterdir()} == {
- "input.wav", "input.natural.wav", "logs"}
+ "input.wav", "input.custom.wav", "producer-root"}
+ report = json.loads(report_of(tmp_path / "input.custom.wav").read_text())
+ assert report["settings"]["denoiser"] == "none"
assert source.read_bytes() == original
assert capsys.readouterr().err.count("Written:") == 1
@@ -189,7 +195,7 @@ def test_process_keyword_is_optional(wav, tmp_path, ffmpeg, invoke, capsys):
def test_leading_flag_still_selects_process(wav, tmp_path, ffmpeg, invoke, capsys):
source = wav()
invoke("--no-denoise", source)
- assert (tmp_path / "input.natural.wav").exists()
+ assert (tmp_path / "input.custom.wav").exists()
def test_output_and_output_dir_cannot_be_combined(wav, tmp_path, invoke, capsys):
@@ -206,20 +212,20 @@ def test_missing_later_input_fails_before_any_processing(wav, tmp_path, invoke,
invoke("process", source, tmp_path / "missing.wav", "--no-denoise")
assert error.value.code == 1
assert "existing WAV file" in capsys.readouterr().err
- assert not (tmp_path / "input.natural.wav").exists()
+ assert not (tmp_path / "input.custom.wav").exists()
def test_existing_output_in_batch_fails_before_any_processing(wav, tmp_path, invoke, capsys, monkeypatch):
(tmp_path / "a").mkdir()
(tmp_path / "b").mkdir()
sources = [wav(name="a/take.wav"), wav(name="b/take.wav")]
- (tmp_path / "b" / "take.natural.wav").write_bytes(b"previous result")
+ (tmp_path / "b" / "take.custom.wav").write_bytes(b"previous result")
monkeypatch.setattr(cli, "process", lambda *a, **kw: pytest.fail("Preflight must reject the batch"))
with pytest.raises(SystemExit) as error:
invoke("process", *sources, "--no-denoise")
assert error.value.code == 1
assert "Output exists" in capsys.readouterr().err
- assert not (tmp_path / "a" / "take.natural.wav").exists()
+ assert not (tmp_path / "a" / "take.custom.wav").exists()
def test_preview_rejects_duplicate_profiles_and_profile_override(wav, invoke, capsys, monkeypatch):
@@ -237,15 +243,16 @@ def test_preview_rejects_duplicate_profiles_and_profile_override(wav, invoke, ca
assert list(source.parent.iterdir()) == [source]
-def test_preview_default_output_is_written_beside_input(wav, tmp_path, ffmpeg, invoke, capsys):
+def test_preview_default_output_is_written_beside_input(wav, tmp_path, ffmpeg, invoke, capsys, report_of):
source = wav()
original = source.read_bytes()
invoke("preview", source, "--no-denoise", "--start", "1", "--duration", "2",
"--profiles", "natural")
assert {path.name for path in tmp_path.iterdir()} == {
- "input.wav", "input.natural.preview.wav", "logs"}
- report = json.loads(pipeline.report_for(tmp_path / "input.natural.preview.wav").read_text())
+ "input.wav", "input.custom.natural.preview.wav", "producer-root"}
+ report = json.loads(report_of(tmp_path / "input.custom.natural.preview.wav").read_text())
assert report["settings"]["profile"] == "natural"
+ assert report["settings"]["denoiser"] == "none"
assert source.read_bytes() == original
@@ -271,14 +278,16 @@ def test_preview_cancellation_cleans_excerpt(wav, tmp_path, ffmpeg, invoke, caps
assert source.read_bytes() == original
-@pytest.mark.parametrize("suffix", [".wav", ".report.json", ".mp3"])
+@pytest.mark.parametrize("suffix", [".wav", ".mp3"])
def test_nonfile_output_with_overwrite_fails_before_tool_setup(
wav, tmp_path, bypass, progress, monkeypatch, suffix
):
+ # Report logs are timestamped and uniquified in the shared logs directory,
+ # so only audio outputs can collide with a non-file.
source = wav()
original = source.read_bytes()
target = tmp_path / "master.wav"
- nonfile = target.with_suffix(suffix) if suffix != ".report.json" else pipeline.report_for(target)
+ nonfile = target.with_suffix(suffix)
nonfile.parent.mkdir(parents=True, exist_ok=True)
nonfile.mkdir()
bypass.mp3 = True
@@ -287,8 +296,5 @@ def test_nonfile_output_with_overwrite_fails_before_tool_setup(
pipeline.process(source, target, bypass, progress, overwrite=True)
assert nonfile.is_dir()
assert list(nonfile.iterdir()) == []
- if suffix == ".report.json":
- assert {path.name for path in tmp_path.iterdir()} == {source.name, "logs"}
- else:
- assert set(tmp_path.iterdir()) == {source, nonfile}
+ assert set(tmp_path.iterdir()) == {source, nonfile}
assert source.read_bytes() == original
diff --git a/lib/project/tests/test_gui.py b/lib/project/tests/test_gui.py
index 2f17549..83a8220 100644
--- a/lib/project/tests/test_gui.py
+++ b/lib/project/tests/test_gui.py
@@ -13,7 +13,7 @@ import soundfile as sf
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
-from producer.config import Settings
+from producer.config import Settings, resolve
from producer import gui as gui_module
from producer.gui import create_app
from producer import gui_jobs
@@ -24,20 +24,21 @@ URL = "http://127.0.0.1:8765"
@pytest.fixture
def client(tmp_path):
with TestClient(create_app(8765, output_dir=tmp_path), base_url=URL) as client:
+ # Per-server upload cache; module-global caches would leak stale
+ # upload ids across tests (id() of dead Path objects gets reused).
+ client._upload_cache = {}
yield client
-_UPLOADED = {}
-
-
def upload(client, path, name=None):
"""Upload once per (file, name) and reuse the entry, like the browser UI."""
- key = (id(path), name)
- if key not in _UPLOADED:
+ cache = client._upload_cache
+ key = (str(path), name)
+ if key not in cache:
response = client.post("/api/uploads", params={"name": name or path.name}, content=path.read_bytes())
assert response.status_code == 200, response.text
- _UPLOADED[key] = response.json()
- return _UPLOADED[key]
+ cache[key] = response.json()
+ return cache[key]
def wait(client, job_id):
@@ -200,16 +201,20 @@ def test_real_preview_context_exact_crop_bypass_and_export_trim(client, tmp_path
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,
- output_dir=str(tmp_path))["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 Path(path).is_absolute() and Path(path).is_file()
- assert len(list(tmp_path.glob("producer-export-*"))) == 2
+ exported = wait(client, submit(client, source, "export", settings=requested,
+ output_dir=str(tmp_path))["id"])
+ assert exported["status"] == "succeeded", exported
+ assert exported["result"]["settings"] == requested
+ assert len(exported["result"]["paths"]) == 3
+ assert "not auditioned" in exported["result"]["notice"]
+ # Audio directly in the output directory; the timestamped report in
+ # the shared logs directory. No producer-export-* folders anywhere.
+ for path in exported["result"]["paths"]:
+ assert Path(path).is_absolute() and Path(path).is_file()
+ assert Path(exported["result"]["paths"][0]) == tmp_path / "input.custom.wav"
+ assert Path(exported["result"]["paths"][1]) == tmp_path / "input.custom.mp3"
+ report = Path(exported["result"]["paths"][2])
+ assert report.parent.name == "logs" and report.name.startswith("input.custom.")
assert hashlib.sha256(source.read_bytes()).hexdigest() == before
@@ -221,6 +226,12 @@ def test_export_output_directory_rules(client, wav, bypass, tmp_path):
"settings": requested}
assert client.post("/api/jobs", json={**base, "output_dir": "relative/folder"}).status_code == 400
assert client.post("/api/jobs", json={**base, "output_dir": str(tmp_path / "missing")}).status_code == 400
+ assert client.post("/api/jobs", json={**base, "overwrite": "yes"}).status_code == 400
+ assert client.post("/api/export/check",
+ json={**base, "input_id": "stale"}).status_code == 400
+ assert client.post("/api/export/check",
+ json={"upload_id": entry["upload_id"], "input_id": entry["input_id"],
+ "settings": requested, "output_dir": "relative"}).status_code == 400
missing = client.post("/api/jobs", json={**base})
assert missing.status_code == 202
job = wait(client, missing.json()["id"])
@@ -229,6 +240,46 @@ def test_export_output_directory_rules(client, wav, bypass, tmp_path):
assert Path(path).is_absolute() and Path(path).is_file()
Path(path).relative_to(tmp_path)
+
+def test_export_check_exists_and_overwrite_replaces_audio(client, wav, bypass, tmp_path, logs):
+ source = wav()
+ entry = upload(client, source)
+ requested = asdict(bypass)
+ body = {"upload_id": entry["upload_id"], "input_id": entry["input_id"],
+ "settings": requested, "output_dir": str(tmp_path)}
+ check = client.post("/api/export/check", json=body)
+ assert check.status_code == 200
+ assert check.json() == {"exists": False, "paths": [str(tmp_path / "input.custom.wav")]}
+ first = wait(client, submit(client, source, "export", settings=requested,
+ output_dir=str(tmp_path))["id"])
+ assert first["status"] == "succeeded", first
+ assert first["result"]["paths"][0] == str(tmp_path / "input.custom.wav")
+ assert client.post("/api/export/check", json=body).json() == {
+ "exists": True, "paths": [str(tmp_path / "input.custom.wav")]}
+ # Without confirmation the export refuses to replace the audio; with the
+ # browser-confirmed overwrite flag it replaces it.
+ refused = wait(client, submit(client, source, "export", settings=requested,
+ output_dir=str(tmp_path))["id"])
+ assert refused["status"] == "failed" and "Output exists" in refused["error"]
+ replaced = wait(client, submit(client, source, "export", settings=requested,
+ output_dir=str(tmp_path), overwrite=True)["id"])
+ assert replaced["status"] == "succeeded", replaced
+ assert [path for path in tmp_path.glob("input.custom.*")] == [tmp_path / "input.custom.wav"]
+ # Reports are timestamped, so both exports left one report each.
+ assert len(list(logs.glob("input.custom.*.report.json"))) == 2
+
+
+def test_export_name_matches_pure_builtin_profile(client, wav, tmp_path):
+ # Naming comes from the server: settings exactly equal to a built-in
+ # profile keep its name; anything else is 'custom' (no processing needed).
+ source = wav()
+ entry = upload(client, source)
+ pure = asdict(resolve(profile="natural"))
+ body = {"upload_id": entry["upload_id"], "input_id": entry["input_id"],
+ "settings": pure, "output_dir": str(tmp_path)}
+ check = client.post("/api/export/check", json=body)
+ assert check.json() == {"exists": False, "paths": [str(tmp_path / "input.natural.wav")]}
+
def test_preview_bounds_and_empty_inputs(client, wav):
source = wav()
entry = upload(client, source)
diff --git a/lib/project/tests/test_gui_browser.py b/lib/project/tests/test_gui_browser.py
index 5edc114..31a7c37 100644
--- a/lib/project/tests/test_gui_browser.py
+++ b/lib/project/tests/test_gui_browser.py
@@ -70,7 +70,7 @@ def test_gui_browser_real_workflow(studio):
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"])
+ browser = playwright.chromium.launch(headless=True, channel=os.environ.get("PRODUCER_BROWSER_CHANNEL") or None, args=["--no-sandbox"])
context = browser.new_context(viewport={"width": 1360, "height": 1000}, accept_downloads=True)
page = context.new_page()
errors, console_errors, media_requests = [], [], []
@@ -173,22 +173,31 @@ def test_gui_browser_real_workflow(studio):
page.locator("#output-dir").fill(str(studio["root"] / "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()
+ # Audio is written directly into the chosen output directory; no
+ # pacing dialog exists anymore, and nothing exists to overwrite yet.
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()
+ assert (studio["root"] / "delivery" / "Narration.custom.wav").is_file()
+ assert (studio["root"] / "delivery" / "Narration.custom.mp3").is_file()
+ reports = list((studio["root"] / "producer-root" / "logs").glob("Narration.custom.*.report.json"))
+ assert len(reports) == 1
import soundfile as sf
- assert sf.info(outputs[0] / "master.wav").duration == pytest.approx(8, abs=.01)
+ assert sf.info(studio["root"] / "delivery" / "Narration.custom.wav").duration == pytest.approx(8, abs=.01)
assert len(media_requests) == preview_requests, "Full exports must not be fetched into browser blobs"
+ # A second export targets the same audio file: the browser asks, and
+ # the red Overwrite confirmation replaces it.
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
+ expect(page.locator("#overwrite-dialog")).to_be_visible()
+ expect(page.locator("#overwrite-paths")).to_contain_text("Narration.custom.wav")
+ page.locator("#overwrite-confirm").click()
+ # Only the latest export stays on display; a new one replaces the card.
+ expect(page.locator("#export-results .export-result")).to_have_count(1, timeout=30000)
+ # The replaced export also leaves its own timestamped report.
+ deadline = time.monotonic() + 30
+ while time.monotonic() < deadline and len(list((studio["root"] / "producer-root" / "logs").glob("Narration.custom.*.report.json"))) < 2:
+ time.sleep(0.2)
+ assert (studio["root"] / "delivery" / "Narration.custom.wav").is_file()
+ assert not list((studio["root"] / "delivery").glob("producer-export-*"))
+ assert len(list((studio["root"] / "producer-root" / "logs").glob("Narration.custom.*.report.json"))) == 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)
@@ -198,7 +207,7 @@ def test_gui_browser_real_workflow(studio):
expect(page.locator("#workspace")).not_to_have_attribute("disabled", "")
expect(page.locator("#comparisons")).to_contain_text("Gentle <snapshot>")
expect(page.locator("#play")).to_be_disabled()
- expect(page.locator("#export-results .export-result")).to_have_count(2, timeout=15000)
+ expect(page.locator("#export-results .export-result")).to_have_count(1, 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)")
@@ -214,7 +223,7 @@ 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"])
+ browser = playwright.chromium.launch(headless=True, channel=os.environ.get("PRODUCER_BROWSER_CHANNEL") or None, args=["--no-sandbox"])
page = browser.new_page(viewport={"width": 1200, "height": 900})
requests, errors = [], []
page.on("request", lambda request: requests.append(request.url))
diff --git a/lib/project/tests/test_gui_browser_races.py b/lib/project/tests/test_gui_browser_races.py
index 5f2638b..32b7eef 100644
--- a/lib/project/tests/test_gui_browser_races.py
+++ b/lib/project/tests/test_gui_browser_races.py
@@ -13,7 +13,7 @@ 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"])
+ browser = playwright.chromium.launch(headless=True, channel=os.environ.get("PRODUCER_BROWSER_CHANNEL") or None, args=["--no-sandbox"])
page = browser.new_page()
page.goto(studio["url"])
page.locator("#pick-files").set_input_files(studio["source"])
@@ -22,6 +22,13 @@ def test_gui_browser_cancellation_is_not_pretend(studio):
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)
+ # The launch override disables denoising, hiding its strength control;
+ # enable the denoiser module (checkbox), then pick FFT and edit
+ # another control while the preview is running.
+ page.locator("#enable-denoiser").check()
+ expect(page.locator("#setting-denoiser")).to_be_enabled()
+ page.locator("#setting-denoiser").select_option("fft")
+ expect(page.locator("#setting-denoise_strength")).to_be_visible()
page.locator("#setting-denoise_strength").fill("0.4")
page.locator("#render-preview").click()
queued = page.locator("#jobs .job").filter(has_text="queued")
@@ -42,7 +49,7 @@ 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"])
+ browser = playwright.chromium.launch(headless=True, channel=os.environ.get("PRODUCER_BROWSER_CHANNEL") or None, 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);
@@ -92,7 +99,7 @@ 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"])
+ browser = playwright.chromium.launch(headless=True, channel=os.environ.get("PRODUCER_BROWSER_CHANNEL") or None, args=["--no-sandbox"])
page = browser.new_page()
page.add_init_script("""window.monitorGains = [];
const createGain = AudioContext.prototype.createGain;
@@ -117,13 +124,11 @@ def test_gui_browser_nonzero_monitoring_gain_is_playback_only(studio):
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
+ # The old pacing export dialog is gone; export submits directly and
+ # nothing exists in the output directory to overwrite.
+ page.locator("#output-dir").fill(str(studio["root"] / "delivery"))
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()
+ expect(page.locator("#export-results")).to_contain_text("Full recording exported", timeout=30000)
page.locator("#match").uncheck()
assert page.evaluate("monitorGains.map(node => node.gain.value)") == [1, 1]
browser.close()
@@ -134,7 +139,7 @@ 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"])
+ browser = playwright.chromium.launch(headless=True, channel=os.environ.get("PRODUCER_BROWSER_CHANNEL") or None, args=["--no-sandbox"])
page = browser.new_page()
page.goto(studio["url"])
if kind == "preview":
@@ -176,7 +181,7 @@ def test_delayed_audio_resume_does_not_start_superseded_playback(studio, interru
from playwright.sync_api import sync_playwright, expect
with sync_playwright() as playwright:
- browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"])
+ browser = playwright.chromium.launch(headless=True, channel=os.environ.get("PRODUCER_BROWSER_CHANNEL") or None, args=["--no-sandbox"])
page = browser.new_page()
page.add_init_script("""window.releaseAudioResume = null;
const resume = AudioContext.prototype.resume;
@@ -196,6 +201,10 @@ def test_delayed_audio_resume_does_not_start_superseded_playback(studio, interru
page.locator("#listen-processed").click()
expect(page.locator("#listen-processed")).to_have_attribute("aria-pressed", "true")
elif interruption == "edit":
+ # The launch override hides the denoiser module; enable it and
+ # pick FFT so the strength control is editable.
+ page.locator("#enable-denoiser").check()
+ page.locator("#setting-denoiser").select_option("fft")
page.locator("#setting-denoise_strength").fill("0.4")
else:
page.route("**/api/jobs", lambda route: route.fulfill(
diff --git a/lib/project/tests/test_pipeline.py b/lib/project/tests/test_pipeline.py
index 0bc2cef..fc40a82 100644
--- a/lib/project/tests/test_pipeline.py
+++ b/lib/project/tests/test_pipeline.py
@@ -1,4 +1,7 @@
+import errno
import json
+import os
+from pathlib import Path
import numpy as np
import pytest
@@ -51,14 +54,18 @@ def test_existing_output_or_sidecar_rejected_before_processing(wav, tmp_path, by
assert existing.read_bytes() == b"existing output"
-def test_existing_report_log_is_replaced_without_overwrite(wav, tmp_path, bypass, progress, ffmpeg):
+def test_existing_report_log_is_kept_and_new_report_published(wav, tmp_path, bypass, progress, ffmpeg, logs):
source = wav()
target = tmp_path / "master.wav"
- existing = pipeline.report_for(target)
- existing.parent.mkdir(parents=True, exist_ok=True)
- existing.write_bytes(b"stale report")
+ logs.mkdir(parents=True, exist_ok=True)
+ stale = logs / "master.20240101-000000.report.json"
+ stale.write_bytes(b"stale report")
report = pipeline.process(source, target, bypass, progress)
- assert json.loads(existing.read_text()) == report
+ # Timestamped reports never replace earlier ones.
+ assert stale.read_bytes() == b"stale report"
+ published = Path(report["report"])
+ assert published.parent == logs and published != stale
+ assert json.loads(published.read_text()) == report
@pytest.mark.parametrize("kind,message", [
@@ -139,7 +146,7 @@ def test_measure_reports_channels_peaks_clipping_and_progress(wav):
@pytest.mark.parametrize("rate", [44100, 48000])
-def test_normalization_peak_ceiling_and_report(wav, tmp_path, progress, ffmpeg, rate):
+def test_normalization_peak_ceiling_and_report(wav, tmp_path, progress, ffmpeg, rate, report_of):
source = wav()
original = source.read_bytes()
target = tmp_path / "nested" / "master.wav"
@@ -155,7 +162,7 @@ def test_normalization_peak_ceiling_and_report(wav, tmp_path, progress, ffmpeg,
assert verified["input_tp"] <= settings.true_peak_db + 0.1
assert report["master"]["near_full_scale_samples"] == 0
assert report["master"]["sample_peak_dbfs"] <= settings.true_peak_db + 0.1
- assert json.loads(pipeline.report_for(target).read_text()) == report
+ assert json.loads(report_of(target).read_text()) == report
assert source.read_bytes() == original
assert not list(target.parent.glob(".producer-*"))
@@ -216,32 +223,37 @@ def test_zero_denoise_strength_skips_ai(wav, tmp_path, bypass, progress, ffmpeg,
assert report["cleaned"]["frames"] == report["input"]["frames"]
-def test_fft_and_mp3_export_can_replace_generated_outputs(wav, tmp_path, bypass, progress, ffmpeg):
+def test_fft_and_mp3_export_can_replace_generated_outputs(wav, tmp_path, bypass, progress, ffmpeg, logs):
source = wav()
original = source.read_bytes()
bypass.denoiser = "fft"
bypass.mp3 = True
target = tmp_path / "master.wav"
- for path in (target, pipeline.report_for(target), target.with_suffix(".mp3")):
- path.parent.mkdir(parents=True, exist_ok=True)
+ logs.mkdir(parents=True, exist_ok=True)
+ stale_report = logs / "master.20240101-000000.report.json"
+ for path in (target, target.with_suffix(".mp3"), stale_report):
path.write_bytes(b"old output")
events = []
report = pipeline.process(source, target, bypass, lambda *event: events.append(event), overwrite=True)
assert any(event[0] == "FFT noise reduction" for event in events)
assert target.with_suffix(".mp3").stat().st_size > 1000
assert report["mp3_loudness"]["input_i"] is not None
- assert json.loads(pipeline.report_for(target).read_text()) == report
+ # The old report stays; the new one is timestamped and returned.
+ assert stale_report.read_bytes() == b"old output"
+ assert json.loads(Path(report["report"]).read_text()) == report
+ assert Path(report["report"]).parent == logs
assert sf.info(target).channels == 1
assert source.read_bytes() == original
-def test_render_failure_preserves_existing_outputs_and_removes_temporary_files(wav, tmp_path, bypass, progress, ffmpeg, monkeypatch):
+def test_render_failure_preserves_existing_outputs_and_removes_temporary_files(wav, tmp_path, bypass, progress, ffmpeg, monkeypatch, logs):
source = wav()
target = tmp_path / "master.wav"
bypass.mp3 = True
- outputs = [target, pipeline.report_for(target), target.with_suffix(".mp3")]
- for path in outputs:
- path.parent.mkdir(parents=True, exist_ok=True)
+ outputs = [target, target.with_suffix(".mp3")]
+ logs.mkdir(parents=True, exist_ok=True)
+ stale_report = logs / "master.20240101-000000.report.json"
+ for path in outputs + [stale_report]:
path.write_bytes(b"previous publication")
real_run = pipeline.ffmpeg_run
@@ -254,6 +266,7 @@ def test_render_failure_preserves_existing_outputs_and_removes_temporary_files(w
with pytest.raises(RuntimeError, match="injected encoder failure"):
pipeline.process(source, target, bypass, progress, overwrite=True)
assert all(path.read_bytes() == b"previous publication" for path in outputs)
+ assert stale_report.read_bytes() == b"previous publication"
assert not list(tmp_path.glob(".producer-*"))
@@ -283,6 +296,51 @@ def test_publish_new_output_moves_staged_file(tmp_path):
assert not staged.exists()
+def test_publish_across_filesystems_falls_back_to_copy(tmp_path, monkeypatch):
+ """Audio outputs and the shared logs directory can be on different mounts:
+ publication then copies atomically into the target directory instead of
+ failing with EXDEV."""
+ real_link, real_replace = os.link, os.replace
+
+ def refuse_cross_dir(src, dst=None):
+ src, dst = Path(src), Path(dst)
+ # Simulate two mounts: links and renames only work within one parent.
+ if dst.parent != src.parent:
+ raise OSError(errno.EXDEV, os.strerror(errno.EXDEV))
+ return real_link(src, dst)
+
+ def refuse_cross_rename(src, dst):
+ src, dst = Path(src), Path(dst)
+ if dst.parent != src.parent:
+ raise OSError(errno.EXDEV, os.strerror(errno.EXDEV))
+ return real_replace(src, dst)
+
+ monkeypatch.setattr(os, "link", refuse_cross_dir)
+ monkeypatch.setattr(os, "replace", refuse_cross_rename)
+ outgoing = tmp_path / "out"
+ incoming = tmp_path / "in"
+ outgoing.mkdir()
+ incoming.mkdir()
+ # Non-overwrite: copy path must still refuse an existing destination.
+ staged = outgoing / "report.json"
+ staged.write_bytes(b"new")
+ (incoming / "report.json").write_bytes(b"old")
+ with pytest.raises(FileExistsError):
+ pipeline.publish(staged, incoming / "report.json", overwrite=False)
+ assert (incoming / "report.json").read_bytes() == b"old"
+ # Successful non-overwrite publication moves the staged file.
+ fresh = outgoing / "fresh.json"
+ fresh.write_bytes(b"new")
+ pipeline.publish(fresh, incoming / "fresh.json", overwrite=False)
+ assert (incoming / "fresh.json").read_bytes() == b"new"
+ assert not list(incoming.glob(".fresh.json*")) and not fresh.exists()
+ # Overwrite replaces the existing destination.
+ staged.write_bytes(b"newer")
+ pipeline.publish(staged, incoming / "report.json", overwrite=True)
+ assert (incoming / "report.json").read_bytes() == b"newer"
+ assert not list(incoming.glob(".report.json*")) and not staged.exists()
+
+
def test_trim_gaps_shortens_silence_and_keeps_speech_exact(wav, tmp_path, progress):
from producer.audio import trim_gaps
sr = 48000