From 464edbc4fd658728f06d3a7d98340aa28ff3e3ec Mon Sep 17 00:00:00 2001 From: historia Date: Sat, 12 Sep 2026 15:25:06 -0400 Subject: feat: slop web gui --- lib/project/src/producer/cli.py | 20 +- lib/project/src/producer/config.py | 9 + lib/project/src/producer/gui.py | 264 ++++++++++++ lib/project/src/producer/gui_jobs.py | 327 +++++++++++++++ lib/project/src/producer/gui_schema.py | 89 ++++ lib/project/src/producer/pauses.py | 177 ++++++++ lib/project/src/producer/pipeline.py | 48 ++- lib/project/src/producer/vad_worker.py | 55 +++ lib/project/src/producer/web/app.js | 685 ++++++++++++++++++++++++++++++ lib/project/src/producer/web/index.html | 73 ++++ lib/project/src/producer/web/state.js | 36 ++ lib/project/src/producer/web/style.css | 710 ++++++++++++++++++++++++++++++++ 12 files changed, 2479 insertions(+), 14 deletions(-) create mode 100644 lib/project/src/producer/gui.py create mode 100644 lib/project/src/producer/gui_jobs.py create mode 100644 lib/project/src/producer/gui_schema.py create mode 100644 lib/project/src/producer/pauses.py create mode 100644 lib/project/src/producer/vad_worker.py create mode 100644 lib/project/src/producer/web/app.js create mode 100644 lib/project/src/producer/web/index.html create mode 100644 lib/project/src/producer/web/state.js create mode 100644 lib/project/src/producer/web/style.css (limited to 'lib/project/src') diff --git a/lib/project/src/producer/cli.py b/lib/project/src/producer/cli.py index 8b34e7c..982e656 100644 --- a/lib/project/src/producer/cli.py +++ b/lib/project/src/producer/cli.py @@ -24,7 +24,7 @@ def parser(): app = argparse.ArgumentParser(prog="producer", description="Local speech cleanup and podcast mastering. Originals are never overwritten.") app.add_argument("--version", action="version", version=__version__) sub = app.add_subparsers(dest="command", required=True) - for name in ("process", "preview", "config"): + for name in ("process", "preview", "config", "gui"): cmd = sub.add_parser(name) cmd.add_argument("--config", type=Path, help="TOML configuration file") cmd.add_argument("--profile", choices=PROFILES, default=None) @@ -41,6 +41,9 @@ def parser(): cmd.add_argument("--no-denoise", dest="denoiser", action="store_const", const="none") 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) if name in ("process", "preview"): @@ -61,7 +64,7 @@ def parser(): return app -def settings_for(args, profile=None): +def settings_overrides(args): overrides = {field.name: getattr(args, field.name) for field in fields(Settings) if field.name != "profile" and getattr(args, field.name, None) is not None} for item in args.set: @@ -74,7 +77,11 @@ def settings_for(args, profile=None): except tomllib.TOMLDecodeError: parsed = value overrides[key.strip()] = parsed - return resolve(args.config, profile or args.profile, overrides) + return overrides + + +def settings_for(args, profile=None): + return resolve(args.config, profile or args.profile, settings_overrides(args)) def require_input(source: Path) -> None: @@ -84,7 +91,7 @@ def require_input(source: Path) -> None: # The 'process' keyword is optional: any first argument that is not a known # command (or a global option) is treated as the start of a process run. -COMMANDS = ("process", "preview", "config", "doctor", "setup") +COMMANDS = ("process", "preview", "config", "doctor", "setup", "gui") GLOBAL_OPTIONS = ("-h", "--help", "--version") @@ -113,6 +120,11 @@ def main(): console.print(f"AI ready: {python}", markup=False) return settings = settings_for(args) + if args.command == "gui": + from .gui import serve + serve(args.root, args.port, config=args.config, profile=args.profile, + overrides=settings_overrides(args)) + return if args.command == "config": print(json.dumps(asdict(settings), indent=2) if args.json else toml(settings), end="\n" if args.json else "") return diff --git a/lib/project/src/producer/config.py b/lib/project/src/producer/config.py index 347504a..34c907b 100644 --- a/lib/project/src/producer/config.py +++ b/lib/project/src/producer/config.py @@ -24,6 +24,12 @@ class Settings: expansion_threshold_db: float = -50.0 expansion_ratio: float = 1.5 expansion_range_db: float = 12.0 + pause_silence: bool = False + pause_threshold: float = 0.35 + pause_pre_ms: float = 120.0 + pause_post_ms: float = 120.0 + pause_fade_ms: float = 10.0 + pause_min_ms: float = 80.0 trim_silence: bool = False max_gap_s: float = 1.5 gap_pad_s: float = 0.6 @@ -68,6 +74,9 @@ RANGES = { "level_target_db": (-40, -12), "max_gain_db": (0, 30), "expansion_threshold_db": (-90, -20), "expansion_ratio": (1, 4), "expansion_range_db": (0, 40), "warmth_db": (-12, 12), + "pause_threshold": (0.1, 0.9), "pause_pre_ms": (30, 500), + "pause_post_ms": (30, 1000), "pause_fade_ms": (2, 100), + "pause_min_ms": (20, 1000), "max_gap_s": (0.5, 10), "gap_pad_s": (0.1, 3), "gap_threshold_db": (-90, -20), "mud_db": (-12, 12), "presence_db": (-12, 12), "compressor_threshold_db": (-50, -5), "compressor_ratio": (1, 10), diff --git a/lib/project/src/producer/gui.py b/lib/project/src/producer/gui.py new file mode 100644 index 0000000..f1fa298 --- /dev/null +++ b/lib/project/src/producer/gui.py @@ -0,0 +1,264 @@ +"""Optional loopback-only web API. Importing the CLI never imports web dependencies.""" +from contextlib import asynccontextmanager +from dataclasses import asdict +import math +from pathlib import Path +import secrets +import socket +import tomllib + +from .config import resolve, toml +from .gui_jobs import Busy, Jobs, input_info +from .gui_schema import schema + +BODY_LIMIT = 65536 + + +class Boundary: + """Authenticate even reads, reject cross-origin/rebinding, 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. + """ + def __init__(self, app, authority, token): + self.app, self.authority, self.token = app, authority, token + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + return await self.app(scope, receive, send) + from starlette.responses import JSONResponse + headers = scope.get("headers", []) + def values(name): + return [value.decode("latin1") for key, value in headers if key == name] + status, detail = None, None + if values(b"host") != [self.authority]: + status, detail = 403, "Untrusted Host" + elif values(b"origin") not in ([], [f"http://{self.authority}"]): + 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([ + (b"cache-control", b"no-store"), (b"referrer-policy", b"no-referrer"), + (b"x-content-type-options", b"nosniff"), (b"x-frame-options", b"DENY"), + (b"content-security-policy", b"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; media-src 'self' blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'"), + ]) + 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/"): + body = bytearray() + while True: + message = await receive() + if message["type"] == "http.disconnect": + return + body.extend(message.get("body", b"")) + if len(body) > BODY_LIMIT: + return await JSONResponse({"detail": "Request body exceeds 64 KiB"}, status_code=413)(scope, receive, secure_send) + if not message.get("more_body", False): + break + sent = False + original_receive = receive + async def bounded_receive(): + nonlocal sent + if not sent: + sent = True + return {"type": "http.request", "body": bytes(body), "more_body": False} + return await original_receive() + receive = bounded_receive + 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") + if value.keys() - allowed: + raise ValueError("Unknown request field(s): " + ", ".join(sorted(value.keys() - allowed))) + return value + + +def create_app(root, port, token, *, config=None, profile=None, overrides=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 + base.update(overrides or {}) + initial = asdict(resolve(overrides=base)) + + def configured(values): + if not isinstance(values, dict): + raise ValueError("settings must be an object") + return resolve(overrides=base | values) + + def path_for(value): + return rooted(root, value) + + @asynccontextmanager + async def lifespan(app): + app.state.jobs = Jobs(root, path_for) + try: + yield + finally: + app.state.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.exception_handler(ValueError) + async def invalid(request, error): + return JSONResponse({"detail": str(error)}, status_code=429 if isinstance(error, Busy) else 400) + + @app.exception_handler(OSError) + async def inaccessible(request, error): + return JSONResponse({"detail": str(error)}, status_code=400) + + @app.exception_handler(KeyError) + async def missing(request, error): + return JSONResponse({"detail": "Unknown or expired job/result, or missing required field"}, status_code=404) + + @app.get("/api/schema") + def settings_schema(): + return schema(initial) | {"root": str(root), "api_version": 1} + + @app.post("/api/resolve") + async def resolve_settings(request: Request): + body = object_body(await request.json(), {"settings"}) + return {"settings": asdict(configured(body.get("settings", {})))} + + @app.post("/api/presets/import") + async def import_preset(request: Request): + body = object_body(await request.json(), {"toml"}) + if not isinstance(body.get("toml"), str): + raise ValueError("toml must be text") + # Import is standalone: defaults/profile + TOML, not the launch config. + return {"settings": asdict(resolve(overrides=tomllib.loads(body["toml"])))} + + @app.post("/api/presets/download") + async def download_preset(request: Request): + body = object_body(await request.json(), {"settings"}) + 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(): + 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} + + @app.get("/api/jobs") + def list_jobs(): + return {"jobs": app.state.jobs.list()} + + @app.get("/api/jobs/{job_id}") + def get_job(job_id: str): + return app.state.jobs.get(job_id) + + @app.post("/api/jobs/{job_id}/cancel") + def cancel_job(job_id: str): + return app.state.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"}) + 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"))) + 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"]} + if kind != "scan": + snapshot["settings"] = asdict(configured(body.get("settings", {}))) + if kind == "preview": + start, duration = body.get("start"), body.get("duration") + if (type(start) not in (int, float) or type(duration) not in (int, float) + or not math.isfinite(start) or not math.isfinite(duration) + or start < 0 or not 0 < duration <= 120 or start + duration > info["duration"]): + 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) + + @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") + + assets = Path(__file__).with_name("web") + @app.get("/") + def index(): + return FileResponse(assets / "index.html") + app.mount("/assets", StaticFiles(directory=assets), name="assets") + return app + + +def serve(root, 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: + import uvicorn + 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. + 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) + 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 new file mode 100644 index 0000000..705b0dd --- /dev/null +++ b/lib/project/src/producer/gui_jobs.py @@ -0,0 +1,327 @@ +"""Single-worker GUI jobs and bounded, stat-keyed sound-comparison results. + +No full-recording arrays: waveform buffers are 65536 frames plus 2048 bins. +Only bounded PCM16 preview snippets are returned to the browser. +""" +from collections import OrderedDict, deque +from copy import deepcopy +from dataclasses import asdict, replace +import hashlib +import json +from pathlib import Path +import shutil +import tempfile +import threading +import time +import uuid + +import numpy as np +import soundfile as sf + +from .audio import ffmpeg_run, loudness +from .config import Settings +from .pipeline import process, targets_for +from .setup import ensure_ffmpeg + +MAX_PENDING = 3 +MAX_HISTORY = 24 +CACHE_ENTRIES = 8 +CACHE_BYTES = 128 * 1024 * 1024 +PREVIEW_SECONDS = 120 +CONTEXT_SECONDS = 10 +WAVEFORM_BINS = 2048 + + +class Cancelled(Exception): + pass + + +class Busy(ValueError): + pass + + +def identity(path): + stat = path.stat() + return hashlib.sha256(json.dumps([str(path), stat.st_dev, stat.st_ino, stat.st_size, + stat.st_mtime_ns, stat.st_ctime_ns]).encode()).hexdigest() + + +def input_info(path): + if not path.is_file() or path.suffix.lower() != ".wav": + raise ValueError("Choose an existing WAV file. Other codecs are not supported as inputs.") + try: + 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 not info.frames: + raise ValueError("The recording is empty") + return {"input_id": identity(path), "frames": info.frames, "duration": info.duration, + "sample_rate": info.samplerate, "channels": info.channels, "subtype": info.subtype} + + +def waveform(path, progress): + """Fixed-size min/max summary; bin edges use integer frame coordinates.""" + with sf.SoundFile(path) as source: + frames = len(source) + low = np.full(WAVEFORM_BINS, np.inf) + high = np.full(WAVEFORM_BINS, -np.inf) + position = 0 + progress("Scanning waveform", 0, frames) + for audio in source.blocks(blocksize=65536, dtype="float32", always_2d=True): + if not np.isfinite(audio).all(): + raise ValueError("Recording contains non-finite samples") + indices = np.minimum((np.arange(len(audio), dtype=np.int64) + position) + * WAVEFORM_BINS // frames, WAVEFORM_BINS - 1) + np.minimum.at(low, indices, audio.min(axis=1)) + np.maximum.at(high, indices, audio.max(axis=1)) + position += len(audio) + progress("Scanning waveform", position, frames) + low[~np.isfinite(low)] = 0 + high[~np.isfinite(high)] = 0 + return {"duration": frames / source.samplerate, "frames": frames, + "sample_rate": source.samplerate, "bins": WAVEFORM_BINS, + "min": low.tolist(), "max": high.tolist()} + + +def copy_range(source, target, start, duration, progress, subtype="FLOAT"): + """Seek directly to any location; copy a bounded interval in bounded blocks.""" + with sf.SoundFile(source) as src: + first = round(start * src.samplerate) + count = round(duration * src.samplerate) + if first < 0 or first + count > len(src) + 1: + raise ValueError("Selection extends beyond the recording") + count = min(count, len(src) - first) + src.seek(first) + with sf.SoundFile(target, "w", samplerate=src.samplerate, channels=src.channels, + format="WAV", subtype=subtype) as dst: + copied = 0 + while copied < count: + progress("Extracting selected audio", copied, count) + block = src.read(min(65536, count - copied), dtype="float32", always_2d=True) + if not len(block): + raise ValueError("Recording changed while reading") + if not np.isfinite(block).all(): + raise ValueError("Recording contains non-finite samples") + dst.write(block) + copied += len(block) + progress("Extracting selected audio", count, count) + + +def monitoring(original, processed): + """Attenuation-only equal-loudness comparison, separate from rendered audio.""" + levels = [item.get("input_i") for item in (original, processed)] + if any(value is None for value in levels): + return {"available": False, "original_gain_db": 0, "processed_gain_db": 0, + "reason": "Not enough measurable audio for integrated loudness matching."} + target = min(levels) + return {"available": True, "target_lufs": target, + "original_gain_db": target - levels[0], "processed_gain_db": target - levels[1], + "reason": "Playback-only attenuation to the quieter snippet; never changes export settings."} + + +def render_preview(source, work, snapshot, progress): + requested = Settings(**snapshot["settings"]) + # The sound comparison preserves timing. Pacing edits are export-only. + effective = replace(requested, trim_silence=False, mp3=False) + start, duration = snapshot["start"], snapshot["duration"] + context_start = max(0, start - CONTEXT_SECONDS) + context_end = min(sf.info(source).duration, start + duration + CONTEXT_SECONDS) + with tempfile.TemporaryDirectory(prefix="context-", dir=work) as name: + temporary = Path(name) + excerpt, master = temporary / "excerpt.wav", temporary / "master.wav" + copy_range(source, excerpt, context_start, context_end - context_start, progress) + report = process(excerpt, master, effective, progress) + original_float, processed_float = temporary / "original.wav", temporary / "processed.wav" + copy_range(source, original_float, start, duration, progress) + copy_range(master, processed_float, start - context_start, duration, progress) + ffmpeg = ensure_ffmpeg() + levels = [] + for kind, path in (("original", original_float), ("processed", processed_float)): + # PCM16 mono 48k is broadly browser-playable and bounded at ~11.6 MB. + ffmpeg_run(ffmpeg, ["-i", str(path), "-ar", "48000", "-c:a", "pcm_s16le", + str(work / f"{kind}.wav")], "Preparing browser audio", duration, progress) + levels.append(loudness(ffmpeg, work / f"{kind}.wav", effective, duration, progress)) + return {"requested_settings": asdict(requested), "effective_settings": asdict(effective), + "start": start, "duration": duration, + "context_start": context_start, "context_end": context_end, + "approximate": True, + "notice": "Approximate sound comparison: chapter normalization and inference context differ. Pacing edits are not previewed; this comparison preserves timing.", + "warnings": report["warnings"], "normalization_mode": report["normalization_mode"], + "original_loudness": levels[0], "processed_loudness": levels[1], + "monitoring": monitoring(*levels)} + + +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 + self.temporary = tempfile.TemporaryDirectory(prefix="producer-gui-") + self.work = Path(self.temporary.name) + self.condition = threading.Condition(threading.RLock()) + self.jobs = OrderedDict() + self.pending = deque() + self.cache = OrderedDict() + self.closed = False + self.thread = threading.Thread(target=self._worker, name="producer-gui-jobs", daemon=True) + self.thread.start() + + def _check_input(self, snapshot): + source = self.resolve_path(snapshot["path"]) + if identity(source) != snapshot["input_id"]: + raise ValueError("Input changed; select and scan it again") + return source + + def submit(self, kind, snapshot): + snapshot = deepcopy(snapshot) + with self.condition: + if self.closed: + raise Busy("Server is shutting down") + if len(self.pending) >= MAX_PENDING: + raise Busy("Queue is full; wait for a job or cancel a queued job") + self._check_input(snapshot) + job_id = uuid.uuid4().hex + # Repeated results only. Export is intentionally never cached. + key = hashlib.sha256(json.dumps([kind, snapshot], sort_keys=True).encode()).hexdigest() + job = {"id": job_id, "kind": kind, "snapshot": snapshot, "status": "queued", + "progress": {"stage": "Queued", "completed": None, "total": None}, + "created": time.time(), "result": None, "error": None, + "cancel_requested": False, "cached": False} + self.jobs[job_id] = job + self.pending.append((job_id, key)) + self._prune_jobs() + self.condition.notify_all() + return deepcopy(job) + + def _prune_jobs(self): + for job_id, job in list(self.jobs.items()): + if len(self.jobs) <= MAX_HISTORY: + break + if job["status"] in ("succeeded", "failed", "cancelled"): + del self.jobs[job_id] + + def list(self): + with self.condition: + return deepcopy(list(self.jobs.values())) + + def get(self, job_id): + with self.condition: + return deepcopy(self.jobs[job_id]) + + def cancel(self, job_id): + with self.condition: + job = self.jobs[job_id] + if job["status"] in ("queued", "running"): + job["cancel_requested"] = True + if job["status"] == "queued": + self.pending = deque(item for item in self.pending if item[0] != job_id) + job["status"] = "cancelled" + return deepcopy(job) + + def _progress(self, job, stage, completed, total): + with self.condition: + if job["cancel_requested"] or self.closed: + raise Cancelled() + job["progress"] = {"stage": stage, "completed": completed, "total": total} + + def _evict(self): + while len(self.cache) > CACHE_ENTRIES or sum(item["bytes"] for item in self.cache.values()) > CACHE_BYTES: + _, item = self.cache.popitem(last=False) + shutil.rmtree(item["directory"], ignore_errors=True) + + def media(self, result_id, kind): + if kind not in ("original", "processed"): + raise KeyError(kind) + with self.condition: + item = next((item for item in self.cache.values() if item["id"] == result_id), None) + if item is None: + raise KeyError(result_id) + self._check_input(item["snapshot"]) + # Bounded snippets, read under lock so eviction cannot race delivery. + return (item["directory"] / f"{kind}.wav").read_bytes() + + def _worker(self): + while True: + with self.condition: + self.condition.wait_for(lambda: self.pending or self.closed) + if self.closed: + return + job_id, key = self.pending.popleft() + job = self.jobs[job_id] + job["status"] = "running" + directory = None + try: + snapshot = job["snapshot"] + progress = lambda stage, completed, total: self._progress(job, stage, completed, total) + progress("Starting", None, None) + source = self._check_input(snapshot) + with self.condition: + cached = self.cache.get(key) + if cached is not None: + self.cache.move_to_end(key) + job.update(status="succeeded", result=deepcopy(cached["result"]), cached=True) + continue + directory = Path(tempfile.mkdtemp(dir=self.work)) + if job["kind"] == "scan": + result = waveform(source, progress) + 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") + # A private, new delivery folder prevents audio AND report clobbering. + delivery = Path(tempfile.mkdtemp(prefix="producer-export-", dir=parent)) + try: + target = delivery / "master.wav" + settings = Settings(**snapshot["settings"]) + report = process(source, target, settings, progress, + provenance={"gui_snapshot": snapshot}) + result = {"paths": [str(path.relative_to(self.root)) for path in targets_for(target, settings)], + "settings": asdict(settings), "warnings": report["warnings"], + "duration": report["master"]["duration_seconds"], + "notice": "Pacing edits changed duration and were not auditioned in the sound comparison." if settings.trim_silence else "Full-file export completed."} + except BaseException: + # If cancellation occurs at publication, preserve any completed audio. + if not (delivery / "master.wav").exists() and not (delivery / "master.mp3").exists(): + shutil.rmtree(delivery, ignore_errors=True) + raise + self._check_input(snapshot) + with self.condition: + if job["kind"] != "export": + progress("Complete", 1, 1) + result_id = uuid.uuid4().hex + result.update(result_id=result_id, input_id=snapshot["input_id"]) + if job["kind"] == "preview": + result["audio"] = {kind: f"/api/results/{result_id}/{kind}.wav" for kind in ("original", "processed")} + self.cache[key] = {"id": result_id, "snapshot": snapshot, "result": result, + "directory": directory, + "bytes": sum(p.stat().st_size for p in directory.rglob("*") if p.is_file())} + directory = None + self._evict() + job.update(status="succeeded", result=deepcopy(result)) + except Cancelled: + with self.condition: + job["status"] = "cancelled" + except Exception as error: + with self.condition: + job.update(status="failed", error=str(error)[-8000:]) + finally: + if directory is not None: + shutil.rmtree(directory, ignore_errors=True) + with self.condition: + self._prune_jobs() + + def close(self): + with self.condition: + self.closed = True + for job_id in list(self.jobs): + self.cancel(job_id) + self.condition.notify_all() + # Existing subprocess runners terminate/reap groups when callbacks raise. + self.thread.join() + self.temporary.cleanup() diff --git a/lib/project/src/producer/gui_schema.py b/lib/project/src/producer/gui_schema.py new file mode 100644 index 0000000..d72b6ea --- /dev/null +++ b/lib/project/src/producer/gui_schema.py @@ -0,0 +1,89 @@ +"""Presentation metadata for the strict, shared Settings resolver.""" +from dataclasses import asdict + +from .config import PROFILES, RANGES, Settings + +# Keep DSP validation in config.resolve; these labels explain the existing knobs. +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.", + }, + "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.", + }, + "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.", + }, + "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.", + }, + "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.", + }, + "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.", + }, + "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.", + }, + "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.", + }, + "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.", + }, +} +CHOICES = {"profile": list(PROFILES), "denoiser": ["deepfilter", "clearvoice", "fft", "none"], + "device": ["auto", "cpu", "cuda"], "hum_hz": [0, 50, 60], + "sample_rate": [44100, 48000], "mp3_bitrate": [128, 160, 192, 224, 256, 320]} + + +def schema(initial): + metadata = {key: (group, help_text) for group, items in GROUPS.items() for key, help_text in items.items()} + defaults = asdict(Settings()) + return {"settings": initial, "package_defaults": defaults, "profiles": PROFILES, + "fields": [{"name": key, "type": type(value).__name__, "default": value, + "group": metadata[key][0], "help": metadata[key][1], + "range": RANGES.get(key), "choices": CHOICES.get(key)} + for key, value in defaults.items()], + "constraints": ["lowpass_hz must be below sample_rate / 2 when lowpass is enabled", + "gap_pad_s must be smaller than max_gap_s when trim_silence is enabled", + "normalize requires limiter"], + "limits": {"preview_seconds": 120, "context_seconds_each_side": 10, + "waveform_bins": 2048, "pending_jobs": 3, "job_history": 24, + "cache_entries": 8, "cache_bytes": 134217728}, + "preview_notice": "Approximate sound comparison, not an exact export preview. Chapter normalization and model context differ. Pacing edits are not previewed; this comparison preserves timing."} diff --git a/lib/project/src/producer/pauses.py b/lib/project/src/producer/pauses.py new file mode 100644 index 0000000..e2b5e78 --- /dev/null +++ b/lib/project/src/producer/pauses.py @@ -0,0 +1,177 @@ +"""Speech-aware, duration-preserving pause silencing with offline lookahead. + +Speech decisions use the pre-denoise recording, not its potentially damaged +onsets. Only gaps outside padded speech are attenuated. Audio is streamed; +VAD metadata grows by ~31 probabilities/second, not by audio sample count. +""" +import hashlib +import json +import math +import os +from pathlib import Path +import shutil +import tempfile +import time +import urllib.request + +import numpy as np +import soundfile as sf + +from .audio import ffmpeg_run +from .setup import data_dir, ensure_uv, run_process, tool_env + +MODEL_URL = "https://raw.githubusercontent.com/snakers4/silero-vad/v5.1/src/silero_vad/data/silero_vad.onnx" +MODEL_SHA256 = "2623a2953f6ff3d2c1e61740c6cdb7168133479b267dfef114a4a3cc5bdd788f" + + +def ensure_vad(progress): + """Small, separate CPU runtime; lock setup and verify pinned model bytes.""" + from .ai import _boot_lock, _interpreter_is_local, _require_supported_platform + + _require_supported_platform("Silero VAD") + root = data_dir() + root.mkdir(parents=True, exist_ok=True) + environment = root / "vad-onnx-1.20.1-py311-v1" + python = environment / "bin/python" + marker = environment / ".producer-ready" + model = root / "models/silero-vad-v5.1.onnx" + with _boot_lock(root, progress, time.monotonic()): + if not (marker.is_file() and python.is_file() and _interpreter_is_local(python, root)): + if environment.exists(): + shutil.rmtree(environment) + uv, env = ensure_uv(progress), tool_env() + run_process([uv, "venv", "--python", "3.11", "--managed-python", + "--relocatable", str(environment)], "Preparing speech detector", progress, env=env) + run_process([uv, "pip", "install", "--python", str(python), + "--only-binary", ":all:", "--index-url", "https://pypi.org/simple", + "onnxruntime==1.20.1", "numpy==1.26.4", "soundfile==0.13.1"], + "Installing speech detector", progress, env=env) + run_process([str(python), "-I", "-c", "import onnxruntime, numpy, soundfile"], + "Checking speech detector runtime", progress, env=env) + marker.touch() + if not model.is_file() or hashlib.sha256(model.read_bytes()).hexdigest() != MODEL_SHA256: + progress("Downloading Silero VAD v5.1", 0, None) + model.parent.mkdir(parents=True, exist_ok=True) + # Bound the download and publish only a hash-verified model. + with urllib.request.urlopen(MODEL_URL, timeout=60) as response: + payload = response.read(4 * 1024 * 1024) + if hashlib.sha256(payload).hexdigest() != MODEL_SHA256: + raise RuntimeError("Silero VAD model checksum mismatch") + fd, name = tempfile.mkstemp(prefix=".silero-", dir=model.parent) + try: + with os.fdopen(fd, "wb") as output: + output.write(payload) + os.replace(name, model) + finally: + Path(name).unlink(missing_ok=True) + return python, model + + +def speech_regions(probabilities, frames, sr, threshold): + """Hysteresis; keep even short detections rather than discarding short words. + + Times refer to 512-sample VAD windows at 16 kHz. Returning no regions is + explicitly handled as fail-open by the caller, never 'mute everything'. + """ + expected = math.ceil(math.ceil(frames * 16000 / sr) / 512) + if len(probabilities) != expected: + raise RuntimeError("Speech detector timeline does not match the recording") + result, start = [], None + for index, probability in enumerate(probabilities): + if not isinstance(probability, (int, float)) or not math.isfinite(probability) or not 0 <= probability <= 1: + raise RuntimeError("Invalid speech probability") + position = min(frames, round(index * 512 * sr / 16000)) + if start is None and probability >= threshold: + start = position + elif start is not None and probability < max(0.01, threshold - 0.15): + result.append((start, position)) + start = None + if start is not None: + result.append((start, frames)) + return result + + +def pause_regions(speech, frames, sr, settings): + """Complement of padded speech, excluding gaps too small for safe fades.""" + pre = round(settings.pause_pre_ms * sr / 1000) + post = round(settings.pause_post_ms * sr / 1000) + fade = max(1, round(settings.pause_fade_ms * sr / 1000)) + minimum = round(settings.pause_min_ms * sr / 1000) + 2 * fade + pauses, cursor = [], 0 + for start, end in speech: + a, b = max(0, start - pre), min(frames, end + post) + if a - cursor >= minimum: + pauses.append((cursor, a)) + cursor = max(cursor, b) + if frames - cursor >= minimum: + pauses.append((cursor, frames)) + return pauses + + +def detect_pauses(source, work, ffmpeg, settings, speech_level, progress): + python, model = ensure_vad(progress) + info = sf.info(source) + detector_input, result = work / "vad.wav", work / "vad.json" + # Detection-only gain helps quiet recordings; never alters the master. + gain = max(-18.0, min(18.0, -23.0 - speech_level)) + ffmpeg_run(ffmpeg, ["-i", str(source), "-af", f"volume={gain}dB", "-ar", "16000", + "-ac", "1", "-c:a", "pcm_f32le", "-rf64", "auto", str(detector_input)], + "Preparing speech detection", info.duration, progress) + run_process([str(python), "-I", str(Path(__file__).with_name("vad_worker.py")), + "--source", str(detector_input), "--target", str(result), "--model", str(model)], + "Detecting speech", progress, env=tool_env()) + values = json.loads(result.read_text()) + speech = speech_regions(values, info.frames, info.samplerate, settings.pause_threshold) + detector_input.unlink() + result.unlink() + # A failed detector must not silently erase an entire recording. + pauses = pause_regions(speech, info.frames, info.samplerate, settings) if speech else [] + return pauses, {"detector": "silero-vad-v5.1", "speech_regions": len(speech), + "detection_gain_db": gain, "pauses_silenced": len(pauses), + "attenuated_seconds": sum(b - a for a, b in pauses) / info.samplerate, + "intervals_seconds": [[a / info.samplerate, b / info.samplerate] for a, b in pauses]} + + +def silence_pauses(source, target, pauses, fade_ms, progress): + """Raised-cosine fades outside speech; exactly zero gain in pause cores. + + Input and output must be distinct staging files. Common gain envelopes + retain samples outside pauses exactly, with no time removal or delay. + """ + if source.resolve() == target.resolve(): + raise ValueError("Pause silencing requires distinct input and output") + with sf.SoundFile(source) as src: + fade = max(1, round(fade_ms * src.samplerate / 1000)) + frames = len(src) + cursor = 0 + for a, b in pauses: + if not cursor <= a < b <= frames or b - a < 2 * fade: + raise ValueError("Invalid pause interval") + cursor = b + output_format = "RF64" if frames * src.channels * 4 > 0xFFFFFFFF - 4096 else "WAV" + with sf.SoundFile(target, "w", samplerate=src.samplerate, channels=src.channels, + format=output_format, subtype="FLOAT") as dst: + position, index = 0, 0 + progress("Silencing pauses", 0, frames) + for audio in src.blocks(blocksize=65536, dtype="float32", always_2d=True): + if not np.isfinite(audio).all(): + raise ValueError("Input contains non-finite audio samples") + end = position + len(audio) + while index < len(pauses) and pauses[index][1] <= position: + index += 1 + current = index + while current < len(pauses) and pauses[current][0] < end: + a, b = pauses[current] + left, right = max(a, position), min(b, end) + samples = np.arange(left, right) + # File boundaries have no adjacent speech to fade from/to: + # do not bring room noise back in the last milliseconds. + left_distance = samples - a if a else np.full(len(samples), fade) + right_distance = b - 1 - samples if b < frames else np.full(len(samples), fade) + distance = np.minimum(left_distance, right_distance) + gain = 0.5 * (1 + np.cos(np.pi * np.minimum(distance / fade, 1))) + audio[left - position:right - position] *= gain[:, None] + current += 1 + dst.write(audio) + position = end + progress("Silencing pauses", position, frames) diff --git a/lib/project/src/producer/pipeline.py b/lib/project/src/producer/pipeline.py index d6c0cf1..31cb9f5 100644 --- a/lib/project/src/producer/pipeline.py +++ b/lib/project/src/producer/pipeline.py @@ -103,6 +103,17 @@ def process(source: Path, destination: Path, settings: Settings, progress, ffmpeg_run(ffmpeg, ["-i", str(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} + if settings.pause_silence: + from .pauses import detect_pauses + pauses, details = detect_pauses(prepared, work, ffmpeg, settings, + original["speech_level_estimate_dbfs"], progress) + pause_report.update(details) + if not details["speech_regions"]: + warnings.append("Speech detector found no speech; pause silencing and gap trimming skipped " + "to avoid erasing the recording. Inspect the input or lower pause_threshold.") + elif not pauses: + warnings.append("No sufficiently long non-speech pauses detected; pause silencing changed nothing.") if settings.denoiser in {"deepfilter", "clearvoice"} and settings.denoise_strength > 0: from .ai import denoise denoise(prepared, cleaned, settings.device, settings.denoise_strength, progress, @@ -113,20 +124,16 @@ def process(source: Path, destination: Path, settings: Settings, progress, "-c:a", "pcm_f32le", "-rf64", "auto", str(cleaned)], "FFT noise reduction", duration, progress) else: cleaned = prepared - # Static gap trimming shortens long silences after cleanup; speech is - # copied through, so it stays independent of the denoiser backend. - trimmed = work / "trimmed.wav" + # Keep the original timeline through dynamics when using the speech + # map. Without VAD, preserve the legacy pre-leveling trim threshold. gaps_trimmed, silence_removed = 0, 0.0 - if settings.trim_silence: + if settings.trim_silence and not settings.pause_silence: from .audio import trim_gaps + trimmed = work / "trimmed.wav" gaps_trimmed, silence_removed = trim_gaps(cleaned, trimmed, - settings.gap_threshold_db, settings.max_gap_s, settings.gap_pad_s, - progress) + settings.gap_threshold_db, settings.max_gap_s, settings.gap_pad_s, progress) cleaned.unlink(missing_ok=True) cleaned = trimmed - if settings.trim_silence and gaps_trimmed == 0: - warnings.append("Gap trimming found no silences below the configured " - "threshold; nothing was trimmed. Check gap_threshold_db.") trimmed_duration = duration - silence_removed clean_stats = measure(cleaned, progress, "Measuring cleaned voice") if clean_stats["sample_peak_dbfs"] < -100: @@ -147,6 +154,24 @@ def process(source: Path, destination: Path, settings: Settings, progress, ffmpeg_run(ffmpeg, ["-i", str(cleaned), "-af", ",".join(chain), "-c:a", "pcm_f32le", "-rf64", "auto", str(shaped)], "EQ, dynamics and de-essing", trimmed_duration, progress) + if pauses: + from .pauses import silence_pauses + silenced = work / "silenced.wav" + silence_pauses(shaped, silenced, pauses, settings.pause_fade_ms, progress) + shaped.unlink() + shaped = silenced + if settings.trim_silence and settings.pause_silence and pause_report["speech_regions"]: + from .audio import trim_gaps + trimmed = work / "trimmed.wav" + # Trim only effectively-zero cores, not weak speech below an + # arbitrary dB threshold. gap_threshold_db is legacy trim-only. + gaps_trimmed, silence_removed = trim_gaps(shaped, trimmed, + -120.0, settings.max_gap_s, settings.gap_pad_s, progress) + shaped.unlink() + shaped = trimmed + if settings.trim_silence and gaps_trimmed == 0: + warnings.append("Gap trimming found no eligible long silences; nothing was trimmed.") + trimmed_duration = duration - silence_removed before = loudness(ffmpeg, shaped, settings, trimmed_duration, progress, "Loudness analysis (pass 1)") if settings.normalize and any(before.get(k) is None for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")): raise ValueError("Cannot normalize: insufficient measurable audio after processing") @@ -157,7 +182,9 @@ def process(source: Path, destination: Path, settings: Settings, progress, f"measured_thresh={before['input_thresh']}:offset={before['target_offset']}:linear=true:print_format=json") elif settings.limiter: final_filters += ["aresample=192000", f"alimiter=limit={10 ** (settings.true_peak_db / 20)}:level=false:latency=true"] - final_filters.append(f"aresample={settings.sample_rate}:output_sample_bits=32:dither_method=triangular") + # At 32-bit PCM, dither has no practical audible benefit here and + # would turn intentional digital silence back into a noise floor. + final_filters.append(f"aresample={settings.sample_rate}:output_sample_bits=32:dither_method=none") master = work / "master.wav" render_log = ffmpeg_run(ffmpeg, ["-i", str(shaped), "-af", ",".join(final_filters), "-ar", str(settings.sample_rate), "-c:a", "pcm_s32le", "-rf64", "auto", str(master)], @@ -197,6 +224,7 @@ def process(source: Path, destination: Path, settings: Settings, progress, report = {"producer_version": __version__, "source": str(source), "output": str(destination), "settings": asdict(settings), "input": original, "cleaned": clean_stats, "level_correction_db": gain, + "pause_cleanup": pause_report, "gaps_trimmed": gaps_trimmed, "silence_removed_seconds": round(silence_removed, 3), "pre_master_loudness": before, "master_loudness": final, "master": final_stats, "mp3_loudness": mp3_stats, diff --git a/lib/project/src/producer/vad_worker.py b/lib/project/src/producer/vad_worker.py new file mode 100644 index 0000000..5935937 --- /dev/null +++ b/lib/project/src/producer/vad_worker.py @@ -0,0 +1,55 @@ +"""Isolated CPU Silero VAD v5.1 ONNX inference (no Torch or audio uploads).""" +import argparse +import json +from pathlib import Path + + +def probabilities(source, model, progress=lambda *_: None): + import numpy as np + import onnxruntime as ort + import soundfile as sf + + options = ort.SessionOptions() + options.inter_op_num_threads = options.intra_op_num_threads = 1 + session = ort.InferenceSession(str(model), sess_options=options, + providers=["CPUExecutionProvider"]) + state = np.zeros((2, 1, 128), dtype="float32") + context = np.zeros((1, 64), dtype="float32") + result = [] + with sf.SoundFile(source) as audio: + if audio.samplerate != 16000 or audio.channels != 1 or not len(audio): + raise ValueError("VAD input must be nonempty mono 16 kHz audio") + for chunk in audio.blocks(blocksize=512, dtype="float32"): + if not np.isfinite(chunk).all(): + raise ValueError("VAD input contains non-finite samples") + chunk = np.pad(chunk, (0, 512 - len(chunk)))[None, :] + window = np.concatenate((context, chunk), axis=1) + output, state = session.run(None, {"input": window, "state": state, + "sr": np.array(16000, dtype="int64")}) + probability = float(output.item()) + if not np.isfinite(probability) or not 0 <= probability <= 1: + raise RuntimeError("VAD returned an invalid probability") + result.append(probability) + context = window[:, -64:] + if len(result) % 32 == 0: + progress("Detecting speech (CPU)", audio.tell(), len(audio)) + progress("Detecting speech (CPU)", len(audio), len(audio)) + return result + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--model", type=Path, required=True) + parser.add_argument("--target", type=Path, required=True) + args = parser.parse_args() + + def progress(*event): + print("PRODUCER_PROGRESS " + json.dumps(event), flush=True) + + values = probabilities(args.source, args.model, progress) + args.target.write_text(json.dumps(values, allow_nan=False)) + + +if __name__ == "__main__": + main() diff --git a/lib/project/src/producer/web/app.js b/lib/project/src/producer/web/app.js new file mode 100644 index 0000000..bdf62c6 --- /dev/null +++ b/lib/project/src/producer/web/app.js @@ -0,0 +1,685 @@ +import {parseTime, formatTime, selection, stable, matches, monitoringGain, absolutePath} 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], + 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: '', + 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); + if (text !== undefined) element.textContent = String(text); + if (className) element.className = className; + return element; +} +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; +} +function action(id, callback) { + $(id).addEventListener('click', async () => { + const button = $(id); + button.disabled = true; + try { await callback(); } catch (e) { error(e); } + finally { button.disabled = false; updateAvailability(); } + }); +} +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' + }); + 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.'; + throw new Error(response.status === 404 ? `${detail}. Cached comparisons expire; restore the snapshot and render again.` : detail); + } + return responseType === 'blob' ? response.blob() : response.json(); +} + +const labels = { + profile: 'Starting profile', device: 'AI processing device', denoiser: 'Noise cleanup method', denoise_strength: 'Cleanup strength · 0–1', + fft_reduction_db: 'FFT reduction · dB', highpass: 'Remove low rumble', highpass_hz: 'Rumble cutoff · Hz', lowpass: 'Soften high frequencies', lowpass_hz: 'High-frequency cutoff · Hz', hum_hz: 'Mains hum · Hz (0 = off)', + leveling: 'Even speech level', level_target_db: 'Speech level target · dBFS', max_gain_db: 'Maximum gain change · dB', expansion: 'Reduce low-level room sound', expansion_threshold_db: 'Expansion threshold · dBFS', expansion_ratio: 'Expansion ratio', expansion_range_db: 'Maximum attenuation · dB', + pause_silence: 'Silence non-speech pauses', pause_threshold: 'Speech detection threshold · 0–1', pause_pre_ms: 'Protect word starts · ms', pause_post_ms: 'Protect word endings · ms', pause_fade_ms: 'Pause fade · ms', pause_min_ms: 'Minimum silent core · ms', + trim_silence: 'Shorten long gaps on export', max_gap_s: 'Gap eligible for shortening · s', gap_pad_s: 'Gap to keep · s', gap_threshold_db: 'Legacy silence threshold · dBFS', + eq: 'Shape vocal tone', warmth_db: 'Warmth · dB', mud_db: 'Low-mid fullness · dB', presence_db: 'Clarity / presence · dB', + 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.' +}; +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 settingControl(field) { + const wrapper = node('div', undefined, 'setting'); + const label = node('label', labels[field.name] || field.name); + const input = document.createElement(field.choices ? 'select' : 'input'); + input.id = `setting-${field.name}`; + input.dataset.setting = field.name; + label.htmlFor = input.id; + if (field.choices) { + field.choices.forEach(value => { + const option = node('option', value); + option.value = value; + input.append(option); + }); + } else if (field.type === 'bool') { + input.type = 'checkbox'; label.className = 'check'; + } else { + input.type = field.type === 'str' ? 'text' : 'number'; + input.required = true; + if (field.type !== 'str') { + input.step = field.type === 'int' ? '1' : 'any'; + if (field.range) [input.min, input.max] = field.range; + } + } + const help = node('p', friendlyHelp[field.name] || 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'); + 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); + } + groups.get(field.group).append(settingControl(field)); + } + $('constraints').textContent = 'Validation rules: ' + state.schema.constraints.join('; ') + '.'; + setSettings(state.schema.settings); + action('apply-profile', async () => { + if (!confirm('Replace all controls with this profile resolved through your launch configuration? Explicit launch settings still take precedence.')) return; + const version = state.version; + const data = await api('/api/resolve', {settings: {profile: $('setting-profile').value}}); + if (version !== state.version) throw new Error('Controls changed while loading the profile. Try again.'); + setSettings(data.settings); changed(); + }); +} +function setSettings(settings) { + for (const field of state.schema.fields) { + const input = $(`setting-${field.name}`); + if (field.type === 'bool') input.checked = settings[field.name]; + else input.value = settings[field.name]; + } +} +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(); + } + 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; + } + return settings; +} +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), + ...selection($('selection-start').value, $('selection-end').value, state.input.duration)}; +} +function previewMatches() { + try { return state.preview && matches(state.preview.snapshot, currentSnapshot()); } catch { return false; } +} +function changed() { + state.version++; + state.mediaEpoch++; + state.mediaAbort?.abort(); + pause(); + updateComparisonStatus(); + updateSelectionNote(); + drawWave(); +} +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(); + $('comparison-status').classList.toggle('stale', Boolean(state.preview && !current)); + $('comparison-status').textContent = !state.preview ? 'Render a passage to listen here.' : current ? + 'Ready · this comparison matches the current recording, passage and controls.' : + 'Controls or passage changed since preview. Playback below is the labelled saved render, NOT the current controls. Render again to hear your changes.'; +} +function updateSelectionNote() { + if (!state.input) return; + try { + const value = selection($('selection-start').value, $('selection-end').value, state.input.duration); + $('selection-note').textContent = `${Number(value.duration.toFixed(3))} seconds selected · full export remains ${formatTime(state.input.duration)}.`; + } 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); + } + $('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) { + 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}`; + $('selection-start').value = '00:00:00'; + $('selection-end').value = formatTime(Math.min(30, data.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); + 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; +} +function drawWave() { + const canvas = $('waveform'), width = Math.max(1, Math.round(canvas.clientWidth)); + const ratio = window.devicePixelRatio || 1; + canvas.width = Math.round(width * ratio); canvas.height = Math.round(180 * ratio); + const ctx = canvas.getContext('2d'); ctx.scale(ratio, ratio); + const [start, end] = state.view, span = end - start; + $('wave-start').textContent = formatTime(start); $('wave-end').textContent = formatTime(end); + ctx.strokeStyle = '#c5d4c7'; ctx.beginPath(); ctx.moveTo(0, 90); ctx.lineTo(width, 90); ctx.stroke(); + if (!span) return; + const wave = state.wave; + if (wave) { + ctx.strokeStyle = '#527c60'; ctx.beginPath(); + for (let x = 0; x < width; x++) { + const first = Math.max(0, Math.floor((start + x / width * span) / wave.duration * wave.bins)); + const last = Math.min(wave.bins - 1, Math.floor((start + (x + 1) / width * span) / wave.duration * wave.bins)); + let low = 0, high = 0; + for (let i = first; i <= last; i++) { low = Math.min(low, wave.min[i]); high = Math.max(high, wave.max[i]); } + ctx.moveTo(x + .5, 90 - Math.min(1, high) * 78); ctx.lineTo(x + .5, 90 - Math.max(-1, low) * 78); + } + ctx.stroke(); + } + try { + const value = selection($('selection-start').value, $('selection-end').value, state.input.duration); + const x = (value.start - start) / span * width, right = (value.start + value.duration - start) / span * width; + ctx.fillStyle = '#bd85332b'; ctx.fillRect(x, 0, right - x, 180); + ctx.strokeStyle = '#a56a18'; ctx.lineWidth = 2; ctx.strokeRect(x, 1, Math.max(2, right - x), 178); + } catch { /* Exact-time validation is shown beside the controls. */ } +} +function setSelection(start, end) { + const total = state.input.duration; + start = Math.max(0, Math.min(start, total - Math.min(.001, total))); + end = Math.min(total, start + 120, Math.max(start + Math.min(.001, total), end)); + $('selection-start').value = formatTime(Math.floor(start * 1000) / 1000); + $('selection-end').value = end === total ? String(total) : formatTime(Math.floor(end * 1000) / 1000); + changed(); +} +let drag = null; +function waveTime(event) { + const box = $('waveform').getBoundingClientRect(); + 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; + drag = {time: waveTime(event), x: event.clientX}; + $('waveform').setPointerCapture(event.pointerId); +}); +$('waveform').addEventListener('pointermove', event => { + if (!drag || !state.input || Math.abs(event.clientX - drag.x) < 3) return; + const time = waveTime(event); + setSelection(Math.min(time, drag.time), Math.max(time, drag.time)); +}); +$('waveform').addEventListener('pointerup', event => { + if (!drag || !state.input) return; + const time = waveTime(event); + if (Math.abs(event.clientX - drag.x) < 3) { + const start = Math.min(time, Math.max(0, state.input.duration - 30)); setSelection(start, Math.min(start + 30, state.input.duration)); + } else setSelection(Math.min(time, drag.time), Math.max(time, drag.time)); + drag = null; +}); +$('waveform').addEventListener('pointercancel', () => { drag = null; }); +new ResizeObserver(drawWave).observe($('waveform')); + +async function submit(kind, snapshot, intent = {}) { + const job = await api('/api/jobs', {kind, ...snapshot}); + state.handled.delete(job.id); + state.intents.set(job.id, intent); state.jobs.set(job.id, job); renderJob(job); + return job; +} +function settingsDetails(snapshot) { + const details = node('details'); details.append(node('summary', 'Submitted snapshot'), node('pre', JSON.stringify(snapshot, null, 2))); return details; +} +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 cancel = node('button', 'Cancel'); cancel.type = 'button'; + cancel.addEventListener('click', async () => { + cancel.disabled = true; + try { const data = await api(`/api/jobs/${job.id}/cancel`, {}); state.jobs.set(job.id, data); renderJob(data); } + catch (e) { cancel.disabled = false; error(e); } + }); + heading.append(title, cancel); + const status = node('p'), progress = node('progress'); + progress.setAttribute('aria-label', `${job.kind} current-stage progress`); + const restore = node('button', 'Restore snapshot & render again'); restore.type = 'button'; restore.hidden = true; + restore.addEventListener('click', () => restoreComparison({snapshot: job.snapshot}).catch(error)); + 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.')); + 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)); + $('jobs').prepend(li); card = {li, status, progress, cancel, restore, loadWave}; state.cards.set(job.id, card); + } + const cancelling = job.cancel_requested && !terminal(job); + card.status.textContent = `${cancelling ? 'Cancelling…' : job.status}${job.cached ? ' · cached result' : ''} · ${job.progress.stage || ''}${job.error ? ' · ' + job.error : ''}`; + card.status.classList.toggle('failed', job.status === 'failed'); + card.cancel.hidden = terminal(job); card.cancel.disabled = cancelling; + card.progress.hidden = terminal(job); + if (job.progress.total > 0 && job.progress.completed !== null) { + card.progress.max = job.progress.total; card.progress.value = job.progress.completed; + } else card.progress.removeAttribute('value'); + card.restore.hidden = job.kind !== 'preview' || !terminal(job); + card.loadWave.hidden = job.kind !== 'scan' || job.status !== 'succeeded'; +} +async function handleCompleted(job) { + if (!terminal(job) || state.handled.has(job.id)) return; + state.handled.add(job.id); + const intent = state.intents.get(job.id); state.intents.delete(job.id); + if (job.status !== 'succeeded') { + if (intent && job.status === 'failed') error(new Error(`${job.kind} failed: ${job.error || 'See Activity for details.'}`)); + if (job.kind === 'scan' && intent?.inputEpoch === state.inputEpoch) $('wave-status').textContent = 'Overview not completed. Re-select & scan to retry; exact times remain available.'; + return; + } + if (job.kind === 'export') { showExport(job); return; } + if (job.kind === 'scan' && intent?.inputEpoch === state.inputEpoch && state.input?.input_id === job.snapshot.input_id) { + state.wave = job.result; $('wave-status').textContent = 'Overview ready · 2,048 peak bins, no full-file audio loaded.'; drawWave(); + } + if (job.kind === 'preview') { + let current; try { current = currentSnapshot(); } catch { /* No valid visible selection. */ } + if (intent?.version === state.version && matches(job.snapshot, current)) await loadPreview(job, intent.version); + else if (intent) state.cards.get(job.id).status.textContent += ' · Older snapshot; not loaded because the controls or passage changed.'; + } +} +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.'; + 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) { + const previous = state.jobs.get(job.id); + if (previous && previous !== before.get(job.id)) continue; + state.jobs.set(job.id, job); + if (!previous || stable(previous) !== stable(job) || !state.cards.has(job.id)) renderJob(job); + // Audio requests run independently of polling, guarded again at publication. + handleCompleted(job).catch(error); + } + 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); } +} + +function pause() { + state.playbackEpoch++; + $('audio-original').pause(); $('audio-processed').pause(); $('play').textContent = 'Play'; +} +function clearMedia() { + pause(); + for (const side of ['original', 'processed']) { const audio = $(`audio-${side}`); audio.removeAttribute('src'); audio.load(); } + for (const url of state.urls) URL.revokeObjectURL(url); + state.urls = []; +} +async function loadPreview(job, version) { + const epoch = ++state.mediaEpoch; + state.mediaAbort?.abort(); const controller = new AbortController(); state.mediaAbort = controller; + $('comparison-status').textContent = 'Loading bounded comparison audio…'; + const paths = ['original', 'processed'].map(side => job.result.audio[side]); + if (paths.some(path => !/^\/api\/results\/[a-f0-9]+\/(original|processed)\.wav$/.test(path))) { + updateComparisonStatus(); throw new Error('Invalid preview media path.'); + } + let blobs; + try { blobs = await Promise.all(paths.map(path => api(path, undefined, 'blob', controller.signal))); } + catch (e) { if (epoch === state.mediaEpoch) updateComparisonStatus(); throw e; } + if (epoch !== state.mediaEpoch || version !== state.version || !matches(job.snapshot, currentSnapshot())) return; + clearMedia(); + state.preview = copy(job); state.side = 'original'; + state.urls = blobs.map(blob => URL.createObjectURL(blob)); + ['original', 'processed'].forEach((side, i) => { const audio = $(`audio-${side}`); audio.src = state.urls[i]; audio.loop = $('loop').checked; audio.load(); }); + $('player').disabled = false; + $('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()}`; + $('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); + updateAB(); updateGains(); updatePlayback(); updateComparisonStatus(); updateAvailability(); renderMeters(job.result); +} +function renderMeters(result) { + const meters = $('meters'); meters.replaceChildren(); meters.hidden = false; + const number = value => Number.isFinite(value) ? value.toFixed(1) : 'Unavailable'; + for (const [side, title] of [['original', 'Original snippet'], ['processed', 'Processed snippet']]) { + const data = result[`${side}_loudness`], meter = node('div', undefined, 'meter'); + meter.append(node('strong', title + ' · measured file level'), node('p', number(data.input_i) + ' LUFS', 'level'), + node('p', number(data.input_tp) + ' dBTP · measured true peak'), node('p', 'Before monitoring gain; not a full-export measurement.')); + meters.append(meter); + } +} +function ensureAudioContext() { + if (!state.context) { + const AudioContext = window.AudioContext || window.webkitAudioContext; + if (!AudioContext) return; + state.context = new AudioContext(); + for (const side of ['original', 'processed']) { + const source = state.context.createMediaElementSource($(`audio-${side}`)); + const gain = state.context.createGain(); source.connect(gain).connect(state.context.destination); state.gains[side] = gain; + } + } + return state.context.resume(); +} +function updateGains() { + const monitoring = state.preview?.result.monitoring; + if (!monitoring) return; + const enabled = $('match').checked && monitoring.available; + for (const side of ['original', 'processed']) if (state.gains[side]) state.gains[side].gain.value = monitoringGain(monitoring[`${side}_gain_db`], enabled); + $('monitoring-note').textContent = enabled ? `Monitoring only: Original ${monitoring.original_gain_db.toFixed(1)} dB; processed ${monitoring.processed_gain_db.toFixed(1)} dB. Attenuation only; export settings and file meters are unchanged.` : + `Matching off. ${monitoring.available ? 'Hear the rendered file levels. Matching attenuates to the quieter snippet and never changes exports.' : monitoring.reason} ${$('match').disabled && monitoring.available ? 'This browser does not support matched monitoring.' : ''}`; +} +function updateAB() { + $('listen-original').setAttribute('aria-pressed', state.side === 'original'); + $('listen-processed').setAttribute('aria-pressed', state.side === 'processed'); +} +function updatePlayback() { + const audio = $(`audio-${state.side}`); + $('play').textContent = audio.paused ? 'Play' : 'Pause'; + $('playback-seek').value = audio.currentTime || 0; + $('playback-time').textContent = `${formatTime(audio.currentTime || 0)} / ${formatTime(state.preview?.snapshot.duration || 0)} · ${state.side === 'original' ? 'A · Original' : 'B · Processed'}`; +} +async function switchSide(side) { + if (!state.preview || state.side === side) return; + const old = $(`audio-${state.side}`), next = $(`audio-${side}`), position = old.currentTime, playing = !old.paused; + pause(); state.side = side; next.currentTime = position; updateAB(); updatePlayback(); + if (playing) await next.play(); +} +for (const side of ['original', 'processed']) { + const audio = $(`audio-${side}`); + for (const event of ['timeupdate', 'play', 'pause', 'ended', 'loadedmetadata']) audio.addEventListener(event, () => { if (state.side === side) updatePlayback(); }); + audio.addEventListener('error', () => { if (audio.getAttribute('src')) error(new Error('Browser audio playback failed. Render again, or try a current desktop browser.')); }); +} + +function loadSaved() { + try { + const saved = JSON.parse(localStorage.getItem(state.storageKey) || '{}'); + state.presets = Array.isArray(saved.presets) ? saved.presets.slice(0, 20) : []; + state.comparisons = Array.isArray(saved.comparisons) ? saved.comparisons.slice(0, 12) : []; + } catch { error(new Error('Browser storage is unavailable or unreadable. You can still work and download TOML presets.')); } + renderSaved(); +} +function persist() { + try { localStorage.setItem(state.storageKey, JSON.stringify({presets: state.presets, comparisons: state.comparisons})); } + catch { throw new Error('Could not save in browser storage. Use Download settings TOML for portable presets.'); } +} +function renderSaved() { + for (const [key, target] of [['presets', 'presets'], ['comparisons', 'comparisons']]) { + $(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')); + const restore = node('button', key === 'presets' ? 'Load settings' : 'Restore & render'); restore.type = 'button'; + restore.addEventListener('click', async () => { + restore.disabled = true; + try { + if (key === 'comparisons') await restoreComparison(entry); + else { + const version = state.version; + const data = await api('/api/resolve', {settings: entry.settings}); + if (version !== state.version) throw new Error('Controls changed while loading. Try again.'); + setSettings(data.settings); changed(); + } + } catch (e) { error(e); } finally { restore.disabled = false; } + }); + const remove = node('button', 'Delete'); remove.type = 'button'; remove.setAttribute('aria-label', `Delete ${entry.name}`); + remove.addEventListener('click', () => { + const previous = state[key]; state[key] = previous.filter(item => item !== entry); + try { persist(); renderSaved(); } catch (e) { state[key] = previous; error(e); } + }); + li.append(title, restore, remove); $(target).append(li); + } + } +} +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})]); + 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}`; + 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}); + // 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}); +} +async function savePreset() { + const name = $('preset-name').value.trim(); + if (!name) throw new Error('Name your preset first.'); + if (state.presets.length >= 20) throw new Error('Delete an old preset first (20 maximum).'); + const settings = readSettings(); + const data = await api('/api/resolve', {settings}); + const entry = {name, settings: copy(data.settings)}; + state.presets.push(entry); + try { persist(); } catch (e) { state.presets.pop(); throw e; } + renderSaved(); $('preset-name').value = ''; +} +function saveComparison() { + const name = $('comparison-name').value.trim(); + if (!name || !state.preview) throw new Error('Render a comparison and give it a name first.'); + if (state.comparisons.length >= 12) throw new Error('Delete an old comparison first (12 maximum).'); + state.comparisons.push({name, snapshot: copy(state.preview.snapshot)}); + try { persist(); } catch (e) { state.comparisons.pop(); throw e; } + renderSaved(); $('comparison-name').value = ''; +} +function download(blob) { + const url = URL.createObjectURL(blob), link = node('a'); link.href = url; link.download = 'producer-preset.toml'; + document.body.append(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 30000); +} +async function prepareExport() { + if (!state.input) throw new Error('Choose a recording first.'); + const version = state.version, input = copy(state.input), settings = readSettings(), output_dir = $('output-dir').value.trim(); + const data = await api('/api/resolve', {settings}); + if (version !== state.version) throw new Error('Controls changed while 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.`; + $('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(); +} +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:')); + 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); } + 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(); +} + +// 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('zoom-selection', () => { + const value = selection($('selection-start').value, $('selection-end').value, state.input.duration); + const padding = Math.max(5, value.duration / 2); + state.view = [Math.max(0, value.start - padding), Math.min(state.input.duration, value.start + value.duration + padding)]; drawWave(); +}); +action('zoom-full', () => { state.view = [0, state.input.duration]; drawWave(); }); +for (const id of ['selection-start', 'selection-end']) $(id).addEventListener('input', changed); +action('reset-launch', () => { if (confirm('Replace all controls with the settings resolved when Producer launched?')) { setSettings(state.schema.settings); changed(); } }); +action('render-preview', async () => { + const snapshot = currentSnapshot(true), version = state.version; + pause(); await submit('preview', snapshot, {version}); + $('jobs-panel').scrollIntoView({block: 'nearest'}); +}); +action('listen-original', () => switchSide('original')); +action('listen-processed', () => switchSide('processed')); +action('play', async () => { + const audio = $(`audio-${state.side}`); + if (!audio.paused) pause(); + else { + const epoch = ++state.playbackEpoch, side = state.side; + await ensureAudioContext(); + if (epoch !== state.playbackEpoch || side !== state.side || state.expired) return; + updateGains(); await audio.play(); + } +}); +$('loop').addEventListener('change', () => { for (const side of ['original', 'processed']) $(`audio-${side}`).loop = $('loop').checked; }); +$('match').addEventListener('change', async () => { try { await ensureAudioContext(); updateGains(); } catch (e) { $('match').checked = false; updateGains(); error(e); } }); +$('playback-seek').addEventListener('input', () => { $(`audio-${state.side}`).currentTime = Number($('playback-seek').value); updatePlayback(); }); +action('save-preset', savePreset); +action('download-preset', async () => download(await api('/api/presets/download', {settings: readSettings()}, 'blob'))); +$('import-preset').addEventListener('change', async () => { + const file = $('import-preset').files[0], version = state.version; + try { + if (!file) return; + if (file.size > 60000) throw new Error('Preset is too large. Choose a small TOML file (under 60 KB).'); + const toml = await file.text(); + if (new TextEncoder().encode(JSON.stringify({toml})).length > 65536) throw new Error('Encoded preset exceeds the 64 KiB API limit.'); + const data = await api('/api/presets/import', {toml}); + if (version !== state.version) throw new Error('Controls changed during import. Import again.'); + setSettings(data.settings); changed(); + } catch (e) { error(e); } finally { $('import-preset').value = ''; } +}); +action('save-comparison', saveComparison); +action('prepare-export', prepareExport); +action('cancel-export', () => { state.export = null; $('export-dialog').close(); }); +$('export-dialog').addEventListener('cancel', () => { state.export = null; }); +action('confirm-export', async () => { + const snapshot = state.export; + if (!snapshot) return; + // Clear the confirmation even on network ambiguity; never blindly retry exports. + state.export = null; $('export-dialog').close(); + await submit('export', snapshot); + $('jobs-panel').scrollIntoView({block: 'nearest'}); +}); +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; + 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); }); diff --git a/lib/project/src/producer/web/index.html b/lib/project/src/producer/web/index.html new file mode 100644 index 0000000..9808e2e --- /dev/null +++ b/lib/project/src/producer/web/index.html @@ -0,0 +1,73 @@ + + + + + + + Producer · Local voice studio + + + + + +
ProducerA little care for your voice.
● Local audio · private by design
+
+

