diff options
Diffstat (limited to 'lib/project/src')
| -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 |
8 files changed, 234 insertions, 57 deletions
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 } |
