aboutsummaryrefslogtreecommitdiff
path: root/lib/project/src
diff options
context:
space:
mode:
Diffstat (limited to 'lib/project/src')
-rw-r--r--lib/project/src/producer/cli.py20
-rw-r--r--lib/project/src/producer/config.py9
-rw-r--r--lib/project/src/producer/gui.py264
-rw-r--r--lib/project/src/producer/gui_jobs.py327
-rw-r--r--lib/project/src/producer/gui_schema.py89
-rw-r--r--lib/project/src/producer/pauses.py177
-rw-r--r--lib/project/src/producer/pipeline.py48
-rw-r--r--lib/project/src/producer/vad_worker.py55
-rw-r--r--lib/project/src/producer/web/app.js685
-rw-r--r--lib/project/src/producer/web/index.html73
-rw-r--r--lib/project/src/producer/web/state.js36
-rw-r--r--lib/project/src/producer/web/style.css710
12 files changed, 2479 insertions, 14 deletions
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 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="referrer" content="no-referrer">
+ <title>Producer · Local voice studio</title>
+ <link rel="icon" href="data:,">
+ <link rel="stylesheet" href="/assets/style.css">
+ <script type="module" src="/assets/app.js"></script>
+</head>
+<body>
+<header class="masthead"><div><span class="brand-mark" aria-hidden="true">p.</span><strong>Producer</strong><span class="tagline">A little care for your voice.</span></div><span class="local-badge">● Local audio · private by design</span></header>
+<main>
+ <div class="intro"><p class="eyebrow">YOUR RECORDING, REFINED</p><h1>Make it sound like you.<br><span>Just easier to listen to.</span></h1><p>Try a small passage. Listen for the difference. Keep what works.</p></div>
+ <nav class="steps" aria-label="Studio steps"><a href="#choose">1 <span>Choose recording</span></a><a href="#try">2 <span>Try a short passage</span></a><a href="#compare">3 <span>Compare sound</span></a><a href="#export">4 <span>Export full recording</span></a></nav>
+ <div id="connection" role="status">Connecting to your local Producer…</div>
+ <div id="error" class="error" role="alert" hidden><span id="error-text"></span><button id="dismiss-error" type="button">Dismiss</button></div>
+ <noscript>This local studio needs JavaScript. Enable it and reopen the private URL printed by Producer.</noscript>
+ <fieldset id="workspace" disabled>
+ <legend class="sr-only">Local voice studio</legend>
+ <section id="choose" class="panel">
+ <div class="section-heading"><span class="step-number">01</span><div><h2>Choose your recording</h2><p>Browse existing mono WAV files. Nothing is uploaded or overwritten.</p></div></div>
+ <details id="file-browser" open><summary>Browse local recordings</summary>
+ <p class="small">Allowed folder: <code id="root-path"></code></p>
+ <div class="toolbar"><button id="folder-up" type="button">↑ Parent folder</button><button id="folder-refresh" type="button">Refresh</button><code id="folder-path">.</code><button id="use-output-folder" type="button">Use this folder for exports</button></div>
+ <ul id="files" class="file-list" aria-label="Folders and WAV recordings"></ul><button id="files-more" type="button" hidden>Show more files</button><p id="files-status" class="small" role="status"></p>
+ </details>
+ <div class="recording"><div><span class="eyebrow">SELECTED RECORDING</span><h3 id="input-name">No recording yet</h3><p id="input-info">Choose a WAV above to begin.</p></div><button id="rescan" type="button" disabled>Re-select & scan</button></div>
+ </section>
+ <section id="try" class="panel">
+ <div class="section-heading"><span class="step-number">02</span><div><h2>Try a short passage</h2><p>Include a quiet phrase, a breath and a pause. Listen for words that sound softened or cut off.</p></div></div>
+ <div class="toolbar"><strong>Recording overview</strong><span id="wave-status" class="small">Select a recording first.</span><button id="zoom-selection" type="button" disabled>Zoom around passage</button><button id="zoom-full" type="button" disabled>Whole recording</button></div>
+ <canvas id="waveform" width="1200" height="180" role="img" aria-label="Recording waveform. Click or drag to select a passage, or use the exact time fields below."></canvas>
+ <div class="wave-labels"><span id="wave-start">00:00:00</span><span id="wave-end">00:00:00</span></div>
+ <p class="small">Click for a 30-second passage, or drag up to 120 seconds. Zoom enlarges the same coarse overview; use exact times for fine adjustments.</p>
+ <div class="selection-fields"><label>Start · HH:MM:SS.sss<input id="selection-start" type="text" inputmode="decimal" value="00:00:00" spellcheck="false" aria-describedby="selection-note"></label><label>End · HH:MM:SS.sss<input id="selection-end" type="text" inputmode="decimal" value="00:00:30" spellcheck="false" aria-describedby="selection-note"></label><p id="selection-note" role="status">Up to 120 seconds. You can also enter seconds directly.</p></div>
+ <div class="settings-heading"><div><h3>Shape the sound</h3><p>Small changes are often enough. Controls never render automatically.</p></div><button id="reset-launch" type="button">Restore launch settings</button></div>
+ <div id="primary-settings" class="primary-settings"></div>
+ <details id="advanced-settings"><summary>All settings & advanced controls <span class="small">Fine-tune without hidden overrides</span></summary><p class="small">Turning an effect off keeps its values for later. Constraints are checked when you render, save or export; dependent effects are never silently switched on.</p><div id="settings-groups"></div><p id="constraints" class="small"></p></details>
+ <details id="presets-panel"><summary>Settings presets <span class="small">Save a sound, not audio</span></summary>
+ <div class="toolbar"><label>Preset name<input id="preset-name" type="text" maxlength="80" placeholder="Gentle narration"></label><button id="save-preset" type="button">Save settings locally</button><button id="download-preset" type="button">Download settings TOML</button><label class="file-import">Import TOML<input id="import-preset" type="file" accept=".toml,text/plain,application/toml"></label></div><ul id="presets" class="saved-list"></ul><p class="small">Up to 20 named presets in this browser, for this root folder. TOML is portable. Imports replace controls with standalone preset settings, not hidden launch overrides.</p>
+ </details>
+ <div class="action-row"><button id="render-preview" class="primary" type="button" disabled>Render sound comparison <span aria-hidden="true">→</span></button><span class="small">Only this passage + up to 10 seconds of context per side.<br>First AI use downloads local models; audio never leaves this machine.</span></div>
+ </section>
+ <section id="compare" class="panel">
+ <div class="section-heading"><span class="step-number">03</span><div><h2>Compare the sound, not the volume</h2><p>Switch between the same moment. Start at a comfortable listening volume.</p></div></div>
+ <p class="notice">Approximate sound comparison, not an exact export preview. Chapter normalization and inference context differ. Pacing edits are not previewed; this comparison preserves timing.</p>
+ <p id="comparison-status" class="comparison-status" role="status">Render a passage to listen here.</p>
+ <p id="comparison-source" class="small"></p>
+ <fieldset id="player" disabled><legend class="sr-only">Passage playback</legend>
+ <div class="player-buttons"><div class="ab-switch" role="group" aria-label="Listen to"><button id="listen-original" type="button" aria-pressed="true">A · Original</button><button id="listen-processed" type="button" aria-pressed="false">B · Processed</button></div><button id="play" class="primary" type="button">Play</button><label class="check"><input id="loop" type="checkbox" checked>Loop passage</label><label class="check"><input id="match" type="checkbox">Match listening loudness</label></div>
+ <label class="seek-label">Passage playback position<input id="playback-seek" type="range" min="0" max="30" step="0.01" value="0"></label><p id="playback-time" class="small">00:00:00 / 00:00:00</p><p id="monitoring-note" class="small">Matching only attenuates playback. It never changes your saved settings or export.</p>
+ </fieldset>
+ <audio id="audio-original" preload="metadata"></audio><audio id="audio-processed" preload="metadata"></audio>
+ <div id="meters" class="meters" hidden></div><p id="preview-warnings" class="small"></p>
+ <details id="preview-snapshot" hidden><summary>Exact requested settings & preview differences</summary><p>Gap trimming and MP3 encoding are disabled only for this timing-preserving WAV comparison. Full export uses the requested settings below.</p><pre id="preview-settings"></pre></details>
+ <div class="toolbar"><label>Name this comparison<input id="comparison-name" type="text" maxlength="80" placeholder="Less noise, softer touch"></label><button id="save-comparison" type="button" disabled>Save comparison snapshot</button></div><ul id="comparisons" class="saved-list"></ul><p class="small">Up to 12 immutable snapshots saved in this browser (source identity, times and requested settings; no audio). Restore & render to listen again. Cached audio can expire; changed source files must be re-selected.</p>
+ </section>
+ <section id="export" class="panel">
+ <div class="section-heading"><span class="step-number">04</span><div><h2>Export the full recording</h2><p>Happy with the sound? Apply the current controls to the whole original, not just your passage.</p></div></div>
+ <div id="delivery-settings" class="delivery-settings"></div>
+ <label class="output-label">Existing output folder · relative to allowed folder<input id="output-dir" type="text" value="." spellcheck="false"></label><p class="small">Use “Use this folder for exports” in the local browser above, or enter an existing relative folder. Each export gets a fresh private folder. Originals stay untouched.</p>
+ <div class="action-row"><button id="prepare-export" class="primary" type="button" disabled>Review full-recording export →</button><span class="small">WAV + optional MP3 and a processing report.<br>No delivery-platform certification or quality score.</span></div>
+ <div id="export-results" aria-label="Completed exports"></div>
+ </section>
+ <section class="panel jobs-panel" id="jobs-panel"><div class="section-heading"><div><h2>Activity</h2><p>One job at a time · up to three waiting · progress is per stage, not overall.</p></div></div><p id="jobs-status" role="status">No work submitted yet.</p><p class="small">Cancellation waits for a safe progress point. A model download can take up to 60 seconds to respond. Files already published may remain.</p><ul id="jobs" class="jobs-list"></ul></section>
+ </fieldset>
+ <footer>Made for listening, not chasing a score. Local processing · no telemetry · no cloud audio.<br>Leave this server running while you work. Stop it in the terminal with Ctrl-C.</footer>
+</main>
+<dialog id="export-dialog" aria-labelledby="export-title"><h2 id="export-title">Export the whole original?</h2><p id="export-summary"></p><p id="export-pacing" class="notice"></p><p>These files are saved on this machine, not downloaded into your browser. A new folder prevents overwriting earlier exports. Monitoring gains are never included.</p><details><summary>Frozen export settings</summary><pre id="export-settings"></pre></details><div class="action-row"><button id="confirm-export" class="primary" type="button">Export full recording</button><button id="cancel-export" type="button">Go back</button></div></dialog>
+</body>
+</html>
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
+ }
+}