Skip to main content

max / audiofiles

v0.3.2: destructive editing, MIDI support, multi-vault, settings panel, skip-activation for alpha Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-02 17:14 UTC
Commit: 0417eb906e6469b63780cf986b249ed34609f5e2
Parent: 590a136
46 files changed, +4889 insertions, -1028 deletions
M Cargo.lock +91 -6
@@ -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"
M Cargo.toml +1
@@ -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(&reg) {
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 + }
M docs/todo.md +15 -7