aboutsummaryrefslogtreecommitdiff
path: root/lib/project/src
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-12 16:48:55 -0400
committerhistoria <historiavg@proton.me>2026-09-12 16:48:55 -0400
commit18346d8deb858cc77d166ffea8ef76d9c9d48bfe (patch)
treee11bb5951a047ed372053906dcbb97b8651cee84 /lib/project/src
parent464edbc4fd658728f06d3a7d98340aa28ff3e3ec (diff)
downloadproducer-18346d8deb858cc77d166ffea8ef76d9c9d48bfe.tar.gz
feat: automatic stereo mixdown
Diffstat (limited to 'lib/project/src')
-rw-r--r--lib/project/src/producer/audio.py61
-rw-r--r--lib/project/src/producer/cli.py3
-rw-r--r--lib/project/src/producer/gui.py165
-rw-r--r--lib/project/src/producer/gui_jobs.py49
-rw-r--r--lib/project/src/producer/gui_schema.py97
-rw-r--r--lib/project/src/producer/pipeline.py18
-rw-r--r--lib/project/src/producer/web/app.js264
-rw-r--r--lib/project/src/producer/web/index.html82
-rw-r--r--lib/project/src/producer/web/state.js6
-rw-r--r--lib/project/src/producer/web/style.css463
10 files changed, 580 insertions, 628 deletions
diff --git a/lib/project/src/producer/audio.py b/lib/project/src/producer/audio.py
index 59fe1ff..fcde8c3 100644
--- a/lib/project/src/producer/audio.py
+++ b/lib/project/src/producer/audio.py
@@ -116,6 +116,67 @@ def measure(path: Path, progress, stage="Analyzing audio") -> dict:
"noise_floor_confidence": "low (quiet-block heuristic, not speech recognition)" if noise_available else "insufficient room tone"}
+def downmix_to_mono(source: Path, target: Path, progress,
+ stage="Downmixing stereo to mono") -> str:
+ """Polarity-safe stereo to mono downmix in float64, written as float32.
+
+ A naive (L + R) / 2 mix cancels a voice when a recorder stores the same
+ signal with inverted polarity on one channel, or halves it when one
+ channel is silent. Streaming channel statistics decide the strategy:
+
+ - one channel silent: keep the other channel at unity gain
+ - channels strongly anti-correlated: invert one, then average
+ - otherwise: plain (L + R) / 2 average
+
+ Returns a human-readable description of the applied method.
+ """
+ with sf.SoundFile(source) as src:
+ if src.channels != 2:
+ raise ValueError("Stereo downmix expects a two-channel recording")
+ if not len(src):
+ raise ValueError("The recording is empty")
+ sums = np.zeros(2)
+ squares = np.zeros(3)
+ frames = 0
+ progress(stage, 0, len(src))
+ for audio in src.blocks(blocksize=65536, dtype="float64", always_2d=True):
+ if not np.isfinite(audio).all():
+ raise ValueError("Recording contains non-finite samples")
+ left, right = audio[:, 0], audio[:, 1]
+ sums += (float(left.sum()), float(right.sum()))
+ squares += (float((left * left).sum()), float((right * right).sum()),
+ float((left * right).sum()))
+ frames += len(audio)
+ progress(stage, frames, len(src))
+ # Covariance without the DC component: correlation must judge the
+ # signal, not any offset between the channels.
+ mean_l, mean_r = sums / frames
+ covariance = squares[2] / frames - mean_l * mean_r
+ variance_l = max(squares[0] / frames - mean_l * mean_l, 0.0)
+ variance_r = max(squares[1] / frames - mean_r * mean_r, 0.0)
+ correlation = covariance / math.sqrt(max(variance_l * variance_r, 1e-30)) \
+ if variance_l > 0 and variance_r > 0 else 1.0
+ if variance_r <= variance_l * 1e-9:
+ keep, invert, method = 0, False, "the right channel is silent; the left channel was kept at unity gain"
+ elif variance_l <= variance_r * 1e-9:
+ keep, invert, method = 1, False, "the left channel is silent; the right channel was kept at unity gain"
+ elif correlation <= -0.5:
+ keep, invert, method = None, True, "the channels were strongly phase-inverted, so the right channel was inverted before averaging"
+ else:
+ keep, invert, method = None, False, "the channels were averaged"
+ with sf.SoundFile(target, "w", samplerate=src.samplerate, channels=1,
+ format="WAV", subtype="FLOAT") as dst:
+ src.seek(0)
+ position = 0
+ for audio in src.blocks(blocksize=65536, dtype="float64", always_2d=True):
+ mono = audio[:, keep] if keep is not None else (audio[:, 0] - audio[:, 1] if invert
+ else audio[:, 0] + audio[:, 1]) * 0.5
+ dst.write(mono)
+ position += len(mono)
+ progress(stage, position, len(src))
+ return method
+
+
def trim_gaps(source: Path, target: Path, threshold_db: float, max_gap_s: float,
pad_s: float, progress,
stage="Trimming long silences") -> tuple[int, float]:
diff --git a/lib/project/src/producer/cli.py b/lib/project/src/producer/cli.py
index 982e656..bf94ed4 100644
--- a/lib/project/src/producer/cli.py
+++ b/lib/project/src/producer/cli.py
@@ -42,7 +42,6 @@ def parser():
if name == "config":
cmd.add_argument("--json", action="store_true")
elif name == "gui":
- cmd.add_argument("--root", type=Path, default=Path.cwd(), help="Allowed local file root (default: current directory)")
cmd.add_argument("--port", type=int, default=8765, help="Loopback HTTP port (default: 8765)")
else:
cmd.add_argument("inputs", type=Path, nargs="+" if name == "process" else 1)
@@ -122,7 +121,7 @@ def main():
settings = settings_for(args)
if args.command == "gui":
from .gui import serve
- serve(args.root, args.port, config=args.config, profile=args.profile,
+ serve(args.port, config=args.config, profile=args.profile,
overrides=settings_overrides(args))
return
if args.command == "config":
diff --git a/lib/project/src/producer/gui.py b/lib/project/src/producer/gui.py
index f1fa298..55bef1a 100644
--- a/lib/project/src/producer/gui.py
+++ b/lib/project/src/producer/gui.py
@@ -1,27 +1,35 @@
-"""Optional loopback-only web API. Importing the CLI never imports web dependencies."""
+"""Optional loopback-only web API. Importing the CLI never imports web dependencies.
+
+There is no credential: every process on this machine can use the API, so the
+server binds only to 127.0.0.1 and rejects cross-origin/rebinding requests.
+Recordings arrive as browser uploads and are processed from a private
+temporary work directory; outputs are written to an existing absolute
+directory chosen per export.
+"""
from contextlib import asynccontextmanager
from dataclasses import asdict
import math
from pathlib import Path
-import secrets
+import re
import socket
import tomllib
+import uuid
from .config import resolve, toml
from .gui_jobs import Busy, Jobs, input_info
from .gui_schema import schema
BODY_LIMIT = 65536
+UNSAFE_NAME = re.compile(r"[^\w .+()-]", re.ASCII)
class Boundary:
- """Authenticate even reads, reject cross-origin/rebinding, and bound JSON bodies.
+ """Reject cross-origin/rebinding requests and bound JSON bodies.
- The token arrives via URL fragment (never sent to HTTP logs), then a Bearer
- header on every API request. No cookies, query credentials, or CORS bypass.
+ Uploads bypass the bounded-body path: recordings stream straight to disk.
"""
- def __init__(self, app, authority, token):
- self.app, self.authority, self.token = app, authority, token
+ def __init__(self, app, authority):
+ self.app, self.authority = app, authority
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
@@ -37,10 +45,6 @@ class Boundary:
status, detail = 403, "Untrusted Origin"
elif any(value not in ("same-origin", "none") for value in values(b"sec-fetch-site")):
status, detail = 403, "Cross-site request denied"
- elif scope["path"].startswith("/api/"):
- auth = values(b"authorization")
- if len(auth) != 1 or not secrets.compare_digest(auth[0].encode("latin1"), f"Bearer {self.token}".encode("ascii")):
- status, detail = 401, "Open the local URL printed by Producer to authenticate"
async def secure_send(message):
if message["type"] == "http.response.start":
message.setdefault("headers", []).extend([
@@ -51,8 +55,8 @@ class Boundary:
await send(message)
if status:
return await JSONResponse({"detail": detail}, status_code=status)(scope, receive, secure_send)
- # Pre-read only the bounded API payload. This also handles chunked bodies.
- if scope["path"].startswith("/api/"):
+ # Pre-read only the bounded JSON payloads. This also handles chunked bodies.
+ if scope["path"].startswith("/api/") and scope["path"] != "/api/uploads":
body = bytearray()
while True:
message = await receive()
@@ -75,15 +79,6 @@ class Boundary:
return await self.app(scope, receive, secure_send)
-def rooted(root, value):
- if not isinstance(value, str) or not value or "\x00" in value or Path(value).is_absolute():
- raise ValueError("Use a relative path inside the selected root")
- path = (root / value).resolve(strict=True)
- if not path.is_relative_to(root):
- raise ValueError("Path escapes the selected root (including symlinks)")
- return path
-
-
def object_body(value, allowed):
if not isinstance(value, dict):
raise ValueError("Expected a JSON object")
@@ -92,14 +87,28 @@ def object_body(value, allowed):
return value
-def create_app(root, port, token, *, config=None, profile=None, overrides=None):
+def output_directory(value, default):
+ """An export needs an existing absolute directory on this machine."""
+ if value in (None, ""):
+ if default is None:
+ raise ValueError("Choose an existing absolute output directory")
+ default.mkdir(parents=True, exist_ok=True)
+ return default
+ if not isinstance(value, str) or "\x00" in value:
+ raise ValueError("Choose an existing absolute output directory on this machine")
+ path = Path(value).expanduser()
+ if not path.is_absolute():
+ raise ValueError("The output directory must be an absolute path on this machine")
+ if not path.is_dir():
+ raise ValueError(f"Output directory does not exist: {path}")
+ return path.resolve()
+
+
+def create_app(port, *, config=None, profile=None, overrides=None, output_dir=None):
# These imports intentionally exist only behind the optional gui command.
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
- root = Path(root).resolve(strict=True)
- if not root.is_dir():
- raise ValueError("GUI root must be an existing directory")
base = tomllib.loads(config.read_text()) if config else {}
if profile is not None:
base["profile"] = profile
@@ -111,19 +120,21 @@ def create_app(root, port, token, *, config=None, profile=None, overrides=None):
raise ValueError("settings must be an object")
return resolve(overrides=base | values)
- def path_for(value):
- return rooted(root, value)
+ # Exports land in producer-gui-exports next to the launch directory
+ # unless an absolute directory is given per export. Recording uploads are
+ # private temporary copies.
+ default_output = Path(output_dir) if output_dir else Path.cwd() / "producer-gui-exports"
+ jobs = Jobs()
@asynccontextmanager
async def lifespan(app):
- app.state.jobs = Jobs(root, path_for)
try:
yield
finally:
- app.state.jobs.close()
+ jobs.close()
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan)
- app.add_middleware(Boundary, authority=f"127.0.0.1:{port}", token=token)
+ app.add_middleware(Boundary, authority=f"127.0.0.1:{port}")
@app.exception_handler(ValueError)
async def invalid(request, error):
@@ -139,7 +150,7 @@ def create_app(root, port, token, *, config=None, profile=None, overrides=None):
@app.get("/api/schema")
def settings_schema():
- return schema(initial) | {"root": str(root), "api_version": 1}
+ return schema(initial) | {"api_version": 1, "output_dir": str(default_output)}
@app.post("/api/resolve")
async def resolve_settings(request: Request):
@@ -160,61 +171,56 @@ def create_app(root, port, token, *, config=None, profile=None, overrides=None):
return Response(toml(configured(body.get("settings", {}))), media_type="application/toml",
headers={"Content-Disposition": 'attachment; filename="producer-preset.toml"'})
- @app.get("/api/files")
- def files(path: str = ".", offset: int = 0, limit: int = 100):
- if offset < 0 or not 1 <= limit <= 200:
- raise ValueError("offset must be nonnegative and limit must be 1..200")
- directory = path_for(path)
- if not directory.is_dir():
- raise ValueError("Choose a directory")
- entries, skipped, more = [], 0, False
- # Deliberately unsorted: do not materialize arbitrarily large directories.
- for entry in directory.iterdir():
+ @app.post("/api/uploads")
+ async def upload(request: Request, name: str = "recording.wav"):
+ """Stream a recording from the browser into the private work directory."""
+ # Display name only: never used as a filesystem path component.
+ safe_name = UNSAFE_NAME.sub("_", Path(name).name).strip(". ") or "recording.wav"
+ if not safe_name.lower().endswith(".wav"):
+ safe_name += ".wav"
+ path = jobs.uploads_dir / f"{uuid.uuid4().hex}-{safe_name}"
+ try:
+ with path.open("wb") as sink:
+ async for chunk in request.stream():
+ sink.write(chunk)
try:
- resolved = path_for(str(entry.relative_to(root)))
- is_dir = resolved.is_dir()
- if not is_dir and not (resolved.is_file() and resolved.suffix.lower() == ".wav"):
- continue
- except (OSError, ValueError):
- continue
- if skipped < offset:
- skipped += 1
- continue
- if len(entries) == limit:
- more = True
- break
- entries.append({"name": entry.name, "path": str(entry.relative_to(root)),
- "kind": "directory" if is_dir else "wav"})
- relative = directory.relative_to(root)
- return {"path": str(relative), "parent": None if directory == root else str(relative.parent),
- "entries": entries, "next_offset": offset + len(entries) if more else None}
-
- @app.get("/api/input")
- def inspect_input(path: str):
- return input_info(path_for(path)) | {"path": path}
+ info = input_info(path)
+ except ValueError:
+ path.unlink(missing_ok=True)
+ raise
+ except BaseException:
+ path.unlink(missing_ok=True)
+ raise
+ upload_id = jobs.register_upload(path, safe_name)
+ return {"upload_id": upload_id, "name": safe_name, **info}
+
+ @app.get("/api/uploads/{upload_id}")
+ def upload_info(upload_id: str):
+ return jobs.upload_info(upload_id)
@app.get("/api/jobs")
def list_jobs():
- return {"jobs": app.state.jobs.list()}
+ return {"jobs": jobs.list()}
@app.get("/api/jobs/{job_id}")
def get_job(job_id: str):
- return app.state.jobs.get(job_id)
+ return jobs.get(job_id)
@app.post("/api/jobs/{job_id}/cancel")
def cancel_job(job_id: str):
- return app.state.jobs.cancel(job_id)
+ return jobs.cancel(job_id)
@app.post("/api/jobs", status_code=202)
async def submit_job(request: Request):
- body = object_body(await request.json(), {"kind", "path", "input_id", "settings", "start", "duration", "output_dir"})
+ body = object_body(await request.json(),
+ {"kind", "upload_id", "input_id", "settings", "start", "duration", "output_dir"})
kind = body.get("kind")
if kind not in ("scan", "preview", "export"):
raise ValueError("kind must be scan, preview or export")
- info = input_info(path_for(body.get("path")))
+ info = jobs.upload_info(body.get("upload_id"))
if body.get("input_id") != info["input_id"]:
raise ValueError("Input changed; select and scan it again")
- snapshot = {"path": body["path"], "input_id": info["input_id"]}
+ snapshot = {"upload_id": body["upload_id"], "name": info["name"], "input_id": info["input_id"]}
if kind != "scan":
snapshot["settings"] = asdict(configured(body.get("settings", {})))
if kind == "preview":
@@ -225,15 +231,12 @@ def create_app(root, port, token, *, config=None, profile=None, overrides=None):
raise ValueError("Preview must be within the recording: start >= 0, duration > 0 and <= 120 seconds")
snapshot.update(start=start, duration=duration)
if kind == "export":
- output_dir = body.get("output_dir", ".")
- if not path_for(output_dir).is_dir():
- raise ValueError("Choose an existing output directory")
- snapshot["output_dir"] = output_dir
- return app.state.jobs.submit(kind, snapshot)
+ snapshot["output_dir"] = str(output_directory(body.get("output_dir"), default_output))
+ return jobs.submit(kind, snapshot)
@app.get("/api/results/{result_id}/{kind}.wav")
def audio(result_id: str, kind: str):
- return Response(app.state.jobs.media(result_id, kind), media_type="audio/wav")
+ return Response(jobs.media(result_id, kind), media_type="audio/wav")
assets = Path(__file__).with_name("web")
@app.get("/")
@@ -243,7 +246,7 @@ def create_app(root, port, token, *, config=None, profile=None, overrides=None):
return app
-def serve(root, port=8765, *, config=None, profile=None, overrides=None):
+def serve(port=8765, *, config=None, profile=None, overrides=None):
if type(port) is not int or not 1 <= port <= 65535:
raise ValueError("port must be between 1 and 65535")
try:
@@ -251,14 +254,14 @@ def serve(root, port=8765, *, config=None, profile=None, overrides=None):
import fastapi # noqa: F401 -- useful dependency diagnostic before binding
except ImportError as error:
raise RuntimeError("GUI dependencies are optional. Use './producer.sh gui' or install 'producer[gui]'.") from error
- token = secrets.token_urlsafe(32)
- app = create_app(root, port, token, config=config, profile=profile, overrides=overrides)
- # Pre-bind so a busy port fails before printing a misleading authenticated URL.
+ app = create_app(port, config=config, profile=profile, overrides=overrides)
+ # Pre-bind so a busy port fails before printing a misleading URL.
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", port))
sock.listen(128)
- print(f"Producer local GUI: http://127.0.0.1:{port}/#token={token}", flush=True)
- print(f"Allowed files: {Path(root).resolve()} (keep this URL private)", flush=True)
+ print(f"Producer local GUI: http://127.0.0.1:{port}/", flush=True)
+ print("Open this address in a browser on this machine. Everything can access this server; "
+ "audio is processed locally and uploaded recordings are temporary copies.", flush=True)
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port,
access_log=False, proxy_headers=False))
server.run(sockets=[sock])
diff --git a/lib/project/src/producer/gui_jobs.py b/lib/project/src/producer/gui_jobs.py
index 705b0dd..06749e2 100644
--- a/lib/project/src/producer/gui_jobs.py
+++ b/lib/project/src/producer/gui_jobs.py
@@ -53,14 +53,16 @@ def input_info(path):
info = sf.info(path)
except (RuntimeError, sf.LibsndfileError) as error:
raise ValueError(f"Unsupported or unreadable WAV: {error}") from error
- if info.channels == 2:
- raise ValueError("Stereo input: downmix to mono first. Automatic downmix could cancel your voice.")
- if info.channels != 1:
- raise ValueError("Only mono voice recordings can be processed.")
+ if info.channels not in (1, 2):
+ raise ValueError("Only mono or stereo voice recordings can be processed.")
if not info.frames:
raise ValueError("The recording is empty")
- return {"input_id": identity(path), "frames": info.frames, "duration": info.duration,
+ result = {"input_id": identity(path), "frames": info.frames, "duration": info.duration,
"sample_rate": info.samplerate, "channels": info.channels, "subtype": info.subtype}
+ if info.channels == 2:
+ result["notice"] = ("Stereo recording: it will be downmixed to mono automatically, "
+ "with phase cancellation prevented, before processing.")
+ return result
def waveform(path, progress):
@@ -156,12 +158,14 @@ def render_preview(source, work, snapshot, progress):
class Jobs:
- """Owned by the app lifespan. All public state is copied under the lock."""
- def __init__(self, root, resolve_path):
- self.root = root
- self.resolve_path = resolve_path
+ """Owned by the app. All public state is copied under the lock."""
+ def __init__(self):
self.temporary = tempfile.TemporaryDirectory(prefix="producer-gui-")
self.work = Path(self.temporary.name)
+ self.uploads_dir = self.work / "uploads"
+ self.uploads_dir.mkdir()
+ # upload_id -> {"path": Path, "name": str}; uploads are the only sources.
+ self.uploads = {}
self.condition = threading.Condition(threading.RLock())
self.jobs = OrderedDict()
self.pending = deque()
@@ -170,8 +174,25 @@ class Jobs:
self.thread = threading.Thread(target=self._worker, name="producer-gui-jobs", daemon=True)
self.thread.start()
+ def register_upload(self, path, name):
+ upload_id = uuid.uuid4().hex
+ with self.condition:
+ self.uploads[upload_id] = {"path": path, "name": name}
+ return upload_id
+
+ def upload_info(self, upload_id):
+ with self.condition:
+ entry = self.uploads.get(upload_id)
+ if entry is None:
+ raise ValueError("Unknown recording; pick the file again")
+ return {"upload_id": upload_id, "name": entry["name"], "path": str(entry["path"]),
+ **input_info(entry["path"])}
+
def _check_input(self, snapshot):
- source = self.resolve_path(snapshot["path"])
+ entry = self.uploads.get(snapshot["upload_id"])
+ if entry is None:
+ raise ValueError("Unknown recording; pick the file again")
+ source = entry["path"]
if identity(source) != snapshot["input_id"]:
raise ValueError("Input changed; select and scan it again")
return source
@@ -271,9 +292,9 @@ class Jobs:
elif job["kind"] == "preview":
result = render_preview(source, directory, snapshot, progress)
else:
- parent = self.resolve_path(snapshot["output_dir"])
- if not parent.is_dir():
- raise ValueError("Choose an existing output directory")
+ parent = Path(snapshot["output_dir"])
+ if not parent.is_absolute() or not parent.is_dir():
+ raise ValueError("Choose an existing absolute output directory on this machine")
# A private, new delivery folder prevents audio AND report clobbering.
delivery = Path(tempfile.mkdtemp(prefix="producer-export-", dir=parent))
try:
@@ -281,7 +302,7 @@ class Jobs:
settings = Settings(**snapshot["settings"])
report = process(source, target, settings, progress,
provenance={"gui_snapshot": snapshot})
- result = {"paths": [str(path.relative_to(self.root)) for path in targets_for(target, settings)],
+ result = {"paths": [str(path) for path in targets_for(target, settings)],
"settings": asdict(settings), "warnings": report["warnings"],
"duration": report["master"]["duration_seconds"],
"notice": "Pacing edits changed duration and were not auditioned in the sound comparison." if settings.trim_silence else "Full-file export completed."}
diff --git a/lib/project/src/producer/gui_schema.py b/lib/project/src/producer/gui_schema.py
index d72b6ea..1a36900 100644
--- a/lib/project/src/producer/gui_schema.py
+++ b/lib/project/src/producer/gui_schema.py
@@ -3,68 +3,73 @@ from dataclasses import asdict
from .config import PROFILES, RANGES, Settings
-# Keep DSP validation in config.resolve; these labels explain the existing knobs.
+# Keep DSP validation in config.resolve. Each help explains what the control
+# does AND how to switch that effect off; dependents are greyed out in the UI.
GROUPS = {
"General": {
- "profile": "Starting sound profile. File and explicit settings override profile values.",
- "device": "AI device: auto selects an available backend; CPU needs no GPU.",
+ "profile": "Starting sound profile (natural, narrator, radio, cleanup-only). A profile only fills unset values; explicit settings always win. To use just one profile's numbers, leave the name as is — no separate off switch exists.",
+ "device": "Where AI noise cleanup runs: auto picks a GPU if available, otherwise CPU. CPU needs no graphics card. Only relevant when 'Noise cleanup method' is DeepFilter or ClearVoice.",
},
"Noise removal": {
- "denoiser": "DeepFilterNet or ClearVoice require local model downloads; FFT is lightweight; none bypasses denoising.",
- "denoise_strength": "Dry/wet denoising blend. Lower values may preserve consonants better.",
- "fft_reduction_db": "Maximum FFT noise reduction, scaled by denoise strength.",
+ "denoiser": "Removes steady background noise (fans, hiss, hum). deepfilter and clearvoice are AI models (first use downloads one); fft is a light fallback that can sound metallic; none disables noise removal entirely — this is how to turn all noise cleanup off.",
+ "denoise_strength": "How much of the detected noise the AI model removes, 0 (keep) to 1 (remove). Inactive unless an AI method is chosen; setting the method to 'none' disables noise removal (this value is then ignored, not reset).",
+ "fft_reduction_db": "Maximum noise reduction for the fft method only, in dB, further scaled by cleanup strength (effective reduction = this × strength). It cannot be 0: values near 0.01 dB mean 'almost no FFT reduction'; to disable FFT reduction entirely choose deepfilter, clearvoice or none as the method.",
},
"Filters": {
- "highpass": "Remove low-frequency rumble.", "highpass_hz": "High-pass cutoff in Hz.",
- "lowpass": "Remove high-frequency content.", "lowpass_hz": "Low-pass cutoff in Hz, below output Nyquist.",
- "hum_hz": "Mains hum notch frequency; 0 bypasses, 50/60 also filter harmonics.",
+ "highpass": "Cuts everything below the rumble cutoff to remove low rumble (traffic, handling, HVAC). Uncheck to disable this filter and keep its cutoff value for later.",
+ "highpass_hz": "Rumble cutoff in Hz; only used while 'Remove low rumble' is checked.",
+ "lowpass": "Cuts everything above the cutoff to soften hiss and sibilance harshness. Uncheck to disable this filter and keep its cutoff value for later.",
+ "lowpass_hz": "High-frequency cutoff in Hz, below the output Nyquist; only used while 'Soften high frequencies' is checked.",
+ "hum_hz": "Notch filter at the mains-hum frequency (50 or 60 Hz) plus its harmonics. Choose 0 to disable hum removal; 50/60 are active.",
},
"Level and expansion": {
- "leveling": "Apply constant gain using an estimated speech level.",
- "level_target_db": "Speech-level target in dBFS before dynamics.",
- "max_gain_db": "Maximum absolute leveling adjustment in dB.",
- "expansion": "Attenuate low-level sound with a downward expander; can affect quiet words.",
- "expansion_threshold_db": "Expander threshold in dBFS.",
- "expansion_ratio": "Expansion ratio; 1 means no expansion.",
- "expansion_range_db": "Maximum expansion attenuation in dB.",
+ "leveling": "Applies one constant gain so speech sits near the target level. Uncheck to disable leveling and keep its values for later.",
+ "level_target_db": "Speech-level target in dBFS before dynamics; only used while 'Even speech level' is checked.",
+ "max_gain_db": "Maximum absolute leveling adjustment in dB (0 disables movement while keeping leveling on); only used while 'Even speech level' is checked.",
+ "expansion": "Downward expander: attenuates sound below the threshold (room noise between words). Can affect quiet words. Uncheck to disable expansion and keep its values for later.",
+ "expansion_threshold_db": "Expander threshold in dBFS; only used while 'Reduce low-level room sound' is checked.",
+ "expansion_ratio": "Expansion ratio; 1 means no expansion. Only used while 'Reduce low-level room sound' is checked.",
+ "expansion_range_db": "Maximum expansion attenuation in dB. Only used while 'Reduce low-level room sound' is checked.",
},
- "Speech-aware pauses": {
- "pause_silence": "Silence non-speech pauses without changing timing. Downloads a local speech detector.",
- "pause_threshold": "Speech probability threshold. Lower values protect more possible speech.",
- "pause_pre_ms": "Protected milliseconds before detected speech.",
- "pause_post_ms": "Protected milliseconds after detected speech.",
- "pause_fade_ms": "Fade length in milliseconds outside protected speech.",
- "pause_min_ms": "Minimum silent core in milliseconds after margins and fades.",
+ "Silence pauses (speech detection)": {
+ "pause_silence": "Uses a local Silero speech detector to zero out non-speech pauses (breaths, gaps) WITHOUT moving anything in time. Can lose breaths or whispers. Uncheck to keep pauses untouched and keep these values for later. First use downloads the detector.",
+ "pause_threshold": "Speech probability threshold; lower values protect more possible speech from being silenced. Only used while 'Silence non-speech pauses' is checked.",
+ "pause_pre_ms": "Protected milliseconds before detected speech. Only used while 'Silence non-speech pauses' is checked.",
+ "pause_post_ms": "Protected milliseconds after detected speech. Only used while 'Silence non-speech pauses' is checked.",
+ "pause_fade_ms": "Fade length in milliseconds outside protected speech, avoiding clicks. Only used while 'Silence non-speech pauses' is checked.",
+ "pause_min_ms": "Minimum silent core in milliseconds after margins and fades. Only used while 'Silence non-speech pauses' is checked.",
},
- "Pacing (export only)": {
- "trim_silence": "Shorten long gaps on export. NOT auditioned in sound-comparison previews; changes duration.",
- "max_gap_s": "Minimum gap length in seconds eligible for shortening.",
- "gap_pad_s": "Total retained gap in seconds; must be smaller than maximum gap.",
- "gap_threshold_db": "Legacy gap threshold in dBFS when speech-aware pauses are off; can mistake quiet words for silence.",
+ "Shorten long gaps (export only)": {
+ "trim_silence": "Shortens gaps longer than 'Gap eligible' down to 'Gap to keep' on the FULL EXPORT ONLY; this changes the duration and is never heard in the sound comparison. Uncheck to keep every gap at its original length and keep these values for later.",
+ "max_gap_s": "Minimum gap length in seconds eligible for shortening. Only used while 'Shorten long gaps on export' is checked.",
+ "gap_pad_s": "Total retained gap in seconds; must be smaller than the maximum gap. Only used while 'Shorten long gaps on export' is checked.",
+ "gap_threshold_db": "Legacy gap threshold in dBFS used only when speech-aware pause silencing is off; it can mistake quiet words for silence. Only used while 'Shorten long gaps on export' is checked.",
},
"Tone": {
- "eq": "Enable warmth, mud and presence equalizers.",
- "warmth_db": "140 Hz warmth gain in dB.", "mud_db": "300 Hz mud-band gain in dB.",
- "presence_db": "3500 Hz presence gain in dB.",
+ "eq": "Three fixed equalizer bands: warmth (140 Hz), mud (300 Hz) and presence (3500 Hz). Uncheck to bypass all three and keep their dB values for later.",
+ "warmth_db": "140 Hz gain in dB: positive adds fullness (too much sounds boomy), negative thins the low end. Only used while 'Shape vocal tone' is checked.",
+ "mud_db": "300 Hz gain in dB; negative removes boxy low-mid build-up. Only used while 'Shape vocal tone' is checked.",
+ "presence_db": "3500 Hz gain in dB: positive clarifies consonants but can sharpen S sounds. Only used while 'Shape vocal tone' is checked.",
},
"Dynamics": {
- "compression": "Reduce dynamic range with a compressor.",
- "compressor_threshold_db": "Compression threshold in dBFS.",
- "compressor_ratio": "Compression ratio; 1 means no compression.",
- "compressor_attack_ms": "Compressor attack in milliseconds; fast attacks can soften speech starts.",
- "compressor_release_ms": "Compressor recovery in milliseconds.",
- "deess": "Reduce sibilance.", "deess_intensity": "Sibilance detector intensity.",
- "deess_amount": "Amount of de-essing.",
+ "compression": "Compressor: brings loud and quiet phrases closer together; too much sounds squeezed or raises room noise. Uncheck to disable compression and keep its values for later.",
+ "compressor_threshold_db": "Compression threshold in dBFS. Only used while 'Smooth loud and quiet phrases' is checked.",
+ "compressor_ratio": "Compression ratio; 1 means no compression. Only used while 'Smooth loud and quiet phrases' is checked.",
+ "compressor_attack_ms": "Attack in milliseconds; fast attacks can soften speech starts. Only used while 'Smooth loud and quiet phrases' is checked.",
+ "compressor_release_ms": "Recovery in milliseconds. Only used while 'Smooth loud and quiet phrases' is checked.",
+ "deess": "Reduces sharp S sounds (sibilance). Uncheck to disable de-essing and keep its values for later.",
+ "deess_intensity": "S-sound detector sensitivity, 0–1. Only used while 'Soften sharp S sounds' is checked.",
+ "deess_amount": "Amount of de-essing applied to detected S sounds, 0–1. Only used while 'Soften sharp S sounds' is checked.",
},
"Loudness and delivery": {
- "normalize": "Two-pass integrated loudness normalization, including true-peak limiting. Preview context differs from a chapter.",
- "target_lufs": "Integrated loudness target in LUFS, not an ACX/Audible compliance guarantee.",
- "loudness_range": "Loudness range target in LU.",
- "limiter": "Limit peaks; must remain enabled when normalization is enabled.",
- "true_peak_db": "True-peak ceiling in dBTP; lossy MP3 can overshoot.",
- "sample_rate": "Mono 32-bit PCM WAV export sample rate in Hz.",
- "mp3": "Also export a constant-bitrate MP3 alongside the WAV.",
- "mp3_bitrate": "MP3 bitrate in kb/s.",
+ "normalize": "Two-pass integrated loudness normalization to the target, including true-peak limiting. Preview context differs from a full chapter. Uncheck to leave overall loudness as the processing left it; the peak limiter stays independently available.",
+ "target_lufs": "Integrated loudness target in LUFS (more negative = quieter); not an ACX/Audible compliance guarantee. Only used while 'Set overall loudness' is checked.",
+ "loudness_range": "Loudness range target in LU. Only used while 'Set overall loudness' is checked.",
+ "limiter": "True-peak limiter on its own, independent of normalization. Disabling it while 'Set overall loudness' is checked is rejected by validation — normalization includes limiting.",
+ "true_peak_db": "True-peak ceiling in dBTP; lossy MP3 can overshoot. Only used while 'Limit peaks' is checked.",
+ "sample_rate": "Export WAV sample rate in Hz (44100 or 48000); recordings are always mono 32-bit PCM. Applies to every export.",
+ "mp3": "Also encode a constant-bitrate MP3 alongside the WAV. Uncheck for WAV only.",
+ "mp3_bitrate": "MP3 bitrate in kb/s. Only used while 'Also make an MP3' is checked.",
},
}
CHOICES = {"profile": list(PROFILES), "denoiser": ["deepfilter", "clearvoice", "fft", "none"],
diff --git a/lib/project/src/producer/pipeline.py b/lib/project/src/producer/pipeline.py
index 31cb9f5..c730016 100644
--- a/lib/project/src/producer/pipeline.py
+++ b/lib/project/src/producer/pipeline.py
@@ -8,7 +8,7 @@ from pathlib import Path
import tempfile
from . import __version__
-from .audio import ffmpeg_run, loudness, measure
+from .audio import downmix_to_mono, ffmpeg_run, loudness, measure
from .config import Settings
from .setup import ensure_ffmpeg
@@ -80,10 +80,8 @@ def process(source: Path, destination: Path, settings: Settings, progress,
original = measure(source, progress, "Analyzing original")
if original["sample_peak_dbfs"] < -100:
raise ValueError("Recording is silent or too quiet to master safely")
- if original["channels"] == 2:
- raise ValueError("Stereo input: downmix to mono first, for example "
- "'ffmpeg -i input.wav -ac 1 mono.wav'. An automatic "
- "downmix could cancel your voice.")
+ if original["channels"] not in (1, 2):
+ raise ValueError("Only mono or stereo voice recordings can be processed")
duration = original["duration_seconds"]
warnings = []
if original["near_full_scale_samples"]:
@@ -95,12 +93,20 @@ def process(source: Path, destination: Path, settings: Settings, progress,
with tempfile.TemporaryDirectory(prefix=".producer-", dir=destination.parent) as temporary:
work = Path(temporary)
prepared, cleaned, shaped = [work / name for name in ("prepared.wav", "cleaned.wav", "shaped.wav")]
+ mono_source = source
+ if original["channels"] == 2:
+ # Automatic, but never destructive: the downmix is polarity-safe
+ # (see downmix_to_mono), so it cannot cancel an inverted voice.
+ downmixed = work / "downmixed.wav"
+ method = downmix_to_mono(source, downmixed, progress)
+ warnings.append(f"Stereo input was downmixed to mono automatically ({method}).")
+ mono_source = downmixed
input_filters = []
if settings.highpass:
input_filters.append(f"highpass=f={settings.highpass_hz}:p=2")
if settings.hum_hz:
input_filters += [f"equalizer=f={settings.hum_hz * n}:t=q:w=25:g=-18" for n in (1, 2, 3)]
- ffmpeg_run(ffmpeg, ["-i", str(source), "-map", "0:a:0", "-af",
+ ffmpeg_run(ffmpeg, ["-i", str(mono_source), "-map", "0:a:0", "-af",
",".join(input_filters or ["anull"]), "-ar", "48000", "-ac", "1",
"-c:a", "pcm_f32le", "-rf64", "auto", str(prepared)], "Preparing mono 48 kHz audio", duration, progress)
pauses, pause_report = [], {"enabled": settings.pause_silence}
diff --git a/lib/project/src/producer/web/app.js b/lib/project/src/producer/web/app.js
index bdf62c6..44608b9 100644
--- a/lib/project/src/producer/web/app.js
+++ b/lib/project/src/producer/web/app.js
@@ -1,23 +1,16 @@
-import {parseTime, formatTime, selection, stable, matches, monitoringGain, absolutePath} from './state.js';
+import {parseTime, formatTime, selection, stable, matches, monitoringGain} from './state.js';
const $ = id => document.getElementById(id);
const copy = value => structuredClone(value);
const terminal = job => ['succeeded', 'failed', 'cancelled'].includes(job.status);
const state = {
- schema: null, input: null, version: 0, inputEpoch: 0, folderEpoch: 0,
- folder: '.', parent: null, next: null, wave: null, view: [0, 0],
+ schema: null, input: null, version: 0, inputEpoch: 0,
+ uploads: [], wave: null, view: [0, 0],
jobs: new Map(), intents: new Map(), handled: new Set(), cards: new Map(),
preview: null, mediaEpoch: 0, mediaAbort: null, urls: [], side: 'original',
- context: null, gains: {}, playbackEpoch: 0, export: null, expired: false, storageKey: '',
+ context: null, gains: {}, playbackEpoch: 0, export: null, storageKey: 'producer-gui-v1',
presets: [], comparisons: [], exportsShown: new Set()
};
-let token = new URLSearchParams(location.hash.slice(1)).get('token');
-// Fragments never reach the server. Keep the credential only in this tab, not presets.
-if (location.hash) history.replaceState(null, '', location.pathname + location.search);
-try {
- if (token) sessionStorage.setItem('producer-token', token);
- else token = sessionStorage.getItem('producer-token');
-} catch { /* A private browser may disable storage; the current tab still works. */ }
function node(tag, text, className) {
const element = document.createElement(tag);
@@ -28,7 +21,6 @@ function node(tag, text, className) {
function error(error) {
if (error?.name === 'AbortError') return;
let message = error instanceof Error ? error.message : String(error);
- if (token) message = message.split(token).join('[credential hidden]');
$('error-text').textContent = message;
$('error').hidden = false;
}
@@ -44,23 +36,13 @@ async function api(path, body, responseType = 'json', signal) {
if (!path.startsWith('/api/')) throw new Error('Invalid local API path.');
const response = await fetch(path, {
method: body === undefined ? 'GET' : 'POST',
- headers: {Authorization: `Bearer ${token || ''}`, ...(body === undefined ? {} : {'Content-Type': 'application/json'})},
- ...(body === undefined ? {} : {body: JSON.stringify(body)}), signal, cache: 'no-store', credentials: 'omit'
+ ...(body === undefined ? {} : {'Content-Type': 'application/json', body: JSON.stringify(body)}),
+ signal, cache: 'no-store', credentials: 'omit'
});
if (!response.ok) {
- if (response.status === 401) {
- state.expired = true;
- state.mediaEpoch++; state.mediaAbort?.abort();
- state.export = null; $('export-dialog').close();
- $('workspace').disabled = true;
- pause();
- try { sessionStorage.removeItem('producer-token'); } catch { /* Storage unavailable. */ }
- $('connection').textContent = 'Session expired. Open the new private URL printed in your Producer terminal. Saved presets remain in this browser.';
- throw new Error('This session is missing or expired. Reopen the exact private launch URL (127.0.0.1, not localhost).');
- }
const data = await response.json().catch(() => ({}));
let detail = typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail || 'Request failed');
- if (detail === 'Normalization includes true-peak limiting; use --no-normalize to bypass it') detail = 'Overall loudness normalization requires the peak limiter. Enable “Limit peaks” in advanced controls, or turn “Set overall loudness” off.';
+ if (detail === 'Normalization includes true-peak limiting; use --no-normalize to bypass it') detail = 'Overall loudness normalization requires the peak limiter. Enable “Limit peaks”, or turn “Set overall loudness” off.';
throw new Error(response.status === 404 ? `${detail}. Cached comparisons expire; restore the snapshot and render again.` : detail);
}
return responseType === 'blob' ? response.blob() : response.json();
@@ -76,23 +58,49 @@ const labels = {
compression: 'Smooth loud and quiet phrases', compressor_threshold_db: 'Compression threshold · dBFS', compressor_ratio: 'Compression ratio', compressor_attack_ms: 'Attack · ms', compressor_release_ms: 'Recovery · ms', deess: 'Soften sharp S sounds', deess_intensity: 'S-sound sensitivity · 0–1', deess_amount: 'S-sound reduction · 0–1',
normalize: 'Set overall loudness', target_lufs: 'Loudness target · LUFS', loudness_range: 'Loudness range · LU', limiter: 'Limit peaks', true_peak_db: 'Peak ceiling · dBTP', sample_rate: 'WAV sample rate · Hz', mp3: 'Also make an MP3', mp3_bitrate: 'MP3 bitrate · kb/s'
};
-const friendlyHelp = {
- denoiser: 'None leaves noise untouched. FFT is light but can sound metallic. AI needs a first-use local download.',
- pause_silence: 'Keeps timing; silences detected non-speech. Can lose breaths or whispers. Uses a local speech detector.',
- eq: 'Switch tone shaping on or off without losing the detailed EQ values below.',
- warmth_db: 'More can sound fuller; too much can sound boomy.', presence_db: 'More can clarify consonants, but also sharpen S sounds.',
- compression: 'Brings phrases closer together; too much can sound squeezed or bring up room noise.',
- normalize: 'Sets whole-recording loudness on export. Requires the peak limiter; it is not enabled for you.',
- target_lufs: 'More negative is quieter. This is an export target, not your listening volume.',
- profile: 'Changing this name alone keeps all explicit values. Use Apply profile to reset from launch configuration.'
+// Which controls belong to which on/off switch. Values list the dependent
+// fields to disable when the function returns true.
+const DEPS = {
+ denoiser: value => ({fft_reduction_db: value !== 'fft', denoise_strength: value === 'none'}),
+ highpass: checked => ({highpass_hz: !checked}),
+ lowpass: checked => ({lowpass_hz: !checked}),
+ leveling: checked => ({level_target_db: !checked, max_gain_db: !checked}),
+ expansion: checked => ({expansion_threshold_db: !checked, expansion_ratio: !checked, expansion_range_db: !checked}),
+ pause_silence: checked => ({pause_threshold: !checked, pause_pre_ms: !checked, pause_post_ms: !checked, pause_fade_ms: !checked, pause_min_ms: !checked}),
+ trim_silence: checked => ({max_gap_s: !checked, gap_pad_s: !checked, gap_threshold_db: !checked}),
+ eq: checked => ({warmth_db: !checked, mud_db: !checked, presence_db: !checked}),
+ compression: checked => ({compressor_threshold_db: !checked, compressor_ratio: !checked, compressor_attack_ms: !checked, compressor_release_ms: !checked}),
+ deess: checked => ({deess_intensity: !checked, deess_amount: !checked}),
+ normalize: checked => ({target_lufs: !checked, loudness_range: !checked, true_peak_db: !checked}),
+ mp3: checked => ({mp3_bitrate: !checked})
};
-const primary = [
- ['Noise cleanup', 'Keep the words, lose some room noise. Stronger is not always better.', ['denoiser', 'denoise_strength']],
- ['Quiet pauses', 'A little room tone can feel natural. Protect quiet words before chasing silence.', ['pause_silence']],
- ['Tone', 'Keep your voice familiar. Listen for fullness and clear, unforced consonants.', ['eq', 'warmth_db', 'presence_db']],
- ['Even volume', 'Make phrases easier to follow, without flattening the performance.', ['compression', 'normalize']]
-];
-const delivery = ['sample_rate', 'mp3', 'mp3_bitrate', 'trim_silence'];
+function refreshDependencies() {
+ const controllerFor = {};
+ for (const parent of Object.keys(DEPS)) {
+ const input = $(`setting-${parent}`);
+ if (input) controllerFor[parent] = input.type === 'checkbox' ? input.checked : input.value;
+ }
+ const controlledBy = {};
+ for (const [parent, resolve] of Object.entries(DEPS)) {
+ if (!(parent in controllerFor)) continue;
+ for (const [field, off] of Object.entries(resolve(controllerFor[parent]))) if (off) controlledBy[field] = labels[parent];
+ }
+ for (const field of state.schema.fields) {
+ const input = $(`setting-${field.name}`);
+ if (!input) continue;
+ const parent = controlledBy[field.name];
+ input.disabled = Boolean(parent);
+ const help = $(`help-${field.name}`);
+ if (parent) {
+ if (!help.dataset.base) help.dataset.base = help.textContent;
+ help.textContent = `${help.dataset.base} Inactive — controlled by “${parent}”.`;
+ } else if (help.dataset.base) {
+ help.textContent = help.dataset.base;
+ delete help.dataset.base;
+ }
+ input.closest('.setting').classList.toggle('inactive', Boolean(parent));
+ }
+}
function settingControl(field) {
const wrapper = node('div', undefined, 'setting');
const label = node('label', labels[field.name] || field.name);
@@ -116,32 +124,22 @@ function settingControl(field) {
if (field.range) [input.min, input.max] = field.range;
}
}
- const help = node('p', friendlyHelp[field.name] || field.help);
+ const help = node('p', field.help);
help.id = `help-${field.name}`;
input.setAttribute('aria-describedby', help.id);
if (field.type === 'bool') { label.prepend(input); wrapper.append(label); }
else wrapper.append(label, input);
wrapper.append(help);
if (field.name === 'profile') {
- const apply = node('button', 'Apply profile & launch defaults');
+ const apply = node('button', 'Apply profile');
apply.type = 'button'; apply.id = 'apply-profile'; wrapper.append(apply);
}
input.addEventListener('input', changed);
return wrapper;
}
function buildSettings() {
- const used = new Set();
- const fields = new Map(state.schema.fields.map(field => [field.name, field]));
- for (const [title, help, names] of primary) {
- const card = node('div', undefined, 'sound-card');
- card.append(node('h3', title), node('p', help));
- for (const name of names) { card.append(settingControl(fields.get(name))); used.add(name); }
- $('primary-settings').append(card);
- }
- for (const name of delivery) { $('delivery-settings').append(settingControl(fields.get(name))); used.add(name); }
const groups = new Map();
for (const field of state.schema.fields) {
- if (used.has(field.name)) continue;
if (!groups.has(field.group)) {
const group = node('div', undefined, 'settings-group'), grid = node('div', undefined, 'settings-grid');
group.append(node('h3', field.group), grid); $('settings-groups').append(group); groups.set(field.group, grid);
@@ -164,16 +162,14 @@ function setSettings(settings) {
if (field.type === 'bool') input.checked = settings[field.name];
else input.value = settings[field.name];
}
+ refreshDependencies();
}
function readSettings(report = true) {
const settings = {};
for (const field of state.schema.fields) {
const input = $(`setting-${field.name}`);
- if (!input.checkValidity()) {
- if (report) {
- if (input.closest('#advanced-settings')) $('advanced-settings').open = true;
- input.reportValidity();
- }
+ if (!input.disabled && !input.checkValidity()) {
+ if (report) input.reportValidity();
throw new Error(`Check ${labels[field.name] || field.name}: use a valid value within its range.`);
}
settings[field.name] = field.type === 'bool' ? input.checked : ['int', 'float'].includes(field.type) ? Number(input.value) : input.value;
@@ -182,7 +178,7 @@ function readSettings(report = true) {
}
function currentSnapshot(report = false) {
if (!state.input) throw new Error('Choose a recording first.');
- return {path: state.input.path, input_id: state.input.input_id, settings: readSettings(report),
+ return {upload_id: state.input.upload_id, input_id: state.input.input_id, settings: readSettings(report),
...selection($('selection-start').value, $('selection-end').value, state.input.duration)};
}
function previewMatches() {
@@ -193,6 +189,7 @@ function changed() {
state.mediaEpoch++;
state.mediaAbort?.abort();
pause();
+ refreshDependencies();
updateComparisonStatus();
updateSelectionNote();
drawWave();
@@ -200,7 +197,6 @@ function changed() {
function updateAvailability() {
for (const id of ['render-preview', 'prepare-export', 'rescan', 'zoom-selection', 'zoom-full']) $(id).disabled = !state.input;
$('save-comparison').disabled = !state.preview;
- $('folder-up').disabled = state.parent === null;
}
function updateComparisonStatus() {
const current = previewMatches();
@@ -217,53 +213,55 @@ function updateSelectionNote() {
} catch (e) { $('selection-note').textContent = e.message; }
}
-async function browse(path = '.', offset = 0) {
- const epoch = ++state.folderEpoch;
- $('files-status').textContent = 'Reading local folder…';
- const data = await api(`/api/files?path=${encodeURIComponent(path)}&offset=${offset}&limit=100`);
- if (epoch !== state.folderEpoch) return;
- state.folder = data.path; state.parent = data.parent; state.next = data.next_offset;
- $('folder-path').textContent = data.path;
- $('folder-up').disabled = data.parent === null;
- if (!offset) $('files').replaceChildren();
- for (const entry of data.entries) {
- const li = node('li'), button = node('button'); button.type = 'button';
- button.append(node('span', entry.kind === 'directory' ? 'FOLDER' : 'WAV', 'file-kind'), document.createTextNode(entry.name));
- button.addEventListener('click', () => (entry.kind === 'directory' ? browse(entry.path) : chooseInput(entry.path)).catch(error));
- li.append(button); $('files').append(li);
+// Recordings arrive as uploads from the browser's own file picker; the server
+// keeps a private temporary copy and never touches the original file.
+async function uploadFile(file) {
+ if (!/\.wav$/i.test(file.name)) throw new Error(`${file.name}: choose a .wav file.`);
+ const status = $('upload-status');
+ status.textContent = `Uploading ${file.name}…`;
+ const response = await fetch(`/api/uploads?name=${encodeURIComponent(file.name)}`,
+ {method: 'POST', body: file, cache: 'no-store', credentials: 'omit'});
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}));
+ throw new Error(typeof data.detail === 'string' ? data.detail : 'Upload failed.');
+ }
+ const entry = await response.json();
+ state.uploads.push(entry);
+ renderUploads();
+ status.textContent = entry.notice || 'Recordings are copied into a temporary working directory; originals stay untouched.';
+ return entry;
+}
+function renderUploads() {
+ $('uploads').replaceChildren();
+ for (const entry of state.uploads) {
+ const li = node('li'), button = node('button', undefined, 'upload-entry');
+ button.type = 'button';
+ button.append(node('span', 'WAV', 'file-kind'), document.createTextNode(entry.name), node('span',
+ ` · ${formatTime(entry.duration)} · ${entry.sample_rate.toLocaleString()} Hz`, 'small'));
+ button.addEventListener('click', () => selectUpload(entry).catch(error));
+ li.append(button); $('uploads').append(li);
}
- $('files-more').hidden = data.next_offset === null;
- $('files-status').textContent = data.entries.length ? 'Only folders and WAVs are shown. Listing order follows the filesystem.' : 'No more folders or WAV recordings here.';
}
-async function chooseInput(path) {
+function channelLabel(entry) {
+ return entry.channels === 2 ? 'stereo · downmixes to mono' : 'mono';
+}
+async function selectUpload(entry) {
const epoch = ++state.inputEpoch;
state.input = null; state.wave = null; state.view = [0, 0];
changed(); updateAvailability();
- $('input-name').textContent = 'Opening ' + path;
- $('input-info').textContent = 'Reading recording metadata…';
- $('wave-status').textContent = 'Waiting for recording…';
- let data;
- try { data = await api(`/api/input?path=${encodeURIComponent(path)}`); }
- catch (e) {
- if (epoch !== state.inputEpoch) return null;
- $('input-name').textContent = 'No recording selected';
- $('input-info').textContent = 'Could not open this WAV. Choose another file or fix the issue below.';
- $('wave-status').textContent = 'Select a readable mono WAV.';
- throw e;
- }
- if (epoch !== state.inputEpoch) return null;
- state.input = data; state.view = [0, data.duration];
- $('input-name').textContent = data.path;
- $('input-info').textContent = `${formatTime(data.duration)} · mono · ${data.sample_rate.toLocaleString()} Hz · ${data.subtype}`;
+ $('input-name').textContent = entry.name;
+ $('input-info').textContent = `${formatTime(entry.duration)} · ${channelLabel(entry)} · ${entry.sample_rate.toLocaleString()} Hz · ${entry.subtype}`;
+ $('wave-status').textContent = 'Scanning a fixed-size overview… You can already enter exact times.';
+ state.input = entry; state.view = [0, entry.duration];
$('selection-start').value = '00:00:00';
- $('selection-end').value = formatTime(Math.min(30, data.duration));
+ $('selection-end').value = formatTime(Math.min(30, entry.duration));
// Rounding time display must not put the end beyond a fractional final frame.
- if (parseTime($('selection-end').value) > data.duration) $('selection-end').value = String(data.duration);
+ if (parseTime($('selection-end').value) > entry.duration) $('selection-end').value = String(entry.duration);
changed(); updateAvailability();
- $('file-browser').open = false;
- $('wave-status').textContent = 'Scanning a fixed-size overview… You can already enter exact times.';
- await submit('scan', {path: data.path, input_id: data.input_id}, {inputEpoch: epoch});
- return epoch === state.inputEpoch ? data : null;
+ for (const li of $('uploads').children) li.classList.remove('selected');
+ $('uploads').children[state.uploads.indexOf(entry)]?.classList.add('selected');
+ await submit('scan', {upload_id: entry.upload_id, input_id: entry.input_id}, {inputEpoch: epoch});
+ return epoch === state.inputEpoch ? entry : null;
}
function drawWave() {
const canvas = $('waveform'), width = Math.max(1, Math.round(canvas.clientWidth));
@@ -307,7 +305,7 @@ function waveTime(event) {
return state.view[0] + Math.max(0, Math.min(1, (event.clientX - box.left) / box.width)) * (state.view[1] - state.view[0]);
}
$('waveform').addEventListener('pointerdown', event => {
- if (!state.input || state.expired) return;
+ if (!state.input) return;
drag = {time: waveTime(event), x: event.clientX};
$('waveform').setPointerCapture(event.pointerId);
});
@@ -340,7 +338,7 @@ function renderJob(job) {
let card = state.cards.get(job.id);
if (!card) {
const li = node('li', undefined, 'job'), heading = node('div', undefined, 'job-heading');
- const title = node('strong', `${job.kind.toUpperCase()} · ${job.snapshot.path}`, 'job-title');
+ const title = node('strong', `${job.kind.toUpperCase()} · ${job.snapshot.name}`, 'job-title');
const cancel = node('button', 'Cancel'); cancel.type = 'button';
cancel.addEventListener('click', async () => {
cancel.disabled = true;
@@ -355,7 +353,7 @@ function renderJob(job) {
const loadWave = node('button', 'Use overview for this recording'); loadWave.type = 'button'; loadWave.hidden = true;
loadWave.addEventListener('click', () => {
const latest = state.jobs.get(job.id);
- if (state.input?.input_id !== latest.snapshot.input_id || state.input?.path !== latest.snapshot.path) return error(new Error('Re-select this recording first.'));
+ if (state.input?.input_id !== latest.snapshot.input_id || state.input?.upload_id !== latest.snapshot.upload_id) return error(new Error('Re-select this recording first.'));
state.wave = latest.result; $('wave-status').textContent = 'Overview ready · 2,048 peak bins, no full-file audio loaded.'; drawWave();
});
li.append(heading, status, progress, restore, loadWave, settingsDetails(job.snapshot));
@@ -392,13 +390,12 @@ async function handleCompleted(job) {
}
}
async function poll() {
- if (state.expired) return;
try {
// Only reconcile objects unchanged since this request began. A delayed
// list must not erase a later submission or undo a cancellation response.
const before = new Map(state.jobs);
const {jobs} = await api('/api/jobs');
- $('connection').textContent = 'Connected to your local studio. Audio stays on this machine.';
+ $('connection').textContent = 'Connected · audio stays on this machine.';
const ids = new Set(jobs.map(job => job.id));
for (const [id, card] of state.cards) if (!ids.has(id) && before.has(id) && state.jobs.get(id) === before.get(id)) { card.li.remove(); state.cards.delete(id); state.jobs.delete(id); state.handled.delete(id); state.intents.delete(id); state.exportsShown.delete(id); }
for (const job of jobs) {
@@ -412,9 +409,9 @@ async function poll() {
const active = [...state.jobs.values()].filter(job => !terminal(job));
$('jobs-status').textContent = active.length ? `${active.length} job(s) active or waiting. ${active.find(job => job.status === 'running')?.progress.stage || 'Waiting for worker'}.` : `${state.jobs.size} recent job(s) · nothing running.`;
} catch (e) {
- if (!state.expired) $('connection').textContent = 'Connection interrupted. Retrying… Keep the Producer terminal running; jobs may still be active. Do not resubmit an export until you check Activity.';
- else error(e);
- } finally { if (!state.expired) setTimeout(poll, 750); }
+ $('connection').textContent = 'Connection interrupted. Retrying… Keep the Producer terminal running; jobs may still be active. Do not resubmit an export until you check Activity.';
+ error(e);
+ } finally { setTimeout(poll, 750); }
}
function pause() {
@@ -447,7 +444,7 @@ async function loadPreview(job, version) {
$('match').checked = false;
$('match').disabled = !job.result.monitoring.available || !(window.AudioContext || window.webkitAudioContext);
$('playback-seek').max = job.snapshot.duration; $('playback-seek').value = 0;
- $('comparison-source').textContent = `${job.snapshot.path} · ${formatTime(job.snapshot.start)}–${formatTime(job.snapshot.start + job.snapshot.duration)} · render ${job.id.slice(0, 8)} · ${new Date(job.created * 1000).toLocaleTimeString()}`;
+ $('comparison-source').textContent = `${job.snapshot.name} · ${formatTime(job.snapshot.start)}–${formatTime(job.snapshot.start + job.snapshot.duration)} · render ${job.id.slice(0, 8)} · ${new Date(job.created * 1000).toLocaleTimeString()}`;
$('preview-warnings').textContent = (job.result.warnings || []).join(' ');
$('preview-snapshot').hidden = false;
$('preview-settings').textContent = JSON.stringify({requested: job.snapshot.settings, preview_only_differences: {trim_silence: false, mp3: false}, normalization_mode: job.result.normalization_mode}, null, 2);
@@ -522,7 +519,7 @@ function renderSaved() {
$(target).replaceChildren();
for (const entry of state[key]) {
const li = node('li'), title = node('div', entry.name, 'saved-title');
- if (key === 'comparisons') title.append(node('div', `${entry.snapshot.path} · ${formatTime(entry.snapshot.start)} · ${entry.snapshot.duration}s`, 'small'));
+ if (key === 'comparisons') title.append(node('div', `${entry.snapshot.name} · ${formatTime(entry.snapshot.start)} · ${entry.snapshot.duration}s`, 'small'));
const restore = node('button', key === 'presets' ? 'Load settings' : 'Restore & render'); restore.type = 'button';
restore.addEventListener('click', async () => {
restore.disabled = true;
@@ -547,20 +544,25 @@ function renderSaved() {
}
async function restoreComparison(entry) {
const snapshot = copy(entry.snapshot), version = state.version;
- const [info, resolved] = await Promise.all([api(`/api/input?path=${encodeURIComponent(snapshot.path)}`), api('/api/resolve', {settings: snapshot.settings})]);
+ // Uploads are gone after a restart; a stale snapshot asks for re-selection.
+ const [info, resolved] = await Promise.all([api(`/api/uploads/${encodeURIComponent(snapshot.upload_id)}`), api('/api/resolve', {settings: snapshot.settings})]);
if (version !== state.version) throw new Error('Controls changed while restoring. Try again.');
- if (info.input_id !== snapshot.input_id) throw new Error('This source file changed since the snapshot. Re-select it and make a new comparison.');
- state.inputEpoch++; state.input = info; state.wave = null; state.view = [0, info.duration];
- $('input-name').textContent = info.path; $('input-info').textContent = `${formatTime(info.duration)} · mono · ${info.sample_rate.toLocaleString()} Hz · ${info.subtype}`;
+ if (info.input_id !== snapshot.input_id) throw new Error('This recording changed since the snapshot. Pick it again and make a new comparison.');
+ state.inputEpoch++;
+ state.input = info; state.wave = null; state.view = [0, info.duration];
+ state.uploads.push({upload_id: info.upload_id, name: info.name, input_id: info.input_id,
+ duration: info.duration, sample_rate: info.sample_rate, subtype: info.subtype});
+ renderUploads();
+ $('input-name').textContent = info.name; $('input-info').textContent = `${formatTime(info.duration)} · ${channelLabel(info)} · ${info.sample_rate.toLocaleString()} Hz · ${info.subtype}`;
setSettings(resolved.settings);
$('selection-start').value = String(snapshot.start); $('selection-end').value = String(snapshot.start + snapshot.duration);
changed(); updateAvailability();
$('wave-status').textContent = 'Scanning restored recording…';
const restoredVersion = state.version;
- await submit('scan', {path: info.path, input_id: info.input_id}, {inputEpoch: state.inputEpoch});
+ await submit('scan', {upload_id: info.upload_id, input_id: info.input_id}, {inputEpoch: state.inputEpoch});
// A later user edit while submitting the scan must not trigger an unintended preview.
const restored = {...snapshot, settings: resolved.settings};
- if (restoredVersion === state.version && matches(restored, currentSnapshot())) await submit('preview', restored, {version: restoredVersion});
+ if (restoredVersion === state.version && matches(restored, currentSnapshot())) await submit('preview', {upload_id: restored.upload_id, input_id: restored.input_id, settings: restored.settings, start: restored.start, duration: restored.duration}, {version: restoredVersion});
}
async function savePreset() {
const name = $('preset-name').value.trim();
@@ -590,8 +592,9 @@ async function prepareExport() {
const version = state.version, input = copy(state.input), settings = readSettings(), output_dir = $('output-dir').value.trim();
const data = await api('/api/resolve', {settings});
if (version !== state.version) throw new Error('Controls changed while reviewing export. Review again.');
- state.export = {kind: 'export', path: input.path, input_id: input.input_id, settings: copy(data.settings), output_dir};
- $('export-summary').textContent = `${input.path} · whole original ${formatTime(input.duration)}. Destination: ${absolutePath(state.schema.root, output_dir)}/producer-export-…/master.wav${data.settings.mp3 ? ' + master.mp3' : ''} and logs/master.report.json. These are the CURRENT controls, not necessarily the last comparison.`;
+ state.export = {kind: 'export', upload_id: input.upload_id, input_id: input.input_id, settings: copy(data.settings), output_dir};
+ const destination = output_dir || state.schema.output_dir;
+ $('export-summary').textContent = `${input.name} · whole original ${formatTime(input.duration)}. Destination: ${destination}/producer-export-…/master.wav${data.settings.mp3 ? ' + master.mp3' : ''} and logs/master.report.json. These are the CURRENT controls, not necessarily the last comparison.`;
$('export-pacing').textContent = data.settings.trim_silence ? 'Pacing edits are ON. Long gaps will be shortened and duration will change. These timing edits were NOT auditioned in the sound comparison.' : 'Pacing edits are OFF. Sound comparison is still approximate: full-recording normalization and inference context can differ.';
$('export-settings').textContent = JSON.stringify(state.export, null, 2);
$('export-dialog').showModal();
@@ -600,9 +603,9 @@ function showExport(job) {
if (state.exportsShown.has(job.id)) return;
state.exportsShown.add(job.id);
const result = node('div', undefined, 'export-result');
- result.append(node('h3', 'Full recording exported'), node('p', `${job.snapshot.path} · job ${job.id.slice(0, 8)} · ${new Date(job.created * 1000).toLocaleString()}. Settings are frozen in the submitted snapshot below.`), node('p', 'Find these persistent files on this machine. No browser download is needed:'));
+ result.append(node('h3', 'Full recording exported'), node('p', `${job.snapshot.name} · job ${job.id.slice(0, 8)} · ${new Date(job.created * 1000).toLocaleString()}. Settings are frozen in the submitted snapshot below.`), node('p', 'Find these files on this machine. No browser download is needed:'));
const paths = node('ul');
- for (const path of job.result.paths) { const li = node('li'); li.append(node('code', absolutePath(state.schema.root, path))); paths.append(li); }
+ for (const path of job.result.paths) { const li = node('li'); li.append(node('code', path)); paths.append(li); }
result.append(paths, node('p', [...(job.result.warnings || []), job.result.notice].filter(Boolean).join(' ')), settingsDetails(job.snapshot));
$('export-results').prepend(result);
while ($('export-results').children.length > 24) $('export-results').lastElementChild.remove();
@@ -610,11 +613,7 @@ function showExport(job) {
// Explicit actions only; settings input events merely invalidate the current render.
action('dismiss-error', () => { $('error').hidden = true; });
-action('folder-up', () => browse(state.parent || '.'));
-action('folder-refresh', () => browse(state.folder));
-action('files-more', () => browse(state.folder, state.next));
-action('use-output-folder', () => { $('output-dir').value = state.folder; });
-action('rescan', () => chooseInput(state.input.path));
+action('rescan', () => selectUpload(state.input));
action('zoom-selection', () => {
const value = selection($('selection-start').value, $('selection-end').value, state.input.duration);
const padding = Math.max(5, value.duration / 2);
@@ -628,6 +627,14 @@ action('render-preview', async () => {
pause(); await submit('preview', snapshot, {version});
$('jobs-panel').scrollIntoView({block: 'nearest'});
});
+$('pick-files').addEventListener('change', async () => {
+ const files = [...$('pick-files').files];
+ $('pick-files').value = '';
+ for (const file of files) {
+ try { await selectUpload(await uploadFile(file)); }
+ catch (e) { error(e); }
+ }
+});
action('listen-original', () => switchSide('original'));
action('listen-processed', () => switchSide('processed'));
action('play', async () => {
@@ -636,7 +643,7 @@ action('play', async () => {
else {
const epoch = ++state.playbackEpoch, side = state.side;
await ensureAudioContext();
- if (epoch !== state.playbackEpoch || side !== state.side || state.expired) return;
+ if (epoch !== state.playbackEpoch || side !== state.side) return;
updateGains(); await audio.play();
}
});
@@ -672,14 +679,11 @@ action('confirm-export', async () => {
window.addEventListener('beforeunload', () => { state.mediaAbort?.abort(); clearMedia(); });
async function init() {
- if (!token) throw new Error('Open the private URL printed by ./producer.sh gui on this machine. No credential was found in this tab.');
state.schema = await api('/api/schema');
if (state.schema.api_version !== 1) throw new Error('This browser interface needs API version 1. Restart Producer with matching packaged assets.');
- $('root-path').textContent = state.schema.root;
- state.storageKey = 'producer-studio-v1:' + state.schema.root;
+ $('output-dir').value = state.schema.output_dir;
buildSettings(); loadSaved();
$('workspace').disabled = false;
- await browse();
poll();
}
-init().catch(e => { $('connection').textContent = 'Studio could not connect. Check the message below and reopen the printed local URL.'; error(e); });
+init().catch(e => { $('connection').textContent = 'Studio could not connect. Reload this page after starting Producer.'; error(e); });
diff --git a/lib/project/src/producer/web/index.html b/lib/project/src/producer/web/index.html
index 9808e2e..7fbb709 100644
--- a/lib/project/src/producer/web/index.html
+++ b/lib/project/src/producer/web/index.html
@@ -4,70 +4,74 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
- <title>Producer · Local voice studio</title>
+ <title>Producer</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/assets/style.css">
<script type="module" src="/assets/app.js"></script>
</head>
<body>
-<header class="masthead"><div><span class="brand-mark" aria-hidden="true">p.</span><strong>Producer</strong><span class="tagline">A little care for your voice.</span></div><span class="local-badge">● Local audio · private by design</span></header>
+<header><strong>Producer</strong><span class="small">local voice cleanup &amp; mastering</span><span id="connection">Connecting…</span></header>
<main>
- <div class="intro"><p class="eyebrow">YOUR RECORDING, REFINED</p><h1>Make it sound like you.<br><span>Just easier to listen to.</span></h1><p>Try a small passage. Listen for the difference. Keep what works.</p></div>
- <nav class="steps" aria-label="Studio steps"><a href="#choose">1 <span>Choose recording</span></a><a href="#try">2 <span>Try a short passage</span></a><a href="#compare">3 <span>Compare sound</span></a><a href="#export">4 <span>Export full recording</span></a></nav>
- <div id="connection" role="status">Connecting to your local Producer…</div>
<div id="error" class="error" role="alert" hidden><span id="error-text"></span><button id="dismiss-error" type="button">Dismiss</button></div>
- <noscript>This local studio needs JavaScript. Enable it and reopen the private URL printed by Producer.</noscript>
+ <noscript>This tool needs JavaScript. Reload this page after starting Producer.</noscript>
<fieldset id="workspace" disabled>
- <legend class="sr-only">Local voice studio</legend>
- <section id="choose" class="panel">
- <div class="section-heading"><span class="step-number">01</span><div><h2>Choose your recording</h2><p>Browse existing mono WAV files. Nothing is uploaded or overwritten.</p></div></div>
- <details id="file-browser" open><summary>Browse local recordings</summary>
- <p class="small">Allowed folder: <code id="root-path"></code></p>
- <div class="toolbar"><button id="folder-up" type="button">↑ Parent folder</button><button id="folder-refresh" type="button">Refresh</button><code id="folder-path">.</code><button id="use-output-folder" type="button">Use this folder for exports</button></div>
- <ul id="files" class="file-list" aria-label="Folders and WAV recordings"></ul><button id="files-more" type="button" hidden>Show more files</button><p id="files-status" class="small" role="status"></p>
- </details>
- <div class="recording"><div><span class="eyebrow">SELECTED RECORDING</span><h3 id="input-name">No recording yet</h3><p id="input-info">Choose a WAV above to begin.</p></div><button id="rescan" type="button" disabled>Re-select & scan</button></div>
- </section>
- <section id="try" class="panel">
- <div class="section-heading"><span class="step-number">02</span><div><h2>Try a short passage</h2><p>Include a quiet phrase, a breath and a pause. Listen for words that sound softened or cut off.</p></div></div>
- <div class="toolbar"><strong>Recording overview</strong><span id="wave-status" class="small">Select a recording first.</span><button id="zoom-selection" type="button" disabled>Zoom around passage</button><button id="zoom-full" type="button" disabled>Whole recording</button></div>
+ <legend class="sr-only">Producer studio</legend>
+
+ <section id="recording" class="panel">
+ <h2>Recording</h2>
+ <div class="toolbar">
+ <label class="browse" for="pick-files">Browse…</label>
+ <input id="pick-files" type="file" accept=".wav,audio/wav" multiple>
+ <span id="upload-status" class="small" role="status">Pick a mono or stereo WAV from anywhere on this machine; stereo is downmixed safely to mono and the original stays untouched.</span>
+ </div>
+ <ul id="uploads" class="file-list" aria-label="Uploaded recordings"></ul>
+ <div class="recording"><div><strong id="input-name">No recording selected</strong><p id="input-info" class="small"></p></div><button id="rescan" type="button" disabled>Re-select &amp; scan</button></div>
+ <div class="toolbar"><span id="wave-status" class="small">Select a recording first.</span><button id="zoom-selection" type="button" disabled>Zoom to passage</button><button id="zoom-full" type="button" disabled>Whole recording</button></div>
<canvas id="waveform" width="1200" height="180" role="img" aria-label="Recording waveform. Click or drag to select a passage, or use the exact time fields below."></canvas>
<div class="wave-labels"><span id="wave-start">00:00:00</span><span id="wave-end">00:00:00</span></div>
- <p class="small">Click for a 30-second passage, or drag up to 120 seconds. Zoom enlarges the same coarse overview; use exact times for fine adjustments.</p>
- <div class="selection-fields"><label>Start · HH:MM:SS.sss<input id="selection-start" type="text" inputmode="decimal" value="00:00:00" spellcheck="false" aria-describedby="selection-note"></label><label>End · HH:MM:SS.sss<input id="selection-end" type="text" inputmode="decimal" value="00:00:30" spellcheck="false" aria-describedby="selection-note"></label><p id="selection-note" role="status">Up to 120 seconds. You can also enter seconds directly.</p></div>
- <div class="settings-heading"><div><h3>Shape the sound</h3><p>Small changes are often enough. Controls never render automatically.</p></div><button id="reset-launch" type="button">Restore launch settings</button></div>
- <div id="primary-settings" class="primary-settings"></div>
- <details id="advanced-settings"><summary>All settings & advanced controls <span class="small">Fine-tune without hidden overrides</span></summary><p class="small">Turning an effect off keeps its values for later. Constraints are checked when you render, save or export; dependent effects are never silently switched on.</p><div id="settings-groups"></div><p id="constraints" class="small"></p></details>
+ <div class="selection-fields">
+ <label>Start · HH:MM:SS.sss<input id="selection-start" type="text" inputmode="decimal" value="00:00:00" spellcheck="false" aria-describedby="selection-note"></label>
+ <label>End · HH:MM:SS.sss<input id="selection-end" type="text" inputmode="decimal" value="00:00:30" spellcheck="false" aria-describedby="selection-note"></label>
+ <p id="selection-note" role="status" class="small">Up to 120 seconds. Click for a 30-second passage, or drag on the waveform. You can also enter seconds directly.</p>
+ </div>
+ </section>
+
+ <section id="settings" class="panel">
+ <div class="settings-heading"><h2>Settings</h2><p class="small">Turning an effect off keeps its values. Greyed-out controls belong to an effect that is switched off; their help text says which control re-enables them. Every control's help explains what it does and how to disable it.</p><button id="reset-launch" type="button">Restore launch settings</button></div>
+ <div id="settings-groups"></div>
+ <p id="constraints" class="small"></p>
<details id="presets-panel"><summary>Settings presets <span class="small">Save a sound, not audio</span></summary>
- <div class="toolbar"><label>Preset name<input id="preset-name" type="text" maxlength="80" placeholder="Gentle narration"></label><button id="save-preset" type="button">Save settings locally</button><button id="download-preset" type="button">Download settings TOML</button><label class="file-import">Import TOML<input id="import-preset" type="file" accept=".toml,text/plain,application/toml"></label></div><ul id="presets" class="saved-list"></ul><p class="small">Up to 20 named presets in this browser, for this root folder. TOML is portable. Imports replace controls with standalone preset settings, not hidden launch overrides.</p>
+ <div class="toolbar"><label>Preset name<input id="preset-name" type="text" maxlength="80" placeholder="Gentle narration"></label><button id="save-preset" type="button">Save settings locally</button><button id="download-preset" type="button">Download settings TOML</button><label class="file-import">Import TOML<input id="import-preset" type="file" accept=".toml,text/plain,application/toml"></label></div>
+ <ul id="presets" class="saved-list"></ul><p class="small">Up to 20 named presets in this browser. TOML is portable; imports replace the controls with standalone preset settings.</p>
</details>
- <div class="action-row"><button id="render-preview" class="primary" type="button" disabled>Render sound comparison <span aria-hidden="true">→</span></button><span class="small">Only this passage + up to 10 seconds of context per side.<br>First AI use downloads local models; audio never leaves this machine.</span></div>
+ <div class="action-row"><button id="render-preview" class="primary" type="button" disabled>Render sound comparison</button><span class="small">Processes only this passage plus up to 10 seconds of context per side; result is approximate, not an exact export preview. First AI use downloads local models; audio never leaves this machine.</span></div>
</section>
+
<section id="compare" class="panel">
- <div class="section-heading"><span class="step-number">03</span><div><h2>Compare the sound, not the volume</h2><p>Switch between the same moment. Start at a comfortable listening volume.</p></div></div>
- <p class="notice">Approximate sound comparison, not an exact export preview. Chapter normalization and inference context differ. Pacing edits are not previewed; this comparison preserves timing.</p>
+ <h2>Compare</h2>
<p id="comparison-status" class="comparison-status" role="status">Render a passage to listen here.</p>
<p id="comparison-source" class="small"></p>
<fieldset id="player" disabled><legend class="sr-only">Passage playback</legend>
- <div class="player-buttons"><div class="ab-switch" role="group" aria-label="Listen to"><button id="listen-original" type="button" aria-pressed="true">A · Original</button><button id="listen-processed" type="button" aria-pressed="false">B · Processed</button></div><button id="play" class="primary" type="button">Play</button><label class="check"><input id="loop" type="checkbox" checked>Loop passage</label><label class="check"><input id="match" type="checkbox">Match listening loudness</label></div>
- <label class="seek-label">Passage playback position<input id="playback-seek" type="range" min="0" max="30" step="0.01" value="0"></label><p id="playback-time" class="small">00:00:00 / 00:00:00</p><p id="monitoring-note" class="small">Matching only attenuates playback. It never changes your saved settings or export.</p>
+ <div class="player-buttons"><div class="ab-switch" role="group" aria-label="Listen to"><button id="listen-original" type="button" aria-pressed="true">A · Original</button><button id="listen-processed" type="button" aria-pressed="false">B · Processed</button></div><button id="play" class="primary" type="button">Play</button><label class="check"><input id="loop" type="checkbox" checked>Loop</label><label class="check"><input id="match" type="checkbox">Match listening loudness</label></div>
+ <label class="seek-label">Playback position<input id="playback-seek" type="range" min="0" max="30" step="0.01" value="0"></label><p id="playback-time" class="small">00:00:00 / 00:00:00</p><p id="monitoring-note" class="small">Matching only attenuates playback. It never changes your saved settings or export.</p>
</fieldset>
<audio id="audio-original" preload="metadata"></audio><audio id="audio-processed" preload="metadata"></audio>
<div id="meters" class="meters" hidden></div><p id="preview-warnings" class="small"></p>
- <details id="preview-snapshot" hidden><summary>Exact requested settings & preview differences</summary><p>Gap trimming and MP3 encoding are disabled only for this timing-preserving WAV comparison. Full export uses the requested settings below.</p><pre id="preview-settings"></pre></details>
- <div class="toolbar"><label>Name this comparison<input id="comparison-name" type="text" maxlength="80" placeholder="Less noise, softer touch"></label><button id="save-comparison" type="button" disabled>Save comparison snapshot</button></div><ul id="comparisons" class="saved-list"></ul><p class="small">Up to 12 immutable snapshots saved in this browser (source identity, times and requested settings; no audio). Restore & render to listen again. Cached audio can expire; changed source files must be re-selected.</p>
+ <details id="preview-snapshot" hidden><summary>Exact requested settings &amp; preview differences</summary><p>Gap trimming and MP3 encoding are disabled only for this timing-preserving WAV comparison. Full export uses the requested settings below.</p><pre id="preview-settings"></pre></details>
+ <div class="toolbar"><label>Name this comparison<input id="comparison-name" type="text" maxlength="80" placeholder="Less noise, softer touch"></label><button id="save-comparison" type="button" disabled>Save comparison snapshot</button></div><ul id="comparisons" class="saved-list"></ul><p class="small">Up to 12 snapshots in this browser (source identity, times and requested settings; no audio). Restore &amp; render to listen again. Cached audio can expire; changed source files must be re-uploaded and re-selected.</p>
</section>
+
<section id="export" class="panel">
- <div class="section-heading"><span class="step-number">04</span><div><h2>Export the full recording</h2><p>Happy with the sound? Apply the current controls to the whole original, not just your passage.</p></div></div>
- <div id="delivery-settings" class="delivery-settings"></div>
- <label class="output-label">Existing output folder · relative to allowed folder<input id="output-dir" type="text" value="." spellcheck="false"></label><p class="small">Use “Use this folder for exports” in the local browser above, or enter an existing relative folder. Each export gets a fresh private folder. Originals stay untouched.</p>
- <div class="action-row"><button id="prepare-export" class="primary" type="button" disabled>Review full-recording export →</button><span class="small">WAV + optional MP3 and a processing report.<br>No delivery-platform certification or quality score.</span></div>
+ <h2>Export</h2>
+ <label class="output-label">Output folder · absolute path on this machine<input id="output-dir" type="text" spellcheck="false"></label><p class="small">Must already exist. Each export gets a fresh <code>producer-export-*</code> folder: <code>master.wav</code>, optional <code>master.mp3</code> and <code>logs/master.report.json</code>.</p>
+ <div class="action-row"><button id="prepare-export" class="primary" type="button" disabled>Review full-recording export</button><span class="small">Processes the entire original, not just the selected passage. Originals are never overwritten.</span></div>
<div id="export-results" aria-label="Completed exports"></div>
</section>
- <section class="panel jobs-panel" id="jobs-panel"><div class="section-heading"><div><h2>Activity</h2><p>One job at a time · up to three waiting · progress is per stage, not overall.</p></div></div><p id="jobs-status" role="status">No work submitted yet.</p><p class="small">Cancellation waits for a safe progress point. A model download can take up to 60 seconds to respond. Files already published may remain.</p><ul id="jobs" class="jobs-list"></ul></section>
+
+ <section class="panel jobs-panel" id="jobs-panel"><h2>Activity</h2><p id="jobs-status" role="status">No work submitted yet.</p><p class="small">One job at a time, up to three waiting. Cancellation is cooperative: a model download can take up to 60 seconds to reach a safe stop.</p><ul id="jobs" class="jobs-list"></ul></section>
</fieldset>
- <footer>Made for listening, not chasing a score. Local processing · no telemetry · no cloud audio.<br>Leave this server running while you work. Stop it in the terminal with Ctrl-C.</footer>
+ <footer>Local processing · no telemetry · no cloud audio. Stop the server in the terminal with Ctrl-C.</footer>
</main>
-<dialog id="export-dialog" aria-labelledby="export-title"><h2 id="export-title">Export the whole original?</h2><p id="export-summary"></p><p id="export-pacing" class="notice"></p><p>These files are saved on this machine, not downloaded into your browser. A new folder prevents overwriting earlier exports. Monitoring gains are never included.</p><details><summary>Frozen export settings</summary><pre id="export-settings"></pre></details><div class="action-row"><button id="confirm-export" class="primary" type="button">Export full recording</button><button id="cancel-export" type="button">Go back</button></div></dialog>
+<dialog id="export-dialog" aria-labelledby="export-title"><h2 id="export-title">Export the whole original?</h2><p id="export-summary"></p><p id="export-pacing" class="notice"></p><p>Files are saved on this machine, not downloaded into your browser. A new folder prevents overwriting earlier exports. Monitoring gains are never included.</p><details><summary>Frozen export settings</summary><pre id="export-settings"></pre></details><div class="action-row"><button id="confirm-export" class="primary" type="button">Export full recording</button><button id="cancel-export" type="button">Go back</button></div></dialog>
</body>
</html>
diff --git a/lib/project/src/producer/web/state.js b/lib/project/src/producer/web/state.js
index 8c1f9a6..4016f53 100644
--- a/lib/project/src/producer/web/state.js
+++ b/lib/project/src/producer/web/state.js
@@ -25,12 +25,10 @@ export function stable(value) {
return JSON.stringify(value);
}
export function matches(snapshot, current) {
- return Boolean(current && snapshot.path === current.path && snapshot.input_id === current.input_id &&
+ return Boolean(current && snapshot.upload_id === current.upload_id &&
+ snapshot.input_id === current.input_id &&
snapshot.start === current.start && snapshot.duration === current.duration && stable(snapshot.settings) === stable(current.settings));
}
export function monitoringGain(db, enabled) {
return enabled && Number.isFinite(db) ? 10 ** (Math.min(0, db) / 20) : 1;
}
-export function absolutePath(root, relative) {
- return root.replace(/\/$/, '') + '/' + relative.replace(/^\.\//, '');
-}
diff --git a/lib/project/src/producer/web/style.css b/lib/project/src/producer/web/style.css
index 935b51c..fb96614 100644
--- a/lib/project/src/producer/web/style.css
+++ b/lib/project/src/producer/web/style.css
@@ -11,14 +11,13 @@
box-sizing:border-box
}
html {
- scroll-behavior:smooth;
- scroll-padding-top:24px
+ scroll-padding-top:12px
}
body {
margin:0;
background:var(--paper);
color:var(--ink);
- font:16px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif
+ font:15px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif
}
button,input,select {
font:inherit
@@ -26,9 +25,9 @@ button,input,select {
button {
cursor:pointer;
border:1px solid var(--line);
- border-radius:8px;
+ border-radius:7px;
background:#fff;
- padding:10px 15px;
+ padding:7px 12px;
color:var(--ink);
font-weight:600;
line-height:1.3
@@ -51,21 +50,21 @@ button.primary:hover:not(:disabled) {
}
button:focus-visible,a:focus-visible,summary:focus-visible,input:focus-visible,select:focus-visible {
outline:3px solid var(--focus);
- outline-offset:3px
+ outline-offset:2px
}
-input:not([type=checkbox]):not([type=range]),select {
+input:not([type=checkbox]):not([type=range]):not([type=file]),select {
display:block;
width:100%;
- min-height:43px;
- padding:8px 10px;
+ min-height:38px;
+ padding:6px 9px;
border:1px solid #aebbb0;
- border-radius:6px;
+ border-radius:5px;
background:#fff;
color:var(--ink)
}
input[type=checkbox] {
- width:18px;
- height:18px;
+ width:17px;
+ height:17px;
accent-color:var(--green);
flex-shrink:0
}
@@ -77,44 +76,34 @@ input:invalid {
border-color:#9f3c27
}
label {
- font-size:.9rem;
+ font-size:.88rem;
font-weight:600
}
h1,h2,h3,p {
margin-top:0
}
-h1 {
- font-size:clamp(2rem,4.5vw,3.55rem);
- letter-spacing:-.055em;
- line-height:1.13;
- font-weight:650;
- margin-bottom:20px
-}
-h1 span {
- color:var(--muted)
-}
h2 {
- font-size:1.5rem;
- letter-spacing:-.035em;
- margin-bottom:5px
+ font-size:1.2rem;
+ letter-spacing:-.02em;
+ margin-bottom:8px
}
h3 {
- font-size:1.05rem;
- margin-bottom:7px
+ font-size:.98rem;
+ margin:0 0 8px
}
p {
- margin-bottom:14px
+ margin-bottom:10px
}
code,pre {
font-family:ui-monospace,SFMono-Regular,Consolas,monospace;
- font-size:.82rem;
+ font-size:.8rem;
overflow-wrap:anywhere
}
pre {
white-space:pre-wrap;
max-height:320px;
overflow:auto;
- padding:14px;
+ padding:12px;
background:var(--paper);
border:1px solid var(--line);
border-radius:6px
@@ -128,140 +117,85 @@ fieldset {
a {
color:var(--green)
}
-.masthead {
- display:flex;
- justify-content:space-between;
- align-items:center;
- padding:23px 5%;
- border-bottom:1px solid var(--line);
- gap:15px
-}
-.masthead>div {
+header {
display:flex;
- align-items:center;
- gap:13px
-}
-.brand-mark {
- background:var(--green);
- color:#fff;
- width:35px;
- height:35px;
- border-radius:10px;
- text-align:center;
- font-size:1.6rem;
- line-height:1.1;
- font-weight:700
-}
-.tagline {
- color:var(--muted);
- font-size:.85rem;
- margin-left:18px
+ align-items:baseline;
+ gap:12px;
+ padding:10px 20px;
+ border-bottom:1px solid var(--line)
}
-.local-badge {
+#connection {
+ margin-left:auto;
font-size:.78rem;
- color:var(--green);
- background:var(--mint);
- padding:6px 12px;
- border-radius:99px
+ color:var(--muted)
}
main {
- max-width:1192px;
+ max-width:1100px;
margin:0 auto;
- padding:44px 32px
-}
-.intro p:last-child {
- color:var(--muted)
-}
-.eyebrow {
- font-size:.69rem;
- letter-spacing:.14em;
- font-weight:750;
- color:var(--green);
- margin-bottom:14px
-}
-.steps {
- display:grid;
- grid-template-columns:repeat(4,1fr);
- gap:12px;
- margin:28px 0
-}
-.steps a {
- text-decoration:none;
- display:flex;
- align-items:center;
- gap:10px;
- border-top:2px solid var(--green);
- padding:12px 0;
- font-weight:650;
- font-size:.85rem
-}
-.steps a:first-letter {
- font-weight:bold
+ padding:16px 20px 24px
}
.panel {
- padding:28px;
- margin:22px 0;
+ padding:16px 20px;
+ margin:12px 0;
background:#fff;
border:1px solid var(--line);
- border-radius:15px;
- box-shadow:0 3px 14px #263a2610;
- scroll-margin-top:22px
-}
-.section-heading {
- display:flex;
- gap:17px;
- align-items:flex-start;
- margin-bottom:18px
-}
-.section-heading p,.settings-heading p {
- color:var(--muted);
- font-size:.9rem;
- margin:0
-}
-.step-number {
- display:grid;
- place-items:center;
- background:var(--mint);
- color:var(--green);
- min-width:38px;
- height:38px;
- border-radius:50%;
- font-size:.75rem;
- font-weight:700
+ border-radius:10px;
+ box-shadow:0 2px 8px #263a260d;
+ scroll-margin-top:12px
}
.small {
- font-size:.81rem;
+ font-size:.78rem;
color:var(--muted)
}
.toolbar {
display:flex;
- gap:12px;
+ gap:10px;
align-items:center;
flex-wrap:wrap;
- margin:16px 0
+ margin:10px 0
}
.toolbar label {
- min-width:190px
+ min-width:180px
}
.toolbar>code {
flex:1
}
.toolbar button {
- font-size:.8rem
+ font-size:.78rem
+}
+/* Native browse control, styled as a button. */
+.browse {
+ display:inline-block;
+ padding:7px 14px;
+ border:1px solid var(--green);
+ border-radius:7px;
+ background:var(--green);
+ color:#fff;
+ font-weight:600;
+ cursor:pointer
}
-.toolbar>strong {
- margin-right:auto
+.browse:hover {
+ background:#214637
+}
+input[type=file] {
+ position:absolute;
+ width:1px;
+ height:1px;
+ opacity:0
}
.file-list,.saved-list,.jobs-list {
list-style:none;
padding:0;
- margin:12px 0
+ margin:8px 0
}
.file-list {
- max-height:290px;
+ max-height:220px;
overflow:auto;
border-top:1px solid var(--line)
}
+.file-list:empty {
+ border:0
+}
.file-list li {
border-bottom:1px solid var(--line)
}
@@ -271,14 +205,18 @@ main {
width:100%;
text-align:left;
font-weight:500;
- padding:10px;
+ padding:8px 10px;
overflow-wrap:anywhere
}
+.file-list li.selected button {
+ background:var(--mint);
+ font-weight:650
+}
.file-kind {
display:inline-block;
color:var(--muted);
- font-size:.7rem;
- min-width:65px;
+ font-size:.68rem;
+ min-width:38px;
font-weight:700
}
.recording {
@@ -286,130 +224,111 @@ main {
justify-content:space-between;
gap:15px;
align-items:center;
- padding:18px;
+ padding:12px;
background:var(--mint);
- border-radius:9px;
- margin-top:20px
+ border-radius:8px;
+ margin:10px 0
}
-.recording h3 {
+.recording strong {
overflow-wrap:anywhere
}
-.recording .eyebrow {
- font-size:.63rem
-}
.recording p {
- margin:0;
- font-size:.83rem;
- color:var(--muted)
+ margin:0
}
canvas {
display:block;
width:100%;
- height:180px;
+ height:160px;
background:#f0f4f0;
border:1px solid var(--line);
- border-radius:8px;
+ border-radius:7px;
touch-action:none;
cursor:crosshair
}
.wave-labels {
display:flex;
justify-content:space-between;
- font-size:.75rem;
+ font-size:.72rem;
font-variant-numeric:tabular-nums;
color:var(--muted);
- margin:6px 0 12px
+ margin:4px 0 10px
}
.selection-fields {
display:flex;
align-items:center;
- gap:18px;
+ gap:14px;
flex-wrap:wrap
}
.selection-fields input {
- font-variant-numeric:tabular-nums
+ font-variant-numeric:tabular-nums;
+ max-width:140px
}
.selection-fields p {
flex:1;
- font-size:.8rem;
+ font-size:.78rem;
min-width:150px;
color:var(--muted);
- margin:20px 0 0
+ margin:0
}
.settings-heading {
display:flex;
justify-content:space-between;
align-items:center;
- gap:16px;
- margin:28px 0 18px
-}
-.settings-heading button {
- font-size:.8rem
-}
-.primary-settings {
- display:grid;
- grid-template-columns:repeat(4,1fr);
- gap:12px;
- margin:16px 0 22px
-}
-.sound-card {
- background:var(--paper);
- border:1px solid var(--line);
- border-radius:10px;
- padding:16px
+ gap:14px;
+ margin-bottom:12px
}
-.sound-card h3 {
- color:var(--green)
+.settings-heading p {
+ flex:1
}
-.sound-card>p {
- font-size:.78rem;
- min-height:72px;
- color:var(--muted)
+.settings-heading button {
+ font-size:.78rem
}
.setting {
- margin:13px 0
+ margin:0
}
.setting label {
display:block;
- margin-bottom:5px
+ margin-bottom:3px
}
.setting label.check,.check {
display:flex;
align-items:center;
- gap:8px
+ gap:7px
}
.setting p {
- font-size:.75rem;
- color:var(--muted);
- margin:6px 0 0
-}
-.setting-name {
- display:block;
- font-size:.68rem;
- font-weight:400;
+ font-size:.72rem;
color:var(--muted);
- letter-spacing:.01em
+ margin:3px 0 0
}
.settings-grid {
display:grid;
grid-template-columns:repeat(3,1fr);
- gap:8px 24px
+ gap:10px 22px;
+ align-items:start
}
.settings-group {
border-top:1px solid var(--line);
- padding-top:18px;
- margin-top:20px
+ padding-top:10px;
+ margin-top:12px
}
.settings-group h3 {
color:var(--green)
}
+/* Dependent controls are visibly inactive while their effect is off. */
+.setting.inactive input,.setting.inactive select {
+ opacity:.45;
+ background:var(--paper)
+}
+.setting.inactive label {
+ color:var(--muted)
+}
details {
- margin-top:17px
+ margin-top:10px
}
summary {
cursor:pointer;
font-weight:650;
- padding:10px 0;
+ padding:8px 0;
color:var(--green)
}
summary .small {
@@ -417,27 +336,27 @@ summary .small {
margin-left:10px
}
.file-import input {
- font-size:.75rem;
+ font-size:.72rem;
max-width:250px
}
.action-row {
display:flex;
align-items:center;
flex-wrap:wrap;
- gap:18px;
- margin-top:24px
+ gap:14px;
+ margin-top:16px
}
.notice {
border-left:3px solid #b37b25;
background:#fcf5e7;
color:#6d4a19;
border-radius:0 6px 6px 0;
- padding:13px 16px;
- font-size:.85rem
+ padding:10px 14px;
+ font-size:.82rem
}
.comparison-status {
font-weight:650;
- font-size:.9rem
+ font-size:.88rem
}
.comparison-status.stale {
color:#945117
@@ -445,58 +364,58 @@ summary .small {
.player-buttons {
display:flex;
align-items:center;
- gap:18px;
+ gap:14px;
flex-wrap:wrap;
- margin:20px 0
+ margin:12px 0
}
.ab-switch {
display:flex;
border:1px solid var(--line);
- border-radius:9px;
+ border-radius:8px;
overflow:hidden
}
.ab-switch button {
border:0;
border-radius:0;
- padding:13px 20px
+ padding:10px 16px
}
.ab-switch button[aria-pressed=true] {
background:var(--green);
color:#fff
}
.seek-label {
- font-size:.8rem;
+ font-size:.78rem;
display:block
}
.meters {
display:grid;
grid-template-columns:1fr 1fr;
- gap:14px;
- margin:20px 0
+ gap:12px;
+ margin:12px 0
}
.meter {
border:1px solid var(--line);
- border-radius:9px;
- padding:16px;
+ border-radius:8px;
+ padding:12px;
background:var(--paper)
}
.meter strong {
display:block;
- font-size:.85rem
+ font-size:.82rem
}
.meter p {
- margin:7px 0 0;
- font-size:.8rem
+ margin:5px 0 0;
+ font-size:.78rem
}
.meter .level {
- font-size:1.5rem;
+ font-size:1.3rem;
font-variant-numeric:tabular-nums;
- letter-spacing:-.04em;
+ letter-spacing:-.03em;
color:var(--green)
}
.saved-list li,.job {
border-top:1px solid var(--line);
- padding:14px 0
+ padding:10px 0
}
.saved-list li {
display:flex;
@@ -510,39 +429,33 @@ summary .small {
overflow-wrap:anywhere
}
.saved-list button {
- font-size:.78rem
-}
-.delivery-settings {
- display:grid;
- grid-template-columns:repeat(4,1fr);
- gap:20px
+ font-size:.76rem
}
.output-label {
display:block;
- max-width:640px;
- margin-top:16px
+ max-width:640px
}
.job-heading {
display:flex;
justify-content:space-between;
align-items:center;
gap:12px;
- font-size:.86rem
+ font-size:.84rem
}
.job-title {
overflow-wrap:anywhere
}
.job p {
- margin:6px 0;
- font-size:.78rem;
+ margin:5px 0;
+ font-size:.76rem;
color:var(--muted)
}
.job button {
- font-size:.75rem
+ font-size:.73rem
}
.job progress {
width:100%;
- height:10px;
+ height:9px;
accent-color:var(--green)
}
.job .failed {
@@ -551,18 +464,15 @@ summary .small {
.export-result {
border:1px solid var(--line);
border-left:4px solid var(--green);
- padding:18px;
- margin-top:20px;
- border-radius:8px
+ padding:14px;
+ margin-top:14px;
+ border-radius:7px
}
.export-result ul {
padding-left:20px
}
-.export-result li {
- margin:7px 0
-}
.export-result p {
- font-size:.85rem
+ font-size:.82rem
}
.error {
display:flex;
@@ -572,24 +482,21 @@ summary .small {
background:#fcebe5;
border:1px solid #cc8875;
border-radius:8px;
- padding:16px;
+ padding:14px;
position:sticky;
top:8px;
z-index:5;
- font-size:.85rem;
- overflow-wrap:anywhere
-}
-#connection {
- font-size:.82rem;
- color:var(--muted)
+ font-size:.84rem;
+ overflow-wrap:anywhere;
+ margin-bottom:12px
}
dialog {
- max-width:650px;
+ max-width:640px;
max-height:90vh;
overflow:auto;
border:1px solid var(--line);
- border-radius:15px;
- padding:28px;
+ border-radius:12px;
+ padding:24px;
color:var(--ink);
background:#fff
}
@@ -599,8 +506,8 @@ dialog::backdrop {
footer {
text-align:center;
color:var(--muted);
- font-size:.77rem;
- padding:30px 0 10px
+ font-size:.74rem;
+ padding:18px 0 6px
}
.sr-only {
position:absolute;
@@ -614,55 +521,23 @@ footer {
display:none!important
}
@media(max-width:950px) {
- .primary-settings {
- grid-template-columns:repeat(2,1fr)
- }
- .sound-card>p {
- min-height:0
- }
.settings-grid {
grid-template-columns:repeat(2,1fr)
}
- .delivery-settings {
- grid-template-columns:repeat(2,1fr)
- }
- .tagline {
- display:none
- }
}
@media(max-width:600px) {
main {
- padding:25px 14px
- }
- .masthead {
- padding:16px;
- flex-wrap:wrap
- }
- .local-badge {
- font-size:.68rem
+ padding:12px
}
.panel {
- padding:18px
- }
- .steps {
- grid-template-columns:repeat(2,1fr);
- gap:4px 15px
+ padding:14px
}
- .steps a {
- font-size:.77rem
- }
- .primary-settings,.settings-grid {
+ .settings-grid {
grid-template-columns:1fr
}
- .selection-fields {
- align-items:stretch
- }
.selection-fields label {
width:100%
}
- .selection-fields p {
- margin:0
- }
.recording,.settings-heading {
align-items:flex-start;
flex-direction:column
@@ -670,41 +545,17 @@ footer {
.meters {
grid-template-columns:1fr
}
- .toolbar {
- gap:9px
- }
- .toolbar>code {
- flex-basis:100%
- }
.player-buttons {
- gap:13px
- }
- .player-buttons .check {
- font-size:.82rem
- }
- .delivery-settings {
- gap:12px
- }
- h2 {
- font-size:1.25rem
- }
- .section-heading {
gap:10px
}
- summary .small {
- display:block;
- margin:3px 0 0
+ header {
+ flex-wrap:wrap
}
.action-row .primary {
width:100%
}
dialog {
- padding:20px;
+ padding:16px;
width:calc(100% - 24px)
}
}
-@media(prefers-reduced-motion:reduce) {
- html {
- scroll-behavior:auto
- }
-}