diff options
Diffstat (limited to 'lib')
| -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 |
6 files changed, 85 insertions, 84 deletions
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; |