YOUR RECORDING, REFINED

Make it sound like you.
Just easier to listen to.

Try a small passage. Listen for the difference. Keep what works.

+ +
Connecting to your local Producer…
+ + +
+ Local voice studio +
+
01

Choose your recording

Browse existing mono WAV files. Nothing is uploaded or overwritten.

+
Browse local recordings +

Allowed folder:

+
.
+

    +
    +
    SELECTED RECORDING

    No recording yet

    Choose a WAV above to begin.

    +
    +
    +
    02

    Try a short passage

    Include a quiet phrase, a breath and a pause. Listen for words that sound softened or cut off.

    +
    Recording overviewSelect a recording first.
    + +
    00:00:0000:00:00
    +

    Click for a 30-second passage, or drag up to 120 seconds. Zoom enlarges the same coarse overview; use exact times for fine adjustments.

    +

    Up to 120 seconds. You can also enter seconds directly.

    +

    Shape the sound

    Small changes are often enough. Controls never render automatically.

    +
    +
    All settings & advanced controls Fine-tune without hidden overrides

    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.

    +
    Settings presets Save a sound, not audio +

      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.

      +
      +
      Only this passage + up to 10 seconds of context per side.
      First AI use downloads local models; audio never leaves this machine.
      +
      +
      +
      03

      Compare the sound, not the volume

      Switch between the same moment. Start at a comfortable listening volume.

      +

      Approximate sound comparison, not an exact export preview. Chapter normalization and inference context differ. Pacing edits are not previewed; this comparison preserves timing.

      +

      Render a passage to listen here.

      +

      +
      Passage playback +
      +

      00:00:00 / 00:00:00

      Matching only attenuates playback. It never changes your saved settings or export.

      +
      + +

      + +

        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.

        +
        +
        +
        04

        Export the full recording

        Happy with the sound? Apply the current controls to the whole original, not just your passage.

        +
        +

        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.

        +
        WAV + optional MP3 and a processing report.
        No delivery-platform certification or quality score.
        +
        +
        +

        Activity

        One job at a time · up to three waiting · progress is per stage, not overall.

        No work submitted yet.

        Cancellation waits for a safe progress point. A model download can take up to 60 seconds to respond. Files already published may remain.

          +
          +
          Made for listening, not chasing a score. Local processing · no telemetry · no cloud audio.
          Leave this server running while you work. Stop it in the terminal with Ctrl-C.
          +
          +

          Export the whole original?

          These files are saved on this machine, not downloaded into your browser. A new folder prevents overwriting earlier exports. Monitoring gains are never included.

          Frozen export settings
          + + diff --git a/lib/project/src/producer/web/state.js b/lib/project/src/producer/web/state.js new file mode 100644 index 0000000..8c1f9a6 --- /dev/null +++ b/lib/project/src/producer/web/state.js @@ -0,0 +1,36 @@ +// Pure helpers shared by the studio and its small Node test suite. +export function parseTime(text) { + const value = text.trim(); + if (/^\d+(?:\.\d+)?$/.test(value)) return Number(value); + const parts = /^(\d+):([0-5]\d):([0-5]\d(?:\.\d+)?)$/.exec(value); + if (!parts) throw new Error('Use HH:MM:SS.sss (for example 01:23:45), or seconds.'); + return Number(parts[1]) * 3600 + Number(parts[2]) * 60 + Number(parts[3]); +} +export function formatTime(seconds) { + const ms = Math.max(0, Math.round(seconds * 1000)); + const h = Math.floor(ms / 3600000), m = Math.floor(ms / 60000) % 60; + const s = Math.floor(ms / 1000) % 60, fraction = ms % 1000; + return [h, m, s].map(n => String(n).padStart(2, '0')).join(':') + + (fraction ? '.' + String(fraction).padStart(3, '0') : ''); +} +export function selection(startText, endText, duration) { + const start = parseTime(startText), end = parseTime(endText); + if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start || end > duration || end - start > 120) + throw new Error('Choose a passage inside the recording, longer than 0 and no more than 120 seconds.'); + return {start, duration: end - start}; +} +export function stable(value) { + if (Array.isArray(value)) return '[' + value.map(stable).join(',') + ']'; + if (value && typeof value === 'object') return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + stable(value[k])).join(',') + '}'; + return JSON.stringify(value); +} +export function matches(snapshot, current) { + return Boolean(current && snapshot.path === current.path && 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 new file mode 100644 index 0000000..935b51c --- /dev/null +++ b/lib/project/src/producer/web/style.css @@ -0,0 +1,710 @@ +:root { + --paper:#f6f7f2; + --ink:#25382e; + --muted:#59685e; + --green:#315c46; + --mint:#eaf1e9; + --line:#d9e1d6; + --focus:#bb7925 +} +* { + box-sizing:border-box +} +html { + scroll-behavior:smooth; + scroll-padding-top:24px +} +body { + margin:0; + background:var(--paper); + color:var(--ink); + font:16px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif +} +button,input,select { + font:inherit +} +button { + cursor:pointer; + border:1px solid var(--line); + border-radius:8px; + background:#fff; + padding:10px 15px; + color:var(--ink); + font-weight:600; + line-height:1.3 +} +button:hover:not(:disabled) { + border-color:var(--green); + background:var(--mint) +} +button:disabled { + opacity:.5; + cursor:not-allowed +} +button.primary { + background:var(--green); + color:#fff; + border-color:var(--green) +} +button.primary:hover:not(:disabled) { + background:#214637 +} +button:focus-visible,a:focus-visible,summary:focus-visible,input:focus-visible,select:focus-visible { + outline:3px solid var(--focus); + outline-offset:3px +} +input:not([type=checkbox]):not([type=range]),select { + display:block; + width:100%; + min-height:43px; + padding:8px 10px; + border:1px solid #aebbb0; + border-radius:6px; + background:#fff; + color:var(--ink) +} +input[type=checkbox] { + width:18px; + height:18px; + accent-color:var(--green); + flex-shrink:0 +} +input[type=range] { + accent-color:var(--green); + width:100% +} +input:invalid { + border-color:#9f3c27 +} +label { + font-size:.9rem; + 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 +} +h3 { + font-size:1.05rem; + margin-bottom:7px +} +p { + margin-bottom:14px +} +code,pre { + font-family:ui-monospace,SFMono-Regular,Consolas,monospace; + font-size:.82rem; + overflow-wrap:anywhere +} +pre { + white-space:pre-wrap; + max-height:320px; + overflow:auto; + padding:14px; + background:var(--paper); + border:1px solid var(--line); + border-radius:6px +} +fieldset { + border:0; + margin:0; + padding:0; + min-width:0 +} +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 { + 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 +} +.local-badge { + font-size:.78rem; + color:var(--green); + background:var(--mint); + padding:6px 12px; + border-radius:99px +} +main { + max-width:1192px; + 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 +} +.panel { + padding:28px; + margin:22px 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 +} +.small { + font-size:.81rem; + color:var(--muted) +} +.toolbar { + display:flex; + gap:12px; + align-items:center; + flex-wrap:wrap; + margin:16px 0 +} +.toolbar label { + min-width:190px +} +.toolbar>code { + flex:1 +} +.toolbar button { + font-size:.8rem +} +.toolbar>strong { + margin-right:auto +} +.file-list,.saved-list,.jobs-list { + list-style:none; + padding:0; + margin:12px 0 +} +.file-list { + max-height:290px; + overflow:auto; + border-top:1px solid var(--line) +} +.file-list li { + border-bottom:1px solid var(--line) +} +.file-list button { + border:0; + border-radius:0; + width:100%; + text-align:left; + font-weight:500; + padding:10px; + overflow-wrap:anywhere +} +.file-kind { + display:inline-block; + color:var(--muted); + font-size:.7rem; + min-width:65px; + font-weight:700 +} +.recording { + display:flex; + justify-content:space-between; + gap:15px; + align-items:center; + padding:18px; + background:var(--mint); + border-radius:9px; + margin-top:20px +} +.recording h3 { + overflow-wrap:anywhere +} +.recording .eyebrow { + font-size:.63rem +} +.recording p { + margin:0; + font-size:.83rem; + color:var(--muted) +} +canvas { + display:block; + width:100%; + height:180px; + background:#f0f4f0; + border:1px solid var(--line); + border-radius:8px; + touch-action:none; + cursor:crosshair +} +.wave-labels { + display:flex; + justify-content:space-between; + font-size:.75rem; + font-variant-numeric:tabular-nums; + color:var(--muted); + margin:6px 0 12px +} +.selection-fields { + display:flex; + align-items:center; + gap:18px; + flex-wrap:wrap +} +.selection-fields input { + font-variant-numeric:tabular-nums +} +.selection-fields p { + flex:1; + font-size:.8rem; + min-width:150px; + color:var(--muted); + margin:20px 0 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 +} +.sound-card h3 { + color:var(--green) +} +.sound-card>p { + font-size:.78rem; + min-height:72px; + color:var(--muted) +} +.setting { + margin:13px 0 +} +.setting label { + display:block; + margin-bottom:5px +} +.setting label.check,.check { + display:flex; + align-items:center; + gap:8px +} +.setting p { + font-size:.75rem; + color:var(--muted); + margin:6px 0 0 +} +.setting-name { + display:block; + font-size:.68rem; + font-weight:400; + color:var(--muted); + letter-spacing:.01em +} +.settings-grid { + display:grid; + grid-template-columns:repeat(3,1fr); + gap:8px 24px +} +.settings-group { + border-top:1px solid var(--line); + padding-top:18px; + margin-top:20px +} +.settings-group h3 { + color:var(--green) +} +details { + margin-top:17px +} +summary { + cursor:pointer; + font-weight:650; + padding:10px 0; + color:var(--green) +} +summary .small { + font-weight:400; + margin-left:10px +} +.file-import input { + font-size:.75rem; + max-width:250px +} +.action-row { + display:flex; + align-items:center; + flex-wrap:wrap; + gap:18px; + margin-top:24px +} +.notice { + border-left:3px solid #b37b25; + background:#fcf5e7; + color:#6d4a19; + border-radius:0 6px 6px 0; + padding:13px 16px; + font-size:.85rem +} +.comparison-status { + font-weight:650; + font-size:.9rem +} +.comparison-status.stale { + color:#945117 +} +.player-buttons { + display:flex; + align-items:center; + gap:18px; + flex-wrap:wrap; + margin:20px 0 +} +.ab-switch { + display:flex; + border:1px solid var(--line); + border-radius:9px; + overflow:hidden +} +.ab-switch button { + border:0; + border-radius:0; + padding:13px 20px +} +.ab-switch button[aria-pressed=true] { + background:var(--green); + color:#fff +} +.seek-label { + font-size:.8rem; + display:block +} +.meters { + display:grid; + grid-template-columns:1fr 1fr; + gap:14px; + margin:20px 0 +} +.meter { + border:1px solid var(--line); + border-radius:9px; + padding:16px; + background:var(--paper) +} +.meter strong { + display:block; + font-size:.85rem +} +.meter p { + margin:7px 0 0; + font-size:.8rem +} +.meter .level { + font-size:1.5rem; + font-variant-numeric:tabular-nums; + letter-spacing:-.04em; + color:var(--green) +} +.saved-list li,.job { + border-top:1px solid var(--line); + padding:14px 0 +} +.saved-list li { + display:flex; + align-items:center; + gap:10px; + flex-wrap:wrap +} +.saved-list .saved-title { + flex:1; + min-width:180px; + overflow-wrap:anywhere +} +.saved-list button { + font-size:.78rem +} +.delivery-settings { + display:grid; + grid-template-columns:repeat(4,1fr); + gap:20px +} +.output-label { + display:block; + max-width:640px; + margin-top:16px +} +.job-heading { + display:flex; + justify-content:space-between; + align-items:center; + gap:12px; + font-size:.86rem +} +.job-title { + overflow-wrap:anywhere +} +.job p { + margin:6px 0; + font-size:.78rem; + color:var(--muted) +} +.job button { + font-size:.75rem +} +.job progress { + width:100%; + height:10px; + accent-color:var(--green) +} +.job .failed { + color:#9f3c27 +} +.export-result { + border:1px solid var(--line); + border-left:4px solid var(--green); + padding:18px; + margin-top:20px; + border-radius:8px +} +.export-result ul { + padding-left:20px +} +.export-result li { + margin:7px 0 +} +.export-result p { + font-size:.85rem +} +.error { + display:flex; + align-items:center; + gap:20px; + justify-content:space-between; + background:#fcebe5; + border:1px solid #cc8875; + border-radius:8px; + padding:16px; + position:sticky; + top:8px; + z-index:5; + font-size:.85rem; + overflow-wrap:anywhere +} +#connection { + font-size:.82rem; + color:var(--muted) +} +dialog { + max-width:650px; + max-height:90vh; + overflow:auto; + border:1px solid var(--line); + border-radius:15px; + padding:28px; + color:var(--ink); + background:#fff +} +dialog::backdrop { + background:#172e23aa +} +footer { + text-align:center; + color:var(--muted); + font-size:.77rem; + padding:30px 0 10px +} +.sr-only { + position:absolute; + width:1px; + height:1px; + overflow:hidden; + clip:rect(0,0,0,0); + white-space:nowrap +} +[hidden] { + 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 + } + .panel { + padding:18px + } + .steps { + grid-template-columns:repeat(2,1fr); + gap:4px 15px + } + .steps a { + font-size:.77rem + } + .primary-settings,.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 + } + .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 + } + .action-row .primary { + width:100% + } + dialog { + padding:20px; + width:calc(100% - 24px) + } +} +@media(prefers-reduced-motion:reduce) { + html { + scroll-behavior:auto + } +} -- cgit v1.2.3