diff options
| -rw-r--r-- | 8 | 0 | ||||
| -rw-r--r-- | README.md | 160 | ||||
| -rw-r--r-- | lib/project/src/producer/audio.py | 61 | ||||
| -rw-r--r-- | lib/project/src/producer/cli.py | 3 | ||||
| -rw-r--r-- | lib/project/src/producer/gui.py | 165 | ||||
| -rw-r--r-- | lib/project/src/producer/gui_jobs.py | 49 | ||||
| -rw-r--r-- | lib/project/src/producer/gui_schema.py | 97 | ||||
| -rw-r--r-- | lib/project/src/producer/pipeline.py | 18 | ||||
| -rw-r--r-- | lib/project/src/producer/web/app.js | 264 | ||||
| -rw-r--r-- | lib/project/src/producer/web/index.html | 82 | ||||
| -rw-r--r-- | lib/project/src/producer/web/state.js | 6 | ||||
| -rw-r--r-- | lib/project/src/producer/web/style.css | 463 | ||||
| -rw-r--r-- | lib/project/tests/gui_state.test.mjs | 10 | ||||
| -rw-r--r-- | lib/project/tests/test_gui.py | 162 | ||||
| -rw-r--r-- | lib/project/tests/test_gui_assets.py | 14 | ||||
| -rw-r--r-- | lib/project/tests/test_gui_browser.py | 76 | ||||
| -rw-r--r-- | lib/project/tests/test_gui_browser_races.py | 27 | ||||
| -rw-r--r-- | lib/project/tests/test_gui_jobs.py | 20 | ||||
| -rw-r--r-- | lib/project/tests/test_gui_launcher.py | 16 | ||||
| -rw-r--r-- | lib/project/tests/test_pipeline.py | 49 | ||||
| -rwxr-xr-x | producer.sh | 2 |
21 files changed, 878 insertions, 866 deletions
@@ -74,109 +74,115 @@ Edit default settings in `config.toml` ## Optional local browser GUI ```bash -./producer.sh gui --root /path/to/recordings +./producer.sh gui # Optional alternate loopback port: -./producer.sh gui --root /path/to/recordings --port 8766 +./producer.sh gui --port 8766 ``` -The browser studio guides you through **Choose recording → Try a short passage -→ Compare sound → Export full recording**. Use a current desktop browser -(Chromium tested); JavaScript is required. No Node/npm build, CDN, telemetry, -or cloud audio service is used. The launcher installs the optional -FastAPI/uvicorn extra only when `gui` is requested. Existing CLI commands do -not need it. Direct package installs can use `producer[gui]` and need system -FFmpeg or the `bundled-ffmpeg` extra for processing. - -1. Browse folders beneath your chosen root and select a mono WAV. The overview - is a streaming scan reduced to 2,048 peak bins, not a full recording loaded +The studio is a compact workbench: pick a WAV, select a passage, tune +settings, compare, and export. Use a current desktop browser (Chromium +tested); JavaScript is required. No Node/npm build, CDN, telemetry, or cloud +audio service is used. The launcher installs the optional FastAPI/uvicorn +extra only when `gui` is requested. Existing CLI commands do not need it. +Direct package installs can use `producer[gui]` and need system FFmpeg or the +`bundled-ffmpeg` extra for processing. + +1. Click **Browse…** and choose a mono WAV from anywhere on this machine (the + file picker can also batch-select several). The browser uploads a temporary + copy for processing; your original file is never touched. The overview is a + streaming scan reduced to 2,048 peak bins, not the whole recording loaded into the browser. You can enter times before scanning finishes. 2. Click or drag the waveform, or enter exact `HH:MM:SS.sss` start/end times (seconds also work). Zoom enlarges the same coarse overview; exact times are best for fine selection on multi-hour recordings. -3. Start with noise cleanup, quiet pauses, tone and even volume. Expand **All - settings & advanced controls** for every other setting and its units/help. - Disabled effects retain their knob values. Nothing renders per keystroke, - and no dependent effect is silently enabled. Click **Render sound comparison**. +3. Every control's help text explains what it does and how to switch the + effect off. Turning an effect off greys out and disables its dependent + values — for example unchecking "Remove low rumble" deactivates its cutoff + field — while keeping those values for later. FFT reduction only applies + to the lightweight FFT denoiser: it is scaled by cleanup strength, cannot + be set to 0, and disappears entirely when you pick DeepFilter, ClearVoice + or "none". "Silence pauses" uses a local speech detector to zero non-speech + pauses without moving anything; "Shorten long gaps (export only)" trims + long gaps on the full export and is never auditioned in the comparison. + Click **Render sound comparison**. 4. Play/loop the passage and switch A/B without losing your place. Optional matching attenuates the louder snippet for fair listening. File loudness and peak readings are separate from monitoring gain, not quality scores or full-export measurements. Changes pause playback and mark older renders; late jobs never replace the visible comparison after you edit the selection. -5. Choose an existing output folder, review the frozen current settings and - confirm **Export full recording**. This processes the entire original even - if you selected only a few seconds. Completed local paths appear in the - export panel; find the files with your operating system's file manager. - -Open the printed `http://127.0.0.1:.../#token=...` URL on the same machine and -keep it private. The server binds only to loopback, rejects untrusted -Host/Origin headers, and requires the session credential for every API read -or mutation. Do not expose it through a proxy or port-forwarding service. -`--root` bounds browsing and output directories, including resolved symlinks; -its default is the current working directory. There are no recording uploads -or arbitrary host-file download endpoints. Stop the server with Ctrl-C. - -**Containers/remote shells:** loopback belongs to the machine/network namespace -running Producer. A browser on the host cannot normally reach a container's -`127.0.0.1`; publishing a container port does not change this loopback-only -binding. The simplest supported setup is to run `./producer.sh gui` directly -on the machine with your browser and recordings. This development container -has no GUI. Do not change the bind to `0.0.0.0`, disable the Host/Origin checks, -or expose the token through a proxy to work around access. No insecure -container-host access mode is provided. - -Settings use the same strict resolver and configuration precedence as the CLI. -The initial controls include your launch TOML and CLI overrides. **Restore -launch settings** resets edits. **Apply profile & launch defaults** resets -through that precedence too: explicit launch values still win. Changing only -the profile name keeps your other explicit knob values. TOML import instead -loads standalone settings (package defaults/profile plus the imported TOML). - -Save up to 20 named settings presets and 12 immutable comparison snapshots per -root in this browser. Snapshots retain the source identity, excerpt times and +5. Enter an existing **absolute** output folder (default: + `producer-gui-exports` next to where you launched), review the frozen + current settings and confirm **Export full recording**. 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. + +The server binds only to loopback (127.0.0.1) and has no login: **anything +that can reach this port on this machine can use it**, so run it only on a +machine you control and do not expose it through a proxy, port-forwarding, or +`0.0.0.0`. Stop the server with Ctrl-C. + +**Containers/remote shells:** loopback belongs to the machine/network +namespace running Producer. A browser on the host cannot normally reach a +container's `127.0.0.1`; publishing a container port does not help because the +server still only answers `127.0.0.1`. The simplest supported setup is to run +`./producer.sh gui` directly on the machine with your browser and recordings. + +Settings use the same strict resolver and configuration precedence as the +CLI. The initial controls include your launch TOML and CLI overrides. +**Restore launch settings** resets edits. **Apply profile** resets through +that precedence too: explicit launch values still win. Changing only the +profile name keeps your other explicit knob values. TOML import instead loads +standalone settings (package defaults/profile plus the imported TOML). + +Save up to 20 named settings presets and 12 immutable comparison snapshots in +this browser. Snapshots retain the recording identity, excerpt times and requested settings, never audio. **Restore & render** explicitly recreates a -comparison; a changed source file must be re-selected. Download/import small -TOML presets to move settings between browsers. Local browser storage can be -cleared or disabled and is specific to the loopback address/port; use TOML for -backup. Session credentials are never saved in presets or comparison snapshots. - -Waveform scans and jobs run in the background, one at a time, with up to three -waiting. Activity shows per-stage progress, frozen snapshots and cancellation. -Cancellation is cooperative: a model download may take up to 60 seconds to -reach a safe callback, and files already published can remain. There is no -total recording-duration cap; typical 1–4 hour and longer recordings still need -sufficient processing time and temporary disk space. Inputs must be readable -mono WAVs; stereo needs an explicit downmix outside Producer first. +comparison; a changed source file must be re-uploaded and re-selected. +Download/import small TOML presets to move settings between browsers. +Browser storage can be cleared or disabled; use TOML for backup. + +Uploads, waveform scans and jobs run in the background, one at a time, with +up to three waiting. Activity shows per-stage progress, frozen snapshots and +cancellation. Cancellation is cooperative: a model download may take up to 60 +seconds to reach a safe callback, and files already published can remain. +There is no total recording-duration cap; typical 1–4 hour and longer +recordings still need sufficient processing time and temporary disk space. +Inputs must be readable mono WAVs; stereo needs an explicit downmix outside +Producer first. Uploaded copies live in a private temporary directory and are +deleted when the server stops. First AI noise cleanup or pause silencing use downloads dependencies/models; processing stays local afterwards. For a quick no-model trial, launch with -`./producer.sh gui --root /path/to/recordings --no-denoise --no-pause-silence`. -These explicit flags establish initial controls, not hidden permanent bypasses. -Reload retains the credential in the same tab and reconnects to recent jobs, -but does not automatically restore/play old audio. If Producer restarts, open -its newly printed private URL. If a connection drops during an export request, -check Activity before submitting another export; the first may have succeeded. +`./producer.sh gui --no-denoise --no-pause-silence`. These explicit flags +establish initial controls, not hidden permanent bypasses. Reload reconnects +to recent jobs, but does not automatically restore/play old audio. If Producer +restarts, open its printed URL again. If a connection drops during an export +request, check Activity before submitting another export; the first may have +succeeded. Sound-comparison previews select up to 120 seconds anywhere in a recording, -process with up to 10 seconds of context on each side, then crop to the selected -interval. They are **approximate, not exact export previews**: chapter-level -normalization and inference context differ. Pacing edits are not previewed; -comparisons preserve timing even when export gap trimming 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. +process with up to 10 seconds of context on each side, then crop to the +selected interval. They are **approximate, not exact export previews**: +chapter-level normalization and inference context differ. Gap-trimming edits +are not previewed; comparisons preserve timing even when export gap trimming +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 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 preset or source recording is rewritten by the API. +The browser only fetches bounded preview snippets. TOML presets can be +imported as text or downloaded; no source recording is rewritten by the API. The server retains at most eight repeated-result cache entries and 128 MiB of artifacts; expired previews must be rendered again. Job history is limited to 24 entries and is lost when the server stops, while completed output files -persist. Browser preview WAVs are bounded to about 11.6 MB per side (120 seconds -of mono PCM16 at 48 kHz). Full exports never become browser Blobs. These are -generic WAV/MP3 delivery files, not delivery-platform compliance certification. - +persist. Browser preview WAVs are bounded to about 11.6 MB per side (120 +seconds of mono PCM16 at 48 kHz). Full exports never become browser Blobs. +These are generic WAV/MP3 delivery files, not delivery-platform compliance +certification. ### GUI development checks (optional) The application itself needs no browser automation or Node dependencies. With diff --git a/lib/project/src/producer/audio.py b/lib/project/src/producer/audio.py index 59fe1ff..fcde8c3 100644 --- a/lib/project/src/producer/audio.py +++ b/lib/project/src/producer/audio.py @@ -116,6 +116,67 @@ def measure(path: Path, progress, stage="Analyzing audio") -> dict: "noise_floor_confidence": "low (quiet-block heuristic, not speech recognition)" if noise_available else "insufficient room tone"} +def downmix_to_mono(source: Path, target: Path, progress, + stage="Downmixing stereo to mono") -> str: + """Polarity-safe stereo to mono downmix in float64, written as float32. + + A naive (L + R) / 2 mix cancels a voice when a recorder stores the same + signal with inverted polarity on one channel, or halves it when one + channel is silent. Streaming channel statistics decide the strategy: + + - one channel silent: keep the other channel at unity gain + - channels strongly anti-correlated: invert one, then average + - otherwise: plain (L + R) / 2 average + + Returns a human-readable description of the applied method. + """ + with sf.SoundFile(source) as src: + if src.channels != 2: + raise ValueError("Stereo downmix expects a two-channel recording") + if not len(src): + raise ValueError("The recording is empty") + sums = np.zeros(2) + squares = np.zeros(3) + frames = 0 + progress(stage, 0, len(src)) + for audio in src.blocks(blocksize=65536, dtype="float64", always_2d=True): + if not np.isfinite(audio).all(): + raise ValueError("Recording contains non-finite samples") + left, right = audio[:, 0], audio[:, 1] + sums += (float(left.sum()), float(right.sum())) + squares += (float((left * left).sum()), float((right * right).sum()), + float((left * right).sum())) + frames += len(audio) + progress(stage, frames, len(src)) + # Covariance without the DC component: correlation must judge the + # signal, not any offset between the channels. + mean_l, mean_r = sums / frames + covariance = squares[2] / frames - mean_l * mean_r + variance_l = max(squares[0] / frames - mean_l * mean_l, 0.0) + variance_r = max(squares[1] / frames - mean_r * mean_r, 0.0) + correlation = covariance / math.sqrt(max(variance_l * variance_r, 1e-30)) \ + if variance_l > 0 and variance_r > 0 else 1.0 + if variance_r <= variance_l * 1e-9: + keep, invert, method = 0, False, "the right channel is silent; the left channel was kept at unity gain" + elif variance_l <= variance_r * 1e-9: + keep, invert, method = 1, False, "the left channel is silent; the right channel was kept at unity gain" + elif correlation <= -0.5: + keep, invert, method = None, True, "the channels were strongly phase-inverted, so the right channel was inverted before averaging" + else: + keep, invert, method = None, False, "the channels were averaged" + with sf.SoundFile(target, "w", samplerate=src.samplerate, channels=1, + format="WAV", subtype="FLOAT") as dst: + src.seek(0) + position = 0 + for audio in src.blocks(blocksize=65536, dtype="float64", always_2d=True): + mono = audio[:, keep] if keep is not None else (audio[:, 0] - audio[:, 1] if invert + else audio[:, 0] + audio[:, 1]) * 0.5 + dst.write(mono) + position += len(mono) + progress(stage, position, len(src)) + return method + + def trim_gaps(source: Path, target: Path, threshold_db: float, max_gap_s: float, pad_s: float, progress, stage="Trimming long silences") -> tuple[int, float]: diff --git a/lib/project/src/producer/cli.py b/lib/project/src/producer/cli.py index 982e656..bf94ed4 100644 --- a/lib/project/src/producer/cli.py +++ b/lib/project/src/producer/cli.py @@ -42,7 +42,6 @@ def parser(): if name == "config": cmd.add_argument("--json", action="store_true") elif name == "gui": - cmd.add_argument("--root", type=Path, default=Path.cwd(), help="Allowed local file root (default: current directory)") cmd.add_argument("--port", type=int, default=8765, help="Loopback HTTP port (default: 8765)") else: cmd.add_argument("inputs", type=Path, nargs="+" if name == "process" else 1) @@ -122,7 +121,7 @@ def main(): settings = settings_for(args) if args.command == "gui": from .gui import serve - serve(args.root, args.port, config=args.config, profile=args.profile, + serve(args.port, config=args.config, profile=args.profile, overrides=settings_overrides(args)) return if args.command == "config": diff --git a/lib/project/src/producer/gui.py b/lib/project/src/producer/gui.py index f1fa298..55bef1a 100644 --- a/lib/project/src/producer/gui.py +++ b/lib/project/src/producer/gui.py @@ -1,27 +1,35 @@ -"""Optional loopback-only web API. Importing the CLI never imports web dependencies.""" +"""Optional loopback-only web API. Importing the CLI never imports web dependencies. + +There is no credential: every process on this machine can use the API, so the +server binds only to 127.0.0.1 and rejects cross-origin/rebinding requests. +Recordings arrive as browser uploads and are processed from a private +temporary work directory; outputs are written to an existing absolute +directory chosen per export. +""" from contextlib import asynccontextmanager from dataclasses import asdict import math from pathlib import Path -import secrets +import re import socket import tomllib +import uuid from .config import resolve, toml from .gui_jobs import Busy, Jobs, input_info from .gui_schema import schema BODY_LIMIT = 65536 +UNSAFE_NAME = re.compile(r"[^\w .+()-]", re.ASCII) class Boundary: - """Authenticate even reads, reject cross-origin/rebinding, and bound JSON bodies. + """Reject cross-origin/rebinding requests and bound JSON bodies. - The token arrives via URL fragment (never sent to HTTP logs), then a Bearer - header on every API request. No cookies, query credentials, or CORS bypass. + Uploads bypass the bounded-body path: recordings stream straight to disk. """ - def __init__(self, app, authority, token): - self.app, self.authority, self.token = app, authority, token + def __init__(self, app, authority): + self.app, self.authority = app, authority async def __call__(self, scope, receive, send): if scope["type"] != "http": @@ -37,10 +45,6 @@ class Boundary: status, detail = 403, "Untrusted Origin" elif any(value not in ("same-origin", "none") for value in values(b"sec-fetch-site")): status, detail = 403, "Cross-site request denied" - elif scope["path"].startswith("/api/"): - auth = values(b"authorization") - if len(auth) != 1 or not secrets.compare_digest(auth[0].encode("latin1"), f"Bearer {self.token}".encode("ascii")): - status, detail = 401, "Open the local URL printed by Producer to authenticate" async def secure_send(message): if message["type"] == "http.response.start": message.setdefault("headers", []).extend([ @@ -51,8 +55,8 @@ class Boundary: await send(message) if status: return await JSONResponse({"detail": detail}, status_code=status)(scope, receive, secure_send) - # Pre-read only the bounded API payload. This also handles chunked bodies. - if scope["path"].startswith("/api/"): + # Pre-read only the bounded JSON payloads. This also handles chunked bodies. + if scope["path"].startswith("/api/") and scope["path"] != "/api/uploads": body = bytearray() while True: message = await receive() @@ -75,15 +79,6 @@ class Boundary: return await self.app(scope, receive, secure_send) -def rooted(root, value): - if not isinstance(value, str) or not value or "\x00" in value or Path(value).is_absolute(): - raise ValueError("Use a relative path inside the selected root") - path = (root / value).resolve(strict=True) - if not path.is_relative_to(root): - raise ValueError("Path escapes the selected root (including symlinks)") - return path - - def object_body(value, allowed): if not isinstance(value, dict): raise ValueError("Expected a JSON object") @@ -92,14 +87,28 @@ def object_body(value, allowed): return value -def create_app(root, port, token, *, config=None, profile=None, overrides=None): +def output_directory(value, default): + """An export needs an existing absolute directory on this machine.""" + if value in (None, ""): + if default is None: + raise ValueError("Choose an existing absolute output directory") + default.mkdir(parents=True, exist_ok=True) + return default + if not isinstance(value, str) or "\x00" in value: + raise ValueError("Choose an existing absolute output directory on this machine") + path = Path(value).expanduser() + if not path.is_absolute(): + raise ValueError("The output directory must be an absolute path on this machine") + if not path.is_dir(): + raise ValueError(f"Output directory does not exist: {path}") + return path.resolve() + + +def create_app(port, *, config=None, profile=None, overrides=None, output_dir=None): # These imports intentionally exist only behind the optional gui command. from fastapi import FastAPI, Request from fastapi.responses import FileResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles - root = Path(root).resolve(strict=True) - if not root.is_dir(): - raise ValueError("GUI root must be an existing directory") base = tomllib.loads(config.read_text()) if config else {} if profile is not None: base["profile"] = profile @@ -111,19 +120,21 @@ def create_app(root, port, token, *, config=None, profile=None, overrides=None): raise ValueError("settings must be an object") return resolve(overrides=base | values) - def path_for(value): - return rooted(root, value) + # Exports land in producer-gui-exports next to the launch directory + # unless an absolute directory is given per export. Recording uploads are + # private temporary copies. + default_output = Path(output_dir) if output_dir else Path.cwd() / "producer-gui-exports" + jobs = Jobs() @asynccontextmanager async def lifespan(app): - app.state.jobs = Jobs(root, path_for) try: yield finally: - app.state.jobs.close() + jobs.close() app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan) - app.add_middleware(Boundary, authority=f"127.0.0.1:{port}", token=token) + app.add_middleware(Boundary, authority=f"127.0.0.1:{port}") @app.exception_handler(ValueError) async def invalid(request, error): @@ -139,7 +150,7 @@ def create_app(root, port, token, *, config=None, profile=None, overrides=None): @app.get("/api/schema") def settings_schema(): - return schema(initial) | {"root": str(root), "api_version": 1} + return schema(initial) | {"api_version": 1, "output_dir": str(default_output)} @app.post("/api/resolve") async def resolve_settings(request: Request): @@ -160,61 +171,56 @@ def create_app(root, port, token, *, config=None, profile=None, overrides=None): return Response(toml(configured(body.get("settings", {}))), media_type="application/toml", headers={"Content-Disposition": 'attachment; filename="producer-preset.toml"'}) - @app.get("/api/files") - def files(path: str = ".", offset: int = 0, limit: int = 100): - if offset < 0 or not 1 <= limit <= 200: - raise ValueError("offset must be nonnegative and limit must be 1..200") - directory = path_for(path) - if not directory.is_dir(): - raise ValueError("Choose a directory") - entries, skipped, more = [], 0, False - # Deliberately unsorted: do not materialize arbitrarily large directories. - for entry in directory.iterdir(): + @app.post("/api/uploads") + async def upload(request: Request, name: str = "recording.wav"): + """Stream a recording from the browser into the private work directory.""" + # Display name only: never used as a filesystem path component. + safe_name = UNSAFE_NAME.sub("_", Path(name).name).strip(". ") or "recording.wav" + if not safe_name.lower().endswith(".wav"): + safe_name += ".wav" + path = jobs.uploads_dir / f"{uuid.uuid4().hex}-{safe_name}" + try: + with path.open("wb") as sink: + async for chunk in request.stream(): + sink.write(chunk) try: - resolved = path_for(str(entry.relative_to(root))) - is_dir = resolved.is_dir() - if not is_dir and not (resolved.is_file() and resolved.suffix.lower() == ".wav"): - continue - except (OSError, ValueError): - continue - if skipped < offset: - skipped += 1 - continue - if len(entries) == limit: - more = True - break - entries.append({"name": entry.name, "path": str(entry.relative_to(root)), - "kind": "directory" if is_dir else "wav"}) - relative = directory.relative_to(root) - return {"path": str(relative), "parent": None if directory == root else str(relative.parent), - "entries": entries, "next_offset": offset + len(entries) if more else None} - - @app.get("/api/input") - def inspect_input(path: str): - return input_info(path_for(path)) | {"path": path} + info = input_info(path) + except ValueError: + path.unlink(missing_ok=True) + raise + except BaseException: + path.unlink(missing_ok=True) + raise + upload_id = jobs.register_upload(path, safe_name) + return {"upload_id": upload_id, "name": safe_name, **info} + + @app.get("/api/uploads/{upload_id}") + def upload_info(upload_id: str): + return jobs.upload_info(upload_id) @app.get("/api/jobs") def list_jobs(): - return {"jobs": app.state.jobs.list()} + return {"jobs": jobs.list()} @app.get("/api/jobs/{job_id}") def get_job(job_id: str): - return app.state.jobs.get(job_id) + return jobs.get(job_id) @app.post("/api/jobs/{job_id}/cancel") def cancel_job(job_id: str): - return app.state.jobs.cancel(job_id) + return jobs.cancel(job_id) @app.post("/api/jobs", status_code=202) async def submit_job(request: Request): - body = object_body(await request.json(), {"kind", "path", "input_id", "settings", "start", "duration", "output_dir"}) + body = object_body(await request.json(), + {"kind", "upload_id", "input_id", "settings", "start", "duration", "output_dir"}) kind = body.get("kind") if kind not in ("scan", "preview", "export"): raise ValueError("kind must be scan, preview or export") - info = input_info(path_for(body.get("path"))) + 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") - snapshot = {"path": body["path"], "input_id": info["input_id"]} + snapshot = {"upload_id": body["upload_id"], "name": info["name"], "input_id": info["input_id"]} if kind != "scan": snapshot["settings"] = asdict(configured(body.get("settings", {}))) if kind == "preview": @@ -225,15 +231,12 @@ def create_app(root, port, token, *, config=None, profile=None, overrides=None): raise ValueError("Preview must be within the recording: start >= 0, duration > 0 and <= 120 seconds") snapshot.update(start=start, duration=duration) if kind == "export": - output_dir = body.get("output_dir", ".") - if not path_for(output_dir).is_dir(): - raise ValueError("Choose an existing output directory") - snapshot["output_dir"] = output_dir - return app.state.jobs.submit(kind, snapshot) + snapshot["output_dir"] = str(output_directory(body.get("output_dir"), default_output)) + return jobs.submit(kind, snapshot) @app.get("/api/results/{result_id}/{kind}.wav") def audio(result_id: str, kind: str): - return Response(app.state.jobs.media(result_id, kind), media_type="audio/wav") + return Response(jobs.media(result_id, kind), media_type="audio/wav") assets = Path(__file__).with_name("web") @app.get("/") @@ -243,7 +246,7 @@ def create_app(root, port, token, *, config=None, profile=None, overrides=None): return app -def serve(root, port=8765, *, config=None, profile=None, overrides=None): +def serve(port=8765, *, config=None, profile=None, overrides=None): if type(port) is not int or not 1 <= port <= 65535: raise ValueError("port must be between 1 and 65535") try: @@ -251,14 +254,14 @@ def serve(root, port=8765, *, config=None, profile=None, overrides=None): import fastapi # noqa: F401 -- useful dependency diagnostic before binding except ImportError as error: raise RuntimeError("GUI dependencies are optional. Use './producer.sh gui' or install 'producer[gui]'.") from error - token = secrets.token_urlsafe(32) - app = create_app(root, port, token, config=config, profile=profile, overrides=overrides) - # Pre-bind so a busy port fails before printing a misleading authenticated URL. + app = create_app(port, config=config, profile=profile, overrides=overrides) + # Pre-bind so a busy port fails before printing a misleading URL. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind(("127.0.0.1", port)) sock.listen(128) - print(f"Producer local GUI: http://127.0.0.1:{port}/#token={token}", flush=True) - print(f"Allowed files: {Path(root).resolve()} (keep this URL private)", flush=True) + print(f"Producer local GUI: http://127.0.0.1:{port}/", flush=True) + print("Open this address in a browser on this machine. Everything can access this server; " + "audio is processed locally and uploaded recordings are temporary copies.", flush=True) server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, access_log=False, proxy_headers=False)) server.run(sockets=[sock]) diff --git a/lib/project/src/producer/gui_jobs.py b/lib/project/src/producer/gui_jobs.py index 705b0dd..06749e2 100644 --- a/lib/project/src/producer/gui_jobs.py +++ b/lib/project/src/producer/gui_jobs.py @@ -53,14 +53,16 @@ def input_info(path): info = sf.info(path) except (RuntimeError, sf.LibsndfileError) as error: raise ValueError(f"Unsupported or unreadable WAV: {error}") from error - if info.channels == 2: - raise ValueError("Stereo input: downmix to mono first. Automatic downmix could cancel your voice.") - if info.channels != 1: - raise ValueError("Only mono voice recordings can be processed.") + if info.channels not in (1, 2): + raise ValueError("Only mono or stereo voice recordings can be processed.") if not info.frames: raise ValueError("The recording is empty") - return {"input_id": identity(path), "frames": info.frames, "duration": info.duration, + result = {"input_id": identity(path), "frames": info.frames, "duration": info.duration, "sample_rate": info.samplerate, "channels": info.channels, "subtype": info.subtype} + if info.channels == 2: + result["notice"] = ("Stereo recording: it will be downmixed to mono automatically, " + "with phase cancellation prevented, before processing.") + return result def waveform(path, progress): @@ -156,12 +158,14 @@ def render_preview(source, work, snapshot, progress): class Jobs: - """Owned by the app lifespan. All public state is copied under the lock.""" - def __init__(self, root, resolve_path): - self.root = root - self.resolve_path = resolve_path + """Owned by the app. All public state is copied under the lock.""" + def __init__(self): self.temporary = tempfile.TemporaryDirectory(prefix="producer-gui-") self.work = Path(self.temporary.name) + self.uploads_dir = self.work / "uploads" + self.uploads_dir.mkdir() + # upload_id -> {"path": Path, "name": str}; uploads are the only sources. + self.uploads = {} self.condition = threading.Condition(threading.RLock()) self.jobs = OrderedDict() self.pending = deque() @@ -170,8 +174,25 @@ class Jobs: self.thread = threading.Thread(target=self._worker, name="producer-gui-jobs", daemon=True) self.thread.start() + def register_upload(self, path, name): + upload_id = uuid.uuid4().hex + with self.condition: + self.uploads[upload_id] = {"path": path, "name": name} + return upload_id + + def upload_info(self, upload_id): + with self.condition: + entry = self.uploads.get(upload_id) + if entry is None: + raise ValueError("Unknown recording; pick the file again") + return {"upload_id": upload_id, "name": entry["name"], "path": str(entry["path"]), + **input_info(entry["path"])} + def _check_input(self, snapshot): - source = self.resolve_path(snapshot["path"]) + entry = self.uploads.get(snapshot["upload_id"]) + if entry is None: + raise ValueError("Unknown recording; pick the file again") + source = entry["path"] if identity(source) != snapshot["input_id"]: raise ValueError("Input changed; select and scan it again") return source @@ -271,9 +292,9 @@ class Jobs: elif job["kind"] == "preview": result = render_preview(source, directory, snapshot, progress) else: - parent = self.resolve_path(snapshot["output_dir"]) - if not parent.is_dir(): - raise ValueError("Choose an existing output directory") + 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: @@ -281,7 +302,7 @@ class Jobs: settings = Settings(**snapshot["settings"]) report = process(source, target, settings, progress, provenance={"gui_snapshot": snapshot}) - result = {"paths": [str(path.relative_to(self.root)) for path in targets_for(target, settings)], + 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."} diff --git a/lib/project/src/producer/gui_schema.py b/lib/project/src/producer/gui_schema.py index d72b6ea..1a36900 100644 --- a/lib/project/src/producer/gui_schema.py +++ b/lib/project/src/producer/gui_schema.py @@ -3,68 +3,73 @@ from dataclasses import asdict from .config import PROFILES, RANGES, Settings -# Keep DSP validation in config.resolve; these labels explain the existing knobs. +# Keep DSP validation in config.resolve. Each help explains what the control +# does AND how to switch that effect off; dependents are greyed out in the UI. GROUPS = { "General": { - "profile": "Starting sound profile. File and explicit settings override profile values.", - "device": "AI device: auto selects an available backend; CPU needs no GPU.", + "profile": "Starting sound profile (natural, narrator, radio, cleanup-only). A profile only fills unset values; explicit settings always win. To use just one profile's numbers, leave the name as is — no separate off switch exists.", + "device": "Where AI noise cleanup runs: auto picks a GPU if available, otherwise CPU. CPU needs no graphics card. Only relevant when 'Noise cleanup method' is DeepFilter or ClearVoice.", }, "Noise removal": { - "denoiser": "DeepFilterNet or ClearVoice require local model downloads; FFT is lightweight; none bypasses denoising.", - "denoise_strength": "Dry/wet denoising blend. Lower values may preserve consonants better.", - "fft_reduction_db": "Maximum FFT noise reduction, scaled by denoise strength.", + "denoiser": "Removes steady background noise (fans, hiss, hum). deepfilter and clearvoice are AI models (first use downloads one); fft is a light fallback that can sound metallic; none disables noise removal entirely — this is how to turn all noise cleanup off.", + "denoise_strength": "How much of the detected noise the AI model removes, 0 (keep) to 1 (remove). Inactive unless an AI method is chosen; setting the method to 'none' disables noise removal (this value is then ignored, not reset).", + "fft_reduction_db": "Maximum noise reduction for the fft method only, in dB, further scaled by cleanup strength (effective reduction = this × strength). It cannot be 0: values near 0.01 dB mean 'almost no FFT reduction'; to disable FFT reduction entirely choose deepfilter, clearvoice or none as the method.", }, "Filters": { - "highpass": "Remove low-frequency rumble.", "highpass_hz": "High-pass cutoff in Hz.", - "lowpass": "Remove high-frequency content.", "lowpass_hz": "Low-pass cutoff in Hz, below output Nyquist.", - "hum_hz": "Mains hum notch frequency; 0 bypasses, 50/60 also filter harmonics.", + "highpass": "Cuts everything below the rumble cutoff to remove low rumble (traffic, handling, HVAC). Uncheck to disable this filter and keep its cutoff value for later.", + "highpass_hz": "Rumble cutoff in Hz; only used while 'Remove low rumble' is checked.", + "lowpass": "Cuts everything above the cutoff to soften hiss and sibilance harshness. Uncheck to disable this filter and keep its cutoff value for later.", + "lowpass_hz": "High-frequency cutoff in Hz, below the output Nyquist; only used while 'Soften high frequencies' is checked.", + "hum_hz": "Notch filter at the mains-hum frequency (50 or 60 Hz) plus its harmonics. Choose 0 to disable hum removal; 50/60 are active.", }, "Level and expansion": { - "leveling": "Apply constant gain using an estimated speech level.", - "level_target_db": "Speech-level target in dBFS before dynamics.", - "max_gain_db": "Maximum absolute leveling adjustment in dB.", - "expansion": "Attenuate low-level sound with a downward expander; can affect quiet words.", - "expansion_threshold_db": "Expander threshold in dBFS.", - "expansion_ratio": "Expansion ratio; 1 means no expansion.", - "expansion_range_db": "Maximum expansion attenuation in dB.", + "leveling": "Applies one constant gain so speech sits near the target level. Uncheck to disable leveling and keep its values for later.", + "level_target_db": "Speech-level target in dBFS before dynamics; only used while 'Even speech level' is checked.", + "max_gain_db": "Maximum absolute leveling adjustment in dB (0 disables movement while keeping leveling on); only used while 'Even speech level' is checked.", + "expansion": "Downward expander: attenuates sound below the threshold (room noise between words). Can affect quiet words. Uncheck to disable expansion and keep its values for later.", + "expansion_threshold_db": "Expander threshold in dBFS; only used while 'Reduce low-level room sound' is checked.", + "expansion_ratio": "Expansion ratio; 1 means no expansion. Only used while 'Reduce low-level room sound' is checked.", + "expansion_range_db": "Maximum expansion attenuation in dB. Only used while 'Reduce low-level room sound' is checked.", }, - "Speech-aware pauses": { - "pause_silence": "Silence non-speech pauses without changing timing. Downloads a local speech detector.", - "pause_threshold": "Speech probability threshold. Lower values protect more possible speech.", - "pause_pre_ms": "Protected milliseconds before detected speech.", - "pause_post_ms": "Protected milliseconds after detected speech.", - "pause_fade_ms": "Fade length in milliseconds outside protected speech.", - "pause_min_ms": "Minimum silent core in milliseconds after margins and fades.", + "Silence pauses (speech detection)": { + "pause_silence": "Uses a local Silero speech detector to zero out non-speech pauses (breaths, gaps) WITHOUT moving anything in time. Can lose breaths or whispers. Uncheck to keep pauses untouched and keep these values for later. First use downloads the detector.", + "pause_threshold": "Speech probability threshold; lower values protect more possible speech from being silenced. Only used while 'Silence non-speech pauses' is checked.", + "pause_pre_ms": "Protected milliseconds before detected speech. Only used while 'Silence non-speech pauses' is checked.", + "pause_post_ms": "Protected milliseconds after detected speech. Only used while 'Silence non-speech pauses' is checked.", + "pause_fade_ms": "Fade length in milliseconds outside protected speech, avoiding clicks. Only used while 'Silence non-speech pauses' is checked.", + "pause_min_ms": "Minimum silent core in milliseconds after margins and fades. Only used while 'Silence non-speech pauses' is checked.", }, - "Pacing (export only)": { - "trim_silence": "Shorten long gaps on export. NOT auditioned in sound-comparison previews; changes duration.", - "max_gap_s": "Minimum gap length in seconds eligible for shortening.", - "gap_pad_s": "Total retained gap in seconds; must be smaller than maximum gap.", - "gap_threshold_db": "Legacy gap threshold in dBFS when speech-aware pauses are off; can mistake quiet words for silence.", + "Shorten long gaps (export only)": { + "trim_silence": "Shortens gaps longer than 'Gap eligible' down to 'Gap to keep' on the FULL EXPORT ONLY; this changes the duration and is never heard in the sound comparison. Uncheck to keep every gap at its original length and keep these values for later.", + "max_gap_s": "Minimum gap length in seconds eligible for shortening. Only used while 'Shorten long gaps on export' is checked.", + "gap_pad_s": "Total retained gap in seconds; must be smaller than the maximum gap. Only used while 'Shorten long gaps on export' is checked.", + "gap_threshold_db": "Legacy gap threshold in dBFS used only when speech-aware pause silencing is off; it can mistake quiet words for silence. Only used while 'Shorten long gaps on export' is checked.", }, "Tone": { - "eq": "Enable warmth, mud and presence equalizers.", - "warmth_db": "140 Hz warmth gain in dB.", "mud_db": "300 Hz mud-band gain in dB.", - "presence_db": "3500 Hz presence gain in dB.", + "eq": "Three fixed equalizer bands: warmth (140 Hz), mud (300 Hz) and presence (3500 Hz). Uncheck to bypass all three and keep their dB values for later.", + "warmth_db": "140 Hz gain in dB: positive adds fullness (too much sounds boomy), negative thins the low end. Only used while 'Shape vocal tone' is checked.", + "mud_db": "300 Hz gain in dB; negative removes boxy low-mid build-up. Only used while 'Shape vocal tone' is checked.", + "presence_db": "3500 Hz gain in dB: positive clarifies consonants but can sharpen S sounds. Only used while 'Shape vocal tone' is checked.", }, "Dynamics": { - "compression": "Reduce dynamic range with a compressor.", - "compressor_threshold_db": "Compression threshold in dBFS.", - "compressor_ratio": "Compression ratio; 1 means no compression.", - "compressor_attack_ms": "Compressor attack in milliseconds; fast attacks can soften speech starts.", - "compressor_release_ms": "Compressor recovery in milliseconds.", - "deess": "Reduce sibilance.", "deess_intensity": "Sibilance detector intensity.", - "deess_amount": "Amount of de-essing.", + "compression": "Compressor: brings loud and quiet phrases closer together; too much sounds squeezed or raises room noise. Uncheck to disable compression and keep its values for later.", + "compressor_threshold_db": "Compression threshold in dBFS. Only used while 'Smooth loud and quiet phrases' is checked.", + "compressor_ratio": "Compression ratio; 1 means no compression. Only used while 'Smooth loud and quiet phrases' is checked.", + "compressor_attack_ms": "Attack in milliseconds; fast attacks can soften speech starts. Only used while 'Smooth loud and quiet phrases' is checked.", + "compressor_release_ms": "Recovery in milliseconds. Only used while 'Smooth loud and quiet phrases' is checked.", + "deess": "Reduces sharp S sounds (sibilance). Uncheck to disable de-essing and keep its values for later.", + "deess_intensity": "S-sound detector sensitivity, 0–1. Only used while 'Soften sharp S sounds' is checked.", + "deess_amount": "Amount of de-essing applied to detected S sounds, 0–1. Only used while 'Soften sharp S sounds' is checked.", }, "Loudness and delivery": { - "normalize": "Two-pass integrated loudness normalization, including true-peak limiting. Preview context differs from a chapter.", - "target_lufs": "Integrated loudness target in LUFS, not an ACX/Audible compliance guarantee.", - "loudness_range": "Loudness range target in LU.", - "limiter": "Limit peaks; must remain enabled when normalization is enabled.", - "true_peak_db": "True-peak ceiling in dBTP; lossy MP3 can overshoot.", - "sample_rate": "Mono 32-bit PCM WAV export sample rate in Hz.", - "mp3": "Also export a constant-bitrate MP3 alongside the WAV.", - "mp3_bitrate": "MP3 bitrate in kb/s.", + "normalize": "Two-pass integrated loudness normalization to the target, including true-peak limiting. Preview context differs from a full chapter. Uncheck to leave overall loudness as the processing left it; the peak limiter stays independently available.", + "target_lufs": "Integrated loudness target in LUFS (more negative = quieter); not an ACX/Audible compliance guarantee. Only used while 'Set overall loudness' is checked.", + "loudness_range": "Loudness range target in LU. Only used while 'Set overall loudness' is checked.", + "limiter": "True-peak limiter on its own, independent of normalization. Disabling it while 'Set overall loudness' is checked is rejected by validation — normalization includes limiting.", + "true_peak_db": "True-peak ceiling in dBTP; lossy MP3 can overshoot. Only used while 'Limit peaks' is checked.", + "sample_rate": "Export WAV sample rate in Hz (44100 or 48000); recordings are always mono 32-bit PCM. Applies to every export.", + "mp3": "Also encode a constant-bitrate MP3 alongside the WAV. Uncheck for WAV only.", + "mp3_bitrate": "MP3 bitrate in kb/s. Only used while 'Also make an MP3' is checked.", }, } CHOICES = {"profile": list(PROFILES), "denoiser": ["deepfilter", "clearvoice", "fft", "none"], diff --git a/lib/project/src/producer/pipeline.py b/lib/project/src/producer/pipeline.py index 31cb9f5..c730016 100644 --- a/lib/project/src/producer/pipeline.py +++ b/lib/project/src/producer/pipeline.py @@ -8,7 +8,7 @@ from pathlib import Path import tempfile from . import __version__ -from .audio import ffmpeg_run, loudness, measure +from .audio import downmix_to_mono, ffmpeg_run, loudness, measure from .config import Settings from .setup import ensure_ffmpeg @@ -80,10 +80,8 @@ def process(source: Path, destination: Path, settings: Settings, progress, original = measure(source, progress, "Analyzing original") if original["sample_peak_dbfs"] < -100: raise ValueError("Recording is silent or too quiet to master safely") - if original["channels"] == 2: - raise ValueError("Stereo input: downmix to mono first, for example " - "'ffmpeg -i input.wav -ac 1 mono.wav'. An automatic " - "downmix could cancel your voice.") + if original["channels"] not in (1, 2): + raise ValueError("Only mono or stereo voice recordings can be processed") duration = original["duration_seconds"] warnings = [] if original["near_full_scale_samples"]: @@ -95,12 +93,20 @@ def process(source: Path, destination: Path, settings: Settings, progress, 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")] + mono_source = source + if original["channels"] == 2: + # Automatic, but never destructive: the downmix is polarity-safe + # (see downmix_to_mono), so it cannot cancel an inverted voice. + downmixed = work / "downmixed.wav" + method = downmix_to_mono(source, downmixed, progress) + warnings.append(f"Stereo input was downmixed to mono automatically ({method}).") + mono_source = downmixed input_filters = [] if settings.highpass: input_filters.append(f"highpass=f={settings.highpass_hz}:p=2") if settings.hum_hz: input_filters += [f"equalizer=f={settings.hum_hz * n}:t=q:w=25:g=-18" for n in (1, 2, 3)] - ffmpeg_run(ffmpeg, ["-i", str(source), "-map", "0:a:0", "-af", + ffmpeg_run(ffmpeg, ["-i", str(mono_source), "-map", "0:a:0", "-af", ",".join(input_filters or ["anull"]), "-ar", "48000", "-ac", "1", "-c:a", "pcm_f32le", "-rf64", "auto", str(prepared)], "Preparing mono 48 kHz audio", duration, progress) pauses, pause_report = [], {"enabled": settings.pause_silence} diff --git a/lib/project/src/producer/web/app.js b/lib/project/src/producer/web/app.js index bdf62c6..44608b9 100644 --- a/lib/project/src/producer/web/app.js +++ b/lib/project/src/producer/web/app.js @@ -1,23 +1,16 @@ -import {parseTime, formatTime, selection, stable, matches, monitoringGain, absolutePath} from './state.js'; +import {parseTime, formatTime, selection, stable, matches, monitoringGain} from './state.js'; const $ = id => document.getElementById(id); const copy = value => structuredClone(value); const terminal = job => ['succeeded', 'failed', 'cancelled'].includes(job.status); const state = { - schema: null, input: null, version: 0, inputEpoch: 0, folderEpoch: 0, - folder: '.', parent: null, next: null, wave: null, view: [0, 0], + schema: null, input: null, version: 0, inputEpoch: 0, + uploads: [], wave: null, view: [0, 0], jobs: new Map(), intents: new Map(), handled: new Set(), cards: new Map(), preview: null, mediaEpoch: 0, mediaAbort: null, urls: [], side: 'original', - context: null, gains: {}, playbackEpoch: 0, export: null, expired: false, storageKey: '', + context: null, gains: {}, playbackEpoch: 0, export: null, storageKey: 'producer-gui-v1', presets: [], comparisons: [], exportsShown: new Set() }; -let token = new URLSearchParams(location.hash.slice(1)).get('token'); -// Fragments never reach the server. Keep the credential only in this tab, not presets. -if (location.hash) history.replaceState(null, '', location.pathname + location.search); -try { - if (token) sessionStorage.setItem('producer-token', token); - else token = sessionStorage.getItem('producer-token'); -} catch { /* A private browser may disable storage; the current tab still works. */ } function node(tag, text, className) { const element = document.createElement(tag); @@ -28,7 +21,6 @@ function node(tag, text, className) { function error(error) { if (error?.name === 'AbortError') return; let message = error instanceof Error ? error.message : String(error); - if (token) message = message.split(token).join('[credential hidden]'); $('error-text').textContent = message; $('error').hidden = false; } @@ -44,23 +36,13 @@ async function api(path, body, responseType = 'json', signal) { if (!path.startsWith('/api/')) throw new Error('Invalid local API path.'); const response = await fetch(path, { method: body === undefined ? 'GET' : 'POST', - headers: {Authorization: `Bearer ${token || ''}`, ...(body === undefined ? {} : {'Content-Type': 'application/json'})}, - ...(body === undefined ? {} : {body: JSON.stringify(body)}), signal, cache: 'no-store', credentials: 'omit' + ...(body === undefined ? {} : {'Content-Type': 'application/json', body: JSON.stringify(body)}), + signal, cache: 'no-store', credentials: 'omit' }); if (!response.ok) { - if (response.status === 401) { - state.expired = true; - state.mediaEpoch++; state.mediaAbort?.abort(); - state.export = null; $('export-dialog').close(); - $('workspace').disabled = true; - pause(); - try { sessionStorage.removeItem('producer-token'); } catch { /* Storage unavailable. */ } - $('connection').textContent = 'Session expired. Open the new private URL printed in your Producer terminal. Saved presets remain in this browser.'; - throw new Error('This session is missing or expired. Reopen the exact private launch URL (127.0.0.1, not localhost).'); - } const data = await response.json().catch(() => ({})); let detail = typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail || 'Request failed'); - if (detail === 'Normalization includes true-peak limiting; use --no-normalize to bypass it') detail = 'Overall loudness normalization requires the peak limiter. Enable “Limit peaks” in advanced controls, or turn “Set overall loudness” off.'; + if (detail === 'Normalization includes true-peak limiting; use --no-normalize to bypass it') detail = 'Overall loudness normalization requires the peak limiter. Enable “Limit peaks”, or turn “Set overall loudness” off.'; throw new Error(response.status === 404 ? `${detail}. Cached comparisons expire; restore the snapshot and render again.` : detail); } return responseType === 'blob' ? response.blob() : response.json(); @@ -76,23 +58,49 @@ const labels = { compression: 'Smooth loud and quiet phrases', compressor_threshold_db: 'Compression threshold · dBFS', compressor_ratio: 'Compression ratio', compressor_attack_ms: 'Attack · ms', compressor_release_ms: 'Recovery · ms', deess: 'Soften sharp S sounds', deess_intensity: 'S-sound sensitivity · 0–1', deess_amount: 'S-sound reduction · 0–1', normalize: 'Set overall loudness', target_lufs: 'Loudness target · LUFS', loudness_range: 'Loudness range · LU', limiter: 'Limit peaks', true_peak_db: 'Peak ceiling · dBTP', sample_rate: 'WAV sample rate · Hz', mp3: 'Also make an MP3', mp3_bitrate: 'MP3 bitrate · kb/s' }; -const friendlyHelp = { - denoiser: 'None leaves noise untouched. FFT is light but can sound metallic. AI needs a first-use local download.', - pause_silence: 'Keeps timing; silences detected non-speech. Can lose breaths or whispers. Uses a local speech detector.', - eq: 'Switch tone shaping on or off without losing the detailed EQ values below.', - warmth_db: 'More can sound fuller; too much can sound boomy.', presence_db: 'More can clarify consonants, but also sharpen S sounds.', - compression: 'Brings phrases closer together; too much can sound squeezed or bring up room noise.', - normalize: 'Sets whole-recording loudness on export. Requires the peak limiter; it is not enabled for you.', - target_lufs: 'More negative is quieter. This is an export target, not your listening volume.', - profile: 'Changing this name alone keeps all explicit values. Use Apply profile to reset from launch configuration.' +// Which controls belong to which on/off switch. Values list the dependent +// fields to disable when the function returns true. +const DEPS = { + denoiser: value => ({fft_reduction_db: value !== 'fft', denoise_strength: value === 'none'}), + highpass: checked => ({highpass_hz: !checked}), + lowpass: checked => ({lowpass_hz: !checked}), + leveling: checked => ({level_target_db: !checked, max_gain_db: !checked}), + expansion: checked => ({expansion_threshold_db: !checked, expansion_ratio: !checked, expansion_range_db: !checked}), + pause_silence: checked => ({pause_threshold: !checked, pause_pre_ms: !checked, pause_post_ms: !checked, pause_fade_ms: !checked, pause_min_ms: !checked}), + trim_silence: checked => ({max_gap_s: !checked, gap_pad_s: !checked, gap_threshold_db: !checked}), + eq: checked => ({warmth_db: !checked, mud_db: !checked, presence_db: !checked}), + compression: checked => ({compressor_threshold_db: !checked, compressor_ratio: !checked, compressor_attack_ms: !checked, compressor_release_ms: !checked}), + deess: checked => ({deess_intensity: !checked, deess_amount: !checked}), + normalize: checked => ({target_lufs: !checked, loudness_range: !checked, true_peak_db: !checked}), + mp3: checked => ({mp3_bitrate: !checked}) }; -const primary = [ - ['Noise cleanup', 'Keep the words, lose some room noise. Stronger is not always better.', ['denoiser', 'denoise_strength']], - ['Quiet pauses', 'A little room tone can feel natural. Protect quiet words before chasing silence.', ['pause_silence']], - ['Tone', 'Keep your voice familiar. Listen for fullness and clear, unforced consonants.', ['eq', 'warmth_db', 'presence_db']], - ['Even volume', 'Make phrases easier to follow, without flattening the performance.', ['compression', 'normalize']] -]; -const delivery = ['sample_rate', 'mp3', 'mp3_bitrate', 'trim_silence']; +function refreshDependencies() { + const controllerFor = {}; + for (const parent of Object.keys(DEPS)) { + const input = $(`setting-${parent}`); + if (input) controllerFor[parent] = input.type === 'checkbox' ? input.checked : input.value; + } + const controlledBy = {}; + for (const [parent, resolve] of Object.entries(DEPS)) { + if (!(parent in controllerFor)) continue; + for (const [field, off] of Object.entries(resolve(controllerFor[parent]))) if (off) controlledBy[field] = labels[parent]; + } + for (const field of state.schema.fields) { + const input = $(`setting-${field.name}`); + if (!input) continue; + const parent = controlledBy[field.name]; + input.disabled = Boolean(parent); + const help = $(`help-${field.name}`); + if (parent) { + if (!help.dataset.base) help.dataset.base = help.textContent; + help.textContent = `${help.dataset.base} Inactive — controlled by “${parent}”.`; + } else if (help.dataset.base) { + help.textContent = help.dataset.base; + delete help.dataset.base; + } + input.closest('.setting').classList.toggle('inactive', Boolean(parent)); + } +} function settingControl(field) { const wrapper = node('div', undefined, 'setting'); const label = node('label', labels[field.name] || field.name); @@ -116,32 +124,22 @@ function settingControl(field) { if (field.range) [input.min, input.max] = field.range; } } - const help = node('p', friendlyHelp[field.name] || field.help); + const help = node('p', field.help); help.id = `help-${field.name}`; input.setAttribute('aria-describedby', help.id); if (field.type === 'bool') { label.prepend(input); wrapper.append(label); } else wrapper.append(label, input); wrapper.append(help); if (field.name === 'profile') { - const apply = node('button', 'Apply profile & launch defaults'); + const apply = node('button', 'Apply profile'); apply.type = 'button'; apply.id = 'apply-profile'; wrapper.append(apply); } input.addEventListener('input', changed); return wrapper; } function buildSettings() { - const used = new Set(); - const fields = new Map(state.schema.fields.map(field => [field.name, field])); - for (const [title, help, names] of primary) { - const card = node('div', undefined, 'sound-card'); - card.append(node('h3', title), node('p', help)); - for (const name of names) { card.append(settingControl(fields.get(name))); used.add(name); } - $('primary-settings').append(card); - } - for (const name of delivery) { $('delivery-settings').append(settingControl(fields.get(name))); used.add(name); } const groups = new Map(); for (const field of state.schema.fields) { - if (used.has(field.name)) continue; if (!groups.has(field.group)) { const group = node('div', undefined, 'settings-group'), grid = node('div', undefined, 'settings-grid'); group.append(node('h3', field.group), grid); $('settings-groups').append(group); groups.set(field.group, grid); @@ -164,16 +162,14 @@ function setSettings(settings) { if (field.type === 'bool') input.checked = settings[field.name]; else input.value = settings[field.name]; } + refreshDependencies(); } function readSettings(report = true) { const settings = {}; for (const field of state.schema.fields) { const input = $(`setting-${field.name}`); - if (!input.checkValidity()) { - if (report) { - if (input.closest('#advanced-settings')) $('advanced-settings').open = true; - input.reportValidity(); - } + if (!input.disabled && !input.checkValidity()) { + if (report) input.reportValidity(); throw new Error(`Check ${labels[field.name] || field.name}: use a valid value within its range.`); } settings[field.name] = field.type === 'bool' ? input.checked : ['int', 'float'].includes(field.type) ? Number(input.value) : input.value; @@ -182,7 +178,7 @@ function readSettings(report = true) { } function currentSnapshot(report = false) { if (!state.input) throw new Error('Choose a recording first.'); - return {path: state.input.path, input_id: state.input.input_id, settings: readSettings(report), + return {upload_id: state.input.upload_id, input_id: state.input.input_id, settings: readSettings(report), ...selection($('selection-start').value, $('selection-end').value, state.input.duration)}; } function previewMatches() { @@ -193,6 +189,7 @@ function changed() { state.mediaEpoch++; state.mediaAbort?.abort(); pause(); + refreshDependencies(); updateComparisonStatus(); updateSelectionNote(); drawWave(); @@ -200,7 +197,6 @@ function changed() { function updateAvailability() { for (const id of ['render-preview', 'prepare-export', 'rescan', 'zoom-selection', 'zoom-full']) $(id).disabled = !state.input; $('save-comparison').disabled = !state.preview; - $('folder-up').disabled = state.parent === null; } function updateComparisonStatus() { const current = previewMatches(); @@ -217,53 +213,55 @@ function updateSelectionNote() { } catch (e) { $('selection-note').textContent = e.message; } } -async function browse(path = '.', offset = 0) { - const epoch = ++state.folderEpoch; - $('files-status').textContent = 'Reading local folder…'; - const data = await api(`/api/files?path=${encodeURIComponent(path)}&offset=${offset}&limit=100`); - if (epoch !== state.folderEpoch) return; - state.folder = data.path; state.parent = data.parent; state.next = data.next_offset; - $('folder-path').textContent = data.path; - $('folder-up').disabled = data.parent === null; - if (!offset) $('files').replaceChildren(); - for (const entry of data.entries) { - const li = node('li'), button = node('button'); button.type = 'button'; - button.append(node('span', entry.kind === 'directory' ? 'FOLDER' : 'WAV', 'file-kind'), document.createTextNode(entry.name)); - button.addEventListener('click', () => (entry.kind === 'directory' ? browse(entry.path) : chooseInput(entry.path)).catch(error)); - li.append(button); $('files').append(li); +// Recordings arrive as uploads from the browser's own file picker; the server +// keeps a private temporary copy and never touches the original file. +async function uploadFile(file) { + if (!/\.wav$/i.test(file.name)) throw new Error(`${file.name}: choose a .wav file.`); + const status = $('upload-status'); + status.textContent = `Uploading ${file.name}…`; + const response = await fetch(`/api/uploads?name=${encodeURIComponent(file.name)}`, + {method: 'POST', body: file, cache: 'no-store', credentials: 'omit'}); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(typeof data.detail === 'string' ? data.detail : 'Upload failed.'); + } + const entry = await response.json(); + state.uploads.push(entry); + renderUploads(); + status.textContent = entry.notice || 'Recordings are copied into a temporary working directory; originals stay untouched.'; + return entry; +} +function renderUploads() { + $('uploads').replaceChildren(); + for (const entry of state.uploads) { + const li = node('li'), button = node('button', undefined, 'upload-entry'); + button.type = 'button'; + button.append(node('span', 'WAV', 'file-kind'), document.createTextNode(entry.name), node('span', + ` · ${formatTime(entry.duration)} · ${entry.sample_rate.toLocaleString()} Hz`, 'small')); + button.addEventListener('click', () => selectUpload(entry).catch(error)); + li.append(button); $('uploads').append(li); } - $('files-more').hidden = data.next_offset === null; - $('files-status').textContent = data.entries.length ? 'Only folders and WAVs are shown. Listing order follows the filesystem.' : 'No more folders or WAV recordings here.'; } -async function chooseInput(path) { +function channelLabel(entry) { + return entry.channels === 2 ? 'stereo · downmixes to mono' : 'mono'; +} +async function selectUpload(entry) { const epoch = ++state.inputEpoch; state.input = null; state.wave = null; state.view = [0, 0]; changed(); updateAvailability(); - $('input-name').textContent = 'Opening ' + path; - $('input-info').textContent = 'Reading recording metadata…'; - $('wave-status').textContent = 'Waiting for recording…'; - let data; - try { data = await api(`/api/input?path=${encodeURIComponent(path)}`); } - catch (e) { - if (epoch !== state.inputEpoch) return null; - $('input-name').textContent = 'No recording selected'; - $('input-info').textContent = 'Could not open this WAV. Choose another file or fix the issue below.'; - $('wave-status').textContent = 'Select a readable mono WAV.'; - throw e; - } - if (epoch !== state.inputEpoch) return null; - state.input = data; state.view = [0, data.duration]; - $('input-name').textContent = data.path; - $('input-info').textContent = `${formatTime(data.duration)} · mono · ${data.sample_rate.toLocaleString()} Hz · ${data.subtype}`; + $('input-name').textContent = entry.name; + $('input-info').textContent = `${formatTime(entry.duration)} · ${channelLabel(entry)} · ${entry.sample_rate.toLocaleString()} Hz · ${entry.subtype}`; + $('wave-status').textContent = 'Scanning a fixed-size overview… You can already enter exact times.'; + state.input = entry; state.view = [0, entry.duration]; $('selection-start').value = '00:00:00'; - $('selection-end').value = formatTime(Math.min(30, data.duration)); + $('selection-end').value = formatTime(Math.min(30, entry.duration)); // Rounding time display must not put the end beyond a fractional final frame. - if (parseTime($('selection-end').value) > data.duration) $('selection-end').value = String(data.duration); + if (parseTime($('selection-end').value) > entry.duration) $('selection-end').value = String(entry.duration); changed(); updateAvailability(); - $('file-browser').open = false; - $('wave-status').textContent = 'Scanning a fixed-size overview… You can already enter exact times.'; - await submit('scan', {path: data.path, input_id: data.input_id}, {inputEpoch: epoch}); - return epoch === state.inputEpoch ? data : null; + for (const li of $('uploads').children) li.classList.remove('selected'); + $('uploads').children[state.uploads.indexOf(entry)]?.classList.add('selected'); + await submit('scan', {upload_id: entry.upload_id, input_id: entry.input_id}, {inputEpoch: epoch}); + return epoch === state.inputEpoch ? entry : null; } function drawWave() { const canvas = $('waveform'), width = Math.max(1, Math.round(canvas.clientWidth)); @@ -307,7 +305,7 @@ function waveTime(event) { return state.view[0] + Math.max(0, Math.min(1, (event.clientX - box.left) / box.width)) * (state.view[1] - state.view[0]); } $('waveform').addEventListener('pointerdown', event => { - if (!state.input || state.expired) return; + if (!state.input) return; drag = {time: waveTime(event), x: event.clientX}; $('waveform').setPointerCapture(event.pointerId); }); @@ -340,7 +338,7 @@ function renderJob(job) { let card = state.cards.get(job.id); if (!card) { const li = node('li', undefined, 'job'), heading = node('div', undefined, 'job-heading'); - const title = node('strong', `${job.kind.toUpperCase()} · ${job.snapshot.path}`, 'job-title'); + const title = node('strong', `${job.kind.toUpperCase()} · ${job.snapshot.name}`, 'job-title'); const cancel = node('button', 'Cancel'); cancel.type = 'button'; cancel.addEventListener('click', async () => { cancel.disabled = true; @@ -355,7 +353,7 @@ function renderJob(job) { const loadWave = node('button', 'Use overview for this recording'); loadWave.type = 'button'; loadWave.hidden = true; loadWave.addEventListener('click', () => { const latest = state.jobs.get(job.id); - if (state.input?.input_id !== latest.snapshot.input_id || state.input?.path !== latest.snapshot.path) return error(new Error('Re-select this recording first.')); + if (state.input?.input_id !== latest.snapshot.input_id || state.input?.upload_id !== latest.snapshot.upload_id) return error(new Error('Re-select this recording first.')); state.wave = latest.result; $('wave-status').textContent = 'Overview ready · 2,048 peak bins, no full-file audio loaded.'; drawWave(); }); li.append(heading, status, progress, restore, loadWave, settingsDetails(job.snapshot)); @@ -392,13 +390,12 @@ async function handleCompleted(job) { } } async function poll() { - if (state.expired) return; try { // Only reconcile objects unchanged since this request began. A delayed // list must not erase a later submission or undo a cancellation response. const before = new Map(state.jobs); const {jobs} = await api('/api/jobs'); - $('connection').textContent = 'Connected to your local studio. Audio stays on this machine.'; + $('connection').textContent = 'Connected · audio stays on this machine.'; const ids = new Set(jobs.map(job => job.id)); for (const [id, card] of state.cards) if (!ids.has(id) && before.has(id) && state.jobs.get(id) === before.get(id)) { card.li.remove(); state.cards.delete(id); state.jobs.delete(id); state.handled.delete(id); state.intents.delete(id); state.exportsShown.delete(id); } for (const job of jobs) { @@ -412,9 +409,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) { - if (!state.expired) $('connection').textContent = 'Connection interrupted. Retrying… Keep the Producer terminal running; jobs may still be active. Do not resubmit an export until you check Activity.'; - else error(e); - } finally { if (!state.expired) setTimeout(poll, 750); } + $('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); } } function pause() { @@ -447,7 +444,7 @@ async function loadPreview(job, version) { $('match').checked = false; $('match').disabled = !job.result.monitoring.available || !(window.AudioContext || window.webkitAudioContext); $('playback-seek').max = job.snapshot.duration; $('playback-seek').value = 0; - $('comparison-source').textContent = `${job.snapshot.path} · ${formatTime(job.snapshot.start)}–${formatTime(job.snapshot.start + job.snapshot.duration)} · render ${job.id.slice(0, 8)} · ${new Date(job.created * 1000).toLocaleTimeString()}`; + $('comparison-source').textContent = `${job.snapshot.name} · ${formatTime(job.snapshot.start)}–${formatTime(job.snapshot.start + job.snapshot.duration)} · render ${job.id.slice(0, 8)} · ${new Date(job.created * 1000).toLocaleTimeString()}`; $('preview-warnings').textContent = (job.result.warnings || []).join(' '); $('preview-snapshot').hidden = false; $('preview-settings').textContent = JSON.stringify({requested: job.snapshot.settings, preview_only_differences: {trim_silence: false, mp3: false}, normalization_mode: job.result.normalization_mode}, null, 2); @@ -522,7 +519,7 @@ function renderSaved() { $(target).replaceChildren(); for (const entry of state[key]) { const li = node('li'), title = node('div', entry.name, 'saved-title'); - if (key === 'comparisons') title.append(node('div', `${entry.snapshot.path} · ${formatTime(entry.snapshot.start)} · ${entry.snapshot.duration}s`, 'small')); + if (key === 'comparisons') title.append(node('div', `${entry.snapshot.name} · ${formatTime(entry.snapshot.start)} · ${entry.snapshot.duration}s`, 'small')); const restore = node('button', key === 'presets' ? 'Load settings' : 'Restore & render'); restore.type = 'button'; restore.addEventListener('click', async () => { restore.disabled = true; @@ -547,20 +544,25 @@ function renderSaved() { } async function restoreComparison(entry) { const snapshot = copy(entry.snapshot), version = state.version; - const [info, resolved] = await Promise.all([api(`/api/input?path=${encodeURIComponent(snapshot.path)}`), api('/api/resolve', {settings: snapshot.settings})]); + // Uploads are gone after a restart; a stale snapshot asks for re-selection. + const [info, resolved] = await Promise.all([api(`/api/uploads/${encodeURIComponent(snapshot.upload_id)}`), api('/api/resolve', {settings: snapshot.settings})]); if (version !== state.version) throw new Error('Controls changed while restoring. Try again.'); - if (info.input_id !== snapshot.input_id) throw new Error('This source file changed since the snapshot. Re-select it and make a new comparison.'); - state.inputEpoch++; state.input = info; state.wave = null; state.view = [0, info.duration]; - $('input-name').textContent = info.path; $('input-info').textContent = `${formatTime(info.duration)} · mono · ${info.sample_rate.toLocaleString()} Hz · ${info.subtype}`; + if (info.input_id !== snapshot.input_id) throw new Error('This recording changed since the snapshot. Pick it again and make a new comparison.'); + state.inputEpoch++; + state.input = info; state.wave = null; state.view = [0, info.duration]; + state.uploads.push({upload_id: info.upload_id, name: info.name, input_id: info.input_id, + duration: info.duration, sample_rate: info.sample_rate, subtype: info.subtype}); + renderUploads(); + $('input-name').textContent = info.name; $('input-info').textContent = `${formatTime(info.duration)} · ${channelLabel(info)} · ${info.sample_rate.toLocaleString()} Hz · ${info.subtype}`; setSettings(resolved.settings); $('selection-start').value = String(snapshot.start); $('selection-end').value = String(snapshot.start + snapshot.duration); changed(); updateAvailability(); $('wave-status').textContent = 'Scanning restored recording…'; const restoredVersion = state.version; - await submit('scan', {path: info.path, input_id: info.input_id}, {inputEpoch: state.inputEpoch}); + await submit('scan', {upload_id: info.upload_id, input_id: info.input_id}, {inputEpoch: state.inputEpoch}); // A later user edit while submitting the scan must not trigger an unintended preview. const restored = {...snapshot, settings: resolved.settings}; - if (restoredVersion === state.version && matches(restored, currentSnapshot())) await submit('preview', restored, {version: restoredVersion}); + if (restoredVersion === state.version && matches(restored, currentSnapshot())) await submit('preview', {upload_id: restored.upload_id, input_id: restored.input_id, settings: restored.settings, start: restored.start, duration: restored.duration}, {version: restoredVersion}); } async function savePreset() { const name = $('preset-name').value.trim(); @@ -590,8 +592,9 @@ async function prepareExport() { 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 reviewing export. Review again.'); - state.export = {kind: 'export', path: input.path, input_id: input.input_id, settings: copy(data.settings), output_dir}; - $('export-summary').textContent = `${input.path} · whole original ${formatTime(input.duration)}. Destination: ${absolutePath(state.schema.root, output_dir)}/producer-export-…/master.wav${data.settings.mp3 ? ' + master.mp3' : ''} and logs/master.report.json. These are the CURRENT controls, not necessarily the last comparison.`; + state.export = {kind: 'export', upload_id: input.upload_id, input_id: input.input_id, settings: copy(data.settings), output_dir}; + const destination = output_dir || state.schema.output_dir; + $('export-summary').textContent = `${input.name} · whole original ${formatTime(input.duration)}. Destination: ${destination}/producer-export-…/master.wav${data.settings.mp3 ? ' + master.mp3' : ''} and logs/master.report.json. These are the CURRENT controls, not necessarily the last comparison.`; $('export-pacing').textContent = data.settings.trim_silence ? 'Pacing edits are ON. Long gaps will be shortened and duration will change. These timing edits were NOT auditioned in the sound comparison.' : 'Pacing edits are OFF. Sound comparison is still approximate: full-recording normalization and inference context can differ.'; $('export-settings').textContent = JSON.stringify(state.export, null, 2); $('export-dialog').showModal(); @@ -600,9 +603,9 @@ function showExport(job) { if (state.exportsShown.has(job.id)) return; state.exportsShown.add(job.id); const result = node('div', undefined, 'export-result'); - result.append(node('h3', 'Full recording exported'), node('p', `${job.snapshot.path} · job ${job.id.slice(0, 8)} · ${new Date(job.created * 1000).toLocaleString()}. Settings are frozen in the submitted snapshot below.`), node('p', 'Find these persistent files on this machine. No browser download is needed:')); + result.append(node('h3', 'Full recording exported'), node('p', `${job.snapshot.name} · job ${job.id.slice(0, 8)} · ${new Date(job.created * 1000).toLocaleString()}. Settings are frozen in the submitted snapshot below.`), node('p', 'Find these files on this machine. No browser download is needed:')); const paths = node('ul'); - for (const path of job.result.paths) { const li = node('li'); li.append(node('code', absolutePath(state.schema.root, path))); paths.append(li); } + for (const path of job.result.paths) { const li = node('li'); li.append(node('code', path)); paths.append(li); } result.append(paths, node('p', [...(job.result.warnings || []), job.result.notice].filter(Boolean).join(' ')), settingsDetails(job.snapshot)); $('export-results').prepend(result); while ($('export-results').children.length > 24) $('export-results').lastElementChild.remove(); @@ -610,11 +613,7 @@ function showExport(job) { // Explicit actions only; settings input events merely invalidate the current render. action('dismiss-error', () => { $('error').hidden = true; }); -action('folder-up', () => browse(state.parent || '.')); -action('folder-refresh', () => browse(state.folder)); -action('files-more', () => browse(state.folder, state.next)); -action('use-output-folder', () => { $('output-dir').value = state.folder; }); -action('rescan', () => chooseInput(state.input.path)); +action('rescan', () => selectUpload(state.input)); action('zoom-selection', () => { const value = selection($('selection-start').value, $('selection-end').value, state.input.duration); const padding = Math.max(5, value.duration / 2); @@ -628,6 +627,14 @@ action('render-preview', async () => { pause(); await submit('preview', snapshot, {version}); $('jobs-panel').scrollIntoView({block: 'nearest'}); }); +$('pick-files').addEventListener('change', async () => { + const files = [...$('pick-files').files]; + $('pick-files').value = ''; + for (const file of files) { + try { await selectUpload(await uploadFile(file)); } + catch (e) { error(e); } + } +}); action('listen-original', () => switchSide('original')); action('listen-processed', () => switchSide('processed')); action('play', async () => { @@ -636,7 +643,7 @@ action('play', async () => { else { const epoch = ++state.playbackEpoch, side = state.side; await ensureAudioContext(); - if (epoch !== state.playbackEpoch || side !== state.side || state.expired) return; + if (epoch !== state.playbackEpoch || side !== state.side) return; updateGains(); await audio.play(); } }); @@ -672,14 +679,11 @@ action('confirm-export', async () => { window.addEventListener('beforeunload', () => { state.mediaAbort?.abort(); clearMedia(); }); async function init() { - if (!token) throw new Error('Open the private URL printed by ./producer.sh gui on this machine. No credential was found in this tab.'); state.schema = await api('/api/schema'); if (state.schema.api_version !== 1) throw new Error('This browser interface needs API version 1. Restart Producer with matching packaged assets.'); - $('root-path').textContent = state.schema.root; - state.storageKey = 'producer-studio-v1:' + state.schema.root; + $('output-dir').value = state.schema.output_dir; buildSettings(); loadSaved(); $('workspace').disabled = false; - await browse(); poll(); } -init().catch(e => { $('connection').textContent = 'Studio could not connect. Check the message below and reopen the printed local URL.'; error(e); }); +init().catch(e => { $('connection').textContent = 'Studio could not connect. Reload this page after starting Producer.'; error(e); }); diff --git a/lib/project/src/producer/web/index.html b/lib/project/src/producer/web/index.html index 9808e2e..7fbb709 100644 --- a/lib/project/src/producer/web/index.html +++ b/lib/project/src/producer/web/index.html @@ -4,70 +4,74 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="referrer" content="no-referrer"> - <title>Producer · Local voice studio</title> + <title>Producer</title> <link rel="icon" href="data:,"> <link rel="stylesheet" href="/assets/style.css"> <script type="module" src="/assets/app.js"></script> </head> <body> -<header class="masthead"><div><span class="brand-mark" aria-hidden="true">p.</span><strong>Producer</strong><span class="tagline">A little care for your voice.</span></div><span class="local-badge">● Local audio · private by design</span></header> +<header><strong>Producer</strong><span class="small">local voice cleanup & mastering</span><span id="connection">Connecting…</span></header> <main> - <div class="intro"><p class="eyebrow">YOUR RECORDING, REFINED</p><h1>Make it sound like you.<br><span>Just easier to listen to.</span></h1><p>Try a small passage. Listen for the difference. Keep what works.</p></div> - <nav class="steps" aria-label="Studio steps"><a href="#choose">1 <span>Choose recording</span></a><a href="#try">2 <span>Try a short passage</span></a><a href="#compare">3 <span>Compare sound</span></a><a href="#export">4 <span>Export full recording</span></a></nav> - <div id="connection" role="status">Connecting to your local Producer…</div> <div id="error" class="error" role="alert" hidden><span id="error-text"></span><button id="dismiss-error" type="button">Dismiss</button></div> - <noscript>This local studio needs JavaScript. Enable it and reopen the private URL printed by Producer.</noscript> + <noscript>This tool needs JavaScript. Reload this page after starting Producer.</noscript> <fieldset id="workspace" disabled> - <legend class="sr-only">Local voice studio</legend> - <section id="choose" class="panel"> - <div class="section-heading"><span class="step-number">01</span><div><h2>Choose your recording</h2><p>Browse existing mono WAV files. Nothing is uploaded or overwritten.</p></div></div> - <details id="file-browser" open><summary>Browse local recordings</summary> - <p class="small">Allowed folder: <code id="root-path"></code></p> - <div class="toolbar"><button id="folder-up" type="button">↑ Parent folder</button><button id="folder-refresh" type="button">Refresh</button><code id="folder-path">.</code><button id="use-output-folder" type="button">Use this folder for exports</button></div> - <ul id="files" class="file-list" aria-label="Folders and WAV recordings"></ul><button id="files-more" type="button" hidden>Show more files</button><p id="files-status" class="small" role="status"></p> - </details> - <div class="recording"><div><span class="eyebrow">SELECTED RECORDING</span><h3 id="input-name">No recording yet</h3><p id="input-info">Choose a WAV above to begin.</p></div><button id="rescan" type="button" disabled>Re-select & scan</button></div> - </section> - <section id="try" class="panel"> - <div class="section-heading"><span class="step-number">02</span><div><h2>Try a short passage</h2><p>Include a quiet phrase, a breath and a pause. Listen for words that sound softened or cut off.</p></div></div> - <div class="toolbar"><strong>Recording overview</strong><span id="wave-status" class="small">Select a recording first.</span><button id="zoom-selection" type="button" disabled>Zoom around passage</button><button id="zoom-full" type="button" disabled>Whole recording</button></div> + <legend class="sr-only">Producer studio</legend> + + <section id="recording" class="panel"> + <h2>Recording</h2> + <div class="toolbar"> + <label class="browse" for="pick-files">Browse…</label> + <input id="pick-files" type="file" accept=".wav,audio/wav" multiple> + <span id="upload-status" class="small" role="status">Pick a mono or stereo WAV from anywhere on this machine; stereo is downmixed safely to mono and the original stays untouched.</span> + </div> + <ul id="uploads" class="file-list" aria-label="Uploaded recordings"></ul> + <div class="recording"><div><strong id="input-name">No recording selected</strong><p id="input-info" class="small"></p></div><button id="rescan" type="button" disabled>Re-select & scan</button></div> + <div class="toolbar"><span id="wave-status" class="small">Select a recording first.</span><button id="zoom-selection" type="button" disabled>Zoom to passage</button><button id="zoom-full" type="button" disabled>Whole recording</button></div> <canvas id="waveform" width="1200" height="180" role="img" aria-label="Recording waveform. Click or drag to select a passage, or use the exact time fields below."></canvas> <div class="wave-labels"><span id="wave-start">00:00:00</span><span id="wave-end">00:00:00</span></div> - <p class="small">Click for a 30-second passage, or drag up to 120 seconds. Zoom enlarges the same coarse overview; use exact times for fine adjustments.</p> - <div class="selection-fields"><label>Start · HH:MM:SS.sss<input id="selection-start" type="text" inputmode="decimal" value="00:00:00" spellcheck="false" aria-describedby="selection-note"></label><label>End · HH:MM:SS.sss<input id="selection-end" type="text" inputmode="decimal" value="00:00:30" spellcheck="false" aria-describedby="selection-note"></label><p id="selection-note" role="status">Up to 120 seconds. You can also enter seconds directly.</p></div> - <div class="settings-heading"><div><h3>Shape the sound</h3><p>Small changes are often enough. Controls never render automatically.</p></div><button id="reset-launch" type="button">Restore launch settings</button></div> - <div id="primary-settings" class="primary-settings"></div> - <details id="advanced-settings"><summary>All settings & advanced controls <span class="small">Fine-tune without hidden overrides</span></summary><p class="small">Turning an effect off keeps its values for later. Constraints are checked when you render, save or export; dependent effects are never silently switched on.</p><div id="settings-groups"></div><p id="constraints" class="small"></p></details> + <div class="selection-fields"> + <label>Start · HH:MM:SS.sss<input id="selection-start" type="text" inputmode="decimal" value="00:00:00" spellcheck="false" aria-describedby="selection-note"></label> + <label>End · HH:MM:SS.sss<input id="selection-end" type="text" inputmode="decimal" value="00:00:30" spellcheck="false" aria-describedby="selection-note"></label> + <p id="selection-note" role="status" class="small">Up to 120 seconds. Click for a 30-second passage, or drag on the waveform. You can also enter seconds directly.</p> + </div> + </section> + + <section id="settings" class="panel"> + <div class="settings-heading"><h2>Settings</h2><p class="small">Turning an effect off keeps its values. Greyed-out controls belong to an effect that is switched off; their help text says which control re-enables them. Every control's help explains what it does and how to disable it.</p><button id="reset-launch" type="button">Restore launch settings</button></div> + <div id="settings-groups"></div> + <p id="constraints" class="small"></p> <details id="presets-panel"><summary>Settings presets <span class="small">Save a sound, not audio</span></summary> - <div class="toolbar"><label>Preset name<input id="preset-name" type="text" maxlength="80" placeholder="Gentle narration"></label><button id="save-preset" type="button">Save settings locally</button><button id="download-preset" type="button">Download settings TOML</button><label class="file-import">Import TOML<input id="import-preset" type="file" accept=".toml,text/plain,application/toml"></label></div><ul id="presets" class="saved-list"></ul><p class="small">Up to 20 named presets in this browser, for this root folder. TOML is portable. Imports replace controls with standalone preset settings, not hidden launch overrides.</p> + <div class="toolbar"><label>Preset name<input id="preset-name" type="text" maxlength="80" placeholder="Gentle narration"></label><button id="save-preset" type="button">Save settings locally</button><button id="download-preset" type="button">Download settings TOML</button><label class="file-import">Import TOML<input id="import-preset" type="file" accept=".toml,text/plain,application/toml"></label></div> + <ul id="presets" class="saved-list"></ul><p class="small">Up to 20 named presets in this browser. TOML is portable; imports replace the controls with standalone preset settings.</p> </details> - <div class="action-row"><button id="render-preview" class="primary" type="button" disabled>Render sound comparison <span aria-hidden="true">→</span></button><span class="small">Only this passage + up to 10 seconds of context per side.<br>First AI use downloads local models; audio never leaves this machine.</span></div> + <div class="action-row"><button id="render-preview" class="primary" type="button" disabled>Render sound comparison</button><span class="small">Processes only this passage plus up to 10 seconds of context per side; result is approximate, not an exact export preview. First AI use downloads local models; audio never leaves this machine.</span></div> </section> + <section id="compare" class="panel"> - <div class="section-heading"><span class="step-number">03</span><div><h2>Compare the sound, not the volume</h2><p>Switch between the same moment. Start at a comfortable listening volume.</p></div></div> - <p class="notice">Approximate sound comparison, not an exact export preview. Chapter normalization and inference context differ. Pacing edits are not previewed; this comparison preserves timing.</p> + <h2>Compare</h2> <p id="comparison-status" class="comparison-status" role="status">Render a passage to listen here.</p> <p id="comparison-source" class="small"></p> <fieldset id="player" disabled><legend class="sr-only">Passage playback</legend> - <div class="player-buttons"><div class="ab-switch" role="group" aria-label="Listen to"><button id="listen-original" type="button" aria-pressed="true">A · Original</button><button id="listen-processed" type="button" aria-pressed="false">B · Processed</button></div><button id="play" class="primary" type="button">Play</button><label class="check"><input id="loop" type="checkbox" checked>Loop passage</label><label class="check"><input id="match" type="checkbox">Match listening loudness</label></div> - <label class="seek-label">Passage playback position<input id="playback-seek" type="range" min="0" max="30" step="0.01" value="0"></label><p id="playback-time" class="small">00:00:00 / 00:00:00</p><p id="monitoring-note" class="small">Matching only attenuates playback. It never changes your saved settings or export.</p> + <div class="player-buttons"><div class="ab-switch" role="group" aria-label="Listen to"><button id="listen-original" type="button" aria-pressed="true">A · Original</button><button id="listen-processed" type="button" aria-pressed="false">B · Processed</button></div><button id="play" class="primary" type="button">Play</button><label class="check"><input id="loop" type="checkbox" checked>Loop</label><label class="check"><input id="match" type="checkbox">Match listening loudness</label></div> + <label class="seek-label">Playback position<input id="playback-seek" type="range" min="0" max="30" step="0.01" value="0"></label><p id="playback-time" class="small">00:00:00 / 00:00:00</p><p id="monitoring-note" class="small">Matching only attenuates playback. It never changes your saved settings or export.</p> </fieldset> <audio id="audio-original" preload="metadata"></audio><audio id="audio-processed" preload="metadata"></audio> <div id="meters" class="meters" hidden></div><p id="preview-warnings" class="small"></p> - <details id="preview-snapshot" hidden><summary>Exact requested settings & preview differences</summary><p>Gap trimming and MP3 encoding are disabled only for this timing-preserving WAV comparison. Full export uses the requested settings below.</p><pre id="preview-settings"></pre></details> - <div class="toolbar"><label>Name this comparison<input id="comparison-name" type="text" maxlength="80" placeholder="Less noise, softer touch"></label><button id="save-comparison" type="button" disabled>Save comparison snapshot</button></div><ul id="comparisons" class="saved-list"></ul><p class="small">Up to 12 immutable snapshots saved in this browser (source identity, times and requested settings; no audio). Restore & render to listen again. Cached audio can expire; changed source files must be re-selected.</p> + <details id="preview-snapshot" hidden><summary>Exact requested settings & preview differences</summary><p>Gap trimming and MP3 encoding are disabled only for this timing-preserving WAV comparison. Full export uses the requested settings below.</p><pre id="preview-settings"></pre></details> + <div class="toolbar"><label>Name this comparison<input id="comparison-name" type="text" maxlength="80" placeholder="Less noise, softer touch"></label><button id="save-comparison" type="button" disabled>Save comparison snapshot</button></div><ul id="comparisons" class="saved-list"></ul><p class="small">Up to 12 snapshots in this browser (source identity, times and requested settings; no audio). Restore & render to listen again. Cached audio can expire; changed source files must be re-uploaded and re-selected.</p> </section> + <section id="export" class="panel"> - <div class="section-heading"><span class="step-number">04</span><div><h2>Export the full recording</h2><p>Happy with the sound? Apply the current controls to the whole original, not just your passage.</p></div></div> - <div id="delivery-settings" class="delivery-settings"></div> - <label class="output-label">Existing output folder · relative to allowed folder<input id="output-dir" type="text" value="." spellcheck="false"></label><p class="small">Use “Use this folder for exports” in the local browser above, or enter an existing relative folder. Each export gets a fresh private folder. Originals stay untouched.</p> - <div class="action-row"><button id="prepare-export" class="primary" type="button" disabled>Review full-recording export →</button><span class="small">WAV + optional MP3 and a processing report.<br>No delivery-platform certification or quality score.</span></div> + <h2>Export</h2> + <label class="output-label">Output folder · absolute path on this machine<input id="output-dir" type="text" spellcheck="false"></label><p class="small">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> + <div class="action-row"><button id="prepare-export" class="primary" type="button" disabled>Review full-recording 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> - <section class="panel jobs-panel" id="jobs-panel"><div class="section-heading"><div><h2>Activity</h2><p>One job at a time · up to three waiting · progress is per stage, not overall.</p></div></div><p id="jobs-status" role="status">No work submitted yet.</p><p class="small">Cancellation waits for a safe progress point. A model download can take up to 60 seconds to respond. Files already published may remain.</p><ul id="jobs" class="jobs-list"></ul></section> + + <section class="panel jobs-panel" id="jobs-panel"><h2>Activity</h2><p id="jobs-status" role="status">No work submitted yet.</p><p class="small">One job at a time, up to three waiting. Cancellation is cooperative: a model download can take up to 60 seconds to reach a safe stop.</p><ul id="jobs" class="jobs-list"></ul></section> </fieldset> - <footer>Made for listening, not chasing a score. Local processing · no telemetry · no cloud audio.<br>Leave this server running while you work. Stop it in the terminal with Ctrl-C.</footer> + <footer>Local processing · no telemetry · no cloud audio. Stop the server in the terminal with Ctrl-C.</footer> </main> -<dialog id="export-dialog" aria-labelledby="export-title"><h2 id="export-title">Export the whole original?</h2><p id="export-summary"></p><p id="export-pacing" class="notice"></p><p>These files are saved on this machine, not downloaded into your browser. A new folder prevents overwriting earlier exports. Monitoring gains are never included.</p><details><summary>Frozen export settings</summary><pre id="export-settings"></pre></details><div class="action-row"><button id="confirm-export" class="primary" type="button">Export full recording</button><button id="cancel-export" type="button">Go back</button></div></dialog> +<dialog id="export-dialog" aria-labelledby="export-title"><h2 id="export-title">Export the whole original?</h2><p id="export-summary"></p><p id="export-pacing" class="notice"></p><p>Files are saved on this machine, not downloaded into your browser. A new folder prevents overwriting earlier exports. Monitoring gains are never included.</p><details><summary>Frozen export settings</summary><pre id="export-settings"></pre></details><div class="action-row"><button id="confirm-export" class="primary" type="button">Export full recording</button><button id="cancel-export" type="button">Go back</button></div></dialog> </body> </html> diff --git a/lib/project/src/producer/web/state.js b/lib/project/src/producer/web/state.js index 8c1f9a6..4016f53 100644 --- a/lib/project/src/producer/web/state.js +++ b/lib/project/src/producer/web/state.js @@ -25,12 +25,10 @@ export function stable(value) { return JSON.stringify(value); } export function matches(snapshot, current) { - return Boolean(current && snapshot.path === current.path && snapshot.input_id === current.input_id && + return Boolean(current && snapshot.upload_id === current.upload_id && + snapshot.input_id === current.input_id && snapshot.start === current.start && snapshot.duration === current.duration && stable(snapshot.settings) === stable(current.settings)); } export function monitoringGain(db, enabled) { return enabled && Number.isFinite(db) ? 10 ** (Math.min(0, db) / 20) : 1; } -export function absolutePath(root, relative) { - return root.replace(/\/$/, '') + '/' + relative.replace(/^\.\//, ''); -} diff --git a/lib/project/src/producer/web/style.css b/lib/project/src/producer/web/style.css index 935b51c..fb96614 100644 --- a/lib/project/src/producer/web/style.css +++ b/lib/project/src/producer/web/style.css @@ -11,14 +11,13 @@ box-sizing:border-box } html { - scroll-behavior:smooth; - scroll-padding-top:24px + scroll-padding-top:12px } body { margin:0; background:var(--paper); color:var(--ink); - font:16px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif + font:15px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif } button,input,select { font:inherit @@ -26,9 +25,9 @@ button,input,select { button { cursor:pointer; border:1px solid var(--line); - border-radius:8px; + border-radius:7px; background:#fff; - padding:10px 15px; + padding:7px 12px; color:var(--ink); font-weight:600; line-height:1.3 @@ -51,21 +50,21 @@ button.primary:hover:not(:disabled) { } button:focus-visible,a:focus-visible,summary:focus-visible,input:focus-visible,select:focus-visible { outline:3px solid var(--focus); - outline-offset:3px + outline-offset:2px } -input:not([type=checkbox]):not([type=range]),select { +input:not([type=checkbox]):not([type=range]):not([type=file]),select { display:block; width:100%; - min-height:43px; - padding:8px 10px; + min-height:38px; + padding:6px 9px; border:1px solid #aebbb0; - border-radius:6px; + border-radius:5px; background:#fff; color:var(--ink) } input[type=checkbox] { - width:18px; - height:18px; + width:17px; + height:17px; accent-color:var(--green); flex-shrink:0 } @@ -77,44 +76,34 @@ input:invalid { border-color:#9f3c27 } label { - font-size:.9rem; + font-size:.88rem; font-weight:600 } h1,h2,h3,p { margin-top:0 } -h1 { - font-size:clamp(2rem,4.5vw,3.55rem); - letter-spacing:-.055em; - line-height:1.13; - font-weight:650; - margin-bottom:20px -} -h1 span { - color:var(--muted) -} h2 { - font-size:1.5rem; - letter-spacing:-.035em; - margin-bottom:5px + font-size:1.2rem; + letter-spacing:-.02em; + margin-bottom:8px } h3 { - font-size:1.05rem; - margin-bottom:7px + font-size:.98rem; + margin:0 0 8px } p { - margin-bottom:14px + margin-bottom:10px } code,pre { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; - font-size:.82rem; + font-size:.8rem; overflow-wrap:anywhere } pre { white-space:pre-wrap; max-height:320px; overflow:auto; - padding:14px; + padding:12px; background:var(--paper); border:1px solid var(--line); border-radius:6px @@ -128,140 +117,85 @@ fieldset { a { color:var(--green) } -.masthead { - display:flex; - justify-content:space-between; - align-items:center; - padding:23px 5%; - border-bottom:1px solid var(--line); - gap:15px -} -.masthead>div { +header { display:flex; - align-items:center; - gap:13px -} -.brand-mark { - background:var(--green); - color:#fff; - width:35px; - height:35px; - border-radius:10px; - text-align:center; - font-size:1.6rem; - line-height:1.1; - font-weight:700 -} -.tagline { - color:var(--muted); - font-size:.85rem; - margin-left:18px + align-items:baseline; + gap:12px; + padding:10px 20px; + border-bottom:1px solid var(--line) } -.local-badge { +#connection { + margin-left:auto; font-size:.78rem; - color:var(--green); - background:var(--mint); - padding:6px 12px; - border-radius:99px + color:var(--muted) } main { - max-width:1192px; + max-width:1100px; margin:0 auto; - padding:44px 32px -} -.intro p:last-child { - color:var(--muted) -} -.eyebrow { - font-size:.69rem; - letter-spacing:.14em; - font-weight:750; - color:var(--green); - margin-bottom:14px -} -.steps { - display:grid; - grid-template-columns:repeat(4,1fr); - gap:12px; - margin:28px 0 -} -.steps a { - text-decoration:none; - display:flex; - align-items:center; - gap:10px; - border-top:2px solid var(--green); - padding:12px 0; - font-weight:650; - font-size:.85rem -} -.steps a:first-letter { - font-weight:bold + padding:16px 20px 24px } .panel { - padding:28px; - margin:22px 0; + padding:16px 20px; + margin:12px 0; background:#fff; border:1px solid var(--line); - border-radius:15px; - box-shadow:0 3px 14px #263a2610; - scroll-margin-top:22px -} -.section-heading { - display:flex; - gap:17px; - align-items:flex-start; - margin-bottom:18px -} -.section-heading p,.settings-heading p { - color:var(--muted); - font-size:.9rem; - margin:0 -} -.step-number { - display:grid; - place-items:center; - background:var(--mint); - color:var(--green); - min-width:38px; - height:38px; - border-radius:50%; - font-size:.75rem; - font-weight:700 + border-radius:10px; + box-shadow:0 2px 8px #263a260d; + scroll-margin-top:12px } .small { - font-size:.81rem; + font-size:.78rem; color:var(--muted) } .toolbar { display:flex; - gap:12px; + gap:10px; align-items:center; flex-wrap:wrap; - margin:16px 0 + margin:10px 0 } .toolbar label { - min-width:190px + min-width:180px } .toolbar>code { flex:1 } .toolbar button { - font-size:.8rem + font-size:.78rem +} +/* Native browse control, styled as a button. */ +.browse { + display:inline-block; + padding:7px 14px; + border:1px solid var(--green); + border-radius:7px; + background:var(--green); + color:#fff; + font-weight:600; + cursor:pointer } -.toolbar>strong { - margin-right:auto +.browse:hover { + background:#214637 +} +input[type=file] { + position:absolute; + width:1px; + height:1px; + opacity:0 } .file-list,.saved-list,.jobs-list { list-style:none; padding:0; - margin:12px 0 + margin:8px 0 } .file-list { - max-height:290px; + max-height:220px; overflow:auto; border-top:1px solid var(--line) } +.file-list:empty { + border:0 +} .file-list li { border-bottom:1px solid var(--line) } @@ -271,14 +205,18 @@ main { width:100%; text-align:left; font-weight:500; - padding:10px; + padding:8px 10px; overflow-wrap:anywhere } +.file-list li.selected button { + background:var(--mint); + font-weight:650 +} .file-kind { display:inline-block; color:var(--muted); - font-size:.7rem; - min-width:65px; + font-size:.68rem; + min-width:38px; font-weight:700 } .recording { @@ -286,130 +224,111 @@ main { justify-content:space-between; gap:15px; align-items:center; - padding:18px; + padding:12px; background:var(--mint); - border-radius:9px; - margin-top:20px + border-radius:8px; + margin:10px 0 } -.recording h3 { +.recording strong { overflow-wrap:anywhere } -.recording .eyebrow { - font-size:.63rem -} .recording p { - margin:0; - font-size:.83rem; - color:var(--muted) + margin:0 } canvas { display:block; width:100%; - height:180px; + height:160px; background:#f0f4f0; border:1px solid var(--line); - border-radius:8px; + border-radius:7px; touch-action:none; cursor:crosshair } .wave-labels { display:flex; justify-content:space-between; - font-size:.75rem; + font-size:.72rem; font-variant-numeric:tabular-nums; color:var(--muted); - margin:6px 0 12px + margin:4px 0 10px } .selection-fields { display:flex; align-items:center; - gap:18px; + gap:14px; flex-wrap:wrap } .selection-fields input { - font-variant-numeric:tabular-nums + font-variant-numeric:tabular-nums; + max-width:140px } .selection-fields p { flex:1; - font-size:.8rem; + font-size:.78rem; min-width:150px; color:var(--muted); - margin:20px 0 0 + margin:0 } .settings-heading { display:flex; justify-content:space-between; align-items:center; - gap:16px; - margin:28px 0 18px -} -.settings-heading button { - font-size:.8rem -} -.primary-settings { - display:grid; - grid-template-columns:repeat(4,1fr); - gap:12px; - margin:16px 0 22px -} -.sound-card { - background:var(--paper); - border:1px solid var(--line); - border-radius:10px; - padding:16px + gap:14px; + margin-bottom:12px } -.sound-card h3 { - color:var(--green) +.settings-heading p { + flex:1 } -.sound-card>p { - font-size:.78rem; - min-height:72px; - color:var(--muted) +.settings-heading button { + font-size:.78rem } .setting { - margin:13px 0 + margin:0 } .setting label { display:block; - margin-bottom:5px + margin-bottom:3px } .setting label.check,.check { display:flex; align-items:center; - gap:8px + gap:7px } .setting p { - font-size:.75rem; - color:var(--muted); - margin:6px 0 0 -} -.setting-name { - display:block; - font-size:.68rem; - font-weight:400; + font-size:.72rem; color:var(--muted); - letter-spacing:.01em + margin:3px 0 0 } .settings-grid { display:grid; grid-template-columns:repeat(3,1fr); - gap:8px 24px + gap:10px 22px; + align-items:start } .settings-group { border-top:1px solid var(--line); - padding-top:18px; - margin-top:20px + padding-top:10px; + margin-top:12px } .settings-group h3 { color:var(--green) } +/* Dependent controls are visibly inactive while their effect is off. */ +.setting.inactive input,.setting.inactive select { + opacity:.45; + background:var(--paper) +} +.setting.inactive label { + color:var(--muted) +} details { - margin-top:17px + margin-top:10px } summary { cursor:pointer; font-weight:650; - padding:10px 0; + padding:8px 0; color:var(--green) } summary .small { @@ -417,27 +336,27 @@ summary .small { margin-left:10px } .file-import input { - font-size:.75rem; + font-size:.72rem; max-width:250px } .action-row { display:flex; align-items:center; flex-wrap:wrap; - gap:18px; - margin-top:24px + gap:14px; + margin-top:16px } .notice { border-left:3px solid #b37b25; background:#fcf5e7; color:#6d4a19; border-radius:0 6px 6px 0; - padding:13px 16px; - font-size:.85rem + padding:10px 14px; + font-size:.82rem } .comparison-status { font-weight:650; - font-size:.9rem + font-size:.88rem } .comparison-status.stale { color:#945117 @@ -445,58 +364,58 @@ summary .small { .player-buttons { display:flex; align-items:center; - gap:18px; + gap:14px; flex-wrap:wrap; - margin:20px 0 + margin:12px 0 } .ab-switch { display:flex; border:1px solid var(--line); - border-radius:9px; + border-radius:8px; overflow:hidden } .ab-switch button { border:0; border-radius:0; - padding:13px 20px + padding:10px 16px } .ab-switch button[aria-pressed=true] { background:var(--green); color:#fff } .seek-label { - font-size:.8rem; + font-size:.78rem; display:block } .meters { display:grid; grid-template-columns:1fr 1fr; - gap:14px; - margin:20px 0 + gap:12px; + margin:12px 0 } .meter { border:1px solid var(--line); - border-radius:9px; - padding:16px; + border-radius:8px; + padding:12px; background:var(--paper) } .meter strong { display:block; - font-size:.85rem + font-size:.82rem } .meter p { - margin:7px 0 0; - font-size:.8rem + margin:5px 0 0; + font-size:.78rem } .meter .level { - font-size:1.5rem; + font-size:1.3rem; font-variant-numeric:tabular-nums; - letter-spacing:-.04em; + letter-spacing:-.03em; color:var(--green) } .saved-list li,.job { border-top:1px solid var(--line); - padding:14px 0 + padding:10px 0 } .saved-list li { display:flex; @@ -510,39 +429,33 @@ summary .small { overflow-wrap:anywhere } .saved-list button { - font-size:.78rem -} -.delivery-settings { - display:grid; - grid-template-columns:repeat(4,1fr); - gap:20px + font-size:.76rem } .output-label { display:block; - max-width:640px; - margin-top:16px + max-width:640px } .job-heading { display:flex; justify-content:space-between; align-items:center; gap:12px; - font-size:.86rem + font-size:.84rem } .job-title { overflow-wrap:anywhere } .job p { - margin:6px 0; - font-size:.78rem; + margin:5px 0; + font-size:.76rem; color:var(--muted) } .job button { - font-size:.75rem + font-size:.73rem } .job progress { width:100%; - height:10px; + height:9px; accent-color:var(--green) } .job .failed { @@ -551,18 +464,15 @@ summary .small { .export-result { border:1px solid var(--line); border-left:4px solid var(--green); - padding:18px; - margin-top:20px; - border-radius:8px + padding:14px; + margin-top:14px; + border-radius:7px } .export-result ul { padding-left:20px } -.export-result li { - margin:7px 0 -} .export-result p { - font-size:.85rem + font-size:.82rem } .error { display:flex; @@ -572,24 +482,21 @@ summary .small { background:#fcebe5; border:1px solid #cc8875; border-radius:8px; - padding:16px; + padding:14px; position:sticky; top:8px; z-index:5; - font-size:.85rem; - overflow-wrap:anywhere -} -#connection { - font-size:.82rem; - color:var(--muted) + font-size:.84rem; + overflow-wrap:anywhere; + margin-bottom:12px } dialog { - max-width:650px; + max-width:640px; max-height:90vh; overflow:auto; border:1px solid var(--line); - border-radius:15px; - padding:28px; + border-radius:12px; + padding:24px; color:var(--ink); background:#fff } @@ -599,8 +506,8 @@ dialog::backdrop { footer { text-align:center; color:var(--muted); - font-size:.77rem; - padding:30px 0 10px + font-size:.74rem; + padding:18px 0 6px } .sr-only { position:absolute; @@ -614,55 +521,23 @@ footer { display:none!important } @media(max-width:950px) { - .primary-settings { - grid-template-columns:repeat(2,1fr) - } - .sound-card>p { - min-height:0 - } .settings-grid { grid-template-columns:repeat(2,1fr) } - .delivery-settings { - grid-template-columns:repeat(2,1fr) - } - .tagline { - display:none - } } @media(max-width:600px) { main { - padding:25px 14px - } - .masthead { - padding:16px; - flex-wrap:wrap - } - .local-badge { - font-size:.68rem + padding:12px } .panel { - padding:18px - } - .steps { - grid-template-columns:repeat(2,1fr); - gap:4px 15px + padding:14px } - .steps a { - font-size:.77rem - } - .primary-settings,.settings-grid { + .settings-grid { grid-template-columns:1fr } - .selection-fields { - align-items:stretch - } .selection-fields label { width:100% } - .selection-fields p { - margin:0 - } .recording,.settings-heading { align-items:flex-start; flex-direction:column @@ -670,41 +545,17 @@ footer { .meters { grid-template-columns:1fr } - .toolbar { - gap:9px - } - .toolbar>code { - flex-basis:100% - } .player-buttons { - gap:13px - } - .player-buttons .check { - font-size:.82rem - } - .delivery-settings { - gap:12px - } - h2 { - font-size:1.25rem - } - .section-heading { gap:10px } - summary .small { - display:block; - margin:3px 0 0 + header { + flex-wrap:wrap } .action-row .primary { width:100% } dialog { - padding:20px; + padding:16px; width:calc(100% - 24px) } } -@media(prefers-reduced-motion:reduce) { - html { - scroll-behavior:auto - } -} diff --git a/lib/project/tests/gui_state.test.mjs b/lib/project/tests/gui_state.test.mjs index 10775eb..0556a4a 100644 --- a/lib/project/tests/gui_state.test.mjs +++ b/lib/project/tests/gui_state.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {parseTime, formatTime, selection, stable, matches, monitoringGain, absolutePath} from '../src/producer/web/state.js'; +import {parseTime, formatTime, selection, stable, matches, monitoringGain} from '../src/producer/web/state.js'; test('exact excerpt times work beyond four hours without wrapping', () => { assert.equal(parseTime('05:12:03.125'), 18723.125); @@ -12,11 +12,11 @@ test('exact excerpt times work beyond four hours without wrapping', () => { for (const [start, end] of [['0', '121'], ['2', '1'], ['0', '0'], ['14399', '14401']]) assert.throws(() => selection(start, end, 14400)); }); -test('preview identity includes source, exact selection and every requested setting', () => { - const snapshot = {path: 'take.wav', input_id: 'identity', start: 10, duration: 30, settings: {mp3: true, trim_silence: true, denoiser: 'none'}}; +test('preview identity includes uploaded source, exact selection and every requested setting', () => { + const snapshot = {upload_id: 'upload', input_id: 'identity', start: 10, duration: 30, settings: {mp3: true, trim_silence: true, denoiser: 'none'}}; assert.ok(matches(snapshot, structuredClone(snapshot))); assert.ok(matches(snapshot, {...snapshot, settings: {denoiser: 'none', trim_silence: true, mp3: true}})); - for (const change of [{path: 'other.wav'}, {input_id: 'changed'}, {start: 11}, {duration: 29}, {settings: {...snapshot.settings, trim_silence: false}}]) assert.equal(matches(snapshot, {...snapshot, ...change}), false); + for (const change of [{upload_id: 'other'}, {input_id: 'changed'}, {start: 11}, {duration: 29}, {settings: {...snapshot.settings, trim_silence: false}}]) assert.equal(matches(snapshot, {...snapshot, ...change}), false); assert.equal(matches(snapshot, null), false); assert.equal(stable({b: 2, a: 1}), stable({a: 1, b: 2})); }); @@ -26,6 +26,4 @@ test('monitoring is attenuation only and independent of export settings', () => assert.equal(monitoringGain(6, true), 1); assert.equal(monitoringGain(NaN, true), 1); assert.ok(Math.abs(monitoringGain(-6, true) - 0.501187) < 0.000001); - assert.equal(absolutePath('/recordings', 'delivery/master.wav'), '/recordings/delivery/master.wav'); - assert.equal(absolutePath('/', './master.wav'), '/master.wav'); }); diff --git a/lib/project/tests/test_gui.py b/lib/project/tests/test_gui.py index c9e5285..e490e99 100644 --- a/lib/project/tests/test_gui.py +++ b/lib/project/tests/test_gui.py @@ -1,4 +1,4 @@ -"""Optional GUI API, origin/root boundary, snapshots, and real sound previews.""" +"""Optional GUI API, origin boundary, uploads, snapshots, and real sound previews.""" from dataclasses import asdict import hashlib from pathlib import Path @@ -16,17 +16,28 @@ from producer.config import Settings from producer.gui import create_app from producer import gui_jobs -TOKEN = "test-session-credential" -AUTH = {"Authorization": f"Bearer {TOKEN}"} URL = "http://127.0.0.1:8765" @pytest.fixture def client(tmp_path): - with TestClient(create_app(tmp_path, 8765, TOKEN), base_url=URL, headers=AUTH) as client: + with TestClient(create_app(8765, output_dir=tmp_path), base_url=URL) as client: 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: + 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] + + def wait(client, job_id): deadline = time.monotonic() + 30 while time.monotonic() < deadline: @@ -38,68 +49,79 @@ def wait(client, job_id): def submit(client, path, kind, **kwargs): - info = client.get("/api/input", params={"path": path.name}) - assert info.status_code == 200, info.text - response = client.post("/api/jobs", json={"kind": kind, "path": path.name, - "input_id": info.json()["input_id"], **kwargs}) + entry = upload(client, path) + response = client.post("/api/jobs", json={"kind": kind, "upload_id": entry["upload_id"], + "input_id": entry["input_id"], **kwargs}) assert response.status_code == 202, response.text return response.json() -def test_security_credentials_origins_hosts_and_headers(tmp_path): - with TestClient(create_app(tmp_path, 8765, TOKEN), base_url=URL) as client: +def test_security_origins_hosts_and_headers(tmp_path): + with TestClient(create_app(8765), base_url=URL) as client: assert client.get("/").status_code == 200 - assert client.get("/api/schema").status_code == 401 - assert client.get(f"/api/schema?token={TOKEN}").status_code == 401 - assert client.get("/api/schema", headers={"Cookie": f"token={TOKEN}"}).status_code == 401 - for headers in [AUTH | {"Host": "evil.example:8765"}, - AUTH | {"Host": "localhost:8765"}, - AUTH | {"Origin": "https://evil.example"}, - AUTH | {"Origin": "null"}, - AUTH | {"Origin": "http://127.0.0.1:9999"}, - AUTH | {"Sec-Fetch-Site": "cross-site"}]: + assert client.get("/api/schema").status_code == 200 + assert "token" not in client.get("/").text.lower() + for headers in [{"Host": "evil.example:8765"}, + {"Host": "localhost:8765"}, + {"Origin": "https://evil.example"}, + {"Origin": "null"}, + {"Origin": "http://127.0.0.1:9999"}, + {"Sec-Fetch-Site": "cross-site"}]: assert client.get("/api/schema", headers=headers).status_code == 403 assert client.post("/api/jobs", json={}, headers=headers).status_code == 403 - response = client.get("/api/schema", headers=AUTH | {"Origin": URL}) + response = client.get("/api/schema", headers={"Origin": URL}) assert response.status_code == 200 assert response.headers["Cache-Control"] == "no-store" assert response.headers["Referrer-Policy"] == "no-referrer" assert "frame-ancestors 'none'" in response.headers["Content-Security-Policy"] assert "Access-Control-Allow-Origin" not in response.headers - assert TOKEN not in response.text assert client.get("/openapi.json").status_code == 404 - assert client.post("/api/resolve", content=b"x" * 65537, headers=AUTH).status_code == 413 - assert client.post("/api/resolve", content="{", headers=AUTH).status_code == 400 + assert client.post("/api/resolve", content=b"x" * 65537).status_code == 413 + assert client.post("/api/resolve", content="{").status_code == 400 -def test_root_browser_symlinks_traversal_and_no_arbitrary_download(client, tmp_path, wav): +def test_upload_pipeline_validates_names_and_content(client, tmp_path, wav): source = wav() - outside = tmp_path.parent / "outside.wav" - outside.write_bytes(b"not accessible") - (tmp_path / "escape.wav").symlink_to(outside) - (tmp_path / "escape-dir").symlink_to(tmp_path.parent, target_is_directory=True) - (tmp_path / "inside.wav").symlink_to(source) - (tmp_path / "note.txt").write_text("not exposed") - (tmp_path / "subdir").mkdir() - listing = client.get("/api/files").json() - assert listing["parent"] is None - assert {item["name"] for item in listing["entries"]} == {"input.wav", "inside.wav", "subdir"} - for path in ("../outside.wav", str(outside), "escape.wav", "escape-dir", "subdir/../../outside.wav"): - assert client.get("/api/input", params={"path": path}).status_code == 400 - assert client.get("/api/files", params={"path": ".."}).status_code == 400 - assert client.get("/api/files", params={"limit": 201}).status_code == 400 - assert len(client.get("/api/files", params={"limit": 1}).json()["entries"]) == 1 - assert client.get("/api/files", params={"limit": 1}).json()["next_offset"] == 1 - assert client.get("/assets/../gui.py").status_code == 404 - assert client.get("/assets/%2e%2e/gui.py").status_code == 404 - assert client.get("/api/results/missing/original.wav").status_code == 404 + entry = upload(client, source) + assert entry["name"] == "input.wav" + assert entry["duration"] == pytest.approx(3) + info = client.get(f"/api/uploads/{entry['upload_id']}").json() + assert info["input_id"] == entry["input_id"] + # Display names are sanitized; they never reach the filesystem. + escaped = upload(client, source, name="../evil name<>.wav") + assert ".." not in escaped["name"] and "/" not in escaped["name"] + assert client.get("/api/uploads/unknown-id").status_code == 400 + (tmp_path / "note.txt").write_text("not a wav") + assert client.post("/api/uploads", params={"name": "note.txt"}, content=b"nope").status_code == 400 + assert client.post("/api/uploads", params={"name": "x.wav"}, content=b"not a wav").status_code == 400 + stereo = wav(np.zeros((20, 2)), name="stereo.wav") + response = client.post("/api/uploads", params={"name": "stereo.wav"}, content=stereo.read_bytes()) + assert response.status_code == 200 and response.json()["channels"] == 2 + assert "downmixed" in response.json()["notice"] + assert "downmixed" in client.get(f"/api/uploads/{response.json()['upload_id']}").text + surround = wav(np.zeros((20, 3)), name="surround.wav") + assert client.post("/api/uploads", params={"name": "surround.wav"}, content=surround.read_bytes()).status_code == 400 + # Jobs require a known upload and a matching identity. + assert client.post("/api/jobs", json={"kind": "scan", "upload_id": "unknown", + "input_id": "x"}).status_code == 400 + assert client.post("/api/jobs", json={"kind": "scan", "upload_id": entry["upload_id"], + "input_id": "wrong"}).status_code == 400 + assert "changed" in client.post("/api/jobs", json={"kind": "scan", "upload_id": entry["upload_id"], + "input_id": "wrong"}).text def test_schema_complete_strict_resolution_and_preset_roundtrip(client, bypass): schema = client.get("/api/schema").json() assert {item["name"] for item in schema["fields"]} == set(asdict(Settings())) assert all(item["group"] and item["help"] for item in schema["fields"]) + help_for = {item["name"]: item["help"] for item in schema["fields"]} + assert len(set(help_for.values())) == len(help_for) + assert "cannot be 0" in help_for["fft_reduction_db"] + assert "Uncheck" in help_for["highpass"] + assert "Uncheck" in help_for["pause_silence"] and "Uncheck" in help_for["trim_silence"] + assert "0 to disable" in help_for["hum_hz"] assert schema["settings"] == asdict(Settings()) + assert Path(schema["output_dir"]).is_absolute() for value in ({"unknown": 1}, {"normalize": "false"}, {"target_lufs": -100}, {"normalize": True, "limiter": False}, {"sample_rate": 1234}): assert client.post("/api/resolve", json={"settings": value}).status_code == 400 @@ -115,9 +137,9 @@ def test_schema_complete_strict_resolution_and_preset_roundtrip(client, bypass): def test_config_precedence_and_startup_snapshot(tmp_path): config = tmp_path / "config.toml" config.write_text('profile = "narrator"\ntarget_lufs = -25\n') - app = create_app(tmp_path, 8765, TOKEN, config=config, profile="radio", overrides={"target_lufs": -22}) + app = create_app(8765, config=config, profile="radio", overrides={"target_lufs": -22}) config.write_text("this file changed after startup") - with TestClient(app, base_url=URL, headers=AUTH) as client: + with TestClient(app, base_url=URL) as client: settings = client.get("/api/schema").json()["settings"] assert settings["profile"] == "radio" and settings["compressor_ratio"] == 3.5 assert settings["target_lufs"] == -22 @@ -138,15 +160,9 @@ def test_scan_fixed_bins_cache_and_stale_identity(client, wav, monkeypatch): second = wait(client, submit(client, source, "scan")["id"]) assert second["cached"] and second["result"] == result assert source.read_bytes() == original - old_identity = first["snapshot"]["input_id"] - with source.open("ab") as handle: - handle.write(b"changed") - response = client.post("/api/jobs", json={"kind": "scan", "path": source.name, "input_id": old_identity}) - assert response.status_code == 400 - assert "changed" in response.text -def test_real_preview_context_exact_crop_bypass_and_export_trim(client, wav, bypass, ffmpeg): +def test_real_preview_context_exact_crop_bypass_and_export_trim(client, tmp_path, wav, bypass, ffmpeg): t = np.arange(48000 * 8) / 48000 samples = 0.15 * np.sin(2 * np.pi * 440 * t) source = wav(samples) @@ -183,35 +199,41 @@ def test_real_preview_context_exact_crop_bypass_and_export_trim(client, wav, byp changed = wait(client, submit(client, source, "preview", settings=requested | {"highpass": True}, start=2, duration=3)["id"]) assert not changed["cached"] and changed["result"]["result_id"] != result["result_id"] for _ in range(2): - exported = wait(client, submit(client, source, "export", settings=requested)["id"]) + 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 (source.parent / path).is_file() - assert len(list(source.parent.glob("producer-export-*"))) == 2 + assert Path(path).is_absolute() and Path(path).is_file() + assert len(list(tmp_path.glob("producer-export-*"))) == 2 assert hashlib.sha256(source.read_bytes()).hexdigest() == before - # A cached audio URL cannot read stale results after the recording changes. - with source.open("ab") as handle: - handle.write(b"changed") - assert client.get(result["audio"]["original"]).status_code == 400 -def test_preview_bounds_settings_errors_and_unsupported_input(client, wav): +def test_export_output_directory_rules(client, wav, bypass, tmp_path): + source = wav() + entry = upload(client, source) + requested = asdict(bypass) + base = {"kind": "export", "upload_id": entry["upload_id"], "input_id": entry["input_id"], + "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 + missing = client.post("/api/jobs", json={**base}) + assert missing.status_code == 202 + job = wait(client, missing.json()["id"]) + assert job["status"] == "succeeded", job + for path in job["result"]["paths"]: + assert Path(path).is_absolute() and Path(path).is_file() + Path(path).relative_to(tmp_path) + +def test_preview_bounds_and_empty_inputs(client, wav): source = wav() + entry = upload(client, source) for start, duration in ((-1, 1), (0, 121), (0, 0), (2, 2), (True, 1)): - info = client.get("/api/input", params={"path": source.name}).json() - response = client.post("/api/jobs", json={"kind": "preview", "path": source.name, - "input_id": info["input_id"], "start": start, "duration": duration}) + response = client.post("/api/jobs", json={"kind": "preview", "upload_id": entry["upload_id"], + "input_id": entry["input_id"], "start": start, "duration": duration}) assert response.status_code == 400 - stereo = wav(np.zeros((20, 2)), name="stereo.wav") - assert "Stereo" in client.get("/api/input", params={"path": stereo.name}).json()["detail"] - surround = wav(np.zeros((20, 3)), name="surround.wav") - assert client.get("/api/input", params={"path": surround.name}).status_code == 400 - source.write_bytes(b"not a wav") - response = client.get("/api/input", params={"path": source.name}) - assert response.status_code == 400 and "Unsupported" in response.text def test_no_total_duration_cap_and_seek_to_end_of_four_hour_input(tmp_path, progress): diff --git a/lib/project/tests/test_gui_assets.py b/lib/project/tests/test_gui_assets.py index b7f949a..961f15e 100644 --- a/lib/project/tests/test_gui_assets.py +++ b/lib/project/tests/test_gui_assets.py @@ -25,8 +25,8 @@ class AssetReferences(HTMLParser): self.stylesheets.append(attrs.get("href")) -def test_gui_packaged_external_assets_and_guided_workflow(tmp_path): - with TestClient(create_app(tmp_path, 8765, "test-token"), base_url="http://127.0.0.1:8765") as client: +def test_gui_packaged_external_assets_and_condensed_workflow(tmp_path): + with TestClient(create_app(8765, output_dir=tmp_path), base_url="http://127.0.0.1:8765") as client: page = client.get("/") assert page.status_code == 200 parser = AssetReferences() @@ -35,12 +35,14 @@ def test_gui_packaged_external_assets_and_guided_workflow(tmp_path): assert parser.stylesheets == ["/assets/style.css"] assert not parser.inline_attributes assert "unsafe-inline" not in page.headers["Content-Security-Policy"] - assert "Approximate sound comparison, not an exact export preview." in page.text - for name in ("Choose your recording", "Try a short passage", "Compare the sound", "Export the full recording"): - assert name in page.text + assert "approximate, not an exact export preview" in page.text + for name in ("Recording", "Settings", "Compare", "Export", "Activity"): + assert f">{name}</h2>" in page.text or name in page.text + for banned in ("#token", "private by design", "Make it sound like you"): + assert banned not in page.text for path in parser.scripts + parser.stylesheets + ["/assets/state.js"]: response = client.get(path) assert response.status_code == 200, path assert response.headers["Cache-Control"] == "no-store" assert len(response.content) > 100 - assert client.get("/api/schema").status_code == 401 + assert client.get("/api/schema").status_code == 200 diff --git a/lib/project/tests/test_gui_browser.py b/lib/project/tests/test_gui_browser.py index 1e38964..385de44 100644 --- a/lib/project/tests/test_gui_browser.py +++ b/lib/project/tests/test_gui_browser.py @@ -1,4 +1,4 @@ -"""Opt-in real Chromium/loopback smoke; no AI models or recording uploads. +"""Opt-in real Chromium/loopback smoke; no AI models. Uploads come from the browser. Install Playwright externally, then run: PRODUCER_BROWSER_TESTS=1 python -m pytest tests/test_gui_browser.py -q @@ -8,8 +8,6 @@ The long overview case uses mocked fixed-size scan metadata, not 4-hour AI infer from dataclasses import asdict import hashlib import json -import os -import re from pathlib import Path import socket import threading @@ -31,7 +29,6 @@ def studio(tmp_path, wav, bypass, ffmpeg, monkeypatch): source = wav(0.15 * np.sin(2 * np.pi * 440 * np.arange(48000 * 8) / 48000), name="Narration.wav") wav(np.zeros((100, 2)), name="Stereo.wav") (tmp_path / "delivery").mkdir() - (tmp_path / "subfolder").mkdir() control = {"hold": False, "started": threading.Event(), "release": threading.Event()} actual = gui_jobs.render_preview @@ -43,12 +40,11 @@ def studio(tmp_path, wav, bypass, ffmpeg, monkeypatch): return actual(*args) monkeypatch.setattr(gui_jobs, "render_preview", controlled_preview) - token = "browser-test-private-credential" sock = socket.socket() sock.bind(("127.0.0.1", 0)) sock.listen(128) port = sock.getsockname()[1] - app = create_app(tmp_path, port, token, overrides=asdict(bypass)) + app = create_app(port, output_dir=tmp_path, overrides=asdict(bypass)) server = uvicorn.Server(uvicorn.Config(app, log_level="error", access_log=False, proxy_headers=False)) thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True) thread.start() @@ -57,7 +53,7 @@ def studio(tmp_path, wav, bypass, ffmpeg, monkeypatch): time.sleep(0.02) assert server.started try: - yield {"url": f"http://127.0.0.1:{port}", "token": token, "root": tmp_path, "source": source, "control": control} + yield {"url": f"http://127.0.0.1:{port}", "root": tmp_path, "source": source, "control": control} finally: control["release"].set() server.should_exit = True @@ -80,19 +76,21 @@ def test_gui_browser_real_workflow(studio): page.on("pageerror", lambda exc: errors.append(str(exc))) page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) page.on("request", lambda req: media_requests.append(req.url) if "/api/results/" in req.url else None) - page.goto(studio["url"] + "/#token=" + studio["token"]) + page.goto(studio["url"]) expect(page.locator("#workspace")).not_to_have_attribute("disabled", "") - assert "#" not in page.url - assert studio["token"] not in page.locator("body").inner_text() + assert "token" not in page.locator("body").inner_text() assert page.locator("[data-setting]").count() == 47 expect(page.locator("#setting-denoiser")).to_have_value("none") - expect(page.locator("#folder-up")).to_be_disabled() + # Dependent controls of a disabled effect are visibly inactive. + expect(page.locator("#setting-denoise_strength")).to_be_disabled() + expect(page.locator("#setting-fft_reduction_db")).to_be_disabled() + expect(page.locator("#setting-highpass_hz")).to_be_disabled() + expect(page.locator("#setting-highpass_hz")).to_have_value("70") # Unsupported input errors are human-readable, not raw HTML. - page.get_by_role("button", name=re.compile(r"WAV\s*Stereo.wav")).click() + page.locator("#pick-files").set_input_files([studio["root"] / "Stereo.wav", studio["source"]]) expect(page.locator("#error")).to_be_visible() expect(page.locator("#error-text")).to_contain_text("Stereo") page.locator("#dismiss-error").click() - page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=15000) expect(page.locator("#input-info")).to_contain_text("00:00:08") # Both drag selection and keyboard-accessible exact inputs. @@ -110,6 +108,7 @@ def test_gui_browser_real_workflow(studio): # Explicit render, requested pacing+MP3 preserved in snapshot but bypassed internally. page.locator("#setting-trim_silence").check() page.locator("#setting-mp3").check() + expect(page.locator("#setting-mp3_bitrate")).not_to_be_disabled() page.locator("#render-preview").click() expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000) assert len(media_requests) == 2 @@ -140,39 +139,39 @@ def test_gui_browser_real_workflow(studio): toml_path = screenshots / "roundtrip-preset.toml" downloaded.value.save_as(toml_path) assert "trim_silence = true" in toml_path.read_text() - assert studio["token"] not in toml_path.read_text() - page.locator("#setting-denoise_strength").fill("0.4") + # Editing a control invalidates the comparison; importing the preset restores it. + page.locator("#setting-eq").check() expect(page.locator("#comparison-status")).to_contain_text("changed since preview") page.locator("#import-preset").set_input_files(toml_path) - expect(page.locator("#setting-denoise_strength")).to_have_value("0.85") + expect(page.locator("#setting-eq")).not_to_be_checked() # The saved copy cannot change when controls are edited later. - page.locator("#setting-denoise_strength").fill("0.5") - saved = page.evaluate("JSON.parse(localStorage.getItem(Object.keys(localStorage).find(k => k.startsWith('producer-studio-v1:'))))") - assert saved["comparisons"][0]["snapshot"]["settings"]["denoise_strength"] == .85 - assert saved["presets"][0]["settings"]["denoise_strength"] == .85 - assert studio["token"] not in json.dumps(saved) + page.locator("#setting-eq").check() + saved = page.evaluate("JSON.parse(localStorage.getItem(Object.keys(localStorage).find(k => k.startsWith('producer-gui-v1'))))") + assert saved["comparisons"][0]["snapshot"]["settings"]["eq"] is False + assert saved["presets"][0]["settings"]["eq"] is False # Delay real preview completion. Editing after submission must never publish it as current. studio["control"]["hold"] = True page.locator("#render-preview").click() assert studio["control"]["started"].wait(10) old_source = page.locator("#comparison-source").inner_text() - page.locator("#setting-denoise_strength").fill("0.6") + page.locator("#setting-eq").uncheck() studio["control"]["release"].set() expect(page.locator("#jobs")).to_contain_text("Older snapshot; not loaded", timeout=30000) assert page.locator("#comparison-source").inner_text() == old_source assert len(media_requests) == 2 # Restore is an explicit render and restores the original immutable requested settings. page.locator("#comparisons button", has_text="Restore & render").click() - expect(page.locator("#setting-denoise_strength")).to_have_value("0.85") + expect(page.locator("#setting-eq")).not_to_be_checked() expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000) # Cross-field errors do not silently enable the limiter. page.locator("#setting-normalize").check() + expect(page.locator("#setting-target_lufs")).not_to_be_disabled() page.locator("#render-preview").click() expect(page.locator("#error-text")).to_contain_text("limiter") assert not page.locator("#setting-limiter").is_checked() page.locator("#dismiss-error").click() page.locator("#setting-normalize").uncheck() - page.locator("#output-dir").fill("delivery") + 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() @@ -195,7 +194,7 @@ def test_gui_browser_real_workflow(studio): page.locator("#compare").scroll_into_view_if_needed() page.screenshot(path=str(screenshots / "desktop-comparison.png"), full_page=True) page.locator("#compare").screenshot(path=str(screenshots / "comparison-panel.png")) - # Reload retains per-tab authorization and local snapshots, not misleading current audio. + # Reload keeps local snapshots, not misleading current audio. page.reload() expect(page.locator("#workspace")).not_to_have_attribute("disabled", "") expect(page.locator("#comparisons")).to_contain_text("Gentle <snapshot>") @@ -206,18 +205,8 @@ def test_gui_browser_real_workflow(studio): page.evaluate("window.scrollTo(0, 0)") page.screenshot(path=str(screenshots / "mobile-entry.png")) assert page.evaluate("document.documentElement.scrollWidth <= innerWidth") - # A fresh tab without the private fragment cannot read API data. - unauth = context.new_page() - unauth.goto(studio["url"]) - expect(unauth.locator("#error-text")).to_contain_text("private URL") - expect(unauth.locator("#workspace")).to_have_attribute("disabled", "") - unauth.close() - # Simulate server restart credential expiry and verify recovery guidance. - page.route("**/api/jobs", lambda route: route.fulfill(status=401, json={"detail": "expired"})) - expect(page.locator("#workspace")).to_have_attribute("disabled", "", timeout=10000) - expect(page.locator("#connection")).to_contain_text("Session expired") assert errors == [] - # Expected HTTP errors for stereo, invalid constraint and the expiry test are allowed. + # Expected HTTP errors for stereo and the invalid constraint are allowed. assert not [msg for msg in console_errors if "Content Security Policy" in msg or "Refused" in msg] browser.close() @@ -231,19 +220,20 @@ def test_gui_browser_long_overview(studio): requests, errors = [], [] page.on("request", lambda request: requests.append(request.url)) page.on("pageerror", lambda exc: errors.append(str(exc))) - page.goto(studio["url"] + "/#token=" + studio["token"]) - expect(page.locator("#workspace")).not_to_have_attribute("disabled", "") + page.goto(studio["url"]) # Four-hour metadata plus fixed-size peaks: browser never receives full recording samples. - info = {"path": "Narration.wav", "input_id": "long-identity", "frames": 14400 * 8000, - "duration": 14400, "sample_rate": 8000, "channels": 1, "subtype": "PCM_16"} + info = {"upload_id": "long-upload", "name": "Narration.wav", "input_id": "long-identity", + "frames": 14400 * 8000, "duration": 14400, "sample_rate": 8000, + "channels": 1, "subtype": "PCM_16"} result = {"input_id": "long-identity", "duration": 14400, "frames": info["frames"], "sample_rate": 8000, "bins": 2048, "min": [-.3] * 2048, "max": [.4] * 2048} - job = {"id": "mock-scan", "kind": "scan", "snapshot": {"path": "Narration.wav", "input_id": "long-identity"}, + job = {"id": "mock-scan", "kind": "scan", + "snapshot": {"upload_id": "long-upload", "name": "Narration.wav", "input_id": "long-identity"}, "status": "succeeded", "progress": {"stage": "Done", "completed": 1, "total": 1}, "created": time.time(), "result": result, "error": None, "cancel_requested": False, "cached": False} - page.route("**/api/input?*", lambda route: route.fulfill(json=info)) + page.route("**/api/uploads*", lambda route: route.fulfill(json=info)) page.route("**/api/jobs", lambda route: route.fulfill(json=job if route.request.method == "POST" else {"jobs": [job]})) - page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + page.locator("#pick-files").set_input_files(studio["source"]) expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) expect(page.locator("#wave-end")).to_have_text("04:00:00") page.locator("#selection-start").fill("03:59:30") diff --git a/lib/project/tests/test_gui_browser_races.py b/lib/project/tests/test_gui_browser_races.py index fab60ba..5f2638b 100644 --- a/lib/project/tests/test_gui_browser_races.py +++ b/lib/project/tests/test_gui_browser_races.py @@ -15,8 +15,8 @@ def test_gui_browser_cancellation_is_not_pretend(studio): with sync_playwright() as playwright: browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"]) page = browser.new_page() - page.goto(studio["url"] + "/#token=" + studio["token"]) - page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + page.goto(studio["url"]) + page.locator("#pick-files").set_input_files(studio["source"]) expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) studio["control"]["hold"] = True page.locator("#render-preview").click() @@ -48,8 +48,8 @@ def test_gui_browser_late_audio_is_aborted_and_urls_are_released(studio): const create = URL.createObjectURL.bind(URL), revoke = URL.revokeObjectURL.bind(URL); URL.createObjectURL = blob => { const url = create(blob); objectURLs.created.push(url); return url; }; URL.revokeObjectURL = url => { objectURLs.revoked.push(url); revoke(url); };""") - page.goto(studio["url"] + "/#token=" + studio["token"]) - page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + page.goto(studio["url"]) + page.locator("#pick-files").set_input_files(studio["source"]) expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) page.locator("#selection-start").fill("1") page.locator("#selection-end").fill("4") @@ -99,10 +99,9 @@ def test_gui_browser_nonzero_monitoring_gain_is_playback_only(studio): AudioContext.prototype.createGain = function() { const gain = createGain.call(this); monitorGains.push(gain); return gain; };""") - page.goto(studio["url"] + "/#token=" + studio["token"]) - page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + page.goto(studio["url"]) + page.locator("#pick-files").set_input_files(studio["source"]) expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) - page.locator("#advanced-settings summary").click() # Real FFmpeg leveling makes a measurable difference without any AI runtime. page.locator("#setting-leveling").check() page.locator("#setting-level_target_db").fill("-12") @@ -137,9 +136,9 @@ def test_delayed_job_list_preserves_new_submission_intent(studio, kind): with sync_playwright() as playwright: browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"]) page = browser.new_page() - page.goto(studio["url"] + "/#token=" + studio["token"]) + page.goto(studio["url"]) if kind == "preview": - page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + page.locator("#pick-files").set_input_files(studio["source"]) expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) held = [] @@ -158,7 +157,7 @@ def test_delayed_job_list_preserves_new_submission_intent(studio, kind): with page.expect_response(lambda response: response.request.method == "POST" and response.url.endswith("/api/jobs")) as posted: if kind == "scan": - page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + page.locator("#pick-files").set_input_files(studio["source"]) else: page.locator("#render-preview").click() assert posted.value.status == 202 @@ -186,8 +185,8 @@ def test_delayed_audio_resume_does_not_start_superseded_playback(studio, interru window.releaseAudioResume = () => { resume.call(this).then(resolve); }; }); };""") - page.goto(studio["url"] + "/#token=" + studio["token"]) - page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + page.goto(studio["url"]) + page.locator("#pick-files").set_input_files(studio["source"]) expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) page.locator("#render-preview").click() expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000) @@ -200,8 +199,8 @@ def test_delayed_audio_resume_does_not_start_superseded_playback(studio, interru page.locator("#setting-denoise_strength").fill("0.4") else: page.route("**/api/jobs", lambda route: route.fulfill( - status=401, content_type="application/json", body='{"detail":"Expired"}')) - expect(page.locator("#workspace")).to_have_attribute("disabled", "", timeout=10000) + status=500, content_type="application/json", body='{"detail":"down"}')) + expect(page.locator("#connection")).to_contain_text("interrupted", timeout=10000) page.evaluate("releaseAudioResume()") # Let both the resume and any erroneously scheduled play promise settle. page.wait_for_timeout(300) diff --git a/lib/project/tests/test_gui_jobs.py b/lib/project/tests/test_gui_jobs.py index 6116a72..d44772f 100644 --- a/lib/project/tests/test_gui_jobs.py +++ b/lib/project/tests/test_gui_jobs.py @@ -7,8 +7,6 @@ import time import pytest from producer import gui_jobs, setup -from producer.gui import rooted - def terminal(jobs, job_id): deadline = time.monotonic() + 10 @@ -34,13 +32,13 @@ def test_single_worker_bounded_queue_and_running_process_cancellation(wav, tmp_p "Waiting for worker", progress) return {"bins": 2048} monkeypatch.setattr(gui_jobs, "waveform", slow_scan) - jobs = gui_jobs.Jobs(tmp_path, lambda value: rooted(tmp_path, value)) - snapshot = {"path": source.name, "input_id": gui_jobs.identity(source)} + jobs = gui_jobs.Jobs() + snapshot = {"upload_id": jobs.register_upload(source, source.name), "input_id": gui_jobs.identity(source)} try: first = jobs.submit("scan", snapshot) assert started.wait(5) # Public snapshots cannot mutate the immutable in-flight job. - first["snapshot"]["path"] = "missing.wav" + first["snapshot"]["name"] = "renamed.wav" queued = [jobs.submit("scan", snapshot) for _ in range(3)] with pytest.raises(gui_jobs.Busy): jobs.submit("scan", snapshot) @@ -71,8 +69,8 @@ def test_history_and_result_cache_are_bounded_and_evicted_media_expires(wav, tmp (work / "processed.wav").write_bytes(b"preview") return {} monkeypatch.setattr(gui_jobs, "render_preview", preview) - jobs = gui_jobs.Jobs(tmp_path, lambda value: rooted(tmp_path, value)) - snapshot = {"path": source.name, "input_id": gui_jobs.identity(source)} + jobs = gui_jobs.Jobs() + snapshot = {"upload_id": jobs.register_upload(source, source.name), "input_id": gui_jobs.identity(source)} try: results = [] for index in range(10): @@ -82,7 +80,7 @@ def test_history_and_result_cache_are_bounded_and_evicted_media_expires(wav, tmp results.append(result["result"]["result_id"]) assert len(jobs.list()) == 5 assert len(jobs.cache) == 2 - assert len(list(jobs.work.iterdir())) == 2 + assert len([item for item in jobs.work.iterdir() if item.name != "uploads"]) == 2 with pytest.raises(KeyError): jobs.media(results[0], "original") assert jobs.media(results[-1], "original") == b"preview" @@ -93,7 +91,7 @@ def test_history_and_result_cache_are_bounded_and_evicted_media_expires(wav, tmp with jobs.condition: jobs._evict() assert not jobs.cache - assert not list(jobs.work.iterdir()) + assert not [item for item in jobs.work.iterdir() if item.name != "uploads"] finally: jobs.close() @@ -107,8 +105,8 @@ def test_queued_input_replacement_is_rejected_and_shutdown_cancels_scan(wav, tmp progress("Scanning", 1, 10) time.sleep(0.01) monkeypatch.setattr(gui_jobs, "waveform", scan) - jobs = gui_jobs.Jobs(tmp_path, lambda value: rooted(tmp_path, value)) - snapshot = {"path": source.name, "input_id": gui_jobs.identity(source)} + jobs = gui_jobs.Jobs() + snapshot = {"upload_id": jobs.register_upload(source, source.name), "input_id": gui_jobs.identity(source)} running = jobs.submit("scan", snapshot) assert started.wait(5) queued = jobs.submit("scan", snapshot) diff --git a/lib/project/tests/test_gui_launcher.py b/lib/project/tests/test_gui_launcher.py index 8111c67..758d034 100644 --- a/lib/project/tests/test_gui_launcher.py +++ b/lib/project/tests/test_gui_launcher.py @@ -18,10 +18,10 @@ def test_cli_gui_dispatch_preserves_configuration_flags(tmp_path, monkeypatch): config.write_text('profile = "narrator"\n') received = [] monkeypatch.setattr(gui, "serve", lambda *args, **kwargs: received.append((args, kwargs))) - monkeypatch.setattr(sys, "argv", ["producer", "gui", "--root", str(tmp_path), "--port", "9001", + monkeypatch.setattr(sys, "argv", ["producer", "gui", "--port", "9001", "--config", str(config), "--profile", "radio", "--target-lufs", "-21", "--set", "target_lufs=-18"]) cli.main() - assert received == [((tmp_path, 9001), {"config": config, "profile": "radio", "overrides": {"target_lufs": -18}})] + assert received == [((9001,), {"config": config, "profile": "radio", "overrides": {"target_lufs": -18}})] def test_launcher_only_gui_installs_extra_and_preserves_config_injection(tmp_path): @@ -58,7 +58,7 @@ def test_launcher_only_gui_installs_extra_and_preserves_config_injection(tmp_pat calls = [json.loads(line) for line in log.read_text().splitlines()] installs = [call for call in calls if call[0] == "pip"] assert len(installs) == 1 and installs[0][-1].endswith("[bundled-ffmpeg]") - args = launch("gui", "--root", str(checkout), "--port", "8766") + args = launch("gui", "--port", "8766") assert args[3:5] == ["gui", f"--config={checkout / 'config.toml'}"] calls = [json.loads(line) for line in log.read_text().splitlines()] installs = [call for call in calls if call[0] == "pip"] @@ -86,7 +86,7 @@ assert parser().parse_args(['config']).command == 'config' assert result.returncode == 0, result.stderr -def test_server_binds_only_loopback_and_prints_fragment_token(tmp_path, monkeypatch, capsys): +def test_server_binds_only_loopback_and_prints_plain_url(tmp_path, monkeypatch, capsys): pytest.importorskip("fastapi") uvicorn = pytest.importorskip("uvicorn") import socket @@ -103,11 +103,11 @@ def test_server_binds_only_loopback_and_prints_fragment_token(tmp_path, monkeypa def run(self, sockets): seen.append(sockets[0].getsockname()) monkeypatch.setattr(uvicorn, "Server", Server) - gui.serve(tmp_path, port) + gui.serve(port) assert seen == [("127.0.0.1", port)] output = capsys.readouterr().out - assert f"http://127.0.0.1:{port}/#token=" in output - assert len(output.split("#token=")[1].splitlines()[0]) >= 43 + assert f"http://127.0.0.1:{port}/\n" in output + assert "#token" not in output for invalid in (0, -1, 65536): with pytest.raises(ValueError): - gui.serve(tmp_path, invalid) + gui.serve(invalid) diff --git a/lib/project/tests/test_pipeline.py b/lib/project/tests/test_pipeline.py index fe04519..0bc2cef 100644 --- a/lib/project/tests/test_pipeline.py +++ b/lib/project/tests/test_pipeline.py @@ -64,13 +64,13 @@ def test_existing_report_log_is_replaced_without_overwrite(wav, tmp_path, bypass @pytest.mark.parametrize("kind,message", [ ("empty", "empty"), ("silence", "silent or too quiet"), ("quiet", "silent or too quiet"), ("surround", "Only mono or stereo"), - ("nonfinite", "non-finite"), ("stereo", "downmix to mono"), + ("nonfinite", "non-finite"), ]) def test_unusable_inputs_leave_no_outputs(wav, tmp_path, bypass, progress, ffmpeg, kind, message): samples = { "empty": np.empty(0), "silence": np.zeros(48000), "quiet": np.full(48000, 1e-6), "surround": np.ones((4800, 3)) * 0.1, - "nonfinite": np.array([0.1, np.nan]), "stereo": np.ones((4800, 2)) * 0.1, + "nonfinite": np.array([0.1, np.nan]), }[kind] source = wav(samples) target = tmp_path / "outputs" / "master.wav" @@ -79,6 +79,51 @@ def test_unusable_inputs_leave_no_outputs(wav, tmp_path, bypass, progress, ffmpe assert not target.parent.exists() +def test_downmix_to_mono_strategies(wav, tmp_path, progress): + from producer.audio import downmix_to_mono + time = np.arange(48000 * 3) / 48000 + voice = 0.1 * np.sin(2 * np.pi * 440 * time) + # Identical channels: a plain average is lossless and keeps full level. + mono = tmp_path / "same.wav" + method = downmix_to_mono(wav(np.column_stack([voice, voice])), mono, progress) + assert method == "the channels were averaged" + assert sf.info(mono).channels == 1 + assert np.abs(sf.read(mono)[0] - voice).max() == pytest.approx(0, abs=1e-7) + # Inverted channels: the phase-cancel case the old error guarded against. + mono = tmp_path / "inverted.wav" + method = downmix_to_mono(wav(np.column_stack([voice, -voice])), mono, progress) + assert "inverted" in method + assert np.abs(sf.read(mono)[0] - voice).max() == pytest.approx(0, abs=1e-7) + # One silent channel: keep the live channel at unity gain, not -6 dB. + mono = tmp_path / "silent.wav" + method = downmix_to_mono(wav(np.column_stack([voice, np.zeros_like(voice)])), mono, progress) + assert "right channel is silent" in method + assert np.abs(sf.read(mono)[0] - voice).max() == pytest.approx(0, abs=1e-7) + method = downmix_to_mono(wav(np.column_stack([np.zeros_like(voice), voice])), + tmp_path / "silent-left.wav", progress) + assert "left channel is silent" in method + with pytest.raises(ValueError, match="non-finite"): + downmix_to_mono(wav(np.array([[0.1, np.nan]])), tmp_path / "bad.wav", progress) + + +def test_stereo_input_is_downmixed_not_rejected(wav, tmp_path, bypass, progress, ffmpeg): + time = np.arange(48000 * 3) / 48000 + voice = 0.1 * np.sin(2 * np.pi * 440 * time) + source = wav(np.column_stack([voice, -voice])) + original = source.read_bytes() + target = tmp_path / "master.wav" + report = pipeline.process(source, target, bypass, progress) + assert any("downmixed to mono" in warning and "inverted" in warning + for warning in report["warnings"]) + stats = sf.info(target) + assert stats.channels == 1 and stats.frames == 144000 + # A naive (L + R) / 2 mix of these channels is digital silence; the master + # must carry real signal instead. + assert report["master"]["sample_peak_dbfs"] > -30 + assert source.read_bytes() == original + assert not list(target.parent.glob(".producer-*")) + + def test_measure_reports_channels_peaks_clipping_and_progress(wav): samples = np.tile([1.0, -0.25], (4800, 1)) events = [] diff --git a/producer.sh b/producer.sh index 5814abc..884d3ac 100755 --- a/producer.sh +++ b/producer.sh @@ -46,7 +46,7 @@ Usage: ./producer.sh COMMAND [options], or simply: process RECORDING.wav... The same, with the explicit command keyword preview RECORDING.wav Render loudness-matched profile previews config Print resolved settings (TOML, or JSON with --json) - gui [--root PATH] [--port N] Open the optional local browser GUI (loopback only) + gui [--port N] Open the local browser GUI (loopback only; upload any WAV from the browser) doctor Check FFmpeg, data directory and CUDA driver setup --device cpu|cuda Install and self-test the AI backend (--denoiser clearvoice for the MossFormer2 backend) |
