diff options
| author | historia <historiavg@proton.me> | 2026-09-12 16:48:55 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-12 16:48:55 -0400 |
| commit | 18346d8deb858cc77d166ffea8ef76d9c9d48bfe (patch) | |
| tree | e11bb5951a047ed372053906dcbb97b8651cee84 /lib/project | |
| parent | 464edbc4fd658728f06d3a7d98340aa28ff3e3ec (diff) | |
| download | producer-18346d8deb858cc77d166ffea8ef76d9c9d48bfe.tar.gz | |
feat: automatic stereo mixdown
Diffstat (limited to 'lib/project')
| -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 |
18 files changed, 794 insertions, 788 deletions
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 = [] |
