aboutsummaryrefslogtreecommitdiff
path: root/lib/project
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-12 18:45:33 -0400
committerhistoria <historiavg@proton.me>2026-09-12 18:45:33 -0400
commita5bca8573e7781f71aba9575acd99f1a7e82cbe4 (patch)
treee469d0f80308792dde6d1bd9894769e3e058cabc /lib/project
parent45cb401b194be6dbffd2fbd226b88de0f1a2df30 (diff)
downloadproducer-a5bca8573e7781f71aba9575acd99f1a7e82cbe4.tar.gz
feat: gui notifications when changing sound profile
Diffstat (limited to 'lib/project')
-rw-r--r--lib/project/src/producer/config.py20
-rw-r--r--lib/project/src/producer/gui.py17
-rw-r--r--lib/project/src/producer/gui_schema.py30
-rw-r--r--lib/project/src/producer/web/app.js43
-rw-r--r--lib/project/src/producer/web/index.html1
-rw-r--r--lib/project/src/producer/web/style.css35
-rw-r--r--lib/project/tests/test_gui.py31
-rw-r--r--lib/project/tests/test_gui_browser.py1
8 files changed, 148 insertions, 30 deletions
diff --git a/lib/project/src/producer/config.py b/lib/project/src/producer/config.py
index 34c907b..1d18054 100644
--- a/lib/project/src/producer/config.py
+++ b/lib/project/src/producer/config.py
@@ -24,16 +24,6 @@ class Settings:
expansion_threshold_db: float = -50.0
expansion_ratio: float = 1.5
expansion_range_db: float = 12.0
- pause_silence: bool = False
- pause_threshold: float = 0.35
- pause_pre_ms: float = 120.0
- pause_post_ms: float = 120.0
- pause_fade_ms: float = 10.0
- pause_min_ms: float = 80.0
- trim_silence: bool = False
- max_gap_s: float = 1.5
- gap_pad_s: float = 0.6
- gap_threshold_db: float = -50.0
eq: bool = True
warmth_db: float = 0.5
mud_db: float = -1.5
@@ -54,6 +44,16 @@ class Settings:
sample_rate: int = 48000
mp3: bool = False
mp3_bitrate: int = 192
+ pause_silence: bool = False
+ pause_threshold: float = 0.35
+ pause_pre_ms: float = 120.0
+ pause_post_ms: float = 120.0
+ pause_fade_ms: float = 10.0
+ pause_min_ms: float = 80.0
+ trim_silence: bool = False
+ max_gap_s: float = 1.5
+ gap_pad_s: float = 0.6
+ gap_threshold_db: float = -50.0
PROFILES = {
diff --git a/lib/project/src/producer/gui.py b/lib/project/src/producer/gui.py
index 09be6bd..00ea229 100644
--- a/lib/project/src/producer/gui.py
+++ b/lib/project/src/producer/gui.py
@@ -154,8 +154,16 @@ def create_app(port, *, config=None, profile=None, overrides=None, output_dir=No
@app.post("/api/resolve")
async def resolve_settings(request: Request):
- body = object_body(await request.json(), {"settings"})
- return {"settings": asdict(configured(body.get("settings", {})))}
+ body = object_body(await request.json(), {"settings", "standalone"})
+ values = body.get("settings", {})
+ if not isinstance(values, dict):
+ raise ValueError("settings must be an object")
+ if body.get("standalone"):
+ # Like a preset import: package defaults plus the profile's own
+ # values, not the launch config, whose explicit values would
+ # otherwise override most profile differences.
+ return {"settings": asdict(resolve(overrides={"profile": values.get("profile", "natural")}))}
+ return {"settings": asdict(configured(values))}
@app.post("/api/presets/import")
async def import_preset(request: Request):
@@ -257,6 +265,11 @@ def serve(port=8765, *, config=None, profile=None, overrides=None):
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:
+ # Without SO_REUSEADDR a restart within a minute of a shutdown fails
+ # with 'Address already in use': connections the server closed first
+ # linger in TIME_WAIT while holding the local port. uvicorn sets this
+ # flag only when it binds itself, not for pre-bound sockets.
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", port))
sock.listen(128)
print(f"Producer local GUI: http://127.0.0.1:{port}/", flush=True)
diff --git a/lib/project/src/producer/gui_schema.py b/lib/project/src/producer/gui_schema.py
index c27ba89..9c3ee2b 100644
--- a/lib/project/src/producer/gui_schema.py
+++ b/lib/project/src/producer/gui_schema.py
@@ -7,7 +7,7 @@ from .config import PROFILES, RANGES, Settings
# control does; dependents are greyed out in the UI.
GROUPS = {
"General": {
- "profile": "Starting sound profile (natural, narrator, radio, cleanup-only); a profile only fills unset values, explicit settings always win.",
+ "profile": "Sound profile (natural, narrator, radio, cleanup-only). Applying it overwrites all controls on this page with the profile's values.",
"device": "Where AI noise cleanup runs: auto picks a GPU if available, otherwise CPU. Only relevant when the denoiser is deepfilter or clearvoice.",
},
"Noise removal": {
@@ -31,20 +31,6 @@ GROUPS = {
"expansion_ratio": "Expansion ratio; 1 means no expansion.",
"expansion_range_db": "Maximum expansion attenuation in dB.",
},
- "Pause silencing (VAD)": {
- "pause_silence": "Uses a local Silero speech detector (VAD) to zero out non-speech pauses (breaths, gaps) without moving anything in time. Can lose breaths or whispers. First use downloads the detector.",
- "pause_threshold": "Speech probability threshold; lower values protect more possible speech from being silenced.",
- "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, avoiding clicks.",
- "pause_min_ms": "Minimum silent core in milliseconds after margins and fades.",
- },
- "Gap shortening (export only)": {
- "trim_silence": "Shortens gaps longer than 'Gap eligible' down to 'Gap to keep' on the full export only.",
- "max_gap_s": "Minimum gap length in seconds eligible for shortening.",
- "gap_pad_s": "Total retained gap in seconds; must be smaller than the maximum gap.",
- "gap_threshold_db": "Legacy gap threshold in dBFS used only when pause silencing is off; it can mistake quiet words for silence.",
- },
"Equalizer": {
"eq": "Three fixed equalizer bands at 140 Hz, 300 Hz and 3500 Hz.",
"warmth_db": "140 Hz gain in dB; positive adds low-end fullness, negative thins it.",
@@ -71,6 +57,20 @@ GROUPS = {
"mp3": "Also encode a constant-bitrate MP3 alongside the WAV.",
"mp3_bitrate": "MP3 bitrate in kb/s.",
},
+ "Pause silencing (VAD)": {
+ "pause_silence": "Uses a local Silero speech detector (VAD) to zero out non-speech pauses (breaths, gaps) without moving anything in time. Can lose breaths or whispers. First use downloads the detector.",
+ "pause_threshold": "Speech probability threshold; lower values protect more possible speech from being silenced.",
+ "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, avoiding clicks.",
+ "pause_min_ms": "Minimum silent core in milliseconds after margins and fades.",
+ },
+ "Gap shortening (export only)": {
+ "trim_silence": "Shortens gaps longer than 'Gap eligible' down to 'Gap to keep' on the full export only.",
+ "max_gap_s": "Minimum gap length in seconds eligible for shortening.",
+ "gap_pad_s": "Total retained gap in seconds; must be smaller than the maximum gap.",
+ "gap_threshold_db": "Legacy gap threshold in dBFS used only when pause silencing is off; it can mistake quiet words for silence.",
+ },
}
CHOICES = {"profile": list(PROFILES), "denoiser": ["deepfilter", "clearvoice", "fft", "none"],
"device": ["auto", "cpu", "cuda"], "hum_hz": [0, 50, 60],
diff --git a/lib/project/src/producer/web/app.js b/lib/project/src/producer/web/app.js
index dad702c..faa8ea5 100644
--- a/lib/project/src/producer/web/app.js
+++ b/lib/project/src/producer/web/app.js
@@ -24,6 +24,20 @@ function error(error) {
$('error-text').textContent = message;
$('error').hidden = false;
}
+let toastTimer;
+function notify(title, lines = []) {
+ $('toast-title').textContent = title;
+ const list = $('toast-list');
+ list.replaceChildren(...lines.map(line => node('li', line)));
+ list.hidden = !lines.length;
+ const toast = $('toast');
+ toast.hidden = false;
+ toast.classList.remove('fade-out');
+ clearTimeout(toastTimer);
+ const visible = 8000, fade = visible * 0.25;
+ toastTimer = setTimeout(() => toast.classList.add('fade-out'), visible - fade);
+ toastTimer = setTimeout(() => { toast.hidden = true; toast.classList.remove('fade-out'); }, visible);
+}
function action(id, callback) {
$(id).addEventListener('click', async () => {
const button = $(id);
@@ -49,7 +63,7 @@ async function api(path, body, responseType = 'json', signal) {
}
const labels = {
- profile: 'Starting profile', device: 'AI processing device', denoiser: 'Denoiser', denoise_strength: 'Denoise strength · 0–1',
+ profile: 'Sound profile', device: 'AI processing device', denoiser: 'Denoiser', denoise_strength: 'Denoise strength · 0–1',
fft_reduction_db: 'FFT reduction · dB', highpass: 'High-pass filter', highpass_hz: 'High-pass cutoff · Hz', lowpass: 'Low-pass filter', lowpass_hz: 'Low-pass cutoff · Hz', hum_hz: 'Mains hum · Hz (0 = off)',
leveling: 'Leveling', level_target_db: 'Speech level target · dBFS', max_gain_db: 'Maximum gain change · dB', expansion: 'Expander', expansion_threshold_db: 'Expander threshold · dBFS', expansion_ratio: 'Expansion ratio', expansion_range_db: 'Maximum attenuation · dB',
pause_silence: 'Pause silencing (VAD)', pause_threshold: 'Speech detection threshold · 0–1', pause_pre_ms: 'Protect word starts · ms', pause_post_ms: 'Protect word endings · ms', pause_fade_ms: 'Pause fade · ms', pause_min_ms: 'Minimum silent core · ms',
@@ -141,13 +155,30 @@ function buildSettings() {
$('constraints').textContent = 'Validation rules: ' + state.schema.constraints.join('; ') + '.';
setSettings(state.schema.settings);
action('apply-profile', async () => {
- if (!confirm('Replace all controls with this profile resolved through your launch configuration? Explicit launch settings still take precedence.')) return;
const version = state.version;
- const data = await api('/api/resolve', {settings: {profile: $('setting-profile').value}});
+ const before = rawValues();
+ const data = await api('/api/resolve', {settings: {profile: $('setting-profile').value}, standalone: true});
if (version !== state.version) throw new Error('Controls changed while loading the profile. Try again.');
setSettings(data.settings); changed();
+ const differences = state.schema.fields
+ .filter(field => field.name !== 'profile' && before[field.name] !== data.settings[field.name])
+ .map(field => `${labels[field.name] || field.name}: ${shown(before[field.name])} → ${shown(data.settings[field.name])}`);
+ notify(`Sound profile “${data.settings.profile}” applied.`, differences);
});
}
+function rawValues() {
+ const settings = {};
+ for (const field of state.schema.fields) {
+ const input = $(`setting-${field.name}`);
+ settings[field.name] = field.type === 'bool' ? input.checked :
+ ['int', 'float'].includes(field.type) ? Number(input.value) : input.value;
+ }
+ return settings;
+}
+function shown(value) {
+ if (typeof value === 'number' && Number.isNaN(value)) return 'unset';
+ return typeof value === 'boolean' ? (value ? 'on' : 'off') : String(value);
+}
function setSettings(settings) {
for (const field of state.schema.fields) {
const input = $(`setting-${field.name}`);
@@ -604,6 +635,12 @@ function showExport(job) {
}
// Explicit actions only; settings input events merely invalidate the current render.
+function hideToast() {
+ clearTimeout(toastTimer);
+ $('toast').hidden = true;
+ $('toast').classList.remove('fade-out');
+}
+$('toast').addEventListener('click', hideToast);
action('dismiss-error', () => { $('error').hidden = true; });
action('rescan', () => selectUpload(state.input));
action('zoom-selection', () => {
diff --git a/lib/project/src/producer/web/index.html b/lib/project/src/producer/web/index.html
index 1113c8a..88f8767 100644
--- a/lib/project/src/producer/web/index.html
+++ b/lib/project/src/producer/web/index.html
@@ -13,6 +13,7 @@
<header><strong>Producer</strong><span class="small">local voice cleanup &amp; mastering</span><span id="connection">Connecting…</span></header>
<main>
<div id="error" class="error" role="alert" hidden><span id="error-text"></span><button id="dismiss-error" type="button">Dismiss</button></div>
+ <div id="toast" class="toast" role="status" hidden><div><strong id="toast-title"></strong><ul id="toast-list"></ul></div></div>
<noscript>This tool needs JavaScript. Reload this page after starting Producer.</noscript>
<fieldset id="workspace" disabled>
<legend class="sr-only">Producer studio</legend>
diff --git a/lib/project/src/producer/web/style.css b/lib/project/src/producer/web/style.css
index 5ffd5e4..c5b9c78 100644
--- a/lib/project/src/producer/web/style.css
+++ b/lib/project/src/producer/web/style.css
@@ -502,6 +502,41 @@ summary .small {
overflow-wrap:anywhere;
margin-bottom:12px
}
+.toast {
+ display:flex;
+ align-items:flex-start;
+ gap:20px;
+ justify-content:space-between;
+ background:var(--mint);
+ border:1px solid var(--green);
+ border-radius:8px;
+ padding:14px;
+ position:fixed;
+ right:16px;
+ bottom:16px;
+ z-index:20;
+ max-width:min(420px, calc(100vw - 32px));
+ font-size:.84rem;
+ overflow-wrap:anywhere;
+ box-shadow:0 4px 14px rgba(37, 56, 46, .18);
+ cursor:pointer;
+ opacity:1;
+ transition:opacity 2s ease
+}
+.toast.fade-out {
+ opacity:0;
+ pointer-events:none
+}
+.toast strong {
+ display:block
+}
+.toast ul {
+ margin:6px 0 0;
+ padding-left:18px
+}
+.toast li::marker {
+ color:var(--muted)
+}
dialog {
max-width:640px;
max-height:90vh;
diff --git a/lib/project/tests/test_gui.py b/lib/project/tests/test_gui.py
index e490e99..2f17549 100644
--- a/lib/project/tests/test_gui.py
+++ b/lib/project/tests/test_gui.py
@@ -1,5 +1,6 @@
"""Optional GUI API, origin boundary, uploads, snapshots, and real sound previews."""
from dataclasses import asdict
+import socket
import hashlib
from pathlib import Path
import struct
@@ -13,6 +14,7 @@ pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from producer.config import Settings
+from producer import gui as gui_module
from producer.gui import create_app
from producer import gui_jobs
@@ -280,3 +282,32 @@ def test_waveform_buffers_bounded_and_scan_cancellable(wav, monkeypatch):
with pytest.raises(gui_jobs.Cancelled):
gui_jobs.waveform(source, cancel)
assert reads == [65536]
+
+
+def test_serve_listener_allows_quick_restart(monkeypatch):
+ """serve() must set SO_REUSEADDR: without it a restart right after Ctrl+C
+ fails while connections the server closed first sit in TIME_WAIT."""
+ captured = {}
+
+ class FakeServer:
+ def __init__(self, config):
+ pass
+ def run(self, sockets):
+ captured["sockets"] = sockets
+ (sock,) = sockets
+ # Sample the state while serve() still holds the socket open.
+ captured["reuseaddr"] = sock.getsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR)
+ captured["address"] = sock.getsockname()
+
+ class FakeConfig:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ class FakeUvicorn:
+ Server, Config = FakeServer, FakeConfig
+
+ monkeypatch.setitem(__import__("sys").modules, "uvicorn", FakeUvicorn)
+ monkeypatch.setattr(gui_module, "create_app", lambda *a, **k: object())
+ gui_module.serve(18765)
+ assert captured["reuseaddr"] == 1
+ assert captured["address"] == ("127.0.0.1", 18765)
diff --git a/lib/project/tests/test_gui_browser.py b/lib/project/tests/test_gui_browser.py
index 385de44..efc6e55 100644
--- a/lib/project/tests/test_gui_browser.py
+++ b/lib/project/tests/test_gui_browser.py
@@ -8,6 +8,7 @@ The long overview case uses mocked fixed-size scan metadata, not 4-hour AI infer
from dataclasses import asdict
import hashlib
import json
+import os
from pathlib import Path
import socket
import threading