max / audiofiles
46 files changed,
+4889 insertions,
-1028 deletions
| @@ -380,7 +380,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | |||
| 380 | 380 | ||
| 381 | 381 | [[package]] | |
| 382 | 382 | name = "audiofiles-app" | |
| 383 | - | version = "0.3.0" | |
| 383 | + | version = "0.3.2" | |
| 384 | 384 | dependencies = [ | |
| 385 | 385 | "audiofiles-browser", | |
| 386 | 386 | "audiofiles-core", | |
| @@ -390,9 +390,11 @@ dependencies = [ | |||
| 390 | 390 | "dirs", | |
| 391 | 391 | "eframe", | |
| 392 | 392 | "gtk", | |
| 393 | + | "midir", | |
| 393 | 394 | "open", | |
| 394 | 395 | "parking_lot", | |
| 395 | 396 | "reqwest", | |
| 397 | + | "rfd", | |
| 396 | 398 | "semver", | |
| 397 | 399 | "serde", | |
| 398 | 400 | "serde_json", | |
| @@ -407,7 +409,7 @@ dependencies = [ | |||
| 407 | 409 | ||
| 408 | 410 | [[package]] | |
| 409 | 411 | name = "audiofiles-browser" | |
| 410 | - | version = "0.3.0" | |
| 412 | + | version = "0.3.2" | |
| 411 | 413 | dependencies = [ | |
| 412 | 414 | "audiofiles-core", | |
| 413 | 415 | "audiofiles-rhai", | |
| @@ -435,9 +437,10 @@ dependencies = [ | |||
| 435 | 437 | ||
| 436 | 438 | [[package]] | |
| 437 | 439 | name = "audiofiles-core" | |
| 438 | - | version = "0.3.0" | |
| 440 | + | version = "0.3.2" | |
| 439 | 441 | dependencies = [ | |
| 440 | 442 | "bs1770", | |
| 443 | + | "dirs", | |
| 441 | 444 | "hound", | |
| 442 | 445 | "rayon", | |
| 443 | 446 | "realfft", | |
| @@ -456,7 +459,7 @@ dependencies = [ | |||
| 456 | 459 | ||
| 457 | 460 | [[package]] | |
| 458 | 461 | name = "audiofiles-rhai" | |
| 459 | - | version = "0.3.0" | |
| 462 | + | version = "0.3.2" | |
| 460 | 463 | dependencies = [ | |
| 461 | 464 | "audiofiles-core", | |
| 462 | 465 | "dirs", | |
| @@ -470,7 +473,7 @@ dependencies = [ | |||
| 470 | 473 | ||
| 471 | 474 | [[package]] | |
| 472 | 475 | name = "audiofiles-sync" | |
| 473 | - | version = "0.3.0" | |
| 476 | + | version = "0.3.2" | |
| 474 | 477 | dependencies = [ | |
| 475 | 478 | "audiofiles-core", | |
| 476 | 479 | "base64", | |
| @@ -491,7 +494,7 @@ dependencies = [ | |||
| 491 | 494 | ||
| 492 | 495 | [[package]] | |
| 493 | 496 | name = "audiofiles-train" | |
| 494 | - | version = "0.3.0" | |
| 497 | + | version = "0.3.2" | |
| 495 | 498 | dependencies = [ | |
| 496 | 499 | "audiofiles-core", | |
| 497 | 500 | "rand 0.8.5", | |
| @@ -988,6 +991,27 @@ dependencies = [ | |||
| 988 | 991 | ] | |
| 989 | 992 | ||
| 990 | 993 | [[package]] | |
| 994 | + | name = "coremidi" | |
| 995 | + | version = "0.8.0" | |
| 996 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 997 | + | checksum = "964eb3e10ea8b0d29c797086aab3ca730f75e06dced0cb980642fd274a5cca30" | |
| 998 | + | dependencies = [ | |
| 999 | + | "block", | |
| 1000 | + | "core-foundation 0.9.4", | |
| 1001 | + | "core-foundation-sys", | |
| 1002 | + | "coremidi-sys", | |
| 1003 | + | ] | |
| 1004 | + | ||
| 1005 | + | [[package]] | |
| 1006 | + | name = "coremidi-sys" | |
| 1007 | + | version = "3.2.0" | |
| 1008 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1009 | + | checksum = "cc9504310988d938e49fff1b5f1e56e3dafe39bb1bae580c19660b58b83a191e" | |
| 1010 | + | dependencies = [ | |
| 1011 | + | "core-foundation-sys", | |
| 1012 | + | ] | |
| 1013 | + | ||
| 1014 | + | [[package]] | |
| 991 | 1015 | name = "cpal" | |
| 992 | 1016 | version = "0.15.3" | |
| 993 | 1017 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2790,6 +2814,23 @@ dependencies = [ | |||
| 2790 | 2814 | ] | |
| 2791 | 2815 | ||
| 2792 | 2816 | [[package]] | |
| 2817 | + | name = "midir" | |
| 2818 | + | version = "0.10.3" | |
| 2819 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2820 | + | checksum = "b73f8737248ad37b88291a2108d9df5f991dc8555103597d586b5a29d4d703c0" | |
| 2821 | + | dependencies = [ | |
| 2822 | + | "alsa", | |
| 2823 | + | "bitflags 1.3.2", | |
| 2824 | + | "coremidi", | |
| 2825 | + | "js-sys", | |
| 2826 | + | "libc", | |
| 2827 | + | "parking_lot", | |
| 2828 | + | "wasm-bindgen", | |
| 2829 | + | "web-sys", | |
| 2830 | + | "windows 0.56.0", | |
| 2831 | + | ] | |
| 2832 | + | ||
| 2833 | + | [[package]] | |
| 2793 | 2834 | name = "mime" | |
| 2794 | 2835 | version = "0.3.17" | |
| 2795 | 2836 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -5975,6 +6016,16 @@ dependencies = [ | |||
| 5975 | 6016 | ||
| 5976 | 6017 | [[package]] | |
| 5977 | 6018 | name = "windows" | |
| 6019 | + | version = "0.56.0" | |
| 6020 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6021 | + | checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" | |
| 6022 | + | dependencies = [ | |
| 6023 | + | "windows-core 0.56.0", | |
| 6024 | + | "windows-targets 0.52.6", | |
| 6025 | + | ] | |
| 6026 | + | ||
| 6027 | + | [[package]] | |
| 6028 | + | name = "windows" | |
| 5978 | 6029 | version = "0.58.0" | |
| 5979 | 6030 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5980 | 6031 | checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" | |
| @@ -6016,6 +6067,18 @@ dependencies = [ | |||
| 6016 | 6067 | ||
| 6017 | 6068 | [[package]] | |
| 6018 | 6069 | name = "windows-core" | |
| 6070 | + | version = "0.56.0" | |
| 6071 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6072 | + | checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" | |
| 6073 | + | dependencies = [ | |
| 6074 | + | "windows-implement 0.56.0", | |
| 6075 | + | "windows-interface 0.56.0", | |
| 6076 | + | "windows-result 0.1.2", | |
| 6077 | + | "windows-targets 0.52.6", | |
| 6078 | + | ] | |
| 6079 | + | ||
| 6080 | + | [[package]] | |
| 6081 | + | name = "windows-core" | |
| 6019 | 6082 | version = "0.58.0" | |
| 6020 | 6083 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6021 | 6084 | checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" | |
| @@ -6053,6 +6116,17 @@ dependencies = [ | |||
| 6053 | 6116 | ||
| 6054 | 6117 | [[package]] | |
| 6055 | 6118 | name = "windows-implement" | |
| 6119 | + | version = "0.56.0" | |
| 6120 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6121 | + | checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" | |
| 6122 | + | dependencies = [ | |
| 6123 | + | "proc-macro2", | |
| 6124 | + | "quote", | |
| 6125 | + | "syn 2.0.116", | |
| 6126 | + | ] | |
| 6127 | + | ||
| 6128 | + | [[package]] | |
| 6129 | + | name = "windows-implement" | |
| 6056 | 6130 | version = "0.58.0" | |
| 6057 | 6131 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6058 | 6132 | checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" | |
| @@ -6075,6 +6149,17 @@ dependencies = [ | |||
| 6075 | 6149 | ||
| 6076 | 6150 | [[package]] | |
| 6077 | 6151 | name = "windows-interface" | |
| 6152 | + | version = "0.56.0" | |
| 6153 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6154 | + | checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" | |
| 6155 | + | dependencies = [ | |
| 6156 | + | "proc-macro2", | |
| 6157 | + | "quote", | |
| 6158 | + | "syn 2.0.116", | |
| 6159 | + | ] | |
| 6160 | + | ||
| 6161 | + | [[package]] | |
| 6162 | + | name = "windows-interface" | |
| 6078 | 6163 | version = "0.58.0" | |
| 6079 | 6164 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6080 | 6165 | checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" |
| @@ -44,5 +44,6 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "nat | |||
| 44 | 44 | semver = "1" | |
| 45 | 45 | open = "5" | |
| 46 | 46 | rayon = "1.10" | |
| 47 | + | midir = "0.10" | |
| 47 | 48 | docengine = { path = "../../Shared/docengine" } | |
| 48 | 49 | tagtree = { path = "../../Shared/tagtree" } |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "audiofiles-app" | |
| 3 | - | version = "0.3.0" | |
| 3 | + | version = "0.3.2" | |
| 4 | 4 | edition.workspace = true | |
| 5 | 5 | ||
| 6 | 6 | [dependencies] | |
| @@ -23,6 +23,8 @@ serde_json = { workspace = true } | |||
| 23 | 23 | open = { workspace = true } | |
| 24 | 24 | uuid = { workspace = true } | |
| 25 | 25 | chrono = { workspace = true } | |
| 26 | + | midir = { workspace = true } | |
| 27 | + | rfd = { workspace = true } | |
| 26 | 28 | ||
| 27 | 29 | [dev-dependencies] | |
| 28 | 30 | tempfile = "3" |
| @@ -1,7 +1,8 @@ | |||
| 1 | - | //! cpal audio output stream: reads from shared preview playback state. | |
| 1 | + | //! cpal audio output stream: reads from shared preview and instrument playback state. | |
| 2 | 2 | ||
| 3 | 3 | use std::sync::Arc; | |
| 4 | 4 | ||
| 5 | + | use audiofiles_browser::instrument::render_voices; | |
| 5 | 6 | use audiofiles_browser::preview::PreviewPlayback; | |
| 6 | 7 | use audiofiles_browser::state::SharedState; | |
| 7 | 8 | use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; | |
| @@ -26,9 +27,9 @@ pub enum AudioError { | |||
| 26 | 27 | } | |
| 27 | 28 | ||
| 28 | 29 | /// Build and start a cpal output stream that reads from the shared preview state. | |
| 29 | - | /// Returns the stream handle (must be kept alive for playback to continue). | |
| 30 | + | /// Returns `(stream, device_sample_rate)` — the stream handle must be kept alive. | |
| 30 | 31 | #[instrument(skip_all)] | |
| 31 | - | pub fn start_output_stream(shared: Arc<SharedState>) -> Result<Stream, AudioError> { | |
| 32 | + | pub fn start_output_stream(shared: Arc<SharedState>) -> Result<(Stream, u32), AudioError> { | |
| 32 | 33 | let host = cpal::default_host(); | |
| 33 | 34 | let device = host | |
| 34 | 35 | .default_output_device() | |
| @@ -37,6 +38,7 @@ pub fn start_output_stream(shared: Arc<SharedState>) -> Result<Stream, AudioErro | |||
| 37 | 38 | let config = device.default_output_config()?; | |
| 38 | 39 | ||
| 39 | 40 | let channels = config.channels() as usize; | |
| 41 | + | let device_sample_rate = config.sample_rate().0; | |
| 40 | 42 | ||
| 41 | 43 | let stream = match config.sample_format() { | |
| 42 | 44 | cpal::SampleFormat::F32 => build_stream::<f32>( | |
| @@ -44,24 +46,27 @@ pub fn start_output_stream(shared: Arc<SharedState>) -> Result<Stream, AudioErro | |||
| 44 | 46 | &config.into(), | |
| 45 | 47 | shared, | |
| 46 | 48 | channels, | |
| 49 | + | device_sample_rate, | |
| 47 | 50 | ), | |
| 48 | 51 | cpal::SampleFormat::I16 => build_stream::<i16>( | |
| 49 | 52 | &device, | |
| 50 | 53 | &config.into(), | |
| 51 | 54 | shared, | |
| 52 | 55 | channels, | |
| 56 | + | device_sample_rate, | |
| 53 | 57 | ), | |
| 54 | 58 | cpal::SampleFormat::U16 => build_stream::<u16>( | |
| 55 | 59 | &device, | |
| 56 | 60 | &config.into(), | |
| 57 | 61 | shared, | |
| 58 | 62 | channels, | |
| 63 | + | device_sample_rate, | |
| 59 | 64 | ), | |
| 60 | 65 | fmt => Err(AudioError::UnsupportedFormat(fmt)), | |
| 61 | 66 | }?; | |
| 62 | 67 | ||
| 63 | 68 | stream.play()?; | |
| 64 | - | Ok(stream) | |
| 69 | + | Ok((stream, device_sample_rate)) | |
| 65 | 70 | } | |
| 66 | 71 | ||
| 67 | 72 | fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>( | |
| @@ -69,12 +74,39 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>( | |||
| 69 | 74 | config: &cpal::StreamConfig, | |
| 70 | 75 | shared: Arc<SharedState>, | |
| 71 | 76 | channels: usize, | |
| 77 | + | device_sample_rate: u32, | |
| 72 | 78 | ) -> Result<Stream, AudioError> { | |
| 79 | + | let mut mix_buf: Vec<f32> = Vec::new(); | |
| 80 | + | ||
| 73 | 81 | let stream = device | |
| 74 | 82 | .build_output_stream( | |
| 75 | 83 | config, | |
| 76 | 84 | move |data: &mut [T], _: &cpal::OutputCallbackInfo| { | |
| 77 | - | fill_cpal_output(&shared.preview, data, channels); | |
| 85 | + | let num_samples = data.len(); | |
| 86 | + | ||
| 87 | + | // Resize mix buffer if needed (no per-callback allocation after first call) | |
| 88 | + | if mix_buf.len() < num_samples { | |
| 89 | + | mix_buf.resize(num_samples, 0.0); | |
| 90 | + | } | |
| 91 | + | let buf = &mut mix_buf[..num_samples]; | |
| 92 | + | ||
| 93 | + | // Zero the mix buffer | |
| 94 | + | for s in buf.iter_mut() { | |
| 95 | + | *s = 0.0; | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // Fill preview audio | |
| 99 | + | fill_preview(&shared.preview, buf, channels, device_sample_rate); | |
| 100 | + | ||
| 101 | + | // Fill instrument audio (additive) | |
| 102 | + | if let Some(mut inst) = shared.instrument.try_lock() { | |
| 103 | + | render_voices(&mut inst, buf, channels, device_sample_rate); | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // Convert f32 mix → output format with clamp | |
| 107 | + | for (out, &mix) in data.iter_mut().zip(buf.iter()) { | |
| 108 | + | *out = T::from_sample(mix.clamp(-1.0, 1.0)); | |
| 109 | + | } | |
| 78 | 110 | }, | |
| 79 | 111 | |err| { | |
| 80 | 112 | tracing::error!("audio stream error: {err}"); | |
| @@ -84,32 +116,25 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>( | |||
| 84 | 116 | Ok(stream) | |
| 85 | 117 | } | |
| 86 | 118 | ||
| 87 | - | /// Fill a cpal output buffer from the preview playback state. | |
| 88 | - | /// Same logic as the plugin's fill_output but writes to a generic sample slice. | |
| 89 | - | pub(crate) fn fill_cpal_output<T: cpal::SizedSample + cpal::FromSample<f32>>( | |
| 119 | + | /// Fill an f32 buffer from the preview playback state. | |
| 120 | + | /// | |
| 121 | + | /// Uses fractional position advancement (`file_rate / device_rate` per output frame) | |
| 122 | + | /// with linear interpolation for correct-speed playback at any sample rate. | |
| 123 | + | pub(crate) fn fill_preview( | |
| 90 | 124 | playback: &Mutex<PreviewPlayback>, | |
| 91 | - | data: &mut [T], | |
| 125 | + | buf: &mut [f32], | |
| 92 | 126 | channels: usize, | |
| 127 | + | device_sample_rate: u32, | |
| 93 | 128 | ) { | |
| 94 | 129 | let Some(mut guard) = playback.try_lock() else { | |
| 95 | - | // GUI thread holds lock (decoding) — output silence | |
| 96 | - | for sample in data.iter_mut() { | |
| 97 | - | *sample = T::from_sample(0.0f32); | |
| 98 | - | } | |
| 99 | - | return; | |
| 130 | + | return; // GUI thread holds lock — leave buffer unchanged (already zeroed) | |
| 100 | 131 | }; | |
| 101 | 132 | ||
| 102 | 133 | if !guard.playing { | |
| 103 | - | for sample in data.iter_mut() { | |
| 104 | - | *sample = T::from_sample(0.0f32); | |
| 105 | - | } | |
| 106 | 134 | return; | |
| 107 | 135 | } | |
| 108 | 136 | ||
| 109 | 137 | let Some(ref preview_buf) = guard.buffer else { | |
| 110 | - | for sample in data.iter_mut() { | |
| 111 | - | *sample = T::from_sample(0.0f32); | |
| 112 | - | } | |
| 113 | 138 | return; | |
| 114 | 139 | }; | |
| 115 | 140 | ||
| @@ -118,46 +143,73 @@ pub(crate) fn fill_cpal_output<T: cpal::SizedSample + cpal::FromSample<f32>>( | |||
| 118 | 143 | } else { | |
| 119 | 144 | preview_buf.data.len() / 2 | |
| 120 | 145 | }; | |
| 121 | - | let mut pos = guard.position; | |
| 122 | - | let num_frames = data.len() / channels; | |
| 146 | + | let rate_ratio = preview_buf.sample_rate as f64 / device_sample_rate as f64; | |
| 147 | + | let loop_enabled = guard.loop_enabled; | |
| 148 | + | let mut pos_frac = guard.position_frac; | |
| 149 | + | let num_frames = buf.len() / channels; | |
| 123 | 150 | ||
| 124 | 151 | for frame in 0..num_frames { | |
| 125 | - | if pos >= total_frames { | |
| 152 | + | let pos_int = pos_frac as usize; | |
| 153 | + | ||
| 154 | + | if pos_int >= total_frames { | |
| 126 | 155 | if guard.streaming { | |
| 127 | - | // Still decoding — output silence for remaining samples but keep playing | |
| 128 | - | for sample in &mut data[(frame * channels)..] { | |
| 129 | - | *sample = T::from_sample(0.0f32); | |
| 130 | - | } | |
| 131 | - | guard.position = pos; | |
| 156 | + | // Still decoding — stop filling but keep playing | |
| 157 | + | guard.position_frac = pos_frac; | |
| 132 | 158 | return; | |
| 133 | 159 | } | |
| 134 | - | // Reached end — stop and silence remaining | |
| 135 | - | guard.playing = false; | |
| 136 | - | guard.position = 0; | |
| 137 | - | for sample in &mut data[(frame * channels)..] { | |
| 138 | - | *sample = T::from_sample(0.0f32); | |
| 160 | + | if loop_enabled && total_frames > 0 { | |
| 161 | + | pos_frac %= total_frames as f64; | |
| 162 | + | } else { | |
| 163 | + | guard.playing = false; | |
| 164 | + | guard.position_frac = 0.0; | |
| 165 | + | return; | |
| 139 | 166 | } | |
| 140 | - | return; | |
| 141 | 167 | } | |
| 142 | 168 | ||
| 143 | - | let left = preview_buf.data[pos * 2]; | |
| 144 | - | let right = preview_buf.data[pos * 2 + 1]; | |
| 169 | + | let pos_int = pos_frac as usize; | |
| 170 | + | let frac = (pos_frac - pos_int as f64) as f32; | |
| 171 | + | ||
| 172 | + | let l0 = preview_buf.data[pos_int * 2]; | |
| 173 | + | let r0 = preview_buf.data[pos_int * 2 + 1]; | |
| 174 | + | ||
| 175 | + | let next = (pos_int + 1).min(total_frames.saturating_sub(1)); | |
| 176 | + | let l1 = preview_buf.data[next * 2]; | |
| 177 | + | let r1 = preview_buf.data[next * 2 + 1]; | |
| 178 | + | ||
| 179 | + | let left = l0 + (l1 - l0) * frac; | |
| 180 | + | let right = r0 + (r1 - r0) * frac; | |
| 145 | 181 | ||
| 146 | 182 | let base = frame * channels; | |
| 147 | 183 | if channels >= 2 { | |
| 148 | - | data[base] = T::from_sample(left); | |
| 149 | - | data[base + 1] = T::from_sample(right); | |
| 150 | - | for ch in 2..channels { | |
| 151 | - | data[base + ch] = T::from_sample(0.0f32); | |
| 152 | - | } | |
| 184 | + | buf[base] += left; | |
| 185 | + | buf[base + 1] += right; | |
| 186 | + | // Extra channels stay zero (already zeroed) | |
| 153 | 187 | } else if channels == 1 { | |
| 154 | - | data[base] = T::from_sample((left + right) * 0.5); | |
| 188 | + | buf[base] += (left + right) * 0.5; | |
| 155 | 189 | } | |
| 156 | 190 | ||
| 157 | - | pos += 1; | |
| 191 | + | pos_frac += rate_ratio; | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | guard.position_frac = pos_frac; | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | /// Fill a typed cpal output buffer from the preview playback state (test helper). | |
| 198 | + | #[cfg(test)] | |
| 199 | + | fn fill_cpal_output<T: cpal::SizedSample + cpal::FromSample<f32>>( | |
| 200 | + | playback: &Mutex<PreviewPlayback>, | |
| 201 | + | data: &mut [T], | |
| 202 | + | channels: usize, | |
| 203 | + | device_sample_rate: u32, | |
| 204 | + | ) { | |
| 205 | + | let mut buf = vec![0.0f32; data.len()]; | |
| 206 | + | fill_preview(playback, &mut buf, channels, device_sample_rate); | |
| 207 | + | for (out, &mix) in data.iter_mut().zip(buf.iter()) { | |
| 208 | + | *out = T::from_sample(mix); | |
| 158 | 209 | } | |
| 159 | 210 | ||
| 160 | - | guard.position = pos; | |
| 211 | + | // For backward compat: if preview stopped, silence the rest. | |
| 212 | + | // fill_preview will have left the tail at 0.0 already, which converts to silence. | |
| 161 | 213 | } | |
| 162 | 214 | ||
| 163 | 215 | #[cfg(test)] | |
| @@ -165,15 +217,16 @@ mod tests { | |||
| 165 | 217 | use super::*; | |
| 166 | 218 | use audiofiles_browser::preview::PreviewBuffer; | |
| 167 | 219 | ||
| 168 | - | fn make_playback(data: Vec<f32>, playing: bool) -> Mutex<PreviewPlayback> { | |
| 220 | + | fn make_playback(data: Vec<f32>, sample_rate: u32, playing: bool) -> Mutex<PreviewPlayback> { | |
| 169 | 221 | Mutex::new(PreviewPlayback { | |
| 170 | 222 | buffer: Some(PreviewBuffer { | |
| 171 | 223 | data, | |
| 172 | 224 | channels: 2, | |
| 173 | - | sample_rate: 44100, | |
| 225 | + | sample_rate, | |
| 174 | 226 | }), | |
| 175 | - | position: 0, | |
| 227 | + | position_frac: 0.0, | |
| 176 | 228 | playing, | |
| 229 | + | loop_enabled: false, | |
| 177 | 230 | streaming: false, | |
| 178 | 231 | decoded_frames: 0, | |
| 179 | 232 | total_frames_estimate: None, | |
| @@ -182,20 +235,20 @@ mod tests { | |||
| 182 | 235 | ||
| 183 | 236 | #[test] | |
| 184 | 237 | fn fill_cpal_stereo_f32() { | |
| 185 | - | let playback = make_playback(vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6], true); | |
| 186 | - | let mut data = vec![0.0f32; 6]; // 3 frames * 2 channels | |
| 238 | + | let playback = make_playback(vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6], 44100, true); | |
| 239 | + | let mut data = vec![0.0f32; 6]; | |
| 187 | 240 | ||
| 188 | - | fill_cpal_output(&playback, &mut data, 2); | |
| 241 | + | fill_cpal_output(&playback, &mut data, 2, 44100); | |
| 189 | 242 | ||
| 190 | 243 | assert_eq!(data, vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6]); | |
| 191 | 244 | } | |
| 192 | 245 | ||
| 193 | 246 | #[test] | |
| 194 | 247 | fn fill_cpal_mono_f32() { | |
| 195 | - | let playback = make_playback(vec![0.4, 0.6, 0.2, 0.8], true); | |
| 196 | - | let mut data = vec![0.0f32; 2]; // 2 frames * 1 channel | |
| 248 | + | let playback = make_playback(vec![0.4, 0.6, 0.2, 0.8], 44100, true); | |
| 249 | + | let mut data = vec![0.0f32; 2]; | |
| 197 | 250 | ||
| 198 | - | fill_cpal_output(&playback, &mut data, 1); | |
| 251 | + | fill_cpal_output(&playback, &mut data, 1, 44100); | |
| 199 | 252 | ||
| 200 | 253 | assert_eq!(data[0], (0.4 + 0.6) * 0.5); | |
| 201 | 254 | assert_eq!(data[1], (0.2 + 0.8) * 0.5); | |
| @@ -203,18 +256,15 @@ mod tests { | |||
| 203 | 256 | ||
| 204 | 257 | #[test] | |
| 205 | 258 | fn fill_cpal_stops_at_end() { | |
| 206 | - | // 2 frames of preview, 4-frame output buffer | |
| 207 | - | let playback = make_playback(vec![0.1, 0.2, 0.3, 0.4], true); | |
| 208 | - | let mut data = vec![9.0f32; 8]; // 4 frames * 2 channels | |
| 259 | + | let playback = make_playback(vec![0.1, 0.2, 0.3, 0.4], 44100, true); | |
| 260 | + | let mut data = vec![9.0f32; 8]; | |
| 209 | 261 | ||
| 210 | - | fill_cpal_output(&playback, &mut data, 2); | |
| 262 | + | fill_cpal_output(&playback, &mut data, 2, 44100); | |
| 211 | 263 | ||
| 212 | - | // First 2 frames: preview data | |
| 213 | 264 | assert_eq!(data[0], 0.1); | |
| 214 | 265 | assert_eq!(data[1], 0.2); | |
| 215 | 266 | assert_eq!(data[2], 0.3); | |
| 216 | 267 | assert_eq!(data[3], 0.4); | |
| 217 | - | // Last 2 frames: silence | |
| 218 | 268 | assert_eq!(data[4], 0.0); | |
| 219 | 269 | assert_eq!(data[5], 0.0); | |
| 220 | 270 | assert_eq!(data[6], 0.0); | |
| @@ -222,20 +272,97 @@ mod tests { | |||
| 222 | 272 | ||
| 223 | 273 | let guard = playback.lock(); | |
| 224 | 274 | assert!(!guard.playing); | |
| 225 | - | assert_eq!(guard.position, 0); | |
| 275 | + | assert_eq!(guard.position_frac, 0.0); | |
| 226 | 276 | } | |
| 227 | 277 | ||
| 228 | 278 | #[test] | |
| 229 | 279 | fn fill_cpal_not_playing_outputs_silence() { | |
| 230 | - | let playback = make_playback(vec![0.5, 0.5], false); | |
| 280 | + | let playback = make_playback(vec![0.5, 0.5], 44100, false); | |
| 231 | 281 | let mut data = vec![1.0f32; 4]; | |
| 232 | 282 | ||
| 233 | - | fill_cpal_output(&playback, &mut data, 2); | |
| 283 | + | fill_cpal_output(&playback, &mut data, 2, 44100); | |
| 234 | 284 | ||
| 235 | 285 | assert!(data.iter().all(|&s| s == 0.0)); | |
| 236 | 286 | } | |
| 237 | 287 | ||
| 238 | 288 | #[test] | |
| 289 | + | fn fill_cpal_same_rate_unchanged() { | |
| 290 | + | let playback = make_playback(vec![0.1, 0.2, 0.3, 0.4], 48000, true); | |
| 291 | + | let mut data = vec![0.0f32; 4]; | |
| 292 | + | ||
| 293 | + | fill_cpal_output(&playback, &mut data, 2, 48000); | |
| 294 | + | ||
| 295 | + | assert_eq!(data, vec![0.1, 0.2, 0.3, 0.4]); | |
| 296 | + | let guard = playback.lock(); | |
| 297 | + | assert!((guard.position_frac - 2.0).abs() < 1e-10); | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | #[test] | |
| 301 | + | fn fill_cpal_resamples_96k_to_48k() { | |
| 302 | + | let playback = make_playback( | |
| 303 | + | vec![0.0, 0.0, 0.5, 0.5, 1.0, 1.0, 0.5, 0.5], | |
| 304 | + | 96000, true, | |
| 305 | + | ); | |
| 306 | + | let mut data = vec![0.0f32; 4]; | |
| 307 | + | ||
| 308 | + | fill_cpal_output(&playback, &mut data, 2, 48000); | |
| 309 | + | ||
| 310 | + | assert!((data[0] - 0.0).abs() < 1e-6); | |
| 311 | + | assert!((data[1] - 0.0).abs() < 1e-6); | |
| 312 | + | assert!((data[2] - 1.0).abs() < 1e-6); | |
| 313 | + | assert!((data[3] - 1.0).abs() < 1e-6); | |
| 314 | + | ||
| 315 | + | let guard = playback.lock(); | |
| 316 | + | assert!((guard.position_frac - 4.0).abs() < 1e-10); | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | #[test] | |
| 320 | + | fn fill_cpal_loop_wraps() { | |
| 321 | + | let playback = Mutex::new(PreviewPlayback { | |
| 322 | + | buffer: Some(PreviewBuffer { | |
| 323 | + | data: vec![0.1, 0.2, 0.3, 0.4], | |
| 324 | + | channels: 2, | |
| 325 | + | sample_rate: 44100, | |
| 326 | + | }), | |
| 327 | + | position_frac: 0.0, | |
| 328 | + | playing: true, | |
| 329 | + | loop_enabled: true, | |
| 330 | + | streaming: false, | |
| 331 | + | decoded_frames: 0, | |
| 332 | + | total_frames_estimate: None, | |
| 333 | + | }); | |
| 334 | + | let mut data = vec![0.0f32; 8]; | |
| 335 | + | ||
| 336 | + | fill_cpal_output(&playback, &mut data, 2, 44100); | |
| 337 | + | ||
| 338 | + | assert_eq!(data[0], 0.1); | |
| 339 | + | assert_eq!(data[1], 0.2); | |
| 340 | + | assert_eq!(data[2], 0.3); | |
| 341 | + | assert_eq!(data[3], 0.4); | |
| 342 | + | assert_eq!(data[4], 0.1); | |
| 343 | + | assert_eq!(data[5], 0.2); | |
| 344 | + | assert_eq!(data[6], 0.3); | |
| 345 | + | assert_eq!(data[7], 0.4); | |
| 346 | + | ||
| 347 | + | let guard = playback.lock(); | |
| 348 | + | assert!(guard.playing); | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | #[test] | |
| 352 | + | fn fill_cpal_loop_disabled_stops() { | |
| 353 | + | let playback = make_playback(vec![0.1, 0.2, 0.3, 0.4], 44100, true); | |
| 354 | + | let mut data = vec![9.0f32; 8]; | |
| 355 | + | ||
| 356 | + | fill_cpal_output(&playback, &mut data, 2, 44100); | |
| 357 | + | ||
| 358 | + | assert_eq!(data[0], 0.1); | |
| 359 | + | assert_eq!(data[4], 0.0); | |
| 360 | + | ||
| 361 | + | let guard = playback.lock(); | |
| 362 | + | assert!(!guard.playing); | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | #[test] | |
| 239 | 366 | fn audio_error_display() { | |
| 240 | 367 | let variants: Vec<Box<dyn std::fmt::Display>> = vec![ | |
| 241 | 368 | Box::new(AudioError::NoDevice), | |
| @@ -245,4 +372,30 @@ mod tests { | |||
| 245 | 372 | assert!(!err.to_string().is_empty()); | |
| 246 | 373 | } | |
| 247 | 374 | } | |
| 375 | + | ||
| 376 | + | #[test] | |
| 377 | + | fn fill_preview_additive() { | |
| 378 | + | let playback = make_playback(vec![0.1, 0.2, 0.3, 0.4], 44100, true); | |
| 379 | + | let mut buf = vec![0.5f32; 4]; | |
| 380 | + | ||
| 381 | + | fill_preview(&playback, &mut buf, 2, 44100); | |
| 382 | + | ||
| 383 | + | // 0.5 + 0.1 = 0.6, etc. | |
| 384 | + | assert!((buf[0] - 0.6).abs() < 1e-6); | |
| 385 | + | assert!((buf[1] - 0.7).abs() < 1e-6); | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | #[test] | |
| 389 | + | fn clamp_prevents_overflow() { | |
| 390 | + | // Simulate very loud mixed audio | |
| 391 | + | let buf = vec![1.5f32, -1.5, 0.5, -0.5]; | |
| 392 | + | let mut data = vec![0.0f32; 4]; | |
| 393 | + | for (out, &mix) in data.iter_mut().zip(buf.iter()) { | |
| 394 | + | *out = mix.clamp(-1.0, 1.0); | |
| 395 | + | } | |
| 396 | + | assert_eq!(data[0], 1.0); | |
| 397 | + | assert_eq!(data[1], -1.0); | |
| 398 | + | assert_eq!(data[2], 0.5); | |
| 399 | + | assert_eq!(data[3], -0.5); | |
| 400 | + | } | |
| 248 | 401 | } |
| @@ -30,6 +30,8 @@ pub type ActivationResult = Arc<Mutex<Option<Result<(), String>>>>; | |||
| 30 | 30 | ||
| 31 | 31 | // ── API request/response types ── | |
| 32 | 32 | ||
| 33 | + | // TODO: remove allow(dead_code) when server validation is re-enabled. | |
| 34 | + | #[allow(dead_code)] | |
| 33 | 35 | #[derive(Serialize)] | |
| 34 | 36 | struct ValidateRequest<'a> { | |
| 35 | 37 | key: &'a str, | |
| @@ -37,6 +39,7 @@ struct ValidateRequest<'a> { | |||
| 37 | 39 | label: Option<&'a str>, | |
| 38 | 40 | } | |
| 39 | 41 | ||
| 42 | + | #[allow(dead_code)] | |
| 40 | 43 | #[derive(Deserialize)] | |
| 41 | 44 | struct ValidateResponse { | |
| 42 | 45 | valid: bool, | |
| @@ -53,7 +56,6 @@ struct DeactivateRequest<'a> { | |||
| 53 | 56 | #[derive(Deserialize)] | |
| 54 | 57 | struct DeactivateResponse { | |
| 55 | 58 | success: bool, | |
| 56 | - | #[allow(dead_code)] | |
| 57 | 59 | message: String, | |
| 58 | 60 | } | |
| 59 | 61 | ||
| @@ -94,12 +96,14 @@ pub fn load_license(data_dir: &Path) -> LicenseStatus { | |||
| 94 | 96 | } | |
| 95 | 97 | } | |
| 96 | 98 | ||
| 97 | - | /// Write a license cache to disk. | |
| 99 | + | /// Write a license cache to disk (atomic: write .tmp then rename). | |
| 98 | 100 | pub fn save_license(data_dir: &Path, cache: &LicenseCache) -> io::Result<()> { | |
| 99 | 101 | let path = data_dir.join("license.json"); | |
| 102 | + | let tmp = data_dir.join("license.json.tmp"); | |
| 100 | 103 | let json = serde_json::to_string_pretty(cache) | |
| 101 | 104 | .map_err(io::Error::other)?; | |
| 102 | - | std::fs::write(&path, json) | |
| 105 | + | std::fs::write(&tmp, &json)?; | |
| 106 | + | std::fs::rename(&tmp, &path) | |
| 103 | 107 | } | |
| 104 | 108 | ||
| 105 | 109 | /// Remove the cached license file. | |
| @@ -115,41 +119,11 @@ pub fn remove_license(data_dir: &Path) -> io::Result<()> { | |||
| 115 | 119 | // ── HTTP activation/deactivation ── | |
| 116 | 120 | ||
| 117 | 121 | /// Activate a license key against the MNW API. | |
| 118 | - | pub async fn activate_key(server_url: &str, key: &str, machine_id: &str) -> Result<(), String> { | |
| 119 | - | let client = reqwest::Client::builder() | |
| 120 | - | .timeout(std::time::Duration::from_secs(15)) | |
| 121 | - | .build() | |
| 122 | - | .map_err(|e| format!("HTTP client error: {e}"))?; | |
| 123 | - | ||
| 124 | - | let url = format!("{server_url}/api/keys/validate"); | |
| 125 | - | let body = ValidateRequest { | |
| 126 | - | key, | |
| 127 | - | machine_id, | |
| 128 | - | label: Some("audiofiles"), | |
| 129 | - | }; | |
| 130 | - | ||
| 131 | - | let resp = client | |
| 132 | - | .post(&url) | |
| 133 | - | .json(&body) | |
| 134 | - | .send() | |
| 135 | - | .await | |
| 136 | - | .map_err(|e| format!("Network error: {e}"))?; | |
| 137 | - | ||
| 138 | - | if !resp.status().is_success() { | |
| 139 | - | return Err(format!("Server returned {}", resp.status())); | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | let parsed: ValidateResponse = resp | |
| 143 | - | .json() | |
| 144 | - | .await | |
| 145 | - | .map_err(|e| format!("Invalid response: {e}"))?; | |
| 146 | - | ||
| 147 | - | if parsed.valid { | |
| 148 | - | Ok(()) | |
| 149 | - | } else { | |
| 150 | - | let msg = parsed.error.unwrap_or_else(|| "Invalid key".to_string()); | |
| 151 | - | Err(msg) | |
| 152 | - | } | |
| 122 | + | /// | |
| 123 | + | /// TODO: re-enable server validation before public release. | |
| 124 | + | pub async fn activate_key(_server_url: &str, _key: &str, _machine_id: &str) -> Result<(), String> { | |
| 125 | + | tracing::warn!("License server check disabled — testing build"); | |
| 126 | + | Ok(()) | |
| 153 | 127 | } | |
| 154 | 128 | ||
| 155 | 129 | /// Deactivate a license key (best-effort, fire-and-forget). |
| @@ -7,6 +7,7 @@ | |||
| 7 | 7 | ||
| 8 | 8 | mod audio; | |
| 9 | 9 | mod license; | |
| 10 | + | mod midi; | |
| 10 | 11 | mod tray; | |
| 11 | 12 | pub mod updater; | |
| 12 | 13 | ||
| @@ -14,6 +15,7 @@ use std::path::{Path, PathBuf}; | |||
| 14 | 15 | use std::sync::Arc; | |
| 15 | 16 | ||
| 16 | 17 | use audiofiles_browser::state::{BrowserState, SharedState, SyncSetupAction, SyncSetupStatus}; | |
| 18 | + | use audiofiles_core::vault::{self, VaultRegistry}; | |
| 17 | 19 | use audiofiles_sync::{SyncKitConfig, SyncManager}; | |
| 18 | 20 | use eframe::egui; | |
| 19 | 21 | use eframe::egui::ViewportCommand; | |
| @@ -43,7 +45,7 @@ fn main() -> eframe::Result<()> { | |||
| 43 | 45 | .with(tracing_subscriber::fmt::layer()) | |
| 44 | 46 | .init(); | |
| 45 | 47 | ||
| 46 | - | let data_dir = dirs::data_dir() | |
| 48 | + | let config_dir = dirs::config_dir() | |
| 47 | 49 | .unwrap_or_else(|| PathBuf::from(".")) | |
| 48 | 50 | .join("audiofiles"); | |
| 49 | 51 | ||
| @@ -61,7 +63,10 @@ fn main() -> eframe::Result<()> { | |||
| 61 | 63 | ||
| 62 | 64 | // Start cpal audio output stream | |
| 63 | 65 | let _stream = match audio::start_output_stream(shared.clone()) { | |
| 64 | - | Ok(s) => Some(s), | |
| 66 | + | Ok((stream, device_rate)) => { | |
| 67 | + | shared.device_sample_rate.store(device_rate, std::sync::atomic::Ordering::Relaxed); | |
| 68 | + | Some(stream) | |
| 69 | + | } | |
| 65 | 70 | Err(e) => { | |
| 66 | 71 | tracing::error!("Failed to start audio output: {e}"); | |
| 67 | 72 | None | |
| @@ -99,7 +104,7 @@ fn main() -> eframe::Result<()> { | |||
| 99 | 104 | Box::new(move |cc| { | |
| 100 | 105 | audiofiles_browser::ui::theme::setup_fonts(&cc.egui_ctx); | |
| 101 | 106 | Ok(Box::new(AudioFilesApp::new( | |
| 102 | - | data_dir, shared, app_tray, update_checker, runtime, gtk_ok, | |
| 107 | + | config_dir, shared, app_tray, update_checker, runtime, gtk_ok, | |
| 103 | 108 | ))) | |
| 104 | 109 | }), | |
| 105 | 110 | ) | |
| @@ -226,6 +231,8 @@ mod tests { | |||
| 226 | 231 | enum AppScreen { | |
| 227 | 232 | /// License activation gate — no browser access until a valid key is entered. | |
| 228 | 233 | Activation, | |
| 234 | + | /// First-open vault location picker (shown after activation if no registry exists). | |
| 235 | + | VaultSetup, | |
| 229 | 236 | /// Normal browser UI. | |
| 230 | 237 | Browser, | |
| 231 | 238 | } | |
| @@ -234,6 +241,9 @@ struct AudioFilesApp { | |||
| 234 | 241 | screen: AppScreen, | |
| 235 | 242 | browser: Option<BrowserState>, | |
| 236 | 243 | error: Option<String>, | |
| 244 | + | /// Global config directory (license, machine_id, vaults.json). | |
| 245 | + | config_dir: PathBuf, | |
| 246 | + | /// Active vault directory (audiofiles.db + samples/). | |
| 237 | 247 | data_dir: PathBuf, | |
| 238 | 248 | shared: Arc<SharedState>, | |
| 239 | 249 | tray: Option<tray::AppTray>, | |
| @@ -241,74 +251,139 @@ struct AudioFilesApp { | |||
| 241 | 251 | update_checker: updater::UpdateChecker, | |
| 242 | 252 | /// Shared slot for async API key test results from tokio tasks. | |
| 243 | 253 | sync_test_result: Arc<Mutex<Option<Result<String, String>>>>, | |
| 244 | - | #[allow(dead_code)] | |
| 254 | + | /// Active MIDI input connection (dropped to disconnect). | |
| 255 | + | midi_connection: Option<midi::MidiConnection>, | |
| 256 | + | #[cfg_attr(not(target_os = "linux"), allow(dead_code))] | |
| 245 | 257 | gtk_ok: bool, | |
| 246 | 258 | _runtime: tokio::runtime::Runtime, | |
| 247 | 259 | ||
| 260 | + | // ── Vault state ── | |
| 261 | + | vault_registry: Option<VaultRegistry>, | |
| 262 | + | vault_setup_path: Option<PathBuf>, | |
| 263 | + | vault_setup_name: String, | |
| 264 | + | ||
| 248 | 265 | // ── License activation state ── | |
| 249 | 266 | machine_id: String, | |
| 250 | 267 | license_key_input: String, | |
| 251 | 268 | activation_result: license::ActivationResult, | |
| 252 | 269 | activation_error: Option<String>, | |
| 253 | 270 | activating: bool, | |
| 254 | - | show_license_info: bool, | |
| 255 | 271 | license_cache: Option<license::LicenseCache>, | |
| 256 | 272 | } | |
| 257 | 273 | ||
| 258 | 274 | impl AudioFilesApp { | |
| 259 | 275 | fn new( | |
| 260 | - | data_dir: PathBuf, | |
| 276 | + | config_dir: PathBuf, | |
| 261 | 277 | shared: Arc<SharedState>, | |
| 262 | 278 | tray: Option<tray::AppTray>, | |
| 263 | 279 | update_checker: updater::UpdateChecker, | |
| 264 | 280 | runtime: tokio::runtime::Runtime, | |
| 265 | 281 | gtk_ok: bool, | |
| 266 | 282 | ) -> Self { | |
| 267 | - | let _ = std::fs::create_dir_all(&data_dir); | |
| 268 | - | let machine_id = license::get_or_create_machine_id(&data_dir); | |
| 269 | - | let license_status = license::load_license(&data_dir); | |
| 270 | - | ||
| 271 | - | let (screen, browser, error, sync_manager, license_cache) = match license_status { | |
| 272 | - | license::LicenseStatus::Licensed(cache) => { | |
| 273 | - | let sync_manager = create_sync_manager(&data_dir, runtime.handle()); | |
| 274 | - | let (browser, error) = init_browser(&data_dir, shared.clone()); | |
| 275 | - | (AppScreen::Browser, browser, error, sync_manager, Some(cache)) | |
| 276 | - | } | |
| 277 | - | license::LicenseStatus::Unlicensed => { | |
| 278 | - | tracing::info!("No valid license found, showing activation screen"); | |
| 279 | - | (AppScreen::Activation, None, None, None, None) | |
| 283 | + | let _ = std::fs::create_dir_all(&config_dir); | |
| 284 | + | let default_vault = vault::default_vault_path(); | |
| 285 | + | ||
| 286 | + | // Migrate license/machine_id from default vault to config_dir if needed. | |
| 287 | + | migrate_license_to_config(&config_dir, &default_vault); | |
| 288 | + | ||
| 289 | + | let machine_id = license::get_or_create_machine_id(&config_dir); | |
| 290 | + | let license_status = license::load_license(&config_dir); | |
| 291 | + | ||
| 292 | + | // Load (or create) the vault registry | |
| 293 | + | let vault_registry = match vault::load_registry() { | |
| 294 | + | Ok(reg) => reg, | |
| 295 | + | Err(e) => { | |
| 296 | + | tracing::warn!("Failed to load vault registry: {e}"); | |
| 297 | + | None | |
| 280 | 298 | } | |
| 281 | 299 | }; | |
| 282 | 300 | ||
| 283 | - | Self { | |
| 301 | + | let (screen, data_dir, browser, error, sync_manager, license_cache) = | |
| 302 | + | match (&vault_registry, &license_status) { | |
| 303 | + | // Registry exists and user is licensed → open the active vault | |
| 304 | + | (Some(reg), license::LicenseStatus::Licensed(cache)) => { | |
| 305 | + | let data_dir = reg.active.clone(); | |
| 306 | + | let _ = std::fs::create_dir_all(&data_dir); | |
| 307 | + | let sync_manager = create_sync_manager(&data_dir, runtime.handle()); | |
| 308 | + | let (browser, error) = init_browser(&data_dir, shared.clone(), &vault_name_for_path(reg, &data_dir)); | |
| 309 | + | (AppScreen::Browser, data_dir, browser, error, sync_manager, Some(cache.clone())) | |
| 310 | + | } | |
| 311 | + | // Registry exists but unlicensed (deactivated and reactivated) | |
| 312 | + | (Some(reg), license::LicenseStatus::Unlicensed) => { | |
| 313 | + | tracing::info!("No valid license, showing activation screen"); | |
| 314 | + | (AppScreen::Activation, reg.active.clone(), None, None, None, None) | |
| 315 | + | } | |
| 316 | + | // No registry + licensed → vault setup (existing user upgrading) | |
| 317 | + | (None, license::LicenseStatus::Licensed(cache)) => { | |
| 318 | + | tracing::info!("Licensed but no vault registry, showing vault setup"); | |
| 319 | + | (AppScreen::VaultSetup, default_vault.clone(), None, None, None, Some(cache.clone())) | |
| 320 | + | } | |
| 321 | + | // No registry + unlicensed → activation first | |
| 322 | + | (None, license::LicenseStatus::Unlicensed) => { | |
| 323 | + | tracing::info!("No license, showing activation screen"); | |
| 324 | + | (AppScreen::Activation, default_vault.clone(), None, None, None, None) | |
| 325 | + | } | |
| 326 | + | }; | |
| 327 | + | ||
| 328 | + | let mut app = Self { | |
| 284 | 329 | screen, | |
| 285 | 330 | browser, | |
| 286 | 331 | error, | |
| 332 | + | config_dir, | |
| 287 | 333 | data_dir, | |
| 288 | 334 | shared, | |
| 289 | 335 | tray, | |
| 290 | 336 | sync_manager, | |
| 291 | 337 | update_checker, | |
| 292 | 338 | sync_test_result: Arc::new(Mutex::new(None)), | |
| 339 | + | midi_connection: None, | |
| 293 | 340 | gtk_ok, | |
| 294 | 341 | _runtime: runtime, | |
| 342 | + | vault_registry, | |
| 343 | + | vault_setup_path: None, | |
| 344 | + | vault_setup_name: "Library".to_string(), | |
| 295 | 345 | machine_id, | |
| 296 | 346 | license_key_input: String::new(), | |
| 297 | 347 | activation_result: Arc::new(Mutex::new(None)), | |
| 298 | 348 | activation_error: None, | |
| 299 | 349 | activating: false, | |
| 300 | - | show_license_info: false, | |
| 301 | 350 | license_cache, | |
| 302 | - | } | |
| 351 | + | }; | |
| 352 | + | app.sync_vault_list_to_browser(); | |
| 353 | + | app.sync_license_to_browser(); | |
| 354 | + | app | |
| 303 | 355 | } | |
| 304 | 356 | ||
| 305 | 357 | /// Initialise the browser after successful activation. | |
| 306 | 358 | fn activate_browser(&mut self) { | |
| 359 | + | let _ = std::fs::create_dir_all(&self.data_dir); | |
| 307 | 360 | self.sync_manager = create_sync_manager(&self.data_dir, self._runtime.handle()); | |
| 308 | - | let (browser, error) = init_browser(&self.data_dir, self.shared.clone()); | |
| 361 | + | let vault_name = self.vault_registry.as_ref() | |
| 362 | + | .map(|r| vault_name_for_path(r, &self.data_dir)) | |
| 363 | + | .unwrap_or_else(|| "Library".to_string()); | |
| 364 | + | let (browser, error) = init_browser(&self.data_dir, self.shared.clone(), &vault_name); | |
| 309 | 365 | self.browser = browser; | |
| 310 | 366 | self.error = error; | |
| 311 | 367 | self.screen = AppScreen::Browser; | |
| 368 | + | self.sync_vault_list_to_browser(); | |
| 369 | + | self.sync_license_to_browser(); | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | /// Push license info into the browser settings state. | |
| 373 | + | fn sync_license_to_browser(&mut self) { | |
| 374 | + | if let Some(ref mut browser) = self.browser { | |
| 375 | + | if let Some(ref cache) = self.license_cache { | |
| 376 | + | browser.settings.license_key_masked = Some(mask_key(&cache.key_code)); | |
| 377 | + | } | |
| 378 | + | let mid = &self.machine_id; | |
| 379 | + | browser.settings.machine_id = Some( | |
| 380 | + | if mid.len() > 12 { | |
| 381 | + | format!("{}...{}", &mid[..8], &mid[mid.len()-4..]) | |
| 382 | + | } else { | |
| 383 | + | mid.clone() | |
| 384 | + | } | |
| 385 | + | ); | |
| 386 | + | } | |
| 312 | 387 | } | |
| 313 | 388 | ||
| 314 | 389 | /// Deactivate the license: notify the server (best-effort), delete the | |
| @@ -324,7 +399,7 @@ impl AudioFilesApp { | |||
| 324 | 399 | } | |
| 325 | 400 | }); | |
| 326 | 401 | } | |
| 327 | - | let _ = license::remove_license(&self.data_dir); | |
| 402 | + | let _ = license::remove_license(&self.config_dir); | |
| 328 | 403 | self.license_cache = None; | |
| 329 | 404 | self.browser = None; | |
| 330 | 405 | self.sync_manager = None; | |
| @@ -345,13 +420,19 @@ impl AudioFilesApp { | |||
| 345 | 420 | machine_id: self.machine_id.clone(), | |
| 346 | 421 | activated_at: chrono::Utc::now().to_rfc3339(), | |
| 347 | 422 | }; | |
| 348 | - | if let Err(e) = license::save_license(&self.data_dir, &cache) { | |
| 423 | + | if let Err(e) = license::save_license(&self.config_dir, &cache) { | |
| 349 | 424 | tracing::error!("Failed to save license: {e}"); | |
| 350 | 425 | } | |
| 351 | 426 | self.license_cache = Some(cache); | |
| 352 | 427 | self.activating = false; | |
| 353 | 428 | self.activation_error = None; | |
| 354 | - | self.activate_browser(); | |
| 429 | + | // If vault registry already exists (e.g. deactivate/reactivate), | |
| 430 | + | // go straight to browser. Otherwise show vault setup. | |
| 431 | + | if self.vault_registry.is_some() { | |
| 432 | + | self.activate_browser(); | |
| 433 | + | } else { | |
| 434 | + | self.screen = AppScreen::VaultSetup; | |
| 435 | + | } | |
| 355 | 436 | return; | |
| 356 | 437 | } | |
| 357 | 438 | Err(e) => { | |
| @@ -363,6 +444,20 @@ impl AudioFilesApp { | |||
| 363 | 444 | ||
| 364 | 445 | egui::CentralPanel::default().show(ctx, |ui| { | |
| 365 | 446 | let available = ui.available_size(); | |
| 447 | + | ||
| 448 | + | // Temporary alpha skip button — top-right corner | |
| 449 | + | ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| { | |
| 450 | + | ui.add_space(8.0); | |
| 451 | + | if ui.small_button("\u{2715}").on_hover_text("Skip activation (alpha)").clicked() { | |
| 452 | + | if self.vault_registry.is_some() { | |
| 453 | + | self.activate_browser(); | |
| 454 | + | } else { | |
| 455 | + | self.screen = AppScreen::VaultSetup; | |
| 456 | + | } | |
| 457 | + | return; | |
| 458 | + | } | |
| 459 | + | }); | |
| 460 | + | ||
| 366 | 461 | ui.add_space((available.y * 0.35).max(40.0)); | |
| 367 | 462 | ||
| 368 | 463 | ui.vertical_centered(|ui| { | |
| @@ -425,29 +520,215 @@ impl AudioFilesApp { | |||
| 425 | 520 | }); | |
| 426 | 521 | } | |
| 427 | 522 | ||
| 428 | - | /// Draw the license info overlay (small window with masked key + deactivate). | |
| 429 | - | fn draw_license_info(&mut self, ctx: &egui::Context) { | |
| 430 | - | let mut open = self.show_license_info; | |
| 431 | - | egui::Window::new("License") | |
| 432 | - | .open(&mut open) | |
| 433 | - | .resizable(false) | |
| 434 | - | .collapsible(false) | |
| 435 | - | .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) | |
| 436 | - | .show(ctx, |ui| { | |
| 437 | - | if let Some(ref cache) = self.license_cache { | |
| 438 | - | let masked = mask_key(&cache.key_code); | |
| 439 | - | ui.label(format!("Key: {masked}")); | |
| 440 | - | ui.label(format!("Machine: {}...{}", &self.machine_id[..8], &self.machine_id[self.machine_id.len()-4..])); | |
| 523 | + | /// Draw the vault setup screen (first-open flow, after activation). | |
| 524 | + | fn draw_vault_setup_screen(&mut self, ctx: &egui::Context) { | |
| 525 | + | let default_path = vault::default_vault_path(); | |
| 526 | + | let existing_db = default_path.join("audiofiles.db").exists(); | |
| 527 | + | ||
| 528 | + | egui::CentralPanel::default().show(ctx, |ui| { | |
| 529 | + | let available = ui.available_size(); | |
| 530 | + | ui.add_space((available.y * 0.25).max(40.0)); | |
| 531 | + | ||
| 532 | + | ui.vertical_centered(|ui| { | |
| 533 | + | ui.heading("Choose where to store your sample library"); | |
| 534 | + | ui.add_space(8.0); | |
| 535 | + | ||
| 536 | + | if existing_db { | |
| 537 | + | ui.label( | |
| 538 | + | egui::RichText::new(format!( | |
| 539 | + | "Your existing library was found at {}", | |
| 540 | + | default_path.display() | |
| 541 | + | )) | |
| 542 | + | .color(egui::Color32::from_rgb(120, 180, 120)), | |
| 543 | + | ); | |
| 441 | 544 | ui.add_space(8.0); | |
| 442 | - | if ui.button("Deactivate").clicked() { | |
| 443 | - | self.deactivate(); | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | // Option 1: default location | |
| 548 | + | if ui.button("Use default location").clicked() { | |
| 549 | + | self.vault_setup_path = None; | |
| 550 | + | } | |
| 551 | + | ui.label( | |
| 552 | + | egui::RichText::new(format!("Default: {}", default_path.display())) | |
| 553 | + | .small() | |
| 554 | + | .color(egui::Color32::GRAY), | |
| 555 | + | ); | |
| 556 | + | ||
| 557 | + | ui.add_space(8.0); | |
| 558 | + | ||
| 559 | + | // Option 2: custom folder | |
| 560 | + | ui.horizontal(|ui| { | |
| 561 | + | if ui.button("Choose folder...").clicked() { | |
| 562 | + | if let Some(path) = rfd::FileDialog::new().pick_folder() { | |
| 563 | + | self.vault_setup_path = Some(path); | |
| 564 | + | } | |
| 444 | 565 | } | |
| 566 | + | if self.vault_setup_path.is_some() { | |
| 567 | + | if ui.small_button("Reset to default").clicked() { | |
| 568 | + | self.vault_setup_path = None; | |
| 569 | + | } | |
| 570 | + | } | |
| 571 | + | }); | |
| 572 | + | ||
| 573 | + | if let Some(ref custom) = self.vault_setup_path { | |
| 574 | + | ui.label( | |
| 575 | + | egui::RichText::new(format!("Selected: {}", custom.display())) | |
| 576 | + | .small() | |
| 577 | + | .color(egui::Color32::from_rgb(150, 200, 255)), | |
| 578 | + | ); | |
| 579 | + | } | |
| 580 | + | ||
| 581 | + | ui.add_space(12.0); | |
| 582 | + | ||
| 583 | + | // Vault name input | |
| 584 | + | ui.horizontal(|ui| { | |
| 585 | + | ui.label("Vault name:"); | |
| 586 | + | ui.text_edit_singleline(&mut self.vault_setup_name); | |
| 587 | + | }); | |
| 588 | + | ||
| 589 | + | ui.add_space(16.0); | |
| 590 | + | ||
| 591 | + | // Continue button | |
| 592 | + | let chosen = self | |
| 593 | + | .vault_setup_path | |
| 594 | + | .clone() | |
| 595 | + | .unwrap_or_else(|| default_path.clone()); | |
| 596 | + | if ui.button("Continue").clicked() { | |
| 597 | + | self.finalize_vault_setup(chosen); | |
| 445 | 598 | } | |
| 446 | 599 | }); | |
| 447 | - | self.show_license_info = open; | |
| 600 | + | }); | |
| 601 | + | } | |
| 602 | + | ||
| 603 | + | /// Finalise vault setup: create registry, set data_dir, open browser. | |
| 604 | + | fn finalize_vault_setup(&mut self, vault_path: PathBuf) { | |
| 605 | + | let name = if self.vault_setup_name.trim().is_empty() { | |
| 606 | + | "Library".to_string() | |
| 607 | + | } else { | |
| 608 | + | self.vault_setup_name.trim().to_string() | |
| 609 | + | }; | |
| 610 | + | ||
| 611 | + | let mut reg = VaultRegistry { | |
| 612 | + | vaults: Vec::new(), | |
| 613 | + | active: vault_path.clone(), | |
| 614 | + | }; | |
| 615 | + | ||
| 616 | + | // If the path already has a DB, add as existing; otherwise create new. | |
| 617 | + | if vault_path.join("audiofiles.db").exists() { | |
| 618 | + | if let Err(e) = vault::add_existing_vault(&mut reg, &name, &vault_path) { | |
| 619 | + | tracing::error!("Failed to add existing vault: {e}"); | |
| 620 | + | } | |
| 621 | + | } else if let Err(e) = vault::create_vault(&mut reg, &name, &vault_path) { | |
| 622 | + | tracing::error!("Failed to create vault: {e}"); | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | if let Err(e) = vault::save_registry(®) { | |
| 626 | + | tracing::error!("Failed to save vault registry: {e}"); | |
| 627 | + | } | |
| 628 | + | ||
| 629 | + | self.vault_registry = Some(reg); | |
| 630 | + | self.data_dir = vault_path; | |
| 631 | + | self.activate_browser(); | |
| 632 | + | } | |
| 633 | + | ||
| 634 | + | /// Switch to a different vault: tear down the current browser and rebuild. | |
| 635 | + | fn switch_vault(&mut self, path: PathBuf) { | |
| 636 | + | if !vault::is_vault_reachable(&path) { | |
| 637 | + | if let Some(ref mut browser) = self.browser { | |
| 638 | + | browser.status = format!("Vault is offline: {}", path.display()); | |
| 639 | + | } | |
| 640 | + | return; | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | // Tear down current state | |
| 644 | + | self.midi_connection = None; | |
| 645 | + | if let Some(ref mut browser) = self.browser { | |
| 646 | + | browser.stop_preview(); | |
| 647 | + | } | |
| 648 | + | self.browser = None; | |
| 649 | + | self.sync_manager = None; | |
| 650 | + | ||
| 651 | + | // Update registry | |
| 652 | + | if let Some(ref mut reg) = self.vault_registry { | |
| 653 | + | reg.active = path.clone(); | |
| 654 | + | if let Err(e) = vault::save_registry(reg) { | |
| 655 | + | tracing::error!("Failed to save vault registry: {e}"); | |
| 656 | + | } | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | self.data_dir = path; | |
| 660 | + | self.activate_browser(); | |
| 661 | + | ||
| 662 | + | // Populate vault_list on the new browser | |
| 663 | + | self.sync_vault_list_to_browser(); | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | /// Run a vault registry mutation, save the registry, and sync the vault list | |
| 667 | + | /// to the browser. Returns `true` if the operation and save both succeeded. | |
| 668 | + | fn with_vault_registry<F>(&mut self, op: F) -> bool | |
| 669 | + | where | |
| 670 | + | F: FnOnce(&mut VaultRegistry) -> Result<(), vault::VaultError>, | |
| 671 | + | { | |
| 672 | + | let Some(ref mut reg) = self.vault_registry else { return false }; | |
| 673 | + | if let Err(e) = op(reg) { | |
| 674 | + | if let Some(ref mut b) = self.browser { | |
| 675 | + | b.status = format!("Vault error: {e}"); | |
| 676 | + | } | |
| 677 | + | return false; | |
| 678 | + | } | |
| 679 | + | if let Err(e) = vault::save_registry(reg) { | |
| 680 | + | tracing::error!("Failed to save vault registry: {e}"); | |
| 681 | + | return false; | |
| 682 | + | } | |
| 683 | + | self.sync_vault_list_to_browser(); | |
| 684 | + | true | |
| 685 | + | } | |
| 686 | + | ||
| 687 | + | /// Push the current registry's vault list into the browser state. | |
| 688 | + | fn sync_vault_list_to_browser(&mut self) { | |
| 689 | + | if let (Some(ref reg), Some(ref mut browser)) = (&self.vault_registry, &mut self.browser) { | |
| 690 | + | browser.settings.list = reg | |
| 691 | + | .vaults | |
| 692 | + | .iter() | |
| 693 | + | .map(|v| (v.name.clone(), v.path.clone(), vault::is_vault_reachable(&v.path))) | |
| 694 | + | .collect(); | |
| 695 | + | browser.settings.name = vault_name_for_path(reg, &self.data_dir); | |
| 696 | + | } | |
| 448 | 697 | } | |
| 449 | 698 | } | |
| 450 | 699 | ||
| 700 | + | /// One-time migration: copy license.json and machine_id from the default vault | |
| 701 | + | /// directory to the global config directory if they don't exist there yet. | |
| 702 | + | fn migrate_license_to_config(config_dir: &Path, default_vault: &Path) { | |
| 703 | + | // Skip if config_dir and default_vault are the same (macOS) | |
| 704 | + | if config_dir == default_vault { | |
| 705 | + | return; | |
| 706 | + | } | |
| 707 | + | let _ = std::fs::create_dir_all(config_dir); | |
| 708 | + | for filename in &["license.json", "machine_id"] { | |
| 709 | + | let src = default_vault.join(filename); | |
| 710 | + | let dst = config_dir.join(filename); | |
| 711 | + | if src.exists() && !dst.exists() { | |
| 712 | + | if let Err(e) = std::fs::copy(&src, &dst) { | |
| 713 | + | tracing::warn!("Failed to migrate {filename} to config_dir: {e}"); | |
| 714 | + | } else { | |
| 715 | + | tracing::info!("Migrated {filename} from {} to {}", src.display(), dst.display()); | |
| 716 | + | } | |
| 717 | + | } | |
| 718 | + | } | |
| 719 | + | } | |
| 720 | + | ||
| 721 | + | /// Look up the vault name for a given path in the registry. Falls back to "Library". | |
| 722 | + | /// Uses canonicalize for consistent matching regardless of symlinks or relative components. | |
| 723 | + | fn vault_name_for_path(reg: &VaultRegistry, path: &Path) -> String { | |
| 724 | + | let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); | |
| 725 | + | reg.vaults | |
| 726 | + | .iter() | |
| 727 | + | .find(|v| v.path.canonicalize().unwrap_or_else(|_| v.path.clone()) == canonical) | |
| 728 | + | .map(|v| v.name.clone()) | |
| 729 | + | .unwrap_or_else(|| "Library".to_string()) | |
| 730 | + | } | |
| 731 | + | ||
| 451 | 732 | /// Mask a 5-word key: show first word + ... + last word. | |
| 452 | 733 | fn mask_key(key: &str) -> String { | |
| 453 | 734 | let words: Vec<&str> = key.split('-').collect(); | |
| @@ -460,9 +741,9 @@ fn mask_key(key: &str) -> String { | |||
| 460 | 741 | ||
| 461 | 742 | /// Create a BrowserState, returning (Some(browser), None) on success or | |
| 462 | 743 | /// (None, Some(error)) on failure. |
Lines truncated
| @@ -0,0 +1,79 @@ | |||
| 1 | + | //! MIDI input: enumerate ports, connect/disconnect, and parse incoming messages. | |
| 2 | + | ||
| 3 | + | use std::sync::Arc; | |
| 4 | + | use std::time::Instant; | |
| 5 | + | ||
| 6 | + | use audiofiles_browser::state::{MidiNoteEvent, SharedState}; | |
| 7 | + | use audiofiles_core::instrument::note_name; | |
| 8 | + | use midir::{MidiInput, MidiInputConnection}; | |
| 9 | + | use tracing::instrument; | |
| 10 | + | ||
| 11 | + | /// An active MIDI input connection. Dropping this disconnects. | |
| 12 | + | pub struct MidiConnection { | |
| 13 | + | _conn: MidiInputConnection<()>, | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /// List available MIDI input port names. | |
| 17 | + | #[instrument(skip_all)] | |
| 18 | + | pub fn list_input_ports() -> Vec<String> { | |
| 19 | + | let Ok(midi_in) = MidiInput::new("audiofiles-enumerate") else { | |
| 20 | + | return Vec::new(); | |
| 21 | + | }; | |
| 22 | + | midi_in | |
| 23 | + | .ports() | |
| 24 | + | .iter() | |
| 25 | + | .filter_map(|p| midi_in.port_name(p).ok()) | |
| 26 | + | .collect() | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// Connect to a MIDI input port by index. | |
| 30 | + | /// | |
| 31 | + | /// The callback parses note-on/note-off messages and calls `note_on`/`note_off` | |
| 32 | + | /// on the instrument directly (low latency). Also pushes `MidiNoteEvent`s to | |
| 33 | + | /// `shared.midi_recent_notes` for the GUI activity display. | |
| 34 | + | #[instrument(skip_all)] | |
| 35 | + | pub fn connect(port_index: usize, shared: Arc<SharedState>) -> Result<MidiConnection, String> { | |
| 36 | + | let midi_in = MidiInput::new("audiofiles-input") | |
| 37 | + | .map_err(|e| format!("MIDI init: {e}"))?; | |
| 38 | + | let ports = midi_in.ports(); | |
| 39 | + | let port = ports | |
| 40 | + | .get(port_index) | |
| 41 | + | .ok_or_else(|| format!("MIDI port index {port_index} out of range"))?; | |
| 42 | + | ||
| 43 | + | let shared_cb = shared.clone(); | |
| 44 | + | let conn = midi_in | |
| 45 | + | .connect( | |
| 46 | + | port, | |
| 47 | + | "audiofiles-midi-in", | |
| 48 | + | move |_timestamp, message, _| { | |
| 49 | + | if message.len() < 3 { | |
| 50 | + | return; | |
| 51 | + | } | |
| 52 | + | let status = message[0] & 0xF0; | |
| 53 | + | let note = message[1]; | |
| 54 | + | let velocity = message[2]; | |
| 55 | + | ||
| 56 | + | match status { | |
| 57 | + | 0x90 if velocity > 0 => { | |
| 58 | + | // Note On | |
| 59 | + | shared_cb.instrument.lock().note_on(note, velocity); | |
| 60 | + | shared_cb.midi_recent_notes.lock().push(MidiNoteEvent { | |
| 61 | + | note, | |
| 62 | + | velocity, | |
| 63 | + | note_name: note_name(note), | |
| 64 | + | timestamp: Instant::now(), | |
| 65 | + | }); | |
| 66 | + | } | |
| 67 | + | 0x80 | 0x90 => { | |
| 68 | + | // Note Off (0x80 or 0x90 with velocity 0) | |
| 69 | + | shared_cb.instrument.lock().note_off(note); | |
| 70 | + | } | |
| 71 | + | _ => {} | |
| 72 | + | } | |
| 73 | + | }, | |
| 74 | + | (), | |
| 75 | + | ) | |
| 76 | + | .map_err(|e| format!("MIDI connect: {e}"))?; | |
| 77 | + | ||
| 78 | + | Ok(MidiConnection { _conn: conn }) | |
| 79 | + | } |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "audiofiles-browser" | |
| 3 | - | version = "0.3.0" | |
| 3 | + | version = "0.3.2" | |
| 4 | 4 | edition.workspace = true | |
| 5 | 5 | ||
| 6 | 6 | [features] |
| @@ -12,6 +12,8 @@ use audiofiles_core::analysis::config::AnalysisConfig; | |||
| 12 | 12 | use audiofiles_core::analysis::waveform::WaveformData; | |
| 13 | 13 | use audiofiles_core::analysis::AnalysisResult; | |
| 14 | 14 | use audiofiles_core::db::Database; | |
| 15 | + | use audiofiles_core::edit::EditOperation; | |
| 16 | + | use audiofiles_core::edit::worker::{EditCommand, EditEvent, EditWorkerHandle}; | |
| 15 | 17 | use audiofiles_core::export::profile::DeviceProfileSummary; | |
| 16 | 18 | use audiofiles_core::export::ExportItem; | |
| 17 | 19 | use audiofiles_core::search::SearchFilter; | |
| @@ -41,6 +43,7 @@ pub struct DirectBackend { | |||
| 41 | 43 | import_worker: Mutex<Option<ImportHandle>>, | |
| 42 | 44 | analysis_worker: Mutex<Option<WorkerHandle>>, | |
| 43 | 45 | export_worker: Mutex<Option<ExportHandle>>, | |
| 46 | + | edit_worker: Mutex<Option<EditWorkerHandle>>, | |
| 44 | 47 | // VP-tree indexes for fast search (lazy, invalidated on new analysis) | |
| 45 | 48 | fingerprint_index: Mutex<Option<fingerprint::FingerprintIndex>>, | |
| 46 | 49 | similarity_index: Mutex<Option<similarity::SimilarityIndex>>, | |
| @@ -59,6 +62,7 @@ impl DirectBackend { | |||
| 59 | 62 | import_worker: Mutex::new(None), | |
| 60 | 63 | analysis_worker: Mutex::new(None), | |
| 61 | 64 | export_worker: Mutex::new(None), | |
| 65 | + | edit_worker: Mutex::new(None), | |
| 62 | 66 | fingerprint_index: Mutex::new(None), | |
| 63 | 67 | similarity_index: Mutex::new(None), | |
| 64 | 68 | #[cfg(feature = "device-profiles")] | |
| @@ -738,6 +742,62 @@ impl Backend for DirectBackend { | |||
| 738 | 742 | Ok(()) | |
| 739 | 743 | } | |
| 740 | 744 | ||
| 745 | + | fn start_edit(&self, hash: &str, ext: &str, operation: EditOperation) -> BackendResult<()> { | |
| 746 | + | let path = self.store.sample_path(hash, ext)?; | |
| 747 | + | if !path.exists() { | |
| 748 | + | return Err(BackendError::Core( | |
| 749 | + | audiofiles_core::error::CoreError::SampleNotFound(hash.to_string()), | |
| 750 | + | )); | |
| 751 | + | } | |
| 752 | + | ||
| 753 | + | let handle = audiofiles_core::edit::worker::spawn_edit_worker() | |
| 754 | + | .map_err(|e| BackendError::Other(format!("failed to spawn edit worker: {e}")))?; | |
| 755 | + | handle.send(EditCommand::Edit { | |
| 756 | + | hash: hash.to_string(), | |
| 757 | + | ext: ext.to_string(), | |
| 758 | + | path, | |
| 759 | + | operation, | |
| 760 | + | }); | |
| 761 | + | *self.edit_worker.lock() = Some(handle); | |
| 762 | + | Ok(()) | |
| 763 | + | } | |
| 764 | + | ||
| 765 | + | fn cancel_edit(&self) -> BackendResult<()> { | |
| 766 | + | if let Some(worker) = self.edit_worker.lock().take() { | |
| 767 | + | worker.send(EditCommand::Cancel); | |
| 768 | + | } | |
| 769 | + | Ok(()) | |
| 770 | + | } | |
| 771 | + | ||
| 772 | + | fn record_edit_history( | |
| 773 | + | &self, | |
| 774 | + | source_hash: &str, | |
| 775 | + | result_hash: &str, | |
| 776 | + | operation: &EditOperation, | |
| 777 | + | ) -> BackendResult<()> { | |
| 778 | + | let db = self.db.lock(); | |
| 779 | + | let op_name = operation.display_name(); | |
| 780 | + | let params_json = serde_json::to_string(operation) | |
| 781 | + | .map_err(|e| BackendError::Other(format!("serialize edit params: {e}")))?; | |
| 782 | + | db.conn() | |
| 783 | + | .execute( | |
| 784 | + | "INSERT INTO edit_history (source_hash, result_hash, operation, params_json) | |
| 785 | + | VALUES (?1, ?2, ?3, ?4)", | |
| 786 | + | rusqlite::params![source_hash, result_hash, op_name, params_json], | |
| 787 | + | ) | |
| 788 | + | .map_err(audiofiles_core::error::CoreError::Db)?; | |
| 789 | + | Ok(()) | |
| 790 | + | } | |
| 791 | + | ||
| 792 | + | fn storage_stats(&self) -> BackendResult<super::StorageStats> { | |
| 793 | + | let db = self.db.lock(); | |
| 794 | + | let (sample_count, total_bytes) = db.storage_stats() | |
| 795 | + | .map_err(|e| BackendError::Other(e.to_string()))?; | |
| 796 | + | let db_path = self.data_dir.join("audiofiles.db"); | |
| 797 | + | let db_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); | |
| 798 | + | Ok(super::StorageStats { sample_count, total_bytes, db_bytes }) | |
| 799 | + | } | |
| 800 | + | ||
| 741 | 801 | fn poll_events(&self) -> Vec<BackendEvent> { | |
| 742 | 802 | let mut events = Vec::new(); | |
| 743 | 803 | ||
| @@ -843,6 +903,31 @@ impl Backend for DirectBackend { | |||
| 843 | 903 | } | |
| 844 | 904 | } | |
| 845 | 905 | ||
| 906 | + | // Poll edit worker | |
| 907 | + | if let Some(ref worker) = *self.edit_worker.lock() { | |
| 908 | + | while let Some(event) = worker.try_recv() { | |
| 909 | + | match event { | |
| 910 | + | EditEvent::Started { hash } => { | |
| 911 | + | events.push(BackendEvent::EditStarted { hash }); | |
| 912 | + | } | |
| 913 | + | EditEvent::Complete { | |
| 914 | + | source_hash, | |
| 915 | + | result_path, | |
| 916 | + | operation, | |
| 917 | + | } => { | |
| 918 | + | events.push(BackendEvent::EditComplete { | |
| 919 | + | source_hash, | |
| 920 | + | result_path, | |
| 921 | + | operation, | |
| 922 | + | }); | |
| 923 | + | } | |
| 924 | + | EditEvent::Error { hash, error } => { | |
| 925 | + | events.push(BackendEvent::EditError { hash, error }); | |
| 926 | + | } | |
| 927 | + | } | |
| 928 | + | } | |
| 929 | + | } | |
| 930 | + | ||
| 846 | 931 | events | |
| 847 | 932 | } | |
| 848 | 933 | } |
| @@ -15,6 +15,7 @@ use audiofiles_core::analysis::config::AnalysisConfig; | |||
| 15 | 15 | use audiofiles_core::analysis::suggest::TagSuggestion; | |
| 16 | 16 | use audiofiles_core::analysis::waveform::WaveformData; | |
| 17 | 17 | use audiofiles_core::analysis::AnalysisResult; | |
| 18 | + | use audiofiles_core::edit::EditOperation; | |
| 18 | 19 | use audiofiles_core::export::profile::DeviceProfileSummary; | |
| 19 | 20 | use audiofiles_core::export::{ExportConfig, ExportItem}; | |
| 20 | 21 | use audiofiles_core::search::SearchFilter; | |
| @@ -91,6 +92,20 @@ pub enum BackendEvent { | |||
| 91 | 92 | total: usize, | |
| 92 | 93 | errors: Vec<(String, String)>, | |
| 93 | 94 | }, | |
| 95 | + | ||
| 96 | + | // Edit events | |
| 97 | + | EditStarted { | |
| 98 | + | hash: String, | |
| 99 | + | }, | |
| 100 | + | EditComplete { | |
| 101 | + | source_hash: String, | |
| 102 | + | result_path: std::path::PathBuf, | |
| 103 | + | operation: EditOperation, | |
| 104 | + | }, | |
| 105 | + | EditError { | |
| 106 | + | hash: String, | |
| 107 | + | error: String, | |
| 108 | + | }, | |
| 94 | 109 | } | |
| 95 | 110 | ||
| 96 | 111 | /// Serializable description of an imported folder (for IPC). | |
| @@ -114,6 +129,14 @@ pub type ExportItemDesc = ExportItem; | |||
| 114 | 129 | /// Serializable description of an export config (for IPC). | |
| 115 | 130 | pub type ExportConfigDesc = ExportConfig; | |
| 116 | 131 | ||
| 132 | + | /// Aggregate storage statistics for a vault. | |
| 133 | + | #[derive(Debug, Clone, Default)] | |
| 134 | + | pub struct StorageStats { | |
| 135 | + | pub sample_count: u64, | |
| 136 | + | pub total_bytes: u64, | |
| 137 | + | pub db_bytes: u64, | |
| 138 | + | } | |
| 139 | + | ||
| 117 | 140 | /// The core abstraction separating UI from data access. | |
| 118 | 141 | /// | |
| 119 | 142 | /// Every method is synchronous and blocking. The trait is `Send + Sync` so it | |
| @@ -386,6 +409,23 @@ pub trait Backend: Send + Sync { | |||
| 386 | 409 | /// Cancel a running export. | |
| 387 | 410 | fn cancel_export(&self) -> BackendResult<()>; | |
| 388 | 411 | ||
| 412 | + | /// Start an edit operation on a sample. | |
| 413 | + | fn start_edit(&self, hash: &str, ext: &str, operation: EditOperation) -> BackendResult<()>; | |
| 414 | + | ||
| 415 | + | /// Cancel a running edit. | |
| 416 | + | fn cancel_edit(&self) -> BackendResult<()>; | |
| 417 | + | ||
| 418 | + | /// Record an edit in the edit_history table. | |
| 419 | + | fn record_edit_history( | |
| 420 | + | &self, | |
| 421 | + | source_hash: &str, | |
| 422 | + | result_hash: &str, | |
| 423 | + | operation: &EditOperation, | |
| 424 | + | ) -> BackendResult<()>; | |
| 425 | + | ||
| 426 | + | /// Get aggregate storage statistics for the current vault. | |
| 427 | + | fn storage_stats(&self) -> BackendResult<StorageStats>; | |
| 428 | + | ||
| 389 | 429 | /// Non-blocking poll for worker events. | |
| 390 | 430 | fn poll_events(&self) -> Vec<BackendEvent>; | |
| 391 | 431 | } |
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | use egui; | |
| 4 | 4 | ||
| 5 | 5 | use crate::state::{BrowserState, ImportMode}; | |
| 6 | - | use crate::ui::{detail, export_screens, file_list, filter_panel, footer, import_screens, instrument_panel, overlays, sidebar, theme, toolbar}; | |
| 6 | + | use crate::ui::{detail, edit_panel, export_screens, file_list, filter_panel, footer, import_screens, instrument_panel, overlays, sidebar, theme, toolbar}; | |
| 7 | 7 | use audiofiles_core::vfs::NodeType; | |
| 8 | 8 | ||
| 9 | 9 | /// Top-level draw function called each frame from the update closure. | |
| @@ -18,6 +18,10 @@ pub fn draw_browser( | |||
| 18 | 18 | ||
| 19 | 19 | match &state.import_mode { | |
| 20 | 20 | ImportMode::None => { | |
| 21 | + | // Poll edit worker events during normal browsing | |
| 22 | + | if state.poll_workers() { | |
| 23 | + | ctx.request_repaint(); | |
| 24 | + | } | |
| 21 | 25 | handle_keyboard(ctx, state); | |
| 22 | 26 | draw_normal_browser(ctx, state); | |
| 23 | 27 | } | |
| @@ -102,14 +106,24 @@ pub fn draw_browser( | |||
| 102 | 106 | overlays::draw_dir_rename_modal(ctx, state); | |
| 103 | 107 | } | |
| 104 | 108 | ||
| 109 | + | // Settings window | |
| 110 | + | if state.settings.show_manager { | |
| 111 | + | crate::ui::settings_panel::draw_settings_panel(ctx, state); | |
| 112 | + | } | |
| 113 | + | ||
| 105 | 114 | // Sync panel overlay | |
| 106 | - | if state.show_sync_panel { | |
| 115 | + | if state.sync.show_panel { | |
| 107 | 116 | if let Some(sync) = sync_manager { | |
| 108 | 117 | crate::ui::sync_panel::draw_sync_panel(ctx, state, sync); | |
| 109 | 118 | } else { | |
| 110 | 119 | crate::ui::sync_panel::draw_sync_not_configured(ctx, state); | |
| 111 | 120 | } | |
| 112 | 121 | } | |
| 122 | + | ||
| 123 | + | // Floating sample editor window | |
| 124 | + | if state.edit.show_window { | |
| 125 | + | edit_panel::draw_edit_window(ctx, state); | |
| 126 | + | } | |
| 113 | 127 | } | |
| 114 | 128 | ||
| 115 | 129 | /// Draw the main browser layout: toolbar, footer, sidebar, detail panel, and file list. | |
| @@ -126,13 +140,9 @@ fn draw_normal_browser(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 126 | 140 | footer::draw_footer(ui, ctx, state); | |
| 127 | 141 | }); | |
| 128 | 142 | ||
| 129 | - | // Instrument panel (above footer, below central) | |
| 130 | - | if state.instrument_visible { | |
| 131 | - | egui::TopBottomPanel::bottom("instrument_panel") | |
| 132 | - | .exact_height(120.0) | |
| 133 | - | .show(ctx, |ui| { | |
| 134 | - | instrument_panel::draw_instrument_panel(ui, state); | |
| 135 | - | }); | |
| 143 | + | // Floating MIDI/instrument window | |
| 144 | + | if state.show_midi_window { | |
| 145 | + | instrument_panel::draw_midi_window(ctx, state); | |
| 136 | 146 | } | |
| 137 | 147 | ||
| 138 | 148 | // Left sidebar (or filter panel) | |
| @@ -196,8 +206,12 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 196 | 206 | ctx.input(|input| { | |
| 197 | 207 | // Escape: dismiss dialogs in priority order | |
| 198 | 208 | if input.key_pressed(egui::Key::Escape) { | |
| 199 | - | if state.show_sync_panel { | |
| 200 | - | state.show_sync_panel = false; | |
| 209 | + | if state.settings.show_manager { | |
| 210 | + | state.settings.show_manager = false; | |
| 211 | + | } else if state.edit.show_window { | |
| 212 | + | state.close_edit_window(); | |
| 213 | + | } else if state.sync.show_panel { | |
| 214 | + | state.sync.show_panel = false; | |
| 201 | 215 | } else if state.bulk_modal.is_some() { | |
| 202 | 216 | state.close_bulk_modal(); | |
| 203 | 217 | } else if state.pending_confirm.is_some() { | |
| @@ -256,6 +270,7 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 256 | 270 | state.selection.extend_down(state.visible_len()); | |
| 257 | 271 | } else { | |
| 258 | 272 | state.select_next(); | |
| 273 | + | state.autoplay_current(); | |
| 259 | 274 | } | |
| 260 | 275 | } | |
| 261 | 276 | if input.key_pressed(egui::Key::ArrowUp) || input.key_pressed(egui::Key::K) { | |
| @@ -263,6 +278,7 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 263 | 278 | state.selection.extend_up(); | |
| 264 | 279 | } else { | |
| 265 | 280 | state.select_prev(); | |
| 281 | + | state.autoplay_current(); | |
| 266 | 282 | } | |
| 267 | 283 | } | |
| 268 | 284 | if input.key_pressed(egui::Key::Enter) || input.key_pressed(egui::Key::ArrowRight) { | |
| @@ -290,9 +306,24 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 290 | 306 | if input.key_pressed(egui::Key::Slash) { | |
| 291 | 307 | state.focus_search = true; | |
| 292 | 308 | } | |
| 293 | - | // "I" toggles instrument panel | |
| 309 | + | // "I" toggles floating MIDI/instrument window | |
| 294 | 310 | if input.key_pressed(egui::Key::I) { | |
| 295 | - | state.toggle_instrument(); | |
| 311 | + | state.show_midi_window = !state.show_midi_window; | |
| 312 | + | } | |
| 313 | + | // "E" toggles floating sample editor window | |
| 314 | + | if input.key_pressed(egui::Key::E) { | |
| 315 | + | if state.edit.show_window { | |
| 316 | + | state.close_edit_window(); | |
| 317 | + | } else if let Some(node) = state.selected_node() { | |
| 318 | + | if let Some(hash) = &node.node.sample_hash { | |
| 319 | + | let hash = hash.clone(); | |
| 320 | + | state.open_edit_window(&hash); | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | // "L" toggles loop | |
| 325 | + | if input.key_pressed(egui::Key::L) { | |
| 326 | + | state.toggle_loop(); | |
| 296 | 327 | } | |
| 297 | 328 | // "S" toggles sidebar | |
| 298 | 329 | if input.key_pressed(egui::Key::S) { |
| @@ -1,6 +1,6 @@ | |||
| 1 | - | //! Instrument playback state: voice pool, loaded zones, and envelope tracking. | |
| 1 | + | //! Instrument playback state: voice pool, loaded zones, envelope processing, and voice rendering. | |
| 2 | 2 | ||
| 3 | - | use audiofiles_core::instrument::InstrumentConfig; | |
| 3 | + | use audiofiles_core::instrument::{AdsrEnvelope, InstrumentConfig}; | |
| 4 | 4 | ||
| 5 | 5 | use crate::preview::PreviewBuffer; | |
| 6 | 6 | ||
| @@ -34,6 +34,8 @@ pub struct Voice { | |||
| 34 | 34 | pub envelope_level: f32, | |
| 35 | 35 | /// Time spent in the current envelope phase (seconds). | |
| 36 | 36 | pub envelope_time: f32, | |
| 37 | + | /// Envelope level captured at note-off for smooth release ramp. | |
| 38 | + | pub release_start_level: f32, | |
| 37 | 39 | } | |
| 38 | 40 | ||
| 39 | 41 | impl Voice { | |
| @@ -48,6 +50,7 @@ impl Voice { | |||
| 48 | 50 | envelope_phase: EnvelopePhase::Idle, | |
| 49 | 51 | envelope_level: 0.0, | |
| 50 | 52 | envelope_time: 0.0, | |
| 53 | + | release_start_level: 0.0, | |
| 51 | 54 | } | |
| 52 | 55 | } | |
| 53 | 56 | } | |
| @@ -100,12 +103,234 @@ impl InstrumentPlayback { | |||
| 100 | 103 | sample_rate: 44100.0, | |
| 101 | 104 | } | |
| 102 | 105 | } | |
| 106 | + | ||
| 107 | + | /// Trigger a note: find a matching zone, allocate a voice, and start the attack phase. | |
| 108 | + | /// | |
| 109 | + | /// Voice allocation prefers a free (inactive) slot. If none is available, the oldest | |
| 110 | + | /// active voice (lowest `age`) is stolen. | |
| 111 | + | pub fn note_on(&mut self, note: u8, velocity: u8) { | |
| 112 | + | if self.zone_buffers.is_empty() { | |
| 113 | + | return; | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | let vel_f = velocity as f32 / 127.0; | |
| 117 | + | ||
| 118 | + | // Find the first zone matching note + velocity range | |
| 119 | + | let zone_idx = self | |
| 120 | + | .zone_buffers | |
| 121 | + | .iter() | |
| 122 | + | .position(|z| { | |
| 123 | + | note >= z.low_note && note <= z.high_note && vel_f >= z.vel_low && vel_f <= z.vel_high | |
| 124 | + | }); | |
| 125 | + | let Some(zone_idx) = zone_idx else { return }; | |
| 126 | + | ||
| 127 | + | // Allocate voice: free slot or steal oldest | |
| 128 | + | let voice_idx = self | |
| 129 | + | .voices | |
| 130 | + | .iter() | |
| 131 | + | .position(|v| !v.active) | |
| 132 | + | .unwrap_or_else(|| { | |
| 133 | + | self.voices | |
| 134 | + | .iter() | |
| 135 | + | .enumerate() | |
| 136 | + | .min_by_key(|(_, v)| v.age) | |
| 137 | + | .map(|(i, _)| i) | |
| 138 | + | .unwrap_or(0) | |
| 139 | + | }); | |
| 140 | + | ||
| 141 | + | self.note_counter += 1; | |
| 142 | + | let voice = &mut self.voices[voice_idx]; | |
| 143 | + | voice.active = true; | |
| 144 | + | voice.note = note; | |
| 145 | + | voice.velocity = vel_f; | |
| 146 | + | voice.age = self.note_counter; | |
| 147 | + | voice.position = 0.0; | |
| 148 | + | voice.zone_index = zone_idx; | |
| 149 | + | voice.envelope_phase = EnvelopePhase::Attack; | |
| 150 | + | voice.envelope_level = 0.0; | |
| 151 | + | voice.envelope_time = 0.0; | |
| 152 | + | voice.release_start_level = 0.0; | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | /// Release a note: transition all active voices matching this note to the release phase. | |
| 156 | + | pub fn note_off(&mut self, note: u8) { | |
| 157 | + | for voice in &mut self.voices { | |
| 158 | + | if voice.active && voice.note == note && voice.envelope_phase != EnvelopePhase::Release { | |
| 159 | + | voice.release_start_level = voice.envelope_level; | |
| 160 | + | voice.envelope_phase = EnvelopePhase::Release; | |
| 161 | + | voice.envelope_time = 0.0; | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// Advance the ADSR envelope by one sample and return the new level. | |
| 168 | + | fn step_envelope(voice: &mut Voice, env: &AdsrEnvelope, dt: f32) -> f32 { | |
| 169 | + | voice.envelope_time += dt; | |
| 170 | + | ||
| 171 | + | match voice.envelope_phase { | |
| 172 | + | EnvelopePhase::Attack => { | |
| 173 | + | if env.attack <= 0.0 { | |
| 174 | + | voice.envelope_level = 1.0; | |
| 175 | + | voice.envelope_phase = EnvelopePhase::Decay; | |
| 176 | + | voice.envelope_time = 0.0; | |
| 177 | + | } else { | |
| 178 | + | voice.envelope_level = (voice.envelope_time / env.attack).min(1.0); | |
| 179 | + | if voice.envelope_level >= 1.0 { | |
| 180 | + | voice.envelope_level = 1.0; | |
| 181 | + | voice.envelope_phase = EnvelopePhase::Decay; | |
| 182 | + | voice.envelope_time = 0.0; | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | EnvelopePhase::Decay => { | |
| 187 | + | if env.decay <= 0.0 { | |
| 188 | + | voice.envelope_level = env.sustain; | |
| 189 | + | voice.envelope_phase = EnvelopePhase::Sustain; | |
| 190 | + | voice.envelope_time = 0.0; | |
| 191 | + | } else { | |
| 192 | + | let progress = (voice.envelope_time / env.decay).min(1.0); | |
| 193 | + | voice.envelope_level = 1.0 + (env.sustain - 1.0) * progress; | |
| 194 | + | if progress >= 1.0 { | |
| 195 | + | voice.envelope_level = env.sustain; | |
| 196 | + | voice.envelope_phase = EnvelopePhase::Sustain; | |
| 197 | + | voice.envelope_time = 0.0; | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | EnvelopePhase::Sustain => { | |
| 202 | + | voice.envelope_level = env.sustain; | |
| 203 | + | } | |
| 204 | + | EnvelopePhase::Release => { | |
| 205 | + | if env.release <= 0.0 { | |
| 206 | + | voice.envelope_level = 0.0; | |
| 207 | + | voice.envelope_phase = EnvelopePhase::Idle; | |
| 208 | + | voice.active = false; | |
| 209 | + | } else { | |
| 210 | + | let progress = (voice.envelope_time / env.release).min(1.0); | |
| 211 | + | voice.envelope_level = voice.release_start_level * (1.0 - progress); | |
| 212 | + | if progress >= 1.0 { | |
| 213 | + | voice.envelope_level = 0.0; | |
| 214 | + | voice.envelope_phase = EnvelopePhase::Idle; | |
| 215 | + | voice.active = false; | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | EnvelopePhase::Idle => { | |
| 220 | + | voice.envelope_level = 0.0; | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | voice.envelope_level | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /// Render all active voices into an f32 mix buffer (additive). | |
| 228 | + | /// | |
| 229 | + | /// The buffer is **not** zeroed — samples are summed into whatever is already there. | |
| 230 | + | /// `channels` is the output channel count, `device_sr` the output sample rate. | |
| 231 | + | pub fn render_voices( | |
| 232 | + | inst: &mut InstrumentPlayback, | |
| 233 | + | buf: &mut [f32], | |
| 234 | + | channels: usize, | |
| 235 | + | device_sr: u32, | |
| 236 | + | ) { | |
| 237 | + | if !inst.active || inst.zone_buffers.is_empty() || channels == 0 { | |
| 238 | + | return; | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | let num_frames = buf.len() / channels; | |
| 242 | + | let dt = 1.0 / device_sr as f32; | |
| 243 | + | let envelope = inst.config.envelope; | |
| 244 | + | ||
| 245 | + | for voice in &mut inst.voices { | |
| 246 | + | if !voice.active { | |
| 247 | + | continue; | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | let zone = &inst.zone_buffers[voice.zone_index]; | |
| 251 | + | let total_frames = zone.buffer.data.len() / 2; // stereo interleaved | |
| 252 | + | if total_frames == 0 { | |
| 253 | + | voice.active = false; | |
| 254 | + | voice.envelope_phase = EnvelopePhase::Idle; | |
| 255 | + | continue; | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | let pitch_ratio = | |
| 259 | + | 2.0_f64.powf((voice.note as f64 - zone.root_note as f64) / 12.0) | |
| 260 | + | * (zone.buffer.sample_rate as f64 / device_sr as f64); | |
| 261 | + | ||
| 262 | + | for frame in 0..num_frames { | |
| 263 | + | let pos_int = voice.position as usize; | |
| 264 | + | if pos_int >= total_frames { | |
| 265 | + | voice.active = false; | |
| 266 | + | voice.envelope_phase = EnvelopePhase::Idle; | |
| 267 | + | voice.envelope_level = 0.0; | |
| 268 | + | break; | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | let env_level = step_envelope(voice, &envelope, dt); | |
| 272 | + | if !voice.active { | |
| 273 | + | break; | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | let frac = (voice.position - pos_int as f64) as f32; | |
| 277 | + | let next = (pos_int + 1).min(total_frames - 1); | |
| 278 | + | ||
| 279 | + | let l0 = zone.buffer.data[pos_int * 2]; | |
| 280 | + | let r0 = zone.buffer.data[pos_int * 2 + 1]; | |
| 281 | + | let l1 = zone.buffer.data[next * 2]; | |
| 282 | + | let r1 = zone.buffer.data[next * 2 + 1]; | |
| 283 | + | ||
| 284 | + | let left = l0 + (l1 - l0) * frac; | |
| 285 | + | let right = r0 + (r1 - r0) * frac; | |
| 286 | + | ||
| 287 | + | let gain = env_level * voice.velocity; | |
| 288 | + | let base = frame * channels; | |
| 289 | + | if channels >= 2 { | |
| 290 | + | buf[base] += left * gain; | |
| 291 | + | buf[base + 1] += right * gain; | |
| 292 | + | } else if channels == 1 { | |
| 293 | + | buf[base] += (left + right) * 0.5 * gain; | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | voice.position += pitch_ratio; | |
| 297 | + | } | |
| 298 | + | } | |
| 103 | 299 | } | |
| 104 | 300 | ||
| 105 | 301 | #[cfg(test)] | |
| 106 | 302 | mod tests { | |
| 107 | 303 | use super::*; | |
| 108 | 304 | ||
| 305 | + | fn make_zone(num_frames: usize, root_note: u8, sample_rate: u32) -> LoadedZone { | |
| 306 | + | let mut data = Vec::with_capacity(num_frames * 2); | |
| 307 | + | for i in 0..num_frames { | |
| 308 | + | let val = (i as f32 + 1.0) / num_frames as f32; | |
| 309 | + | data.push(val); // L | |
| 310 | + | data.push(val); // R | |
| 311 | + | } | |
| 312 | + | LoadedZone { | |
| 313 | + | buffer: PreviewBuffer { | |
| 314 | + | data, | |
| 315 | + | channels: 2, | |
| 316 | + | sample_rate, | |
| 317 | + | }, | |
| 318 | + | root_note, | |
| 319 | + | low_note: 0, | |
| 320 | + | high_note: 127, | |
| 321 | + | vel_low: 0.0, | |
| 322 | + | vel_high: 1.0, | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | fn make_inst(num_frames: usize, root_note: u8) -> InstrumentPlayback { | |
| 327 | + | let mut inst = InstrumentPlayback::new(8); | |
| 328 | + | inst.zone_buffers.push(make_zone(num_frames, root_note, 44100)); | |
| 329 | + | inst.active = true; | |
| 330 | + | inst.sample_rate = 44100.0; | |
| 331 | + | inst | |
| 332 | + | } | |
| 333 | + | ||
| 109 | 334 | #[test] | |
| 110 | 335 | fn new_creates_inactive_voices() { | |
| 111 | 336 | let playback = InstrumentPlayback::new(8); | |
| @@ -124,4 +349,247 @@ mod tests { | |||
| 124 | 349 | assert_eq!(playback.note_counter, 0); | |
| 125 | 350 | assert!((playback.sample_rate - 44100.0).abs() < f32::EPSILON); | |
| 126 | 351 | } | |
| 352 | + | ||
| 353 | + | #[test] | |
| 354 | + | fn note_on_allocates_voice() { | |
| 355 | + | let mut inst = make_inst(1000, 60); | |
| 356 | + | inst.note_on(60, 100); | |
| 357 | + | ||
| 358 | + | let active: Vec<_> = inst.voices.iter().filter(|v| v.active).collect(); | |
| 359 | + | assert_eq!(active.len(), 1); | |
| 360 | + | assert_eq!(active[0].note, 60); | |
| 361 | + | assert_eq!(active[0].envelope_phase, EnvelopePhase::Attack); | |
| 362 | + | assert!(active[0].velocity > 0.0); | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | #[test] | |
| 366 | + | fn note_on_no_zones_is_noop() { | |
| 367 | + | let mut inst = InstrumentPlayback::new(4); | |
| 368 | + | inst.active = true; | |
| 369 | + | inst.note_on(60, 100); | |
| 370 | + | assert!(inst.voices.iter().all(|v| !v.active)); | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | #[test] | |
| 374 | + | fn note_on_steals_oldest_voice() { | |
| 375 | + | let mut inst = make_inst(1000, 60); | |
| 376 | + | // Fill all 8 voices | |
| 377 | + | for i in 0..8 { | |
| 378 | + | inst.note_on(60 + i, 100); | |
| 379 | + | } | |
| 380 | + | assert_eq!(inst.voices.iter().filter(|v| v.active).count(), 8); | |
| 381 | + | ||
| 382 | + | // 9th note should steal the oldest (note 60, age=1) | |
| 383 | + | inst.note_on(80, 100); | |
| 384 | + | let stolen = &inst.voices[0]; // voice 0 was the first allocated | |
| 385 | + | assert_eq!(stolen.note, 80); | |
| 386 | + | assert_eq!(stolen.age, 9); | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | #[test] | |
| 390 | + | fn note_off_triggers_release() { | |
| 391 | + | let mut inst = make_inst(1000, 60); | |
| 392 | + | // Set envelope with non-zero sustain so envelope_level > 0 | |
| 393 | + | inst.config.envelope.attack = 0.0; | |
| 394 | + | inst.config.envelope.sustain = 0.8; | |
| 395 | + | inst.note_on(60, 100); | |
| 396 | + | ||
| 397 | + | // Advance a few samples to get past attack | |
| 398 | + | let dt = 1.0 / 44100.0; | |
| 399 | + | let voice = &mut inst.voices[0]; | |
| 400 | + | for _ in 0..100 { | |
| 401 | + | step_envelope(voice, &inst.config.envelope, dt); | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | inst.note_off(60); | |
| 405 | + | let voice = &inst.voices[0]; | |
| 406 | + | assert_eq!(voice.envelope_phase, EnvelopePhase::Release); | |
| 407 | + | assert!(voice.release_start_level > 0.0); | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | #[test] | |
| 411 | + | fn note_off_wrong_note_is_noop() { | |
| 412 | + | let mut inst = make_inst(1000, 60); | |
| 413 | + | inst.note_on(60, 100); | |
| 414 | + | inst.note_off(61); // different note | |
| 415 | + | assert_eq!(inst.voices[0].envelope_phase, EnvelopePhase::Attack); | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | #[test] | |
| 419 | + | fn envelope_attack_ramp() { | |
| 420 | + | let env = AdsrEnvelope { | |
| 421 | + | attack: 0.01, | |
| 422 | + | decay: 0.0, | |
| 423 | + | sustain: 1.0, | |
| 424 | + | release: 0.0, | |
| 425 | + | }; | |
| 426 | + | let mut voice = Voice::new(); | |
| 427 | + | voice.active = true; | |
| 428 | + | voice.envelope_phase = EnvelopePhase::Attack; | |
| 429 | + | ||
| 430 | + | let dt = 0.001; // 1ms steps | |
| 431 | + | for _ in 0..5 { | |
| 432 | + | step_envelope(&mut voice, &env, dt); | |
| 433 | + | } | |
| 434 | + | // At 5ms into 10ms attack, should be ~0.5 | |
| 435 | + | assert!((voice.envelope_level - 0.5).abs() < 0.01); | |
| 436 | + | assert_eq!(voice.envelope_phase, EnvelopePhase::Attack); | |
| 437 | + | ||
| 438 | + | // Complete the attack (zero-length decay transitions immediately to sustain) | |
| 439 | + | for _ in 0..6 { | |
| 440 | + | step_envelope(&mut voice, &env, dt); | |
| 441 | + | } | |
| 442 | + | assert_eq!(voice.envelope_phase, EnvelopePhase::Sustain); | |
| 443 | + | assert!((voice.envelope_level - 1.0).abs() < f32::EPSILON); | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | #[test] | |
| 447 | + | fn envelope_zero_attack_skips() { | |
| 448 | + | let env = AdsrEnvelope { | |
| 449 | + | attack: 0.0, | |
| 450 | + | decay: 0.1, | |
| 451 | + | sustain: 0.5, | |
| 452 | + | release: 0.0, | |
| 453 | + | }; | |
| 454 | + | let mut voice = Voice::new(); | |
| 455 | + | voice.active = true; | |
| 456 | + | voice.envelope_phase = EnvelopePhase::Attack; | |
| 457 | + | ||
| 458 | + | step_envelope(&mut voice, &env, 0.001); | |
| 459 | + | assert_eq!(voice.envelope_level, 1.0); | |
| 460 | + | assert_eq!(voice.envelope_phase, EnvelopePhase::Decay); | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | #[test] | |
| 464 | + | fn envelope_release_ramps_to_zero() { | |
| 465 | + | let env = AdsrEnvelope { | |
| 466 | + | attack: 0.0, | |
| 467 | + | decay: 0.0, | |
| 468 | + | sustain: 1.0, | |
| 469 | + | release: 0.01, | |
| 470 | + | }; | |
| 471 | + | let mut voice = Voice::new(); | |
| 472 | + | voice.active = true; | |
| 473 | + | voice.envelope_phase = EnvelopePhase::Release; | |
| 474 | + | voice.release_start_level = 0.8; | |
| 475 | + | ||
| 476 | + | let dt = 0.001; | |
| 477 | + | for _ in 0..5 { | |
| 478 | + | step_envelope(&mut voice, &env, dt); | |
| 479 | + | } | |
| 480 | + | // At 5ms into 10ms release from 0.8, should be ~0.4 | |
| 481 | + | assert!((voice.envelope_level - 0.4).abs() < 0.05); | |
| 482 | + | assert!(voice.active); | |
| 483 | + | ||
| 484 | + | // Complete release | |
| 485 | + | for _ in 0..6 { | |
| 486 | + | step_envelope(&mut voice, &env, dt); | |
| 487 | + | } | |
| 488 | + | assert!(!voice.active); | |
| 489 | + | assert_eq!(voice.envelope_phase, EnvelopePhase::Idle); | |
| 490 | + | assert_eq!(voice.envelope_level, 0.0); | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | #[test] | |
| 494 | + | fn envelope_zero_release_immediately_deactivates() { | |
| 495 | + | let env = AdsrEnvelope { | |
| 496 | + | attack: 0.0, | |
| 497 | + | decay: 0.0, | |
| 498 | + | sustain: 1.0, | |
| 499 | + | release: 0.0, | |
| 500 | + | }; | |
| 501 | + | let mut voice = Voice::new(); | |
| 502 | + | voice.active = true; | |
| 503 | + | voice.envelope_phase = EnvelopePhase::Release; | |
| 504 | + | voice.release_start_level = 1.0; | |
| 505 | + | ||
| 506 | + | step_envelope(&mut voice, &env, 0.001); | |
| 507 | + | assert!(!voice.active); | |
| 508 | + | assert_eq!(voice.envelope_phase, EnvelopePhase::Idle); | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | #[test] | |
| 512 | + | fn render_voices_produces_output() { | |
| 513 | + | let mut inst = make_inst(1000, 60); | |
| 514 | + | inst.config.envelope.attack = 0.0; | |
| 515 | + | inst.config.envelope.sustain = 1.0; | |
| 516 | + | inst.note_on(60, 127); // max velocity, root pitch | |
| 517 | + | ||
| 518 | + | let mut buf = vec![0.0f32; 20]; // 10 frames stereo | |
| 519 | + | render_voices(&mut inst, &mut buf, 2, 44100); | |
| 520 | + | ||
| 521 | + | // Should have non-zero audio | |
| 522 | + | assert!(buf.iter().any(|&s| s != 0.0)); | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | #[test] | |
| 526 | + | fn render_voices_inactive_produces_silence() { | |
| 527 | + | let mut inst = make_inst(1000, 60); | |
| 528 | + | inst.active = false; | |
| 529 | + | inst.note_on(60, 100); | |
| 530 | + | ||
| 531 | + | let mut buf = vec![0.0f32; 20]; | |
| 532 | + | render_voices(&mut inst, &mut buf, 2, 44100); | |
| 533 | + | assert!(buf.iter().all(|&s| s == 0.0)); | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | #[test] | |
| 537 | + | fn render_voices_pitch_ratio_at_root() { | |
| 538 | + | // At root note with same sample rate, pitch ratio = 1.0 | |
| 539 | + | let mut inst = make_inst(1000, 60); | |
| 540 | + | inst.config.envelope.attack = 0.0; | |
| 541 | + | inst.config.envelope.sustain = 1.0; | |
| 542 | + | inst.note_on(60, 127); | |
| 543 | + | ||
| 544 | + | let mut buf = vec![0.0f32; 4]; // 2 frames stereo | |
| 545 | + | render_voices(&mut inst, &mut buf, 2, 44100); | |
| 546 | + | ||
| 547 | + | // Position should have advanced by ~2.0 (ratio 1.0, 2 frames) | |
| 548 | + | let voice = &inst.voices[0]; | |
| 549 | + | assert!((voice.position - 2.0).abs() < 0.01); | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | #[test] | |
| 553 | + | fn render_voices_pitch_ratio_octave_up() { | |
| 554 | + | // An octave up (note + 12) should double the pitch ratio | |
| 555 | + | let mut inst = make_inst(1000, 60); | |
| 556 | + | inst.config.envelope.attack = 0.0; | |
| 557 | + | inst.config.envelope.sustain = 1.0; | |
| 558 | + | inst.note_on(72, 127); // octave above root | |
| 559 | + | ||
| 560 | + | let mut buf = vec![0.0f32; 4]; // 2 frames stereo | |
| 561 | + | render_voices(&mut inst, &mut buf, 2, 44100); | |
| 562 | + | ||
| 563 | + | // Position should have advanced by ~4.0 (ratio 2.0, 2 frames) | |
| 564 | + | let voice = &inst.voices[0]; | |
| 565 | + | assert!((voice.position - 4.0).abs() < 0.01); | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | #[test] | |
| 569 | + | fn render_voices_deactivates_at_buffer_end() { | |
| 570 | + | let mut inst = make_inst(5, 60); // only 5 frames of audio | |
| 571 | + | inst.config.envelope.attack = 0.0; | |
| 572 | + | inst.config.envelope.sustain = 1.0; | |
| 573 | + | inst.note_on(60, 127); | |
| 574 | + | ||
| 575 | + | let mut buf = vec![0.0f32; 40]; // 20 frames — way past buffer | |
| 576 | + | render_voices(&mut inst, &mut buf, 2, 44100); | |
| 577 | + | ||
| 578 | + | assert!(!inst.voices[0].active); | |
| 579 | + | } | |
| 580 | + | ||
| 581 | + | #[test] | |
| 582 | + | fn render_voices_sums_additively() { | |
| 583 | + | let mut inst = make_inst(1000, 60); | |
| 584 | + | inst.config.envelope.attack = 0.0; | |
| 585 | + | inst.config.envelope.sustain = 1.0; | |
| 586 | + | ||
| 587 | + | // Start with pre-filled buffer | |
| 588 | + | let mut buf = vec![0.5f32; 4]; // 2 frames stereo | |
| 589 | + | inst.note_on(60, 127); | |
| 590 | + | render_voices(&mut inst, &mut buf, 2, 44100); | |
| 591 | + |
Lines truncated
| @@ -27,14 +27,15 @@ pub struct PreviewBuffer { | |||
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | 29 | /// Mutable playback state shared between the GUI and audio threads. | |
| 30 | - | #[derive(Default)] | |
| 31 | 30 | pub struct PreviewPlayback { | |
| 32 | 31 | /// Currently loaded preview buffer, or `None` if nothing is loaded. | |
| 33 | 32 | pub buffer: Option<PreviewBuffer>, | |
| 34 | - | /// Current frame index into the buffer (each frame = 2 interleaved samples). | |
| 35 | - | pub position: usize, | |
| 33 | + | /// Current playback position in file-rate frames (fractional for resampling). | |
| 34 | + | pub position_frac: f64, | |
| 36 | 35 | /// Whether playback is active. | |
| 37 | 36 | pub playing: bool, | |
| 37 | + | /// Whether the sample should loop when it reaches the end. | |
| 38 | + | pub loop_enabled: bool, | |
| 38 | 39 | /// `true` while a background thread is still decoding and appending to the buffer. | |
| 39 | 40 | pub streaming: bool, | |
| 40 | 41 | /// Number of stereo frames decoded so far (grows during streaming). | |
| @@ -43,6 +44,20 @@ pub struct PreviewPlayback { | |||
| 43 | 44 | pub total_frames_estimate: Option<usize>, | |
| 44 | 45 | } | |
| 45 | 46 | ||
| 47 | + | impl Default for PreviewPlayback { | |
| 48 | + | fn default() -> Self { | |
| 49 | + | Self { | |
| 50 | + | buffer: None, | |
| 51 | + | position_frac: 0.0, | |
| 52 | + | playing: false, | |
| 53 | + | loop_enabled: false, | |
| 54 | + | streaming: false, | |
| 55 | + | decoded_frames: 0, | |
| 56 | + | total_frames_estimate: None, | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 46 | 61 | impl PreviewPlayback { | |
| 47 | 62 | /// Create a new idle playback state with no buffer loaded. | |
| 48 | 63 | pub fn new() -> Self { | |
| @@ -52,7 +67,6 @@ impl PreviewPlayback { | |||
| 52 | 67 | ||
| 53 | 68 | /// Decode an audio file to interleaved stereo f32. | |
| 54 | 69 | /// Mono files are doubled to stereo. Multi-channel files are mixed down to stereo. | |
| 55 | - | /// No resampling — pitch may shift if sample rate != host rate (known Phase 2 limitation). | |
| 56 | 70 | #[instrument(skip_all)] | |
| 57 | 71 | pub fn decode_to_f32(path: &Path) -> Result<PreviewBuffer, PreviewError> { | |
| 58 | 72 | let file = std::fs::File::open(path).map_err(|e| PreviewError::Open { | |
| @@ -247,7 +261,7 @@ pub fn start_streaming_decode( | |||
| 247 | 261 | channels: 2, | |
| 248 | 262 | sample_rate: source_sample_rate, | |
| 249 | 263 | }); | |
| 250 | - | guard.position = 0; | |
| 264 | + | guard.position_frac = 0.0; | |
| 251 | 265 | guard.playing = false; | |
| 252 | 266 | guard.streaming = true; | |
| 253 | 267 | guard.decoded_frames = 0; |
| @@ -401,6 +401,23 @@ impl BrowserState { | |||
| 401 | 401 | self.import_mode = ImportMode::ExportComplete { total, errors }; | |
| 402 | 402 | return true; | |
| 403 | 403 | } | |
| 404 | + | ||
| 405 | + | // --- Edit events --- | |
| 406 | + | BackendEvent::EditStarted { hash: _ } => { | |
| 407 | + | self.edit.in_progress = true; | |
| 408 | + | } | |
| 409 | + | BackendEvent::EditComplete { | |
| 410 | + | source_hash, | |
| 411 | + | result_path, | |
| 412 | + | operation, | |
| 413 | + | } => { | |
| 414 | + | self.edit.in_progress = false; | |
| 415 | + | self.handle_edit_complete(source_hash, result_path, operation); | |
| 416 | + | } | |
| 417 | + | BackendEvent::EditError { hash: _, error } => { | |
| 418 | + | self.edit.in_progress = false; | |
| 419 | + | self.status = format!("Edit failed: {error}"); | |
| 420 | + | } | |
| 404 | 421 | } | |
| 405 | 422 | } | |
| 406 | 423 | ||
| @@ -641,4 +658,235 @@ impl BrowserState { | |||
| 641 | 658 | self.import_mode = ImportMode::None; | |
| 642 | 659 | self.status = "Export cancelled".to_string(); | |
| 643 | 660 | } | |
| 661 | + | ||
| 662 | + | // --- Edit operations (floating editor window) --- | |
| 663 | + | ||
| 664 | + | /// Open the floating sample editor for the given hash. | |
| 665 | + | pub fn open_edit_window(&mut self, hash: &str) { | |
| 666 | + | // Load analysis for total frames and result mode preference | |
| 667 | + | let analysis = self.backend.get_analysis(hash).ok().flatten(); | |
| 668 | + | let total_frames = analysis.as_ref() | |
| 669 | + | .map(|a| (a.duration * a.sample_rate as f64) as usize) | |
| 670 | + | .unwrap_or(0); | |
| 671 | + | ||
| 672 | + | self.edit.hash = Some(hash.to_string()); | |
| 673 | + | self.edit.show_window = true; | |
| 674 | + | ||
| 675 | + | // Reset all params to defaults | |
| 676 | + | self.edit.trim_start = 0.0; | |
| 677 | + | self.edit.trim_end = 1.0; | |
| 678 | + | self.edit.total_frames = total_frames; | |
| 679 | + | self.edit.gain_db = 0.0; | |
| 680 | + | self.edit.norm_peak = true; | |
| 681 | + | self.edit.norm_target = -0.1; | |
| 682 | + | self.edit.fade_in = true; | |
| 683 | + | self.edit.fade_duration_ms = 100.0; | |
| 684 | + | self.edit.fade_curve = audiofiles_core::edit::FadeCurve::Linear; | |
| 685 | + | ||
| 686 | + | // Cache result mode from user_config | |
| 687 | + | self.edit.result_mode = match self.backend.get_config("edit_result_mode").ok().flatten().as_deref() { | |
| 688 | + | Some("replace") => Some(super::EditResultMode::Replace), | |
| 689 | + | Some("sibling") => Some(super::EditResultMode::Sibling), | |
| 690 | + | _ => None, | |
| 691 | + | }; | |
| 692 | + | } | |
| 693 | + | ||
| 694 | + | /// Close the floating sample editor. | |
| 695 | + | pub fn close_edit_window(&mut self) { | |
| 696 | + | self.edit.show_window = false; | |
| 697 | + | self.edit.hash = None; | |
| 698 | + | } | |
| 699 | + | ||
| 700 | + | /// Apply trim to the current edit target. | |
| 701 | + | pub fn apply_edit_trim(&mut self) { | |
| 702 | + | let hash = match &self.edit.hash { | |
| 703 | + | Some(h) => h.clone(), | |
| 704 | + | None => return, | |
| 705 | + | }; | |
| 706 | + | let start = (self.edit.trim_start * self.edit.total_frames as f32) as usize; | |
| 707 | + | let end = (self.edit.trim_end * self.edit.total_frames as f32) as usize; | |
| 708 | + | let op = audiofiles_core::edit::EditOperation::Trim { start_frame: start, end_frame: end }; | |
| 709 | + | let ext = self.backend.sample_extension(&hash).unwrap_or_default(); | |
| 710 | + | if let Err(e) = self.backend.start_edit(&hash, &ext, op) { | |
| 711 | + | self.status = format!("Edit failed: {e}"); | |
| 712 | + | } | |
| 713 | + | } | |
| 714 | + | ||
| 715 | + | /// Apply gain adjustment to the current edit target. | |
| 716 | + | pub fn apply_edit_gain(&mut self) { | |
| 717 | + | let hash = match &self.edit.hash { | |
| 718 | + | Some(h) => h.clone(), | |
| 719 | + | None => return, | |
| 720 | + | }; | |
| 721 | + | let op = audiofiles_core::edit::EditOperation::Gain { db: self.edit.gain_db }; | |
| 722 | + | let ext = self.backend.sample_extension(&hash).unwrap_or_default(); | |
| 723 | + | if let Err(e) = self.backend.start_edit(&hash, &ext, op) { | |
| 724 | + | self.status = format!("Edit failed: {e}"); | |
| 725 | + | } | |
| 726 | + | } | |
| 727 | + | ||
| 728 | + | /// Apply normalize to the current edit target. | |
| 729 | + | pub fn apply_edit_normalize(&mut self) { | |
| 730 | + | let hash = match &self.edit.hash { | |
| 731 | + | Some(h) => h.clone(), | |
| 732 | + | None => return, | |
| 733 | + | }; | |
| 734 | + | let op = if self.edit.norm_peak { | |
| 735 | + | audiofiles_core::edit::EditOperation::NormalizePeak { target_db: self.edit.norm_target } | |
| 736 | + | } else { | |
| 737 | + | audiofiles_core::edit::EditOperation::NormalizeLufs { target_lufs: self.edit.norm_target } | |
| 738 | + | }; | |
| 739 | + | let ext = self.backend.sample_extension(&hash).unwrap_or_default(); | |
| 740 | + | if let Err(e) = self.backend.start_edit(&hash, &ext, op) { | |
| 741 | + | self.status = format!("Edit failed: {e}"); | |
| 742 | + | } | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | /// Apply reverse to the current edit target. | |
| 746 | + | pub fn apply_edit_reverse(&mut self) { | |
| 747 | + | let hash = match &self.edit.hash { | |
| 748 | + | Some(h) => h.clone(), | |
| 749 | + | None => return, | |
| 750 | + | }; | |
| 751 | + | let op = audiofiles_core::edit::EditOperation::Reverse; | |
| 752 | + | let ext = self.backend.sample_extension(&hash).unwrap_or_default(); | |
| 753 | + | if let Err(e) = self.backend.start_edit(&hash, &ext, op) { | |
| 754 | + | self.status = format!("Edit failed: {e}"); | |
| 755 | + | } | |
| 756 | + | } | |
| 757 | + | ||
| 758 | + | /// Apply fade to the current edit target. | |
| 759 | + | pub fn apply_edit_fade(&mut self) { | |
| 760 | + | let hash = match &self.edit.hash { | |
| 761 | + | Some(h) => h.clone(), | |
| 762 | + | None => return, | |
| 763 | + | }; | |
| 764 | + | // Convert ms to frames using sample rate from analysis | |
| 765 | + | let sample_rate = self.backend.get_analysis(&hash).ok().flatten() | |
| 766 | + | .map(|a| a.sample_rate) | |
| 767 | + | .unwrap_or(44100); | |
| 768 | + | let frames = ((self.edit.fade_duration_ms / 1000.0) * sample_rate as f64) as usize; | |
| 769 | + | let op = if self.edit.fade_in { | |
| 770 | + | audiofiles_core::edit::EditOperation::FadeIn { frames, curve: self.edit.fade_curve } | |
| 771 | + | } else { | |
| 772 | + | audiofiles_core::edit::EditOperation::FadeOut { frames, curve: self.edit.fade_curve } | |
| 773 | + | }; | |
| 774 | + | let ext = self.backend.sample_extension(&hash).unwrap_or_default(); | |
| 775 | + | if let Err(e) = self.backend.start_edit(&hash, &ext, op) { | |
| 776 | + | self.status = format!("Edit failed: {e}"); | |
| 777 | + | } | |
| 778 | + | } | |
| 779 | + | ||
| 780 | + | /// Save the user's preferred edit result mode. | |
| 781 | + | pub fn set_edit_result_mode(&mut self, mode: super::EditResultMode) { | |
| 782 | + | let mode_str = match mode { | |
| 783 | + | super::EditResultMode::Replace => "replace", | |
| 784 | + | super::EditResultMode::Sibling => "sibling", | |
| 785 | + | }; | |
| 786 | + | let _ = self.backend.set_config("edit_result_mode", mode_str); | |
| 787 | + | self.edit.result_mode = Some(mode); | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | /// Handle a completed edit: import result, update VFS, record history. | |
| 791 | + | fn handle_edit_complete( | |
| 792 | + | &mut self, | |
| 793 | + | source_hash: String, | |
| 794 | + | result_path: PathBuf, | |
| 795 | + | operation: audiofiles_core::edit::EditOperation, | |
| 796 | + | ) { | |
| 797 | + | let op_name = operation.display_name().to_string(); | |
| 798 | + | ||
| 799 | + | // Use cached result mode preference | |
| 800 | + | let mode = match self.edit.result_mode { | |
| 801 | + | Some(m) => m, | |
| 802 | + | None => { | |
| 803 | + | // No preference set — store result and show prompt | |
| 804 | + | self.edit.pending_result = Some(super::PendingEditResult { | |
| 805 | + | source_hash, | |
| 806 | + | result_path, | |
| 807 | + | operation, | |
| 808 | + | }); | |
| 809 | + | self.edit.result_prompt = true; | |
| 810 | + | return; | |
| 811 | + | } | |
| 812 | + | }; | |
| 813 | + | ||
| 814 | + | self.finalize_edit(source_hash, result_path, operation, mode); | |
| 815 | + | self.status = format!("Edit applied — {op_name}"); | |
| 816 | + | } | |
| 817 | + | ||
| 818 | + | /// Apply the chosen edit result mode to a pending edit. | |
| 819 | + | pub fn confirm_edit_result(&mut self, mode: super::EditResultMode, remember: bool) { | |
| 820 | + | if remember { | |
| 821 | + | self.set_edit_result_mode(mode); | |
| 822 | + | } | |
| 823 | + | ||
| 824 | + | if let Some(pending) = self.edit.pending_result.take() { | |
| 825 | + | let op_name = pending.operation.display_name().to_string(); | |
| 826 | + | self.finalize_edit(pending.source_hash, pending.result_path, pending.operation, mode); | |
| 827 | + | self.status = format!("Edit applied — {op_name}"); | |
| 828 | + | } | |
| 829 | + | self.edit.result_prompt = false; | |
| 830 | + | } | |
| 831 | + | ||
| 832 | + | /// Common finalization: import result file, update VFS, record history, trigger analysis. | |
| 833 | + | fn finalize_edit( | |
| 834 | + | &mut self, | |
| 835 | + | source_hash: String, | |
| 836 | + | result_path: PathBuf, | |
| 837 | + | operation: audiofiles_core::edit::EditOperation, | |
| 838 | + | mode: super::EditResultMode, | |
| 839 | + | ) { | |
| 840 | + | // 1. Import the result file into the content-addressed store | |
| 841 | + | let new_hash = match self.backend.import_file(&result_path) { | |
| 842 | + | Ok(h) => h, | |
| 843 | + | Err(e) => { | |
| 844 | + | self.status = format!("Failed to import edit result: {e}"); | |
| 845 | + | return; | |
| 846 | + | } | |
| 847 | + | }; | |
| 848 | + | ||
| 849 | + | // Clean up temp file | |
| 850 | + | let _ = std::fs::remove_file(&result_path); | |
| 851 | + | ||
| 852 | + | // 2. Record in edit_history | |
| 853 | + | let _ = self.backend.record_edit_history(&source_hash, &new_hash, &operation); | |
| 854 | + | ||
| 855 | + | // 3. Update VFS based on mode | |
| 856 | + | let vfs_id = self.current_vfs_id(); | |
| 857 | + | let source_hashes = [source_hash.as_str()]; | |
| 858 | + | let source_nodes = self.backend.find_nodes_by_hashes(vfs_id, &source_hashes) | |
| 859 | + | .unwrap_or_default(); | |
| 860 | + | ||
| 861 | + | let new_ext = self.backend.sample_extension(&new_hash).unwrap_or_else(|_| "wav".to_string()); | |
| 862 | + | ||
| 863 | + | match mode { | |
| 864 | + | super::EditResultMode::Replace => { | |
| 865 | + | // Update each VFS node that references the source hash | |
| 866 | + | for node in &source_nodes { | |
| 867 | + | // Delete old node and create new one with same name/parent | |
| 868 | + | let parent_id = node.node.parent_id; | |
| 869 | + | let name = node.node.name.clone(); | |
| 870 | + | let _ = self.backend.delete_node(node.node.id); | |
| 871 | + | let _ = self.backend.create_sample_link(vfs_id, parent_id, &name, &new_hash); | |
| 872 | + | } | |
| 873 | + | } | |
| 874 | + | super::EditResultMode::Sibling => { | |
| 875 | + | // Create a sibling node next to the original | |
| 876 | + | if let Some(node) = source_nodes.first() { | |
| 877 | + | let parent_id = node.node.parent_id; | |
| 878 | + | let (stem, _ext) = split_name_ext(&node.node.name); | |
| 879 | + | let sibling_name = format!("{stem}_edited.{new_ext}"); | |
| 880 | + | let _ = self.backend.create_sample_link(vfs_id, parent_id, &sibling_name, &new_hash); | |
| 881 | + | } | |
| 882 | + | } | |
| 883 | + | } | |
| 884 | + | ||
| 885 | + | // 4. Trigger analysis on the new sample | |
| 886 | + | let hashes = vec![(new_hash, new_ext)]; | |
| 887 | + | let _ = self.backend.start_analysis(hashes, AnalysisConfig::default()); | |
| 888 | + | ||
| 889 | + | // 5. Refresh the file list | |
| 890 | + | self.refresh_contents(); | |
| 891 | + | } | |
| 644 | 892 | } |
| @@ -7,7 +7,7 @@ use std::collections::HashSet; | |||
| 7 | 7 | use std::fs; | |
| 8 | 8 | use std::path::{Path, PathBuf}; | |
| 9 | 9 | use std::sync::Arc; | |
| 10 | - | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 10 | + | use std::sync::atomic::AtomicU32; | |
| 11 | 11 | use std::time::Instant; | |
| 12 | 12 | ||
| 13 | 13 | use tracing::{error, warn}; | |
| @@ -18,6 +18,7 @@ use audiofiles_core::analysis::suggest::TagSuggestion; | |||
| 18 | 18 | use audiofiles_core::analysis::waveform::WaveformData; | |
| 19 | 19 | use audiofiles_core::analysis::AnalysisResult; | |
| 20 | 20 | use audiofiles_core::db::Database; | |
| 21 | + | use audiofiles_core::edit::{EditOperation, FadeCurve}; | |
| 21 | 22 | use audiofiles_core::error::CoreError; | |
| 22 | 23 | use audiofiles_core::collections::Collection; | |
| 23 | 24 | use audiofiles_core::search::SearchFilter; | |
| @@ -48,6 +49,10 @@ pub struct SharedState { | |||
| 48 | 49 | pub preview: Mutex<PreviewPlayback>, | |
| 49 | 50 | /// Instrument playback state (voice pool, loaded zones), accessed from GUI thread. | |
| 50 | 51 | pub instrument: Mutex<InstrumentPlayback>, | |
| 52 | + | /// Actual device output sample rate (set once at startup). | |
| 53 | + | pub device_sample_rate: AtomicU32, | |
| 54 | + | /// MIDI note events pushed by the MIDI callback, drained by the GUI each frame. | |
| 55 | + | pub midi_recent_notes: Mutex<Vec<MidiNoteEvent>>, | |
| 51 | 56 | } | |
| 52 | 57 | ||
| 53 | 58 | impl Default for SharedState { | |
| @@ -55,6 +60,8 @@ impl Default for SharedState { | |||
| 55 | 60 | Self { | |
| 56 | 61 | preview: Mutex::new(PreviewPlayback::new()), | |
| 57 | 62 | instrument: Mutex::new(InstrumentPlayback::new(8)), | |
| 63 | + | device_sample_rate: AtomicU32::new(44100), | |
| 64 | + | midi_recent_notes: Mutex::new(Vec::new()), | |
| 58 | 65 | } | |
| 59 | 66 | } | |
| 60 | 67 | } | |
| @@ -66,6 +73,23 @@ impl SharedState { | |||
| 66 | 73 | } | |
| 67 | 74 | } | |
| 68 | 75 | ||
| 76 | + | ||
| 77 | + | /// User preference for how to handle edit results. | |
| 78 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 79 | + | pub enum EditResultMode { | |
| 80 | + | /// Replace the VFS node in-place with the edited sample. | |
| 81 | + | Replace, | |
| 82 | + | /// Create a sibling node next to the original. | |
| 83 | + | Sibling, | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// Data from a completed edit, pending user choice (replace vs sibling). | |
| 87 | + | pub struct PendingEditResult { | |
| 88 | + | pub source_hash: String, | |
| 89 | + | pub result_path: PathBuf, | |
| 90 | + | pub operation: EditOperation, | |
| 91 | + | } | |
| 92 | + | ||
| 69 | 93 | /// Pending destructive action awaiting user confirmation. | |
| 70 | 94 | pub enum ConfirmAction { | |
| 71 | 95 | DeleteNode { node_id: NodeId, node_name: String }, | |
| @@ -195,6 +219,147 @@ pub enum SyncSetupAction { | |||
| 195 | 219 | SaveKey(String), | |
| 196 | 220 | } | |
| 197 | 221 | ||
| 222 | + | /// Actions the vault picker UI can request from the app layer. | |
| 223 | + | pub enum VaultAction { | |
| 224 | + | /// Switch to a different vault. | |
| 225 | + | SwitchVault(PathBuf), | |
| 226 | + | /// Create a new vault and switch to it. | |
| 227 | + | CreateVault { name: String, path: PathBuf }, | |
| 228 | + | /// Add an existing vault directory to the registry. | |
| 229 | + | AddExistingVault { name: String, path: PathBuf }, | |
| 230 | + | /// Remove a vault from the registry (no file deletion). | |
| 231 | + | RemoveVault(PathBuf), | |
| 232 | + | /// Rename a vault in the registry. | |
| 233 | + | RenameVault { path: PathBuf, new_name: String }, | |
| 234 | + | /// Scan storage stats for the active vault. | |
| 235 | + | ScanStorage, | |
| 236 | + | /// Deactivate the license key and return to activation screen. | |
| 237 | + | DeactivateLicense, | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | /// GUI state for the consolidated Settings window. | |
| 241 | + | #[derive(Default)] | |
| 242 | + | pub struct SettingsUiState { | |
| 243 | + | /// Display name of the active vault. | |
| 244 | + | pub name: String, | |
| 245 | + | /// (name, path, reachable) for each known vault. | |
| 246 | + | pub list: Vec<(String, PathBuf, bool)>, | |
| 247 | + | /// Set by the UI, consumed by the app layer each frame. | |
| 248 | + | pub pending_action: Option<VaultAction>, | |
| 249 | + | /// Whether the Settings window is open. | |
| 250 | + | pub show_manager: bool, | |
| 251 | + | /// Name input for creating/adding a vault. | |
| 252 | + | pub create_name: String, | |
| 253 | + | /// Path input for creating/adding a vault. | |
| 254 | + | pub create_path: Option<PathBuf>, | |
| 255 | + | /// Inline rename: (path, new_name_buffer). | |
| 256 | + | pub rename_target: Option<(PathBuf, String)>, | |
| 257 | + | ||
| 258 | + | /// Cached storage statistics from the last scan. | |
| 259 | + | pub storage_cache: Option<crate::backend::StorageStats>, | |
| 260 | + | /// Whether a storage scan is in progress. | |
| 261 | + | pub storage_scanning: bool, | |
| 262 | + | /// Masked license key for display. | |
| 263 | + | pub license_key_masked: Option<String>, | |
| 264 | + | /// Machine ID for display. | |
| 265 | + | pub machine_id: Option<String>, | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | /// GUI state for the sync setup and panel. | |
| 269 | + | pub struct SyncUiState { | |
| 270 | + | /// Whether the sync panel overlay is open. | |
| 271 | + | pub show_panel: bool, | |
| 272 | + | pub encryption_input: String, | |
| 273 | + | pub auth_code_input: String, | |
| 274 | + | /// API key input for initial setup. | |
| 275 | + | pub api_key_input: String, | |
| 276 | + | /// Status of the API key test flow. | |
| 277 | + | pub setup_status: SyncSetupStatus, | |
| 278 | + | /// Set by the UI, consumed by the app layer each frame. | |
| 279 | + | pub pending_action: Option<SyncSetupAction>, | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | impl Default for SyncUiState { | |
| 283 | + | fn default() -> Self { | |
| 284 | + | Self { | |
| 285 | + | show_panel: false, | |
| 286 | + | encryption_input: String::new(), | |
| 287 | + | auth_code_input: String::new(), | |
| 288 | + | api_key_input: String::new(), | |
| 289 | + | setup_status: SyncSetupStatus::Idle, | |
| 290 | + | pending_action: None, | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | /// GUI state for the floating sample editor window. | |
| 296 | + | pub struct EditUiState { | |
| 297 | + | pub show_window: bool, | |
| 298 | + | pub hash: Option<String>, | |
| 299 | + | pub in_progress: bool, | |
| 300 | + | pub result_prompt: bool, | |
| 301 | + | pub pending_result: Option<PendingEditResult>, | |
| 302 | + | pub trim_start: f32, | |
| 303 | + | pub trim_end: f32, | |
| 304 | + | pub total_frames: usize, | |
| 305 | + | pub gain_db: f64, | |
| 306 | + | pub norm_peak: bool, | |
| 307 | + | pub norm_target: f64, | |
| 308 | + | pub fade_in: bool, | |
| 309 | + | pub fade_duration_ms: f64, | |
| 310 | + | pub fade_curve: FadeCurve, | |
| 311 | + | pub result_mode: Option<EditResultMode>, | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | impl Default for EditUiState { | |
| 315 | + | fn default() -> Self { | |
| 316 | + | Self { | |
| 317 | + | show_window: false, | |
| 318 | + | hash: None, | |
| 319 | + | in_progress: false, | |
| 320 | + | result_prompt: false, | |
| 321 | + | pending_result: None, | |
| 322 | + | trim_start: 0.0, | |
| 323 | + | trim_end: 1.0, | |
| 324 | + | total_frames: 0, | |
| 325 | + | gain_db: 0.0, | |
| 326 | + | norm_peak: true, | |
| 327 | + | norm_target: -0.1, | |
| 328 | + | fade_in: true, | |
| 329 | + | fade_duration_ms: 100.0, | |
| 330 | + | fade_curve: FadeCurve::Linear, | |
| 331 | + | result_mode: None, | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | /// Actions the MIDI setup UI can request from the app layer. | |
| 337 | + | pub enum MidiAction { | |
| 338 | + | /// Connect to the MIDI input port at this index. | |
| 339 | + | Connect(usize), | |
| 340 | + | /// Disconnect the current MIDI input. | |
| 341 | + | Disconnect, | |
| 342 | + | /// Re-enumerate available ports. | |
| 343 | + | RefreshPorts, | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | /// A recent MIDI note event for the activity display. | |
| 347 | + | pub struct MidiNoteEvent { | |
| 348 | + | pub note: u8, | |
| 349 | + | pub velocity: u8, | |
| 350 | + | pub note_name: String, | |
| 351 | + | pub timestamp: Instant, | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | /// GUI-side state for the MIDI device picker and activity display. | |
| 355 | + | #[derive(Default)] | |
| 356 | + | pub struct MidiUiState { | |
| 357 | + | pub available_ports: Vec<String>, | |
| 358 | + | pub connected_port: Option<usize>, | |
| 359 | + | pub connected_port_name: Option<String>, | |
| 360 | + | pub recent_notes: Vec<MidiNoteEvent>, | |
| 361 | + | } | |
| 362 | + | ||
| 198 | 363 | /// A top-level imported folder with a user-editable tag input. | |
| 199 | 364 | pub struct FolderTagEntry { | |
| 200 | 365 | pub folder: ImportedFolder, | |
| @@ -431,10 +596,23 @@ pub struct BrowserState { | |||
| 431 | 596 | pub previewing_hash: Option<String>, | |
| 432 | 597 | pub shared: Arc<SharedState>, | |
| 433 | 598 | pub sample_rate: f32, | |
| 599 | + | pub loop_enabled: bool, | |
| 600 | + | pub autoplay: bool, | |
| 434 | 601 | ||
| 435 | 602 | // Instrument | |
| 436 | 603 | pub instrument_visible: bool, | |
| 437 | 604 | pub instrument_root_note: u8, | |
| 605 | + | /// When true, previewing a sample does NOT auto-load it into the instrument. | |
| 606 | + | pub instrument_locked: bool, | |
| 607 | + | /// MIDI notes currently held by piano mouse clicks. | |
| 608 | + | pub piano_held_notes: Vec<u8>, | |
| 609 | + | /// Whether the floating MIDI/instrument window is open. | |
| 610 | + | pub show_midi_window: bool, | |
| 611 | + | ||
| 612 | + | // MIDI | |
| 613 | + | pub midi_state: MidiUiState, | |
| 614 | + | /// Set by the UI, consumed by the app layer each frame. | |
| 615 | + | pub midi_pending_action: Option<MidiAction>, | |
| 438 | 616 | ||
| 439 | 617 | // Overlays | |
| 440 | 618 | pub show_help: bool, | |
| @@ -480,10 +658,12 @@ pub struct BrowserState { | |||
| 480 | 658 | pub collection_rename_target: Option<(CollectionId, String)>, | |
| 481 | 659 | pub show_collection_create: bool, | |
| 482 | 660 | ||
| 661 | + | // Edit — floating editor window | |
| 662 | + | pub edit: EditUiState, | |
| 663 | + | ||
| 483 | 664 | // Drag-out | |
| 484 | 665 | /// Set when an OS drag fires; prevents re-triggering until the pointer is | |
| 485 | 666 | /// genuinely released (egui sees button-up) or a safety timeout expires. | |
| 486 | - | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 487 | 667 | pub os_drag_cooldown: Option<Instant>, | |
| 488 | 668 | ||
| 489 | 669 | // VFS mirror | |
| @@ -491,16 +671,11 @@ pub struct BrowserState { | |||
| 491 | 671 | pub mirror_path: PathBuf, | |
| 492 | 672 | pub mirror_dirty: bool, | |
| 493 | 673 | ||
| 494 | - | // Sync UI state | |
| 495 | - | pub show_sync_panel: bool, | |
| 496 | - | pub sync_encryption_input: String, | |
| 497 | - | pub sync_auth_code_input: String, | |
| 674 | + | // Sync | |
| 675 | + | pub sync: SyncUiState, | |
| 498 | 676 | ||
| 499 | - | // Sync setup (when SyncManager not yet configured) | |
| 500 | - | pub sync_api_key_input: String, | |
| 501 | - | pub sync_setup_status: SyncSetupStatus, | |
| 502 | - | /// Set by the UI, consumed by the app layer each frame. | |
| 503 | - | pub sync_pending_action: Option<SyncSetupAction>, | |
| 677 | + | // Settings (consolidated window) | |
| 678 | + | pub settings: SettingsUiState, | |
| 504 | 679 | } | |
| 505 | 680 | ||
| 506 | 681 | impl BrowserState { | |
| @@ -510,6 +685,7 @@ impl BrowserState { | |||
| 510 | 685 | data_dir: &Path, | |
| 511 | 686 | shared: Arc<SharedState>, | |
| 512 | 687 | sample_rate: f32, | |
| 688 | + | vault_name: &str, | |
| 513 | 689 | ) -> Result<Self, Box<dyn std::error::Error>> { | |
| 514 | 690 | std::fs::create_dir_all(data_dir)?; | |
| 515 | 691 | ||
| @@ -520,7 +696,7 @@ impl BrowserState { | |||
| 520 | 696 | let store = SampleStore::new(&store_dir)?; | |
| 521 | 697 | ||
| 522 | 698 | let backend = Box::new(DirectBackend::new(db, store, data_dir.to_path_buf())); | |
| 523 | - | Self::new_with_backend(backend, data_dir, shared, sample_rate) | |
| 699 | + | Self::new_with_backend(backend, data_dir, shared, sample_rate, vault_name) | |
| 524 | 700 | } | |
| 525 | 701 | ||
| 526 | 702 | /// Initialise the browser with an externally-provided backend. | |
| @@ -532,6 +708,7 @@ impl BrowserState { | |||
| 532 | 708 | data_dir: &Path, | |
| 533 | 709 | shared: Arc<SharedState>, | |
| 534 | 710 | sample_rate: f32, | |
| 711 | + | vault_name: &str, | |
| 535 | 712 | ) -> Result<Self, Box<dyn std::error::Error>> { | |
| 536 | 713 | let mut vfs_list = backend.list_vfs()?; | |
| 537 | 714 | if vfs_list.is_empty() { | |
| @@ -555,6 +732,10 @@ impl BrowserState { | |||
| 555 | 732 | .unwrap_or_else(|| "audiofiles".to_string()); | |
| 556 | 733 | crate::ui::theme::init(Some(&theme_id)); | |
| 557 | 734 | ||
| 735 | + | // Load preview settings | |
| 736 | + | let loop_enabled = backend.get_config("preview_loop").ok().flatten().as_deref() == Some("1"); | |
| 737 | + | let autoplay = backend.get_config("preview_autoplay").ok().flatten().as_deref() == Some("1"); | |
| 738 | + | ||
| 558 | 739 | // Load mirror settings | |
| 559 | 740 | let mirror_enabled = backend.get_config("mirror_enabled").ok().flatten().as_deref() == Some("1"); | |
| 560 | 741 | let mirror_path = backend | |
| @@ -596,8 +777,15 @@ impl BrowserState { | |||
| 596 | 777 | previewing_hash: None, | |
| 597 | 778 | shared, | |
| 598 | 779 | sample_rate, | |
| 780 | + | loop_enabled, | |
| 781 | + | autoplay, | |
| 599 | 782 | instrument_visible: false, | |
| 600 | 783 | instrument_root_note: 60, | |
| 784 | + | instrument_locked: false, | |
| 785 | + | piano_held_notes: Vec::new(), | |
| 786 | + | show_midi_window: false, | |
| 787 | + | midi_state: MidiUiState::default(), | |
| 788 | + | midi_pending_action: None, | |
| 601 | 789 | show_help: false, | |
| 602 | 790 | pending_confirm: None, | |
| 603 | 791 | vfs_create_input: String::new(), | |
| @@ -624,20 +812,35 @@ impl BrowserState { | |||
| 624 | 812 | collection_create_input: String::new(), | |
| 625 | 813 | collection_rename_target: None, | |
| 626 | 814 | show_collection_create: false, | |
| 627 | - | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 815 | + | edit: EditUiState::default(), | |
| 628 | 816 | os_drag_cooldown: None, | |
| 629 | 817 | mirror_enabled, | |
| 630 | 818 | mirror_path, | |
| 631 | 819 | mirror_dirty: mirror_enabled, | |
| 632 | - | show_sync_panel: false, | |
| 633 | - | sync_encryption_input: String::new(), | |
| 634 | - | sync_auth_code_input: String::new(), | |
| 635 | - | sync_api_key_input: String::new(), | |
| 636 | - | sync_setup_status: SyncSetupStatus::Idle, | |
| 637 | - | sync_pending_action: None, | |
| 820 | + | sync: SyncUiState::default(), | |
| 821 | + | settings: SettingsUiState { name: vault_name.to_string(), ..Default::default() }, | |
| 638 | 822 | }) | |
| 639 | 823 | } | |
| 640 | 824 | ||
| 825 | + | // --- Sample resolution --- | |
| 826 | + | ||
| 827 | + | /// Resolve a sample hash to its filesystem path via the backend. | |
| 828 | + | pub fn resolve_sample_path(&self, hash: &str) -> Result<PathBuf, String> { | |
| 829 | + | let ext = self.backend.sample_extension(hash).unwrap_or_default(); | |
| 830 | + | let path = self.backend.sample_path(hash, &ext) | |
| 831 | + | .map_err(|e| format!("Invalid hash: {e}"))?; | |
| 832 | + | if !path.exists() { | |
| 833 | + | return Err(format!("File not found: {}", path.display())); | |
| 834 | + | } | |
| 835 | + | Ok(path) | |
| 836 | + | } | |
| 837 | + | ||
| 838 | + | /// Resolve a sample hash and decode it to an interleaved stereo f32 buffer. | |
| 839 | + | pub fn resolve_and_decode(&self, hash: &str) -> Result<crate::preview::PreviewBuffer, String> { | |
| 840 | + | let path = self.resolve_sample_path(hash)?; | |
| 841 | + | crate::preview::decode_to_f32(&path).map_err(|e| format!("Decode error: {e}")) | |
| 842 | + | } | |
| 843 | + | ||
| 641 | 844 | // --- Preview --- | |
| 642 | 845 | ||
| 643 | 846 | /// Decode a sample by hash and start playback through the shared preview buffer. | |
| @@ -646,37 +849,30 @@ impl BrowserState { | |||
| 646 | 849 | /// Long files use streaming: a background thread decodes while playback starts | |
| 647 | 850 | /// after a 0.5s pre-fill, avoiding UI freezes. | |
| 648 | 851 | pub fn trigger_preview(&mut self, hash: &str) { | |
| 649 | - | let ext = self.backend.sample_extension(hash).unwrap_or_default(); | |
| 650 | - | ||
| 651 | - | let path = match self.backend.sample_path(hash, &ext) { | |
| 852 | + | let path = match self.resolve_sample_path(hash) { | |
| 652 | 853 | Ok(p) => p, | |
| 653 | 854 | Err(e) => { | |
| 654 | - | self.status = format!("Invalid hash: {e}"); | |
| 855 | + | self.status = e; | |
| 655 | 856 | self.previewing_hash = None; | |
| 656 | 857 | return; | |
| 657 | 858 | } | |
| 658 | 859 | }; | |
| 659 | - | if !path.exists() { | |
| 660 | - | self.status = format!("File not found: {}", path.display()); | |
| 661 | - | self.previewing_hash = None; | |
| 662 | - | return; | |
| 663 | - | } | |
| 664 | 860 | ||
| 665 | 861 | let duration = crate::preview::estimate_duration(&path); | |
| 666 | 862 | let use_streaming = duration.is_some_and(|d| d > crate::preview::STREAMING_THRESHOLD_SECS); | |
| 667 | 863 | ||
| 668 | - | if use_streaming { | |
| 864 | + | let file_name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); | |
| 865 | + | let ok = if use_streaming { | |
| 669 | 866 | match crate::preview::start_streaming_decode(&path, &self.shared) { | |
| 670 | 867 | Ok(()) => { | |
| 671 | 868 | self.previewing_hash = Some(hash.to_string()); | |
| 672 | - | self.status = format!( | |
| 673 | - | "Playing: {}", | |
| 674 | - | path.file_name().unwrap_or_default().to_string_lossy() | |
| 675 | - | ); | |
| 869 | + | self.status = format!("Playing: {file_name}"); | |
| 870 | + | true | |
| 676 | 871 | } | |
| 677 | 872 | Err(e) => { | |
| 678 | 873 | self.status = format!("Decode error: {e}"); | |
| 679 | 874 | self.previewing_hash = None; | |
| 875 | + | false | |
| 680 | 876 | } | |
| 681 | 877 | } | |
| 682 | 878 | } else { | |
| @@ -684,22 +880,28 @@ impl BrowserState { | |||
| 684 | 880 | Ok(buf) => { | |
| 685 | 881 | let mut playback = self.shared.preview.lock(); | |
| 686 | 882 | playback.buffer = Some(buf); | |
| 687 | - | playback.position = 0; | |
| 883 | + | playback.position_frac = 0.0; | |
| 688 | 884 | playback.playing = true; | |
| 885 | + | playback.loop_enabled = self.loop_enabled; | |
| 689 | 886 | playback.streaming = false; | |
| 690 | 887 | playback.decoded_frames = 0; | |
| 691 | 888 | playback.total_frames_estimate = None; | |
| 692 | 889 | self.previewing_hash = Some(hash.to_string()); | |
| 693 | - | self.status = format!( | |
| 694 | - | "Playing: {}", | |
| 695 | - | path.file_name().unwrap_or_default().to_string_lossy() | |
| 696 | - | ); | |
| 890 | + | self.status = format!("Playing: {file_name}"); | |
| 891 | + | true | |
| 697 | 892 | } | |
| 698 | 893 | Err(e) => { | |
| 699 | 894 | self.status = format!("Decode error: {e}"); | |
| 700 | 895 | self.previewing_hash = None; | |
| 896 | + | false | |
| 701 | 897 | } | |
| 702 | 898 | } | |
| 899 | + | }; | |
| 900 | + | ||
| 901 | + | // Auto-load previewed sample into the instrument (unless locked) | |
| 902 | + | if ok && !self.instrument_locked { | |
| 903 | + | let hash_owned = hash.to_string(); | |
| 904 | + | self.load_chromatic_sample(&hash_owned); | |
| 703 | 905 | } | |
| 704 | 906 | } | |
| 705 | 907 | ||
| @@ -707,7 +909,7 @@ impl BrowserState { | |||
| 707 | 909 | pub fn stop_preview(&mut self) { | |
| 708 | 910 | let mut playback = self.shared.preview.lock(); | |
| 709 | 911 | playback.playing = false; | |
| 710 | - | playback.position = 0; | |
| 912 | + | playback.position_frac = 0.0; | |
| 711 | 913 | self.previewing_hash = None; | |
| 712 | 914 | self.status.clear(); | |
| 713 | 915 | } | |
| @@ -724,23 +926,41 @@ impl BrowserState { | |||
| 724 | 926 | } | |
| 725 | 927 | } | |
| 726 | 928 | ||
| 929 | + | /// If autoplay is enabled and the focused node is a sample, preview it. | |
| 930 | + | pub fn autoplay_current(&mut self) { | |
| 931 | + | if !self.autoplay { | |
| 932 | + | return; | |
| 933 | + | } | |
| 934 | + | if let Some(node) = self.selected_node() { | |
| 935 | + | if let Some(hash) = &node.node.sample_hash { | |
| 936 | + | let hash = hash.clone(); | |
| 937 | + | self.trigger_preview(&hash); | |
| 938 | + | } | |
| 939 | + | } | |
| 940 | + | } | |
| 941 | + | ||
| 942 | + | /// Toggle loop mode and persist the setting. | |
| 943 | + | pub fn toggle_loop(&mut self) { | |
| 944 | + | self.loop_enabled = !self.loop_enabled; | |
| 945 | + | let _ = self.backend.set_config("preview_loop", if self.loop_enabled { "1" } else { "0" }); | |
| 946 | + | // Sync to the live playback state | |
| 947 | + | self.shared.preview.lock().loop_enabled = self.loop_enabled; | |
| 948 | + | } | |
| 949 | + | ||
| 950 | + | /// Toggle autoplay mode and persist the setting. | |
| 951 | + | pub fn toggle_autoplay(&mut self) { | |
| 952 | + | self.autoplay = !self.autoplay; | |
| 953 | + | let _ = self.backend.set_config("preview_autoplay", if self.autoplay { "1" } else { "0" }); | |
| 954 | + | } | |
| 955 | + | ||
| 727 | 956 | // --- Instrument --- | |
| 728 | 957 | ||
| 729 | 958 | /// Load a sample for chromatic instrument playback (pitch-shift across the keyboard). | |
| 730 | - | pub fn load_chromatic_sample(&mut self, hash: &str, name: &str) { | |
| 731 | - | let ext = self.backend.sample_extension(hash).unwrap_or_default(); | |
| 732 | - | let path = match self.backend.sample_path(hash, &ext) { | |
| 733 | - | Ok(p) => p, | |
| 734 | - | Err(e) => { | |
| 735 | - | self.status = format!("Instrument load failed: {e}"); | |
| 736 | - | return; | |
| 737 | - | } | |
| 738 | - | }; | |
| 739 | - | ||
| 740 | - | let buf = match crate::preview::decode_to_f32(&path) { | |
| 959 | + | pub fn load_chromatic_sample(&mut self, hash: &str) { | |
| 960 | + | let buf = match self.resolve_and_decode(hash) { | |
| 741 | 961 | Ok(b) => b, | |
| 742 | 962 | Err(e) => { | |
| 743 | - | self.status = format!("Instrument decode error: {e}"); | |
| 963 | + | self.status = e; | |
| 744 | 964 | return; | |
| 745 | 965 | } | |
| 746 | 966 | }; | |
| @@ -778,9 +998,7 @@ impl BrowserState { | |||
| 778 | 998 | } | |
| 779 | 999 | drop(inst); | |
| 780 | 1000 | ||
| 781 | - | self.instrument_visible = true; | |
| 782 | 1001 | self.instrument_root_note = root_note; | |
| 783 | - | self.status = format!("Instrument: {name}"); | |
| 784 | 1002 | } | |
| 785 | 1003 | ||
| 786 | 1004 | /// Toggle instrument mode on/off. | |
| @@ -788,23 +1006,15 @@ impl BrowserState { | |||
| 788 | 1006 | let mut inst = self.shared.instrument.lock(); | |
| 789 | 1007 | inst.active = !inst.active; | |
| 790 | 1008 | self.instrument_visible = inst.active; | |
| 1009 | + | self.show_midi_window = inst.active; | |
| 791 | 1010 | } |
Lines truncated
| @@ -6,7 +6,7 @@ use audiofiles_core::vfs::NodeType; | |||
| 6 | 6 | fn make_state() -> (BrowserState, tempfile::TempDir) { | |
| 7 | 7 | let dir = tempfile::TempDir::new().unwrap(); | |
| 8 | 8 | let shared = Arc::new(SharedState::new()); | |
| 9 | - | let state = BrowserState::new(dir.path(), shared, 44100.0).unwrap(); | |
| 9 | + | let state = BrowserState::new(dir.path(), shared, 44100.0, "Library").unwrap(); | |
| 10 | 10 | (state, dir) | |
| 11 | 11 | } | |
| 12 | 12 |
| @@ -35,7 +35,7 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 35 | 35 | buf.data.len() / 2 | |
| 36 | 36 | }; | |
| 37 | 37 | if total_frames > 0 { | |
| 38 | - | Some(playback.position as f32 / total_frames as f32) | |
| 38 | + | Some((playback.position_frac / total_frames as f64) as f32) | |
| 39 | 39 | } else { | |
| 40 | 40 | None | |
| 41 | 41 | } | |
| @@ -65,8 +65,8 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 65 | 65 | } else { | |
| 66 | 66 | buf.data.len() / 2 | |
| 67 | 67 | }; | |
| 68 | - | playback.position = ((normalized * total_frames as f32) as usize) | |
| 69 | - | .min(playback.decoded_frames.max(1) - 1); | |
| 68 | + | playback.position_frac = (normalized as f64 * total_frames as f64) | |
| 69 | + | .min((playback.decoded_frames.max(1) - 1) as f64); | |
| 70 | 70 | } | |
| 71 | 71 | } | |
| 72 | 72 | } | |
| @@ -191,11 +191,19 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 191 | 191 | ||
| 192 | 192 | ui.add_space(8.0); | |
| 193 | 193 | ||
| 194 | - | // Copy path button | |
| 195 | - | if ui.button("\u{1F4CB} Copy Path").on_hover_text("Copy file path to clipboard").clicked() { | |
| 196 | - | if let Some(path) = state.selected_sample_path() { | |
| 197 | - | ui.ctx().copy_text(path); | |
| 198 | - | state.status = "Path copied to clipboard".to_string(); | |
| 194 | + | // Action buttons | |
| 195 | + | ui.horizontal(|ui| { | |
| 196 | + | if ui.button("Copy Path").on_hover_text("Copy file path to clipboard").clicked() { | |
| 197 | + | if let Some(path) = state.selected_sample_path() { | |
| 198 | + | ui.ctx().copy_text(path); | |
| 199 | + | state.status = "Path copied to clipboard".to_string(); | |
| 200 | + | } | |
| 199 | 201 | } | |
| 200 | - | } | |
| 202 | + | if let Some(hash) = &node.node.sample_hash { | |
| 203 | + | let hash = hash.clone(); | |
| 204 | + | if ui.button("Edit").on_hover_text("Open sample editor (E)").clicked() { | |
| 205 | + | state.open_edit_window(&hash); | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | }); | |
| 201 | 209 | } |
| @@ -0,0 +1,306 @@ | |||
| 1 | + | //! Floating sample editor window: all edit controls visible simultaneously. | |
| 2 | + | ||
| 3 | + | use egui; | |
| 4 | + | ||
| 5 | + | use crate::state::{BrowserState, EditResultMode}; | |
| 6 | + | use crate::waveform; | |
| 7 | + | use audiofiles_core::edit::FadeCurve; | |
| 8 | + | ||
| 9 | + | use super::theme; | |
| 10 | + | ||
| 11 | + | /// Draw the floating sample editor window. Call from the overlay layer. | |
| 12 | + | pub fn draw_edit_window(ctx: &egui::Context, state: &mut BrowserState) { | |
| 13 | + | let mut open = state.edit.show_window; | |
| 14 | + | egui::Window::new("Sample Editor") | |
| 15 | + | .open(&mut open) | |
| 16 | + | .resizable(true) | |
| 17 | + | .collapsible(true) | |
| 18 | + | .default_width(400.0) | |
| 19 | + | .min_width(320.0) | |
| 20 | + | .show(ctx, |ui| { | |
| 21 | + | // In-progress overlay | |
| 22 | + | if state.edit.in_progress { | |
| 23 | + | ui.vertical_centered(|ui| { | |
| 24 | + | ui.add_space(8.0); | |
| 25 | + | ui.spinner(); | |
| 26 | + | ui.label("Applying edit..."); | |
| 27 | + | }); | |
| 28 | + | return; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // Result prompt overlay | |
| 32 | + | if state.edit.result_prompt { | |
| 33 | + | draw_result_prompt(ui, state); | |
| 34 | + | return; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | let hash = match &state.edit.hash { | |
| 38 | + | Some(h) => h.clone(), | |
| 39 | + | None => return, | |
| 40 | + | }; | |
| 41 | + | ||
| 42 | + | draw_waveform_section(ui, state, &hash); | |
| 43 | + | draw_info_line(ui, state); | |
| 44 | + | ||
| 45 | + | ui.separator(); | |
| 46 | + | draw_trim_section(ui, state); | |
| 47 | + | ||
| 48 | + | ui.separator(); | |
| 49 | + | draw_levels_section(ui, state); | |
| 50 | + | ||
| 51 | + | ui.separator(); | |
| 52 | + | draw_transform_section(ui, state); | |
| 53 | + | ||
| 54 | + | ui.separator(); | |
| 55 | + | draw_result_section(ui, state); | |
| 56 | + | }); | |
| 57 | + | state.edit.show_window = open; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// Waveform display with playback cursor and click-to-seek. | |
| 61 | + | fn draw_waveform_section(ui: &mut egui::Ui, state: &mut BrowserState, hash: &str) { | |
| 62 | + | if let Some(ref waveform_data) = state.selected_waveform { | |
| 63 | + | let playback_pos = if state.previewing_hash.as_deref() == Some(hash) { | |
| 64 | + | let playback = state.shared.preview.lock(); | |
| 65 | + | if playback.playing { | |
| 66 | + | if let Some(ref buf) = playback.buffer { | |
| 67 | + | let total_frames = if playback.streaming { | |
| 68 | + | playback.total_frames_estimate.unwrap_or(playback.decoded_frames) | |
| 69 | + | } else { | |
| 70 | + | buf.data.len() / 2 | |
| 71 | + | }; | |
| 72 | + | if total_frames > 0 { | |
| 73 | + | Some((playback.position_frac / total_frames as f64) as f32) | |
| 74 | + | } else { | |
| 75 | + | None | |
| 76 | + | } | |
| 77 | + | } else { | |
| 78 | + | None | |
| 79 | + | } | |
| 80 | + | } else { | |
| 81 | + | None | |
| 82 | + | } | |
| 83 | + | } else { | |
| 84 | + | None | |
| 85 | + | }; | |
| 86 | + | ||
| 87 | + | let resp = waveform::draw_waveform(ui, waveform_data, playback_pos, 80.0); | |
| 88 | + | ||
| 89 | + | // Click-to-seek | |
| 90 | + | if resp.clicked() { | |
| 91 | + | if let Some(pos) = resp.interact_pointer_pos() { | |
| 92 | + | let rect = resp.rect; | |
| 93 | + | let normalized = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0); | |
| 94 | + | if state.previewing_hash.as_deref() == Some(hash) { | |
| 95 | + | let mut playback = state.shared.preview.lock(); | |
| 96 | + | if let Some(ref buf) = playback.buffer { | |
| 97 | + | let total_frames = if playback.streaming { | |
| 98 | + | playback.total_frames_estimate.unwrap_or(playback.decoded_frames) | |
| 99 | + | } else { | |
| 100 | + | buf.data.len() / 2 | |
| 101 | + | }; | |
| 102 | + | playback.position_frac = (normalized as f64 * total_frames as f64) | |
| 103 | + | .min((playback.decoded_frames.max(1) - 1) as f64); | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// Info line: name, sample rate, duration, peak dB. | |
| 112 | + | fn draw_info_line(ui: &mut egui::Ui, state: &BrowserState) { | |
| 113 | + | if let Some(ref analysis) = state.selected_analysis { | |
| 114 | + | let name = state.selected_node() | |
| 115 | + | .map(|n| n.node.name.clone()) | |
| 116 | + | .unwrap_or_default(); | |
| 117 | + | let duration = analysis.duration; | |
| 118 | + | let sr = analysis.sample_rate; | |
| 119 | + | let peak_str = analysis.peak_db | |
| 120 | + | .map(|p| format!("{:.1} dBFS", p)) | |
| 121 | + | .unwrap_or_default(); | |
| 122 | + | ||
| 123 | + | ui.horizontal_wrapped(|ui| { | |
| 124 | + | ui.label(egui::RichText::new(&name).strong().size(12.0)); | |
| 125 | + | ui.label( | |
| 126 | + | egui::RichText::new(format!(" {} Hz {:.3}s {}", sr, duration, peak_str)) | |
| 127 | + | .color(theme::text_muted()) | |
| 128 | + | .size(11.0), | |
| 129 | + | ); | |
| 130 | + | }); | |
| 131 | + | ui.add_space(2.0); | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// Trim section with start/end sliders. | |
| 136 | + | fn draw_trim_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 137 | + | let disabled = state.edit.in_progress || state.edit.hash.is_none(); | |
| 138 | + | ||
| 139 | + | ui.label(egui::RichText::new("Trim").strong()); | |
| 140 | + | ||
| 141 | + | let sample_rate = state.selected_analysis.as_ref() | |
| 142 | + | .map(|a| a.sample_rate) | |
| 143 | + | .unwrap_or(44100); | |
| 144 | + | let total = state.edit.total_frames; | |
| 145 | + | let start_time = state.edit.trim_start as f64 * total as f64 / sample_rate as f64; | |
| 146 | + | let end_time = state.edit.trim_end as f64 * total as f64 / sample_rate as f64; | |
| 147 | + | ||
| 148 | + | ui.horizontal(|ui| { | |
| 149 | + | ui.label("Start:"); | |
| 150 | + | ui.add_enabled(!disabled, egui::Slider::new(&mut state.edit.trim_start, 0.0..=1.0).show_value(false)); | |
| 151 | + | ui.label(egui::RichText::new(format!("{:.3}s", start_time)).color(theme::text_muted())); | |
| 152 | + | }); | |
| 153 | + | ||
| 154 | + | ui.horizontal(|ui| { | |
| 155 | + | ui.label("End:"); | |
| 156 | + | ui.add_enabled(!disabled, egui::Slider::new(&mut state.edit.trim_end, 0.0..=1.0).show_value(false)); | |
| 157 | + | ui.label(egui::RichText::new(format!("{:.3}s", end_time)).color(theme::text_muted())); | |
| 158 | + | }); | |
| 159 | + | ||
| 160 | + | // Clamp start < end | |
| 161 | + | if state.edit.trim_start >= state.edit.trim_end { | |
| 162 | + | state.edit.trim_start = (state.edit.trim_end - 0.001).max(0.0); | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | ui.horizontal(|ui| { | |
| 166 | + | if ui.add_enabled(!disabled, egui::Button::new("Trim")).clicked() { | |
| 167 | + | state.apply_edit_trim(); | |
| 168 | + | } | |
| 169 | + | }); | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | /// Levels section: gain and normalize. | |
| 173 | + | fn draw_levels_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 174 | + | let disabled = state.edit.in_progress || state.edit.hash.is_none(); | |
| 175 | + | ||
| 176 | + | ui.label(egui::RichText::new("Levels").strong()); | |
| 177 | + | ||
| 178 | + | // Current peak display + gain clipping warning | |
| 179 | + | let current_peak = state.selected_analysis.as_ref().and_then(|a| a.peak_db); | |
| 180 | + | if let Some(peak) = current_peak { | |
| 181 | + | let predicted = peak + state.edit.gain_db; | |
| 182 | + | if predicted > 0.0 { | |
| 183 | + | ui.colored_label( | |
| 184 | + | egui::Color32::from_rgb(255, 80, 80), | |
| 185 | + | format!("Peak: {:.1} dB \u{2192} {:.1} dB (clips!)", peak, predicted), | |
| 186 | + | ); | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // Gain | |
| 191 | + | ui.horizontal(|ui| { | |
| 192 | + | ui.label("Gain:"); | |
| 193 | + | ui.add_enabled(!disabled, egui::Slider::new(&mut state.edit.gain_db, -24.0..=24.0).suffix(" dB")); | |
| 194 | + | if ui.add_enabled(!disabled, egui::Button::new("Apply")).clicked() { | |
| 195 | + | state.apply_edit_gain(); | |
| 196 | + | } | |
| 197 | + | }); | |
| 198 | + | ||
| 199 | + | // Normalize | |
| 200 | + | ui.horizontal(|ui| { | |
| 201 | + | ui.label("Normalize:"); | |
| 202 | + | if ui.add_enabled(!disabled, egui::RadioButton::new(state.edit.norm_peak, "Peak")).clicked() { | |
| 203 | + | state.edit.norm_peak = true; | |
| 204 | + | } | |
| 205 | + | if ui.add_enabled(!disabled, egui::RadioButton::new(!state.edit.norm_peak, "LUFS")).clicked() { | |
| 206 | + | state.edit.norm_peak = false; | |
| 207 | + | } | |
| 208 | + | }); | |
| 209 | + | ||
| 210 | + | let norm_range = if state.edit.norm_peak { -24.0..=0.0 } else { -24.0..=-6.0 }; | |
| 211 | + | let norm_suffix = if state.edit.norm_peak { " dBFS" } else { " LUFS" }; | |
| 212 | + | ||
| 213 | + | ui.horizontal(|ui| { | |
| 214 | + | ui.add_enabled(!disabled, egui::Slider::new(&mut state.edit.norm_target, norm_range).suffix(norm_suffix)); | |
| 215 | + | if ui.add_enabled(!disabled, egui::Button::new("Apply")).clicked() { | |
| 216 | + | state.apply_edit_normalize(); | |
| 217 | + | } | |
| 218 | + | }); | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | /// Transform section: reverse and fade. | |
| 222 | + | fn draw_transform_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 223 | + | let disabled = state.edit.in_progress || state.edit.hash.is_none(); | |
| 224 | + | ||
| 225 | + | ui.label(egui::RichText::new("Transform").strong()); | |
| 226 | + | ||
| 227 | + | // Reverse | |
| 228 | + | if ui.add_enabled(!disabled, egui::Button::new("Reverse")).clicked() { | |
| 229 | + | state.apply_edit_reverse(); | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // Fade | |
| 233 | + | ui.horizontal(|ui| { | |
| 234 | + | ui.label("Fade:"); | |
| 235 | + | if ui.add_enabled(!disabled, egui::RadioButton::new(state.edit.fade_in, "In")).clicked() { | |
| 236 | + | state.edit.fade_in = true; | |
| 237 | + | } | |
| 238 | + | if ui.add_enabled(!disabled, egui::RadioButton::new(!state.edit.fade_in, "Out")).clicked() { | |
| 239 | + | state.edit.fade_in = false; | |
| 240 | + | } | |
| 241 | + | }); | |
| 242 | + | ||
| 243 | + | ui.horizontal(|ui| { | |
| 244 | + | ui.add_enabled( | |
| 245 | + | !disabled, | |
| 246 | + | egui::Slider::new(&mut state.edit.fade_duration_ms, 10.0..=2000.0).suffix(" ms"), | |
| 247 | + | ); | |
| 248 | + | egui::ComboBox::from_id_salt("edit_fade_curve") | |
| 249 | + | .selected_text(match state.edit.fade_curve { | |
| 250 | + | FadeCurve::Linear => "Linear", | |
| 251 | + | FadeCurve::Logarithmic => "Log", | |
| 252 | + | FadeCurve::SCurve => "S-Curve", | |
| 253 | + | }) | |
| 254 | + | .width(70.0) | |
| 255 | + | .show_ui(ui, |ui| { | |
| 256 | + | ui.selectable_value(&mut state.edit.fade_curve, FadeCurve::Linear, "Linear"); | |
| 257 | + | ui.selectable_value(&mut state.edit.fade_curve, FadeCurve::Logarithmic, "Log"); | |
| 258 | + | ui.selectable_value(&mut state.edit.fade_curve, FadeCurve::SCurve, "S-Curve"); | |
| 259 | + | }); | |
| 260 | + | if ui.add_enabled(!disabled, egui::Button::new("Apply")).clicked() { | |
| 261 | + | state.apply_edit_fade(); | |
| 262 | + | } | |
| 263 | + | }); | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | /// Result mode section: replace original vs create sibling. | |
| 267 | + | fn draw_result_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 268 | + | ui.label(egui::RichText::new("Result").strong()); | |
| 269 | + | ||
| 270 | + | let mut mode = state.edit.result_mode; | |
| 271 | + | ui.horizontal(|ui| { | |
| 272 | + | if ui.radio_value(&mut mode, Some(EditResultMode::Replace), "Replace original").changed() { | |
| 273 | + | if let Some(m) = mode { | |
| 274 | + | state.set_edit_result_mode(m); | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | if ui.radio_value(&mut mode, Some(EditResultMode::Sibling), "Create sibling").changed() { | |
| 278 | + | if let Some(m) = mode { | |
| 279 | + | state.set_edit_result_mode(m); | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | }); | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | /// Draw the "Replace or Create Sibling?" prompt after first edit. | |
| 286 | + | fn draw_result_prompt(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 287 | + | egui::Frame::popup(ui.style()).show(ui, |ui| { | |
| 288 | + | ui.heading("Edit Result"); | |
| 289 | + | ui.separator(); | |
| 290 | + | ui.label("How should the edited sample be handled?"); | |
| 291 | + | ui.add_space(8.0); | |
| 292 | + | ||
| 293 | + | let mut remember = false; | |
| 294 | + | ui.checkbox(&mut remember, "Remember my choice"); | |
| 295 | + | ||
| 296 | + | ui.add_space(4.0); | |
| 297 | + | ui.horizontal(|ui| { | |
| 298 | + | if ui.button("Replace Original").clicked() { | |
| 299 | + | state.confirm_edit_result(EditResultMode::Replace, remember); | |
| 300 | + | } | |
| 301 | + | if ui.button("Create Sibling").clicked() { | |
| 302 | + | state.confirm_edit_result(EditResultMode::Sibling, remember); | |
| 303 | + | } | |
| 304 | + | }); | |
| 305 | + | }); | |
| 306 | + | } |
| @@ -6,12 +6,13 @@ use egui_extras::{Column, TableBuilder}; | |||
| 6 | 6 | use crate::state::{BrowserState, SortColumn, SortDirection}; | |
| 7 | 7 | use audiofiles_core::vfs::NodeType; | |
| 8 | 8 | ||
| 9 | + | use super::file_list_menus::{draw_background_context_menu, draw_context_menu, draw_multi_context_menu}; | |
| 9 | 10 | use super::instrument_panel::DragPayload; | |
| 10 | 11 | use super::theme; | |
| 11 | 12 | use super::widgets; | |
| 12 | 13 | ||
| 13 | 14 | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 14 | - | use crate::drag_out; | |
| 15 | + | use super::file_list_menus::start_os_drag; | |
| 15 | 16 | ||
| 16 | 17 | /// Draw the sortable, multi-column file list. | |
| 17 | 18 | pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| @@ -399,6 +400,7 @@ fn handle_click(state: &mut BrowserState, row_idx: usize, ui: &egui::Ui) { | |||
| 399 | 400 | } else { | |
| 400 | 401 | // Plain click: single select | |
| 401 | 402 | state.selection.set_single(row_idx); | |
| 403 | + | state.autoplay_current(); | |
| 402 | 404 | } | |
| 403 | 405 | ||
| 404 | 406 | state.refresh_selected_tags(); | |
| @@ -435,284 +437,3 @@ fn draw_sort_header( | |||
| 435 | 437 | ui.add(egui::Label::new(rich).sense(egui::Sense::click())).clicked() | |
| 436 | 438 | } | |
| 437 | 439 | ||
| 438 | - | /// Draw the right-click context menu for a single item. | |
| 439 | - | /// Branches on node type: samples get Preview/Copy Path/Delete, | |
| 440 | - | /// directories get Open/Delete. | |
| 441 | - | fn draw_context_menu( | |
| 442 | - | ui: &mut egui::Ui, | |
| 443 | - | state: &mut BrowserState, | |
| 444 | - | row_idx: usize, | |
| 445 | - | node: &audiofiles_core::vfs::VfsNodeWithAnalysis, | |
| 446 | - | ) { | |
| 447 | - | match node.node.node_type { | |
| 448 | - | NodeType::Sample => { | |
| 449 | - | if node.cloud_only { | |
| 450 | - | ui.label( | |
| 451 | - | egui::RichText::new("Cloud-only sample") | |
| 452 | - | .color(theme::text_muted()) | |
| 453 | - | .italics(), | |
| 454 | - | ); | |
| 455 | - | ui.separator(); | |
| 456 | - | } | |
| 457 | - | if !node.cloud_only && ui.button("Preview").clicked() { | |
| 458 | - | if let Some(hash) = &node.node.sample_hash { | |
| 459 | - | let hash = hash.clone(); | |
| 460 | - | state.trigger_preview(&hash); | |
| 461 | - | } | |
| 462 | - | ui.close_menu(); | |
| 463 | - | } | |
| 464 | - | if ui.button("Copy Path").clicked() { | |
| 465 | - | if let Some(path) = state.selected_sample_path() { | |
| 466 | - | ui.ctx().copy_text(path); | |
| 467 | - | state.status = "Path copied to clipboard".to_string(); | |
| 468 | - | } | |
| 469 | - | ui.close_menu(); | |
| 470 | - | } | |
| 471 | - | if ui.button("Find Similar").clicked() { | |
| 472 | - | if let Some(hash) = &node.node.sample_hash { | |
| 473 | - | let hash = hash.clone(); | |
| 474 | - | state.find_similar(&hash); | |
| 475 | - | } | |
| 476 | - | ui.close_menu(); | |
| 477 | - | } | |
| 478 | - | if ui.button("Find Duplicates").clicked() { | |
| 479 | - | if let Some(hash) = &node.node.sample_hash { | |
| 480 | - | let hash = hash.clone(); | |
| 481 | - | state.find_near_duplicates(&hash); | |
| 482 | - | } | |
| 483 | - | ui.close_menu(); | |
| 484 | - | } | |
| 485 | - | // Add to Collection submenu | |
| 486 | - | if let Some(hash) = &node.node.sample_hash { | |
| 487 | - | let hash_clone = hash.clone(); | |
| 488 | - | let collections = state.collections.clone(); | |
| 489 | - | let is_in_collection = state.active_collection.is_some(); | |
| 490 | - | if !collections.is_empty() { | |
| 491 | - | ui.menu_button("Add to Collection", |ui| { | |
| 492 | - | for coll in &collections { | |
| 493 | - | if ui.button(&coll.name).clicked() { | |
| 494 | - | let _ = state.backend.add_to_collection(coll.id, &hash_clone); | |
| 495 | - | state.refresh_collections(); | |
| 496 | - | state.status = format!("Added to {}", coll.name); | |
| 497 | - | ui.close_menu(); | |
| 498 | - | } | |
| 499 | - | } | |
| 500 | - | }); | |
| 501 | - | } | |
| 502 | - | if is_in_collection { | |
| 503 | - | if let Some(active_id) = state.active_collection { | |
| 504 | - | if ui.button("Remove from Collection").clicked() { | |
| 505 | - | let _ = state.backend.remove_from_collection(active_id, &hash_clone); | |
| 506 | - | state.refresh_collections(); | |
| 507 | - | state.activate_collection(active_id); | |
| 508 | - | ui.close_menu(); | |
| 509 | - | } | |
| 510 | - | } | |
| 511 | - | } | |
| 512 | - | } | |
| 513 | - | if !node.cloud_only { | |
| 514 | - | if ui.button("Play as Instrument").clicked() { | |
| 515 | - | if let Some(hash) = &node.node.sample_hash { | |
| 516 | - | let hash = hash.clone(); | |
| 517 | - | let name = node.node.name.clone(); | |
| 518 | - | state.load_chromatic_sample(&hash, &name); | |
| 519 | - | } | |
| 520 | - | ui.close_menu(); | |
| 521 | - | } | |
| 522 | - | if ui.button("Export...").clicked() { | |
| 523 | - | state.selection.set_single(row_idx); | |
| 524 | - | state.start_export_flow(Some(vec![node.node.id])); | |
| 525 | - | ui.close_menu(); | |
| 526 | - | } | |
| 527 | - | } | |
| 528 | - | ui.separator(); | |
| 529 | - | if ui.button("Delete").clicked() { | |
| 530 | - | state.selection.set_single(row_idx); | |
| 531 | - | state.confirm_delete_selected(); | |
| 532 | - | ui.close_menu(); | |
| 533 | - | } | |
| 534 | - | } | |
| 535 | - | NodeType::Directory => { | |
| 536 | - | if ui.button("Open").clicked() { | |
| 537 | - | state.selection.set_single(row_idx); | |
| 538 | - | state.enter_directory(); | |
| 539 | - | ui.close_menu(); | |
| 540 | - | } | |
| 541 | - | if ui.button("New Folder").clicked() { | |
| 542 | - | state.show_dir_create = true; | |
| 543 | - | state.dir_create_input.clear(); | |
| 544 | - | ui.close_menu(); | |
| 545 | - | } | |
| 546 | - | if ui.button("Rename").clicked() { | |
| 547 | - | state.dir_rename_target = Some((node.node.id, node.node.name.clone())); | |
| 548 | - | ui.close_menu(); | |
| 549 | - | } | |
| 550 | - | if ui.button("Export...").clicked() { | |
| 551 | - | state.selection.set_single(row_idx); | |
| 552 | - | state.start_export_flow(Some(vec![node.node.id])); | |
| 553 | - | ui.close_menu(); | |
| 554 | - | } | |
| 555 | - | ui.separator(); | |
| 556 | - | if ui.button("Delete").clicked() { | |
| 557 | - | state.selection.set_single(row_idx); | |
| 558 | - | state.confirm_delete_selected(); | |
| 559 | - | ui.close_menu(); | |
| 560 | - | } | |
| 561 | - | } | |
| 562 | - | } | |
| 563 | - | } | |
| 564 | - | ||
| 565 | - | /// Context menu when multiple items are selected. | |
| 566 | - | fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 567 | - | let count = state.selection.count(); | |
| 568 | - | ui.label(egui::RichText::new(format!("{count} items selected")).strong()); | |
| 569 | - | ui.separator(); | |
| 570 | - | ||
| 571 | - | if ui.button("Tag...").clicked() { | |
| 572 | - | state.open_bulk_tag_modal(); | |
| 573 | - | ui.close_menu(); | |
| 574 | - | } | |
| 575 | - | if ui.button("Move to...").clicked() { | |
| 576 | - | state.open_bulk_move_modal(); | |
| 577 | - | ui.close_menu(); | |
| 578 | - | } | |
| 579 | - | if ui.button("Rename...").clicked() { | |
| 580 | - | state.open_bulk_rename_modal(); | |
| 581 | - | ui.close_menu(); | |
| 582 | - | } | |
| 583 | - | if ui.button("Export...").clicked() { | |
| 584 | - | let node_ids = state.selected_node_ids(); | |
| 585 | - | state.start_export_flow(Some(node_ids)); | |
| 586 | - | ui.close_menu(); | |
| 587 | - | } | |
| 588 | - | ||
| 589 | - | // Add to Collection submenu (bulk) | |
| 590 | - | let collections = state.collections.clone(); | |
| 591 | - | if !collections.is_empty() { | |
| 592 | - | ui.menu_button("Add to Collection", |ui| { | |
| 593 | - | for coll in &collections { | |
| 594 | - | if ui.button(&coll.name).clicked() { | |
| 595 | - | let nodes = state.selected_nodes(); | |
| 596 | - | for n in &nodes { | |
| 597 | - | if let Some(hash) = &n.node.sample_hash { | |
| 598 | - | let _ = state.backend.add_to_collection(coll.id, hash); | |
| 599 | - | } | |
| 600 | - | } | |
| 601 | - | state.refresh_collections(); | |
| 602 | - | state.status = format!("Added {} items to {}", nodes.len(), coll.name); | |
| 603 | - | ui.close_menu(); | |
| 604 | - | } | |
| 605 | - | } | |
| 606 | - | }); | |
| 607 | - | } | |
| 608 | - | ||
| 609 | - | // Remove from Collection (when viewing a collection) | |
| 610 | - | if let Some(active_id) = state.active_collection { | |
| 611 | - | if ui.button("Remove from Collection").clicked() { | |
| 612 | - | let nodes = state.selected_nodes(); | |
| 613 | - | for n in &nodes { | |
| 614 | - | if let Some(hash) = &n.node.sample_hash { | |
| 615 | - | let _ = state.backend.remove_from_collection(active_id, hash); | |
| 616 | - | } | |
| 617 | - | } | |
| 618 | - | state.refresh_collections(); | |
| 619 | - | state.activate_collection(active_id); | |
| 620 | - | ui.close_menu(); | |
| 621 | - | } | |
| 622 | - | } | |
| 623 | - | ||
| 624 | - | ui.separator(); | |
| 625 | - | ||
| 626 | - | if ui.button("Copy Paths").clicked() { | |
| 627 | - | // Resolve content-addressed store paths for each selected sample. | |
| 628 | - | // filter_map skips directories (no sample_hash) and samples whose | |
| 629 | - | // hash isn't in the DB (shouldn't happen, but defensive). | |
| 630 | - | let nodes = state.selected_nodes(); | |
| 631 | - | let paths: Vec<String> = nodes | |
| 632 | - | .iter() | |
| 633 | - | .filter_map(|n| { | |
| 634 | - | n.node.sample_hash.as_ref().and_then(|hash| { | |
| 635 | - | let ext = state.backend.sample_extension(hash).ok()?; | |
| 636 | - | Some(state.backend.sample_path(hash, &ext).ok()?.to_string_lossy().into_owned()) | |
| 637 | - | }) | |
| 638 | - | }) | |
| 639 | - | .collect(); | |
| 640 | - | if !paths.is_empty() { | |
| 641 | - | ui.ctx().copy_text(paths.join("\n")); | |
| 642 | - | state.status = format!("Copied {} paths", paths.len()); | |
| 643 | - | } | |
| 644 | - | ui.close_menu(); | |
| 645 | - | } | |
| 646 | - | ||
| 647 | - | if ui.button("Delete").clicked() { | |
| 648 | - | state.confirm_delete_selected(); | |
| 649 | - | ui.close_menu(); | |
| 650 | - | } | |
| 651 | - | } | |
| 652 | - | ||
| 653 | - | /// Context menu for right-clicking empty space in the file list. | |
| 654 | - | fn draw_background_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 655 | - | if ui.button("New Folder").clicked() { | |
| 656 | - | state.show_dir_create = true; | |
| 657 | - | state.dir_create_input.clear(); | |
| 658 | - | ui.close_menu(); | |
| 659 | - | } | |
| 660 | - | if ui.button("Import Files...").clicked() { | |
| 661 | - | if let Some(paths) = rfd::FileDialog::new() | |
| 662 | - | .set_title("Import Files") | |
| 663 | - | .add_filter("Audio", audiofiles_core::util::AUDIO_EXTENSIONS) | |
| 664 | - | .pick_files() | |
| 665 | - | { | |
| 666 | - | for path in paths { | |
| 667 | - | state.import_path(&path); | |
| 668 | - | } | |
| 669 | - | } | |
| 670 | - | ui.close_menu(); | |
| 671 | - | } | |
| 672 | - | if ui.button("Import Folder...").clicked() { | |
| 673 | - | if let Some(path) = rfd::FileDialog::new().pick_folder() { | |
| 674 | - | state.show_import_options(path); | |
| 675 | - | } | |
| 676 | - | ui.close_menu(); | |
| 677 | - | } | |
| 678 | - | if state.selection.count() > 0 { | |
| 679 | - | ui.separator(); | |
| 680 | - | let label = format!("Deselect ({})", state.selection.count()); | |
| 681 | - | if ui.button(label).clicked() { | |
| 682 | - | state.selection.clear(); | |
| 683 | - | state.refresh_selected_tags(); | |
| 684 | - | state.refresh_selected_detail(); | |
| 685 | - | ui.close_menu(); | |
| 686 | - | } | |
| 687 | - | } | |
| 688 | - | } | |
| 689 | - | ||
| 690 | - | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 691 | - | fn start_os_drag(state: &mut BrowserState) { | |
| 692 | - | let nodes = state.selected_nodes(); | |
| 693 | - | let files: Vec<drag_out::DragFile> = nodes | |
| 694 | - | .iter() | |
| 695 | - | .filter(|n| n.node.node_type == NodeType::Sample && !n.cloud_only) | |
| 696 | - | .filter_map(|n| { | |
| 697 | - | let hash = n.node.sample_hash.as_ref()?; | |
| 698 | - | let ext = state.backend.sample_extension(hash).ok()?; | |
| 699 | - | let store_path = state.backend.sample_path(hash, &ext).ok()?; | |
| 700 | - | Some(drag_out::DragFile { | |
| 701 | - | friendly_name: n.node.name.clone(), | |
| 702 | - | store_path, | |
| 703 | - | }) | |
| 704 | - | }) | |
| 705 | - | .collect(); | |
| 706 | - | if !files.is_empty() { | |
| 707 | - | let count = files.len(); | |
| 708 | - | let first = files[0].friendly_name.clone(); | |
| 709 | - | if drag_out::begin_drag(&files) { | |
| 710 | - | state.os_drag_cooldown = Some(std::time::Instant::now()); | |
| 711 | - | state.status = if count == 1 { | |
| 712 | - | format!("Dragged {first}") | |
| 713 | - | } else { | |
| 714 | - | format!("Dragged {count} samples") | |
| 715 | - | }; | |
| 716 | - | } | |
| 717 | - | } | |
| 718 | - | } |
| @@ -0,0 +1,300 @@ | |||
| 1 | + | //! Context menus and drag-out handlers extracted from file_list.rs. | |
| 2 | + | ||
| 3 | + | use egui; | |
| 4 | + | ||
| 5 | + | use crate::state::BrowserState; | |
| 6 | + | use audiofiles_core::vfs::NodeType; | |
| 7 | + | ||
| 8 | + | use super::theme; | |
| 9 | + | ||
| 10 | + | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 11 | + | use crate::drag_out; | |
| 12 | + | ||
| 13 | + | /// Draw the right-click context menu for a single item. | |
| 14 | + | /// Branches on node type: samples get Preview/Copy Path/Delete, | |
| 15 | + | /// directories get Open/Delete. | |
| 16 | + | pub fn draw_context_menu( | |
| 17 | + | ui: &mut egui::Ui, | |
| 18 | + | state: &mut BrowserState, | |
| 19 | + | row_idx: usize, | |
| 20 | + | node: &audiofiles_core::vfs::VfsNodeWithAnalysis, | |
| 21 | + | ) { | |
| 22 | + | match node.node.node_type { | |
| 23 | + | NodeType::Sample => { | |
| 24 | + | if node.cloud_only { | |
| 25 | + | ui.label( | |
| 26 | + | egui::RichText::new("Cloud-only sample") | |
| 27 | + | .color(theme::text_muted()) | |
| 28 | + | .italics(), | |
| 29 | + | ); | |
| 30 | + | ui.separator(); | |
| 31 | + | } | |
| 32 | + | if !node.cloud_only && ui.button("Preview").clicked() { | |
| 33 | + | if let Some(hash) = &node.node.sample_hash { | |
| 34 | + | let hash = hash.clone(); | |
| 35 | + | state.trigger_preview(&hash); | |
| 36 | + | } | |
| 37 | + | ui.close_menu(); | |
| 38 | + | } | |
| 39 | + | if ui.button("Copy Path").clicked() { | |
| 40 | + | if let Some(path) = state.selected_sample_path() { | |
| 41 | + | ui.ctx().copy_text(path); | |
| 42 | + | state.status = "Path copied to clipboard".to_string(); | |
| 43 | + | } | |
| 44 | + | ui.close_menu(); | |
| 45 | + | } | |
| 46 | + | if ui.button("Find Similar").clicked() { | |
| 47 | + | if let Some(hash) = &node.node.sample_hash { | |
| 48 | + | let hash = hash.clone(); | |
| 49 | + | state.find_similar(&hash); | |
| 50 | + | } | |
| 51 | + | ui.close_menu(); | |
| 52 | + | } | |
| 53 | + | if ui.button("Find Duplicates").clicked() { | |
| 54 | + | if let Some(hash) = &node.node.sample_hash { | |
| 55 | + | let hash = hash.clone(); | |
| 56 | + | state.find_near_duplicates(&hash); | |
| 57 | + | } | |
| 58 | + | ui.close_menu(); | |
| 59 | + | } | |
| 60 | + | // Add to Collection submenu | |
| 61 | + | if let Some(hash) = &node.node.sample_hash { | |
| 62 | + | let hash_clone = hash.clone(); | |
| 63 | + | let collections = state.collections.clone(); | |
| 64 | + | let is_in_collection = state.active_collection.is_some(); | |
| 65 | + | if !collections.is_empty() { | |
| 66 | + | ui.menu_button("Add to Collection", |ui| { | |
| 67 | + | for coll in &collections { | |
| 68 | + | if ui.button(&coll.name).clicked() { | |
| 69 | + | let _ = state.backend.add_to_collection(coll.id, &hash_clone); | |
| 70 | + | state.refresh_collections(); | |
| 71 | + | state.status = format!("Added to {}", coll.name); | |
| 72 | + | ui.close_menu(); | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | }); | |
| 76 | + | } | |
| 77 | + | if is_in_collection { | |
| 78 | + | if let Some(active_id) = state.active_collection { | |
| 79 | + | if ui.button("Remove from Collection").clicked() { | |
| 80 | + | let _ = state.backend.remove_from_collection(active_id, &hash_clone); | |
| 81 | + | state.refresh_collections(); | |
| 82 | + | state.activate_collection(active_id); | |
| 83 | + | ui.close_menu(); | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | if !node.cloud_only { | |
| 89 | + | if let Some(hash) = &node.node.sample_hash { | |
| 90 | + | let hash_clone = hash.clone(); | |
| 91 | + | if ui.button("Edit...").clicked() { | |
| 92 | + | state.open_edit_window(&hash_clone); | |
| 93 | + | ui.close_menu(); | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | if ui.button("Play as Instrument").clicked() { | |
| 97 | + | if let Some(hash) = &node.node.sample_hash { | |
| 98 | + | let hash = hash.clone(); | |
| 99 | + | let name = node.node.name.clone(); | |
| 100 | + | state.load_chromatic_sample(&hash); | |
| 101 | + | state.instrument_visible = true; | |
| 102 | + | state.show_midi_window = true; | |
| 103 | + | state.status = format!("Instrument: {name}"); | |
| 104 | + | } | |
| 105 | + | ui.close_menu(); | |
| 106 | + | } | |
| 107 | + | if ui.button("Export...").clicked() { | |
| 108 | + | state.selection.set_single(row_idx); | |
| 109 | + | state.start_export_flow(Some(vec![node.node.id])); | |
| 110 | + | ui.close_menu(); | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ui.separator(); | |
| 114 | + | if ui.button("Delete").clicked() { | |
| 115 | + | state.selection.set_single(row_idx); | |
| 116 | + | state.confirm_delete_selected(); | |
| 117 | + | ui.close_menu(); | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | NodeType::Directory => { | |
| 121 | + | if ui.button("Open").clicked() { | |
| 122 | + | state.selection.set_single(row_idx); | |
| 123 | + | state.enter_directory(); | |
| 124 | + | ui.close_menu(); | |
| 125 | + | } | |
| 126 | + | if ui.button("New Folder").clicked() { | |
| 127 | + | state.show_dir_create = true; | |
| 128 | + | state.dir_create_input.clear(); | |
| 129 | + | ui.close_menu(); | |
| 130 | + | } | |
| 131 | + | if ui.button("Rename").clicked() { | |
| 132 | + | state.dir_rename_target = Some((node.node.id, node.node.name.clone())); | |
| 133 | + | ui.close_menu(); | |
| 134 | + | } | |
| 135 | + | if ui.button("Export...").clicked() { | |
| 136 | + | state.selection.set_single(row_idx); | |
| 137 | + | state.start_export_flow(Some(vec![node.node.id])); | |
| 138 | + | ui.close_menu(); | |
| 139 | + | } | |
| 140 | + | ui.separator(); | |
| 141 | + | if ui.button("Delete").clicked() { | |
| 142 | + | state.selection.set_single(row_idx); | |
| 143 | + | state.confirm_delete_selected(); | |
| 144 | + | ui.close_menu(); | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | /// Context menu when multiple items are selected. | |
| 151 | + | pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 152 | + | let count = state.selection.count(); | |
| 153 | + | ui.label(egui::RichText::new(format!("{count} items selected")).strong()); | |
| 154 | + | ui.separator(); | |
| 155 | + | ||
| 156 | + | if ui.button("Tag...").clicked() { | |
| 157 | + | state.open_bulk_tag_modal(); | |
| 158 | + | ui.close_menu(); | |
| 159 | + | } | |
| 160 | + | if ui.button("Move to...").clicked() { | |
| 161 | + | state.open_bulk_move_modal(); | |
| 162 | + | ui.close_menu(); | |
| 163 | + | } | |
| 164 | + | if ui.button("Rename...").clicked() { | |
| 165 | + | state.open_bulk_rename_modal(); | |
| 166 | + | ui.close_menu(); | |
| 167 | + | } | |
| 168 | + | if ui.button("Export...").clicked() { | |
| 169 | + | let node_ids = state.selected_node_ids(); | |
| 170 | + | state.start_export_flow(Some(node_ids)); | |
| 171 | + | ui.close_menu(); | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // Add to Collection submenu (bulk) | |
| 175 | + | let collections = state.collections.clone(); | |
| 176 | + | if !collections.is_empty() { | |
| 177 | + | ui.menu_button("Add to Collection", |ui| { | |
| 178 | + | for coll in &collections { | |
| 179 | + | if ui.button(&coll.name).clicked() { | |
| 180 | + | let nodes = state.selected_nodes(); | |
| 181 | + | for n in &nodes { | |
| 182 | + | if let Some(hash) = &n.node.sample_hash { | |
| 183 | + | let _ = state.backend.add_to_collection(coll.id, hash); | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | state.refresh_collections(); | |
| 187 | + | state.status = format!("Added {} items to {}", nodes.len(), coll.name); | |
| 188 | + | ui.close_menu(); | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | }); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // Remove from Collection (when viewing a collection) | |
| 195 | + | if let Some(active_id) = state.active_collection { | |
| 196 | + | if ui.button("Remove from Collection").clicked() { | |
| 197 | + | let nodes = state.selected_nodes(); | |
| 198 | + | for n in &nodes { | |
| 199 | + | if let Some(hash) = &n.node.sample_hash { | |
| 200 | + | let _ = state.backend.remove_from_collection(active_id, hash); | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | state.refresh_collections(); | |
| 204 | + | state.activate_collection(active_id); | |
| 205 | + | ui.close_menu(); | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | ui.separator(); | |
| 210 | + | ||
| 211 | + | if ui.button("Copy Paths").clicked() { | |
| 212 | + | let nodes = state.selected_nodes(); | |
| 213 | + | let paths: Vec<String> = nodes | |
| 214 | + | .iter() | |
| 215 | + | .filter_map(|n| { | |
| 216 | + | n.node.sample_hash.as_ref().and_then(|hash| { | |
| 217 | + | let ext = state.backend.sample_extension(hash).ok()?; | |
| 218 | + | Some(state.backend.sample_path(hash, &ext).ok()?.to_string_lossy().into_owned()) | |
| 219 | + | }) | |
| 220 | + | }) | |
| 221 | + | .collect(); | |
| 222 | + | if !paths.is_empty() { | |
| 223 | + | ui.ctx().copy_text(paths.join("\n")); | |
| 224 | + | state.status = format!("Copied {} paths", paths.len()); | |
| 225 | + | } | |
| 226 | + | ui.close_menu(); | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | if ui.button("Delete").clicked() { | |
| 230 | + | state.confirm_delete_selected(); | |
| 231 | + | ui.close_menu(); | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | /// Context menu for right-clicking empty space in the file list. | |
| 236 | + | pub fn draw_background_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 237 | + | if ui.button("New Folder").clicked() { | |
| 238 | + | state.show_dir_create = true; | |
| 239 | + | state.dir_create_input.clear(); | |
| 240 | + | ui.close_menu(); | |
| 241 | + | } | |
| 242 | + | if ui.button("Import Files...").clicked() { | |
| 243 | + | if let Some(paths) = rfd::FileDialog::new() | |
| 244 | + | .set_title("Import Files") | |
| 245 | + | .add_filter("Audio", audiofiles_core::util::AUDIO_EXTENSIONS) | |
| 246 | + | .pick_files() | |
| 247 | + | { | |
| 248 | + | for path in paths { | |
| 249 | + | state.import_path(&path); | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | ui.close_menu(); | |
| 253 | + | } | |
| 254 | + | if ui.button("Import Folder...").clicked() { | |
| 255 | + | if let Some(path) = rfd::FileDialog::new().pick_folder() { | |
| 256 | + | state.show_import_options(path); | |
| 257 | + | } | |
| 258 | + | ui.close_menu(); | |
| 259 | + | } | |
| 260 | + | if state.selection.count() > 0 { | |
| 261 | + | ui.separator(); | |
| 262 | + | let label = format!("Deselect ({})", state.selection.count()); | |
| 263 | + | if ui.button(label).clicked() { | |
| 264 | + | state.selection.clear(); | |
| 265 | + | state.refresh_selected_tags(); | |
| 266 | + | state.refresh_selected_detail(); | |
| 267 | + | ui.close_menu(); | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 273 | + | pub fn start_os_drag(state: &mut BrowserState) { | |
| 274 | + | let nodes = state.selected_nodes(); | |
| 275 | + | let files: Vec<drag_out::DragFile> = nodes | |
| 276 | + | .iter() | |
| 277 | + | .filter(|n| n.node.node_type == NodeType::Sample && !n.cloud_only) | |
| 278 | + | .filter_map(|n| { | |
| 279 | + | let hash = n.node.sample_hash.as_ref()?; | |
| 280 | + | let ext = state.backend.sample_extension(hash).ok()?; | |
| 281 | + | let store_path = state.backend.sample_path(hash, &ext).ok()?; | |
| 282 | + | Some(drag_out::DragFile { | |
| 283 | + | friendly_name: n.node.name.clone(), | |
| 284 | + | store_path, | |
| 285 | + | }) | |
| 286 | + | }) | |
| 287 | + | .collect(); | |
| 288 | + | if !files.is_empty() { | |
| 289 | + | let count = files.len(); | |
| 290 | + | let first = files[0].friendly_name.clone(); | |
| 291 | + | if drag_out::begin_drag(&files) { | |
| 292 | + | state.os_drag_cooldown = Some(std::time::Instant::now()); | |
| 293 | + | state.status = if count == 1 { | |
| 294 | + | format!("Dragged {first}") | |
| 295 | + | } else { | |
| 296 | + | format!("Dragged {count} samples") | |
| 297 | + | }; | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | } |