diff options
| -rw-r--r-- | README.md | 23 | ||||
| -rw-r--r-- | lib/project/src/producer/cli.py | 41 | ||||
| -rw-r--r-- | lib/project/src/producer/config.py | 8 | ||||
| -rw-r--r-- | lib/project/src/producer/gui.py | 26 | ||||
| -rw-r--r-- | lib/project/src/producer/gui_jobs.py | 32 | ||||
| -rw-r--r-- | lib/project/src/producer/pipeline.py | 102 | ||||
| -rw-r--r-- | lib/project/src/producer/web/app.js | 35 | ||||
| -rw-r--r-- | lib/project/src/producer/web/index.html | 11 | ||||
| -rw-r--r-- | lib/project/src/producer/web/style.css | 36 | ||||
| -rw-r--r-- | lib/project/tests/conftest.py | 25 | ||||
| -rw-r--r-- | lib/project/tests/test_cli.py | 52 | ||||
| -rw-r--r-- | lib/project/tests/test_gui.py | 87 | ||||
| -rw-r--r-- | lib/project/tests/test_gui_browser.py | 39 | ||||
| -rw-r--r-- | lib/project/tests/test_gui_browser_races.py | 31 | ||||
| -rw-r--r-- | lib/project/tests/test_pipeline.py | 88 | ||||
| -rwxr-xr-x | producer.sh | 5 |
16 files changed, 495 insertions, 146 deletions
@@ -39,6 +39,9 @@ LUFS with a -1.5 dBTP true-peak ceiling**. ./producer.sh recording.wav --profile narrator ./producer.sh recording.wav --profile radio +# Settings that differ from the built-in profile are named 'custom' +./producer.sh recording.wav --no-denoise # recording.custom.wav + # Process several files at once ./producer.sh take1.wav take2.wav --mp3 @@ -110,8 +113,12 @@ Direct package installs can use `producer[gui]` and need system FFmpeg or the late jobs never replace the visible comparison after you edit the selection. 5. Enter an existing **absolute** output directory (default: the directory you launched Producer from — start it next to your recording and exports - land beside the input), review the frozen current settings and confirm - **Export full recording**. This processes the entire original even if you + land beside the input). The audio file + (`<name>.<profile>.wav`, optional `.mp3`) is written directly into that + directory; the timestamped diagnostic report goes to the `logs/` + directory next to `producer.sh`. Settings that differ from the built-in + profile are named `custom`, and the browser asks before replacing + existing audio. This processes the entire original even if you selected only a few seconds. Completed exports list their full local paths; find the files with your operating system's file manager. @@ -169,9 +176,12 @@ is selected. Normalization and other effects are never forced on for comparison. Optional loudness matching is playback-only attenuation, not a change to export settings. -Exports process the whole original with the requested settings into a new, -non-clobbering `producer-export-*` folder in the chosen output directory: -`master.wav`, optional `master.mp3`, and `logs/master.report.json`. The API +Exports process the whole original with the requested settings and write the +audio file directly into the chosen output directory: +`<name>.<profile>.wav` (named `custom` when settings differ from the built-in +profile), optional `<name>.<profile>.mp3`. Replacing existing audio asks for +explicit confirmation. The timestamped diagnostic report goes to the `logs/` +directory next to `producer.sh`, so reports never collide. The API returns persistent local paths, **not browser blobs of multi-hour exports**. The browser only fetches bounded preview snippets. TOML presets can be imported as text or downloaded; no source recording is rewritten by the API. @@ -245,7 +255,8 @@ than risking chopped words. Controls: The detector can miss whispers, breaths, or unusual speech. If it detects **no speech**, cleanup fails open with a warning instead of muting the file. Other detector failures stop processing, not silently switch to a level gate. -Reports in `logs/` include the attenuated intervals on the original, +Reports in the `logs/` directory next to `producer.sh` include the attenuated +intervals on the original, pre-trim timeline for inspection. With VAD enabled, optional trimming only shortens near-zero cores; without it, legacy `gap_threshold_db` trimming can mistake quiet speech for silence. diff --git a/lib/project/src/producer/cli.py b/lib/project/src/producer/cli.py index bf94ed4..68cca74 100644 --- a/lib/project/src/producer/cli.py +++ b/lib/project/src/producer/cli.py @@ -14,8 +14,8 @@ from rich.console import Console from . import __version__ from .audio import ffmpeg_run -from .config import PROFILES, Settings, resolve, toml -from .pipeline import check_target, process, report_for, targets_for +from .config import PROFILES, Settings, profile_label, resolve, toml +from .pipeline import check_target, process, targets_for from .progress import Display from .setup import data_dir, ensure_ffmpeg @@ -48,7 +48,7 @@ def parser(): if name in ("process", "preview"): cmd.add_argument("--output-dir", type=Path, default=None, help="Directory for generated outputs (default: beside each input)") - cmd.add_argument("--overwrite", action="store_true", help="Replace existing audio outputs, never input files (report logs are always replaced)") + cmd.add_argument("--overwrite", action="store_true", help="Replace existing audio outputs, never input files (report logs are timestamped, never replaced)") if name == "process": cmd.add_argument("-o", "--output", type=Path, help="Explicit WAV destination (one input only)") if name == "preview": @@ -135,7 +135,8 @@ def main(): raise ValueError("--output and --output-dir cannot be combined") # Without an explicit destination, outputs are written beside # each input; --output-dir keeps every result in one directory. - outputs = [args.output or (args.output_dir or p.parent) / f"{p.stem}.{settings.profile}.wav" + # Settings that differ from the built-in profile are 'custom'. + outputs = [args.output or (args.output_dir or p.parent) / f"{p.stem}.{profile_label(settings)}.wav" for p in args.inputs] # Preflight the whole plan so a predictable conflict cannot # fail a batch after earlier inputs have already been published. @@ -151,11 +152,10 @@ def main(): if resolved & sources or any( target.exists() and os.path.samefile(source, target) for target in targets): raise ValueError("An output would overwrite an input recording") - report = report_for(output) for target in targets: - # Report logs are replaced silently; only audio - # outputs require --overwrite. - check_target(source, target, args.overwrite or target == report) + # Report logs are timestamped in the shared logs + # directory; only audio outputs require --overwrite. + check_target(source, target, args.overwrite) for source, output in zip(args.inputs, outputs): report = process(source, output, settings, display.update, args.overwrite) console.print(f"Written: {output} | {report['master_loudness']['input_i']} LUFS | {report['master_loudness']['input_tp']} dBTP", markup=False) @@ -173,21 +173,26 @@ def main(): original = source.resolve() output_dir = args.output_dir or source.parent # Preflight every preview destination before extracting the excerpt. + # Settings that differ from the built-in profile are marked + # 'custom', but the profile name stays in every preview file so + # multiple profiles never collide. claimed: set[Path] = set() for profile in args.profiles: - output = output_dir / f"{source.stem}.{profile}.preview.wav" + configured = settings_for(args, profile) + label = profile_label(configured) + token = profile if label == profile else f"{label}.{profile}" + output = output_dir / f"{source.stem}.{token}.preview.wav" if output.resolve() == original: raise ValueError("Preview output would overwrite the original") - report = report_for(output) - targets = [output, report] + targets = [output] resolved = {target.resolve() for target in targets} if len(resolved) < len(targets) or resolved & claimed: raise ValueError("Preview outputs have duplicate names") claimed |= resolved for target in targets: - # Report logs are replaced silently; only audio outputs - # require --overwrite. - check_target(original, target, args.overwrite or target == report) + # Report logs are timestamped in the shared logs + # directory; only audio outputs require --overwrite. + check_target(original, target, args.overwrite) output_dir.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix=".preview-", dir=output_dir) as tmp: excerpt = Path(tmp) / "excerpt.wav" @@ -195,10 +200,14 @@ def main(): "-map", "0:a:0", "-c:a", "pcm_f32le", str(excerpt)], "Extracting preview", args.duration, display.update) for profile in args.profiles: configured = settings_for(args, profile) - # Equal loudness prevents a louder preset winning an unfair A/B. + # Name previews from the requested settings (the preview + # itself then forces equal-loudness normalization and + # limiting so a louder preset cannot win an unfair A/B). + label = profile_label(configured) + token = profile if label == profile else f"{label}.{profile}" + output = output_dir / f"{source.stem}.{token}.preview.wav" configured.normalize = True configured.limiter = True - output = output_dir / f"{source.stem}.{profile}.preview.wav" report = process(excerpt, output, configured, display.update, args.overwrite, provenance={"original": str(original), "preview_start_seconds": args.start, "requested_duration_seconds": args.duration, diff --git a/lib/project/src/producer/config.py b/lib/project/src/producer/config.py index 1d18054..853d043 100644 --- a/lib/project/src/producer/config.py +++ b/lib/project/src/producer/config.py @@ -124,6 +124,14 @@ def resolve(path: Path | None = None, profile: str | None = None, return Settings(**merged) +def profile_label(settings: Settings) -> str: + """Name to use for generated outputs: the built-in profile, or 'custom' + when any effective setting differs from that pure built-in profile.""" + if asdict(settings) == asdict(resolve(profile=settings.profile)): + return settings.profile + return "custom" + + def toml(settings: Settings) -> str: lines = ["# Producer configuration. CLI flags override these values."] for field in fields(settings): diff --git a/lib/project/src/producer/gui.py b/lib/project/src/producer/gui.py index 00ea229..1f22083 100644 --- a/lib/project/src/producer/gui.py +++ b/lib/project/src/producer/gui.py @@ -15,9 +15,10 @@ import socket import tomllib import uuid -from .config import resolve, toml +from .config import profile_label, resolve, toml from .gui_jobs import Busy, Jobs, input_info from .gui_schema import schema +from .pipeline import targets_for BODY_LIMIT = 65536 UNSAFE_NAME = re.compile(r"[^\w .+()-]", re.ASCII) @@ -221,7 +222,8 @@ def create_app(port, *, config=None, profile=None, overrides=None, output_dir=No @app.post("/api/jobs", status_code=202) async def submit_job(request: Request): body = object_body(await request.json(), - {"kind", "upload_id", "input_id", "settings", "start", "duration", "output_dir"}) + {"kind", "upload_id", "input_id", "settings", "start", "duration", + "output_dir", "overwrite"}) kind = body.get("kind") if kind not in ("scan", "preview", "export"): raise ValueError("kind must be scan, preview or export") @@ -240,8 +242,28 @@ def create_app(port, *, config=None, profile=None, overrides=None, output_dir=No snapshot.update(start=start, duration=duration) if kind == "export": snapshot["output_dir"] = str(output_directory(body.get("output_dir"), default_output)) + overwrite = body.get("overwrite", False) + if type(overwrite) is not bool: + raise ValueError("overwrite must be a boolean") + snapshot["overwrite"] = overwrite return jobs.submit(kind, snapshot) + @app.post("/api/export/check") + async def export_check(request: Request): + """Preflight for the GUI export: exact target paths and whether any + already exists, so the browser can ask before replacing audio.""" + body = object_body(await request.json(), {"upload_id", "input_id", "settings", "output_dir"}) + info = jobs.upload_info(body.get("upload_id")) + if body.get("input_id") != info["input_id"]: + raise ValueError("Input changed; select and scan it again") + settings = configured(body.get("settings", {})) + parent = output_directory(body.get("output_dir"), default_output) + stem = Path(info["name"]).stem or "master" + target = parent / f"{stem}.{profile_label(settings)}.wav" + targets = targets_for(target, settings) + return {"exists": any(target.exists() for target in targets), + "paths": [str(path) for path in targets]} + @app.get("/api/results/{result_id}/{kind}.wav") def audio(result_id: str, kind: str): return Response(jobs.media(result_id, kind), media_type="audio/wav") diff --git a/lib/project/src/producer/gui_jobs.py b/lib/project/src/producer/gui_jobs.py index d35a3d4..2db916f 100644 --- a/lib/project/src/producer/gui_jobs.py +++ b/lib/project/src/producer/gui_jobs.py @@ -19,7 +19,7 @@ import numpy as np import soundfile as sf from .audio import ffmpeg_run, loudness -from .config import Settings +from .config import Settings, profile_label from .pipeline import process, targets_for from .setup import ensure_ffmpeg @@ -294,22 +294,20 @@ class Jobs: parent = Path(snapshot["output_dir"]) if not parent.is_absolute() or not parent.is_dir(): raise ValueError("Choose an existing absolute output directory on this machine") - # A private, new delivery folder prevents audio AND report clobbering. - delivery = Path(tempfile.mkdtemp(prefix="producer-export-", dir=parent)) - try: - target = delivery / "master.wav" - settings = Settings(**snapshot["settings"]) - report = process(source, target, settings, progress, - provenance={"gui_snapshot": snapshot}) - result = {"paths": [str(path) for path in targets_for(target, settings)], - "settings": asdict(settings), "warnings": report["warnings"], - "duration": report["master"]["duration_seconds"], - "notice": "Pacing edits changed duration and were not auditioned in the sound comparison." if settings.trim_silence else "Full-file export completed."} - except BaseException: - # If cancellation occurs at publication, preserve any completed audio. - if not (delivery / "master.wav").exists() and not (delivery / "master.mp3").exists(): - shutil.rmtree(delivery, ignore_errors=True) - raise + # The audio file is written directly into the chosen + # output directory; the timestamped report log goes to + # the shared logs directory (PRODUCER_ROOT/logs). + settings = Settings(**snapshot["settings"]) + stem = Path(snapshot["name"]).stem or "master" + target = parent / f"{stem}.{profile_label(settings)}.wav" + report = process(source, target, settings, progress, + overwrite=snapshot.get("overwrite", False), + provenance={"gui_snapshot": snapshot}) + result = {"paths": [str(path) for path in targets_for(target, settings)] + + [report["report"]], + "settings": asdict(settings), "warnings": report["warnings"], + "duration": report["master"]["duration_seconds"], + "notice": "Pacing edits changed duration and were not auditioned in the sound comparison." if settings.trim_silence else "Full-file export completed."} self._check_input(snapshot) with self.condition: if job["kind"] != "export": diff --git a/lib/project/src/producer/pipeline.py b/lib/project/src/producer/pipeline.py index c730016..d3371e3 100644 --- a/lib/project/src/producer/pipeline.py +++ b/lib/project/src/producer/pipeline.py @@ -1,10 +1,13 @@ """Offline mono voice mastering with float intermediates and atomic outputs.""" from dataclasses import asdict +from datetime import datetime +import errno import fcntl import json import math import os from pathlib import Path +import shutil import tempfile from . import __version__ @@ -31,14 +34,40 @@ def filters(settings: Settings) -> list[str]: return result or ["anull"] +def logs_dir() -> Path: + """Every diagnostic report log lives in the shared logs directory next to + producer.sh (PRODUCER_ROOT, exported by the launcher), no matter where + audio outputs go.""" + root = os.environ.get("PRODUCER_ROOT") + if not root: + home = os.environ.get("PRODUCER_HOME") + if home: + home = Path(home) + # Cover direct launches inside the contained runtime (its layout + # is <project root>/lib/runtime). + if home.name == "runtime" and home.parent.name == "lib": + root = str(home.parent.parent) + return (Path(root) if root else Path.cwd()) / "logs" + + def report_for(destination: Path) -> Path: - """Diagnostic reports live in a logs/ directory, out of the way of outputs.""" - return destination.parent / "logs" / destination.with_suffix(".report.json").name + """Timestamped report path in the shared logs directory. Called at + publication time; never overwrites or collides with an earlier report.""" + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + counter = 1 + while True: + suffix = "" if counter == 1 else f"_{counter}" + path = logs_dir() / f"{destination.stem}.{stamp}{suffix}.report.json" + if not os.path.lexists(path): + return path + counter += 1 def targets_for(destination: Path, settings: Settings) -> list[Path]: - """Every path a successful run publishes: master WAV, report, optional MP3.""" - targets = [destination, report_for(destination)] + """Every audio path a successful run publishes: master WAV, optional MP3. + The timestamped report path is computed at publication time and returned + by process().""" + targets = [destination] if settings.mp3: targets.append(destination.with_suffix(".mp3")) return targets @@ -56,12 +85,36 @@ def check_target(source: Path, target: Path, overwrite: bool) -> None: def publish(source: Path, target: Path, overwrite: bool): + try: + if overwrite: + os.replace(source, target) + else: + # Link is atomic and fails if another process created the destination. + os.link(source, target) + source.unlink() + return + except FileExistsError: + raise + except OSError as error: + if error.errno != errno.EXDEV: + raise + # The staged file and the target live on different filesystems (audio + # outputs and the shared logs directory can be on different mounts): + # copy into the target's directory first, then publish atomically there. + staged = target.parent / f".{target.name}.tmp-{os.getpid()}" + shutil.copyfile(source, staged) if overwrite: - os.replace(source, target) + os.replace(staged, target) else: - # Link is atomic and fails if another process created the destination. - os.link(source, target) - source.unlink() + # Same filesystem now, so the link is atomic again and still fails + # if another process created the destination meanwhile. + try: + os.link(staged, target) + except FileExistsError: + staged.unlink(missing_ok=True) + raise + staged.unlink() + source.unlink(missing_ok=True) def process(source: Path, destination: Path, settings: Settings, progress, @@ -71,11 +124,10 @@ def process(source: Path, destination: Path, settings: Settings, progress, raise ValueError(f"Input must be an existing WAV file: {source}") if destination.suffix.lower() != ".wav": raise ValueError("Master output must have a .wav extension") - report_path = report_for(destination) for target in targets_for(destination, settings): - # Report logs are diagnostic: a rerun replaces them silently. Only - # audio outputs require --overwrite. - check_target(source, target, overwrite or target == report_path) + # Only audio outputs are checked here; the timestamped report path is + # uniquified and never collides. + check_target(source, target, overwrite) ffmpeg = ensure_ffmpeg() original = measure(source, progress, "Analyzing original") if original["sample_peak_dbfs"] < -100: @@ -89,7 +141,6 @@ def process(source: Path, destination: Path, settings: Settings, progress, if max(abs(x) for x in original["dc_offset"]) > 0.01: warnings.append("Significant input DC offset detected; enable highpass filtering to remove it.") destination.parent.mkdir(parents=True, exist_ok=True) - report_path.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix=".producer-", dir=destination.parent) as temporary: work = Path(temporary) prepared, cleaned, shaped = [work / name for name in ("prepared.wav", "cleaned.wav", "shaped.wav")] @@ -239,17 +290,28 @@ def process(source: Path, destination: Path, settings: Settings, progress, if provenance: report["provenance"] = provenance staged_report = work / "report.json" - staged_report.write_text(json.dumps(report, indent=2, allow_nan=False) + "\n") # Publish only after all processing and verification succeeded. The - # directory lock serializes publication so concurrent jobs targeting - # the same output directory cannot interleave WAV/report/MP3 sets. + # directory locks serialize publication so concurrent jobs targeting + # the same output directory or logs directory cannot interleave + # WAV/report/MP3 sets. Lock ordering is output directory first. + logs = logs_dir() + logs.mkdir(parents=True, exist_ok=True) publication_fd = os.open(destination.parent, os.O_RDONLY) try: fcntl.flock(publication_fd, fcntl.LOCK_EX) - if settings.mp3: - publish(mp3, destination.with_suffix(".mp3"), overwrite) - publish(staged_report, report_path, True) - publish(master, destination, overwrite) + logs_fd = os.open(logs, os.O_RDONLY) + try: + fcntl.flock(logs_fd, fcntl.LOCK_EX) + # Timestamped under the held lock, so the name is race-free. + report_path = report_for(destination) + report["report"] = str(report_path) + staged_report.write_text(json.dumps(report, indent=2, allow_nan=False) + "\n") + if settings.mp3: + publish(mp3, destination.with_suffix(".mp3"), overwrite) + publish(staged_report, report_path, True) + publish(master, destination, overwrite) + finally: + os.close(logs_fd) finally: os.close(publication_fd) return report diff --git a/lib/project/src/producer/web/app.js b/lib/project/src/producer/web/app.js index c0bfd8a..f53e054 100644 --- a/lib/project/src/producer/web/app.js +++ b/lib/project/src/producer/web/app.js @@ -501,6 +501,9 @@ async function poll() { const active = [...state.jobs.values()].filter(job => !terminal(job)); $('jobs-status').textContent = active.length ? `${active.length} job(s) active or waiting. ${active.find(job => job.status === 'running')?.progress.stage || 'Waiting for worker'}.` : `${state.jobs.size} recent job(s) · nothing running.`; } catch (e) { + // An interrupted connection may have missed job list updates, so any + // still-pending playback start is superseded until the retry succeeds. + pause(); $('connection').textContent = 'Connection interrupted. Retrying… Keep the Producer terminal running; jobs may still be active. Do not resubmit an export until you check Activity.'; error(e); } finally { setTimeout(poll, 750); } @@ -679,13 +682,43 @@ function download(blob) { const url = URL.createObjectURL(blob), link = node('a'); link.href = url; link.download = 'producer-preset.toml'; document.body.append(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 30000); } +function confirmOverwrite(paths) { + // Small promise-based modal: red Overwrite / Cancel. Resolves true only + // when the user explicitly confirms replacing the listed files. + return new Promise(resolve => { + $('overwrite-paths').replaceChildren(...paths.map(path => node('li', path))); + const dialog = $('overwrite-dialog'); + const done = value => { + dialog.hidden = true; + $('overwrite-confirm').removeEventListener('click', onConfirm); + $('overwrite-cancel').removeEventListener('click', onCancel); + resolve(value); + }; + const onConfirm = () => done(true); + const onCancel = () => done(false); + $('overwrite-confirm').addEventListener('click', onConfirm); + $('overwrite-cancel').addEventListener('click', onCancel); + dialog.hidden = false; + $('overwrite-cancel').focus(); + }); +} async function prepareExport() { if (!state.input) throw new Error('Choose a recording first.'); const version = state.version, input = copy(state.input), settings = readSettings(), output_dir = $('output-dir').value.trim(); const data = await api('/api/resolve', {settings}); if (version !== state.version) throw new Error('Controls changed while starting the export. Try again.'); + // Ask the server for the exact target paths before processing anything. + const check = await api('/api/export/check', {upload_id: input.upload_id, input_id: input.input_id, settings: copy(data.settings), output_dir}); + if (version !== state.version) throw new Error('Controls changed while starting the export. Try again.'); + const payload = {kind: 'export', upload_id: input.upload_id, input_id: input.input_id, settings: copy(data.settings), output_dir}; + if (check.exists) { + const overwrite = await confirmOverwrite(check.paths); + if (version !== state.version) throw new Error('Controls changed while starting the export. Try again.'); + if (!overwrite) return; + payload.overwrite = true; + } // Export starts immediately; settings are frozen in the submitted job snapshot. - await submit('export', {kind: 'export', upload_id: input.upload_id, input_id: input.input_id, settings: copy(data.settings), output_dir}); + await submit('export', payload); $('jobs-panel').scrollIntoView({block: 'nearest'}); } function showExport(job) { diff --git a/lib/project/src/producer/web/index.html b/lib/project/src/producer/web/index.html index ef1247e..bb6d567 100644 --- a/lib/project/src/producer/web/index.html +++ b/lib/project/src/producer/web/index.html @@ -14,6 +14,15 @@ <main> <div id="error" class="error" role="alert" hidden><span id="error-text"></span><button id="dismiss-error" type="button">Dismiss</button></div> <div id="toast" class="toast" role="status" hidden><div><strong id="toast-title"></strong><ul id="toast-list"></ul></div></div> + <div id="overwrite-dialog" class="dialog" hidden role="dialog" aria-modal="true" aria-labelledby="overwrite-title"> + <div class="dialog-box"> + <h3 id="overwrite-title">Replace existing files?</h3> + <p class="small">An export with the current settings would replace:</p> + <ul id="overwrite-paths"></ul> + <p class="small">Original recordings are never touched.</p> + <div class="dialog-actions"><button id="overwrite-cancel" type="button">Cancel</button><button id="overwrite-confirm" type="button" class="danger">Overwrite</button></div> + </div> + </div> <noscript>This tool needs JavaScript. Reload this page after starting Producer.</noscript> <fieldset id="workspace" disabled> <legend class="sr-only">Producer studio</legend> @@ -63,7 +72,7 @@ <section id="export" class="panel"> <h2>Export</h2> - <label class="output-label">Output directory<input id="output-dir" type="text" spellcheck="false"></label><p class="small">Absolute path on this machine; must already exist. Each export gets a fresh <code>producer-export-*</code> folder: <code>master.wav</code>, optional <code>master.mp3</code> and <code>logs/master.report.json</code>.</p> + <label class="output-label">Output directory<input id="output-dir" type="text" spellcheck="false"></label><p class="small">Absolute path on this machine; must already exist. The audio file (<code><name>.<profile>.wav</code>, plus <code><name>.<profile>.mp3</code> when MP3 export is on) is written directly into this directory. The timestamped diagnostic report goes to the <code>logs/</code> directory next to <code>producer.sh</code>. Settings that differ from the built-in profile are named <code>custom</code>.</p> <div class="action-row"><button id="prepare-export" class="primary danger" type="button" disabled>Render and Export</button><span class="small">Processes the entire original, not just the selected passage. Originals are never overwritten.</span></div> <div id="export-results" aria-label="Completed exports"></div> </section> diff --git a/lib/project/src/producer/web/style.css b/lib/project/src/producer/web/style.css index 73ba09b..7633e1a 100644 --- a/lib/project/src/producer/web/style.css +++ b/lib/project/src/producer/web/style.css @@ -632,6 +632,42 @@ summary .small { clip:rect(0,0,0,0); white-space:nowrap } +.dialog { + position:fixed; + inset:0; + background:rgba(37, 56, 46, .45); + display:flex; + align-items:center; + justify-content:center; + z-index:30; + padding:16px +} +.dialog-box { + background:#fff; + border:1px solid var(--line); + border-radius:10px; + padding:18px; + max-width:min(520px, 100%); + box-shadow:0 8px 24px rgba(37, 56, 46, .25); + overflow-wrap:anywhere +} +.dialog-box h3 { + margin:0 0 8px +} +.dialog-box ul { + margin:6px 0; + padding-left:20px; + font-size:.84rem +} +.dialog-box li { + font-family:ui-monospace, monospace +} +.dialog-actions { + display:flex; + justify-content:flex-end; + gap:10px; + margin-top:14px +} [hidden] { display:none!important } 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 diff --git a/producer.sh b/producer.sh index 893cf87..fd4d9f3 100755 --- a/producer.sh +++ b/producer.sh @@ -183,7 +183,7 @@ mkdir -p -- "$RUNTIME/cache/pip" "$RUNTIME/cache/uv" "$RUNTIME/uv" "$RUNTIME/tmp # host are dropped and package-manager configuration files are disabled, so # packages can only come from the explicitly passed trusted indexes and land # inside lib/runtime. The application reads PRODUCER_HOME for AI envs and -# models. +# models, and PRODUCER_ROOT (the project root) for the shared logs directory. unset PYTHONPATH PYTHONHOME VIRTUAL_ENV \ PIP_TARGET PIP_PREFIX PIP_USER PIP_FIND_LINKS PIP_INDEX_URL PIP_EXTRA_INDEX_URL \ PIP_CONSTRAINT \ @@ -194,6 +194,9 @@ export PIP_CACHE_DIR="$RUNTIME/cache/pip" UV_CACHE_DIR="$RUNTIME/cache/uv" export PIP_CONFIG_FILE=/dev/null UV_NO_CONFIG=1 export UV_PYTHON_INSTALL_DIR="$RUNTIME/uv/python" XDG_CACHE_HOME="$RUNTIME/cache" export TMPDIR="$RUNTIME/tmp" PRODUCER_HOME="$RUNTIME" +# The application writes all diagnostic report logs under PRODUCER_ROOT/logs +# (the directory containing producer.sh), no matter where outputs go. +export PRODUCER_ROOT="$ROOT" marker_current() { # Only the version suffix decides; the recorded path is informational. |
