aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-12 17:55:05 -0400
committerhistoria <historiavg@proton.me>2026-09-12 17:55:05 -0400
commit45cb401b194be6dbffd2fbd226b88de0f1a2df30 (patch)
treeffc109265ef32b401906566449d9ec3c1d66ec07
parentd19f7c6b41d1524ce9ab8f8005e3132fa9d07aa0 (diff)
downloadproducer-45cb401b194be6dbffd2fbd226b88de0f1a2df30.tar.gz
feat: clearer descriptions in gui, red buttons
-rw-r--r--80
-rw-r--r--README.md27
-rw-r--r--lib/project/src/producer/gui.py10
-rw-r--r--lib/project/src/producer/gui_jobs.py3
-rw-r--r--lib/project/src/producer/gui_schema.py102
-rw-r--r--lib/project/src/producer/web/app.js26
-rw-r--r--lib/project/src/producer/web/index.html16
-rw-r--r--lib/project/src/producer/web/style.css12
-rwxr-xr-xproducer.sh59
9 files changed, 145 insertions, 110 deletions
diff --git a/8 b/8
deleted file mode 100644
index e69de29..0000000
--- a/8
+++ /dev/null
diff --git a/README.md b/README.md
index 0d9a01e..ee7856f 100644
--- a/README.md
+++ b/README.md
@@ -95,25 +95,24 @@ Direct package installs can use `producer[gui]` and need system FFmpeg or the
2. Click or drag the waveform, or enter exact `HH:MM:SS.sss` start/end times
(seconds also work). Zoom enlarges the same coarse overview; exact times
are best for fine selection on multi-hour recordings.
-3. Every control's help text explains what it does and how to switch the
- effect off. Turning an effect off greys out and disables its dependent
- values — for example unchecking "Remove low rumble" deactivates its cutoff
- field — while keeping those values for later. FFT reduction only applies
- to the lightweight FFT denoiser: it is scaled by cleanup strength, cannot
- be set to 0, and disappears entirely when you pick DeepFilter, ClearVoice
- or "none". "Silence pauses" uses a local speech detector to zero non-speech
- pauses without moving anything; "Shorten long gaps (export only)" trims
- long gaps on the full export and is never auditioned in the comparison.
- Click **Render sound comparison**.
+3. Every control's help text briefly explains what it does. Turning an effect
+ off greys out and disables its dependent values while keeping them. FFT
+ reduction only applies to the lightweight FFT denoiser: it is scaled by
+ cleanup strength and disappears entirely when you pick DeepFilter,
+ ClearVoice or "none". "Pause silencing (VAD)" uses a local speech detector
+ to zero non-speech pauses without moving anything; "Gap shortening (export
+ only)" trims long gaps on the full export and is never auditioned in the
+ comparison. Click **Render sound comparison**.
4. Play/loop the passage and switch A/B without losing your place. Optional
matching attenuates the louder snippet for fair listening. File loudness
and peak readings are separate from monitoring gain, not quality scores
or full-export measurements. Changes pause playback and mark older renders;
late jobs never replace the visible comparison after you edit the selection.
-5. Enter an existing **absolute** output folder (default:
- `producer-gui-exports` next to where you launched), review the frozen
- current settings and confirm **Export full recording**. This processes the
- entire original even if you selected only a few seconds. Completed exports
+5. Enter an existing **absolute** output directory (default: the directory
+ you launched Producer from — start it next to your recording and exports
+ land beside the input), review the frozen current settings and confirm
+ **Export full recording**. This processes the entire original even if you
+ selected only a few seconds. Completed exports
list their full local paths; find the files with your operating system's
file manager.
diff --git a/lib/project/src/producer/gui.py b/lib/project/src/producer/gui.py
index 55bef1a..09be6bd 100644
--- a/lib/project/src/producer/gui.py
+++ b/lib/project/src/producer/gui.py
@@ -120,10 +120,10 @@ def create_app(port, *, config=None, profile=None, overrides=None, output_dir=No
raise ValueError("settings must be an object")
return resolve(overrides=base | values)
- # 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"
+ # Exports land in the launch directory by default — normally the same
+ # directory as the loaded recording — 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()
jobs = Jobs()
@asynccontextmanager
@@ -260,8 +260,6 @@ def serve(port=8765, *, config=None, profile=None, overrides=None):
sock.bind(("127.0.0.1", port))
sock.listen(128)
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 06749e2..d35a3d4 100644
--- a/lib/project/src/producer/gui_jobs.py
+++ b/lib/project/src/producer/gui_jobs.py
@@ -60,8 +60,7 @@ def input_info(path):
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.")
+ result["notice"] = "Stereo recording: it will be downmixed to mono automatically before processing."
return result
diff --git a/lib/project/src/producer/gui_schema.py b/lib/project/src/producer/gui_schema.py
index 1a36900..c27ba89 100644
--- a/lib/project/src/producer/gui_schema.py
+++ b/lib/project/src/producer/gui_schema.py
@@ -3,73 +3,73 @@ from dataclasses import asdict
from .config import PROFILES, RANGES, Settings
-# 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.
+# Keep DSP validation in config.resolve. Each help briefly explains what the
+# 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. 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.",
+ "profile": "Starting sound profile (natural, narrator, radio, cleanup-only); a profile only fills unset values, explicit settings always win.",
+ "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": {
- "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.",
+ "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.",
+ "denoise_strength": "How much of the detected noise the AI model removes, 0 (keep) to 1 (remove). Inactive unless an AI method is chosen.",
+ "fft_reduction_db": "Maximum noise reduction for the fft method only, in dB, further scaled by cleanup strength (effective reduction = this × strength).",
},
"Filters": {
- "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.",
+ "highpass": "High-pass filter: cuts everything below the cutoff to remove low rumble (traffic, handling, HVAC).",
+ "highpass_hz": "High-pass cutoff frequency in Hz.",
+ "lowpass": "Low-pass filter: cuts everything above the cutoff to soften hiss and sibilance harshness.",
+ "lowpass_hz": "Low-pass cutoff frequency in Hz, below the output Nyquist.",
"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": "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.",
+ "leveling": "Leveling: applies one constant gain so speech sits near the target level.",
+ "level_target_db": "Speech-level target in dBFS before dynamics.",
+ "max_gain_db": "Maximum absolute leveling adjustment in dB (0 disables movement while keeping leveling on).",
+ "expansion": "Downward expander: attenuates sound below the threshold (room noise between words). 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.",
},
- "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.",
+ "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.",
},
- "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.",
+ "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.",
},
- "Tone": {
- "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.",
+ "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.",
+ "mud_db": "300 Hz gain in dB; negative reduces low-mid build-up.",
+ "presence_db": "3500 Hz gain in dB; positive emphasizes consonants and S sounds.",
},
"Dynamics": {
- "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.",
+ "compression": "Compressor: brings loud and quiet phrases closer together; too much sounds squeezed or raises room noise.",
+ "compressor_threshold_db": "Compression threshold in dBFS.",
+ "compressor_ratio": "Compression ratio; 1 means no compression.",
+ "compressor_attack_ms": "Attack in milliseconds; fast attacks can soften speech starts.",
+ "compressor_release_ms": "Recovery in milliseconds.",
+ "deess": "De-esser: reduces sibilance (sharp S sounds).",
+ "deess_intensity": "S-sound detector sensitivity, 0–1.",
+ "deess_amount": "Amount of reduction applied to detected S sounds, 0–1.",
},
"Loudness and delivery": {
- "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.",
+ "normalize": "Two-pass integrated loudness normalization to the target, including true-peak limiting.",
+ "target_lufs": "Integrated loudness target in LUFS (more negative = quieter).",
+ "loudness_range": "Loudness range target in LU.",
+ "limiter": "True-peak limiter, independent of normalization; loudness normalization requires it.",
+ "true_peak_db": "True-peak ceiling in dBTP; lossy MP3 can overshoot.",
+ "sample_rate": "Export WAV sample rate in Hz (44100 or 48000); recordings are always mono 32-bit PCM.",
+ "mp3": "Also encode a constant-bitrate MP3 alongside the WAV.",
+ "mp3_bitrate": "MP3 bitrate in kb/s.",
},
}
CHOICES = {"profile": list(PROFILES), "denoiser": ["deepfilter", "clearvoice", "fft", "none"],
diff --git a/lib/project/src/producer/web/app.js b/lib/project/src/producer/web/app.js
index 44608b9..dad702c 100644
--- a/lib/project/src/producer/web/app.js
+++ b/lib/project/src/producer/web/app.js
@@ -42,21 +42,21 @@ async function api(path, body, responseType = 'json', signal) {
if (!response.ok) {
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”, or turn “Set overall loudness” off.';
+ if (detail === 'Normalization includes true-peak limiting; use --no-normalize to bypass it') detail = 'Loudness normalization requires the peak limiter. Enable “Limiter”, or turn “Loudness normalization” off.';
throw new Error(response.status === 404 ? `${detail}. Cached comparisons expire; restore the snapshot and render again.` : detail);
}
return responseType === 'blob' ? response.blob() : response.json();
}
const labels = {
- profile: 'Starting profile', device: 'AI processing device', denoiser: 'Noise cleanup method', denoise_strength: 'Cleanup strength · 0–1',
- fft_reduction_db: 'FFT reduction · dB', highpass: 'Remove low rumble', highpass_hz: 'Rumble cutoff · Hz', lowpass: 'Soften high frequencies', lowpass_hz: 'High-frequency cutoff · Hz', hum_hz: 'Mains hum · Hz (0 = off)',
- leveling: 'Even speech level', level_target_db: 'Speech level target · dBFS', max_gain_db: 'Maximum gain change · dB', expansion: 'Reduce low-level room sound', expansion_threshold_db: 'Expansion threshold · dBFS', expansion_ratio: 'Expansion ratio', expansion_range_db: 'Maximum attenuation · dB',
- pause_silence: 'Silence non-speech pauses', pause_threshold: 'Speech detection threshold · 0–1', pause_pre_ms: 'Protect word starts · ms', pause_post_ms: 'Protect word endings · ms', pause_fade_ms: 'Pause fade · ms', pause_min_ms: 'Minimum silent core · ms',
- trim_silence: 'Shorten long gaps on export', max_gap_s: 'Gap eligible for shortening · s', gap_pad_s: 'Gap to keep · s', gap_threshold_db: 'Legacy silence threshold · dBFS',
- eq: 'Shape vocal tone', warmth_db: 'Warmth · dB', mud_db: 'Low-mid fullness · dB', presence_db: 'Clarity / presence · dB',
- compression: 'Smooth loud and quiet phrases', compressor_threshold_db: 'Compression threshold · dBFS', compressor_ratio: 'Compression ratio', compressor_attack_ms: 'Attack · ms', compressor_release_ms: 'Recovery · ms', deess: 'Soften sharp S sounds', deess_intensity: 'S-sound sensitivity · 0–1', deess_amount: 'S-sound reduction · 0–1',
- normalize: 'Set overall loudness', target_lufs: 'Loudness target · LUFS', loudness_range: 'Loudness range · LU', limiter: 'Limit peaks', true_peak_db: 'Peak ceiling · dBTP', sample_rate: 'WAV sample rate · Hz', mp3: 'Also make an MP3', mp3_bitrate: 'MP3 bitrate · kb/s'
+ profile: 'Starting 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',
+ trim_silence: 'Gap shortening (export only)', max_gap_s: 'Gap eligible for shortening · s', gap_pad_s: 'Gap to keep · s', gap_threshold_db: 'Legacy silence threshold · dBFS',
+ eq: 'Equalizer', warmth_db: '140 Hz gain · dB', mud_db: '300 Hz gain · dB', presence_db: '3500 Hz gain · dB',
+ compression: 'Compressor', compressor_threshold_db: 'Compression threshold · dBFS', compressor_ratio: 'Compression ratio', compressor_attack_ms: 'Attack · ms', compressor_release_ms: 'Recovery · ms', deess: 'De-esser', deess_intensity: 'S-sound sensitivity · 0–1', deess_amount: 'S-sound reduction · 0–1',
+ normalize: 'Loudness normalization', target_lufs: 'Loudness target · LUFS', loudness_range: 'Loudness range · LU', limiter: 'Limiter', true_peak_db: 'Peak ceiling · dBTP', sample_rate: 'WAV sample rate · Hz', mp3: 'MP3 export', mp3_bitrate: 'MP3 bitrate · kb/s'
};
// Which controls belong to which on/off switch. Values list the dependent
// fields to disable when the function returns true.
@@ -90,14 +90,6 @@ function refreshDependencies() {
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));
}
}
diff --git a/lib/project/src/producer/web/index.html b/lib/project/src/producer/web/index.html
index 7fbb709..1113c8a 100644
--- a/lib/project/src/producer/web/index.html
+++ b/lib/project/src/producer/web/index.html
@@ -20,7 +20,7 @@
<section id="recording" class="panel">
<h2>Recording</h2>
<div class="toolbar">
- <label class="browse" for="pick-files">Browse…</label>
+ <label class="browse danger" 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>
@@ -37,14 +37,14 @@
</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 class="settings-heading"><h2>Settings</h2><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. 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</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>
+ <div class="action-row"><button id="render-preview" class="primary danger" 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">
@@ -53,22 +53,22 @@
<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</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>
+ <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"></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 &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>
+ <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.</p>
</section>
<section id="export" class="panel">
<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>
+ <label class="output-label">Output directory<input id="output-dir" type="text" spellcheck="false"></label><p class="small">Absolute path on this machine; 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 danger" type="button" disabled>Render and 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"><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>
+ <section class="panel jobs-panel" id="jobs-panel"><h2>Activity</h2><p id="jobs-status" role="status">No work submitted yet.</p><ul id="jobs" class="jobs-list"></ul></section>
</fieldset>
<footer>Local processing · no telemetry · no cloud audio. Stop the server in the terminal with Ctrl-C.</footer>
</main>
diff --git a/lib/project/src/producer/web/style.css b/lib/project/src/producer/web/style.css
index fb96614..5ffd5e4 100644
--- a/lib/project/src/producer/web/style.css
+++ b/lib/project/src/producer/web/style.css
@@ -48,6 +48,14 @@ button.primary {
button.primary:hover:not(:disabled) {
background:#214637
}
+button.danger {
+ background:#b23b2e;
+ color:#fff;
+ border-color:#b23b2e
+}
+button.danger:hover:not(:disabled),.browse.danger:hover {
+ background:#8f2b20
+}
button:focus-visible,a:focus-visible,summary:focus-visible,input:focus-visible,select:focus-visible {
outline:3px solid var(--focus);
outline-offset:2px
@@ -177,6 +185,10 @@ main {
.browse:hover {
background:#214637
}
+.browse.danger {
+ border-color:#b23b2e;
+ background:#b23b2e
+}
input[type=file] {
position:absolute;
width:1px;
diff --git a/producer.sh b/producer.sh
index cab10dd..893cf87 100755
--- a/producer.sh
+++ b/producer.sh
@@ -62,6 +62,51 @@ USAGE
log() { printf '[producer] %s\n' "$*" >&2; }
fail() { printf '[producer] Error: %s\n' "$*" >&2; exit 1; }
+# uv writes the base interpreter's absolute path into every venv it creates
+# (the 'home' line in pyvenv.cfg and the bin/python* links). This directory
+# is shared between environments that mount it at different paths (a
+# container sees /workspace, the host does not), so re-point all of them to
+# the current path on every launch. This is a tiny local rewrite, never a
+# rebuild, and it self-heals in whichever environment launched last.
+repoint_runtime() {
+ local cfg venv home tail tmp line link target
+ for cfg in "$RUNTIME"/*/pyvenv.cfg; do
+ [[ -f "$cfg" ]] || continue
+ venv="$(dirname -- "$cfg")"
+ home="$(sed -n 's/^home = //p' "$cfg" | head -1)"
+ # Only managed Pythons inside this runtime can be re-pointed; the
+ # tail after '/lib/runtime/' is the stable, path-independent part.
+ if [[ "$home" == */lib/runtime/uv/python/* \
+ && "$home" != "$RUNTIME"/* ]]; then
+ tail="${home#*/lib/runtime/}"
+ if [[ -d "$RUNTIME/$tail" ]]; then
+ tmp="$cfg.tmp.$$"
+ { while IFS= read -r line || [[ -n "$line" ]]; do
+ if [[ "$line" == "home = "* ]]; then
+ printf 'home = %s\n' "$RUNTIME/$tail"
+ else
+ printf '%s\n' "$line"
+ fi
+ done < "$cfg"; } > "$tmp" && mv -f -- "$tmp" "$cfg"
+ fi
+ fi
+ for link in "$venv"/bin/python "$venv"/bin/python3 "$venv"/bin/python3.*; do
+ [[ -L "$link" ]] || continue
+ target="$(readlink -- "$link")"
+ case "$target" in */lib/runtime/uv/python/*)
+ # Map stale mounts of this same directory onto the current
+ # path, then make the link relative. Skip if the target is
+ # missing (a broken install is the bootstrap's problem).
+ tail="${target#*/lib/runtime/}"
+ if [[ -e "$RUNTIME/$tail" ]]; then
+ ln -sfn -- "$(realpath -m --relative-to="$venv/bin" -- "$RUNTIME/$tail")" "$link"
+ fi
+ ;;
+ esac
+ done
+ done
+}
+
# Print the PIDs of processes holding an open descriptor for the given file
# (a leading space each, e.g. " 1234 5678"). A plain /proc scan avoids a
# dependency on fuser or lsof, which may not be installed. Only processes
@@ -183,6 +228,7 @@ bootstrap() {
flock 9
fi
if [[ "$REBUILD" -eq 0 ]] && marker_current; then
+ repoint_runtime
exec 9>&-
# Runtime lock (fd 8): held shared for the application's lifetime so
# a later --rebuild cannot delete files this process runs from.
@@ -243,18 +289,6 @@ bootstrap() {
# The AI worker keeps its own pinned Python 3.11 (glibc only).
"$UV" venv --python 3.13 --managed-python --relocatable --allow-existing "$APP" \
|| fail "Creating the managed Python 3.13 environment failed; check network access and free space."
- # uv creates the interpreter links with absolute targets. Make them
- # relative so one runtime serves every environment that mounts this
- # directory at a different path (host and containers included).
- local link target
- for link in "$APP"/bin/python "$APP"/bin/python3 "$APP"/bin/python3.*; do
- [[ -L "$link" ]] || continue
- target="$(readlink -- "$link")"
- case "$target" in "$RUNTIME"/*)
- ln -sfn -- "$(realpath -m --relative-to="$APP/bin" -- "$target")" "$link"
- ;;
- esac
- done
# The bundled FFmpeg wheel is preferred; where it does not exist (for
# example musl systems), fall back to a system FFmpeg at runtime.
local bundled="$PROJECT[bundled-ffmpeg]" core="$PROJECT"
@@ -281,6 +315,7 @@ bootstrap() {
fi
printf '%s v%s\n' "$RUNTIME" "$RUNTIME_VERSION" > "$READY.tmp"
mv -f -- "$READY.tmp" "$READY"
+ repoint_runtime
# Downgrade to the shared runtime lock for the application's lifetime.
flock -s 8
exec 9>&-