diff options
| -rw-r--r-- | 8 | 0 | ||||
| -rw-r--r-- | README.md | 27 | ||||
| -rw-r--r-- | lib/project/src/producer/gui.py | 10 | ||||
| -rw-r--r-- | lib/project/src/producer/gui_jobs.py | 3 | ||||
| -rw-r--r-- | lib/project/src/producer/gui_schema.py | 102 | ||||
| -rw-r--r-- | lib/project/src/producer/web/app.js | 26 | ||||
| -rw-r--r-- | lib/project/src/producer/web/index.html | 16 | ||||
| -rw-r--r-- | lib/project/src/producer/web/style.css | 12 | ||||
| -rwxr-xr-x | producer.sh | 59 |
9 files changed, 145 insertions, 110 deletions
@@ -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 & 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 & 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 & 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>&- |
