aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-12 21:18:34 -0400
committerhistoria <historiavg@proton.me>2026-09-12 21:18:34 -0400
commit544134f0534d2e4e4fd7ab823d8a0da543fa80ef (patch)
treeef0f787b6377608b3814ac642804e8c778980ddf
parent0cd53b67793e74e8be0201b90711cb51b7186d6e (diff)
downloadproducer-544134f0534d2e4e4fd7ab823d8a0da543fa80ef.tar.gz
fix: output wavs from gui go into correct directory
-rw-r--r--README.md23
-rw-r--r--lib/project/src/producer/cli.py41
-rw-r--r--lib/project/src/producer/config.py8
-rw-r--r--lib/project/src/producer/gui.py26
-rw-r--r--lib/project/src/producer/gui_jobs.py32
-rw-r--r--lib/project/src/producer/pipeline.py102
-rw-r--r--lib/project/src/producer/web/app.js35
-rw-r--r--lib/project/src/producer/web/index.html11
-rw-r--r--lib/project/src/producer/web/style.css36
-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
-rwxr-xr-xproducer.sh5
16 files changed, 495 insertions, 146 deletions
diff --git a/README.md b/README.md
index ee7856f..c1faf42 100644
--- a/README.md
+++ b/README.md
@@ -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>&lt;name&gt;.&lt;profile&gt;.wav</code>, plus <code>&lt;name&gt;.&lt;profile&gt;.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.