diff options
Diffstat (limited to 'lib/project/src')
| -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 |
10 files changed, 580 insertions, 628 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 - } -} |
