max / audiofiles
4 files changed,
+324 insertions,
-100 deletions
| @@ -68,8 +68,14 @@ impl PreviewPlayback { | |||
| 68 | 68 | ||
| 69 | 69 | /// Decode an audio file to interleaved stereo f32. | |
| 70 | 70 | /// Mono files are doubled to stereo. Multi-channel files are mixed down to stereo. | |
| 71 | + | /// | |
| 72 | + | /// Module-private by design: a full-file Symphonia decode must never run on the | |
| 73 | + | /// egui frame thread. UI code reaches decoding only through | |
| 74 | + | /// [`start_streaming_decode`] (preview playback) or [`InstrumentDecodeWorker`] | |
| 75 | + | /// (instrument loading), both of which run it on a background thread. Keeping | |
| 76 | + | /// this private makes a synchronous decode in UI code a compile error. | |
| 71 | 77 | #[instrument(skip_all)] | |
| 72 | - | pub fn decode_to_f32(path: &Path) -> Result<PreviewBuffer, PreviewError> { | |
| 78 | + | fn decode_to_f32(path: &Path) -> Result<PreviewBuffer, PreviewError> { | |
| 73 | 79 | let file = std::fs::File::open(path).map_err(|e| PreviewError::Open { | |
| 74 | 80 | path: path.to_path_buf(), | |
| 75 | 81 | source: e, | |
| @@ -224,9 +230,6 @@ pub fn estimate_duration(path: &Path) -> Option<f64> { | |||
| 224 | 230 | None | |
| 225 | 231 | } | |
| 226 | 232 | ||
| 227 | - | /// Duration threshold in seconds: files longer than this use streaming decode. | |
| 228 | - | pub const STREAMING_THRESHOLD_SECS: f64 = 30.0; | |
| 229 | - | ||
| 230 | 233 | /// Number of seconds to pre-fill before enabling playback during streaming. | |
| 231 | 234 | const PREFILL_SECS: f64 = 0.5; | |
| 232 | 235 | ||
| @@ -412,6 +415,137 @@ pub fn start_streaming_decode( | |||
| 412 | 415 | Ok(()) | |
| 413 | 416 | } | |
| 414 | 417 | ||
| 418 | + | // ---------- Off-thread decode for instrument loading ---------- | |
| 419 | + | ||
| 420 | + | use std::path::PathBuf; | |
| 421 | + | use std::sync::mpsc::{Receiver, Sender}; | |
| 422 | + | use std::thread::JoinHandle; | |
| 423 | + | ||
| 424 | + | /// What a finished decode is used for once it returns to the GUI thread. | |
| 425 | + | #[derive(Clone)] | |
| 426 | + | pub enum DecodePurpose { | |
| 427 | + | /// Load one sample across the whole keyboard (chromatic mode), at `root_note`. | |
| 428 | + | Chromatic { root_note: u8 }, | |
| 429 | + | /// Add one zone to the multi-sample instrument. | |
| 430 | + | Zone { | |
| 431 | + | name: String, | |
| 432 | + | low: u8, | |
| 433 | + | high: u8, | |
| 434 | + | root: u8, | |
| 435 | + | }, | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | struct DecodeRequest { | |
| 439 | + | generation: u64, | |
| 440 | + | hash: String, | |
| 441 | + | path: PathBuf, | |
| 442 | + | purpose: DecodePurpose, | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | /// A finished decode handed back to the GUI thread for application. | |
| 446 | + | pub struct DecodeOutcome { | |
| 447 | + | /// Monotonic id of the request that produced this. The GUI drops superseded | |
| 448 | + | /// chromatic auto-loads by comparing against the latest dispatched id. | |
| 449 | + | pub generation: u64, | |
| 450 | + | pub hash: String, | |
| 451 | + | pub purpose: DecodePurpose, | |
| 452 | + | pub result: Result<PreviewBuffer, PreviewError>, | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | /// Background decoder for instrument loading. | |
| 456 | + | /// | |
| 457 | + | /// Building an instrument zone needs the whole file decoded to an owned | |
| 458 | + | /// `PreviewBuffer`; doing that on the egui thread froze the UI on every preview | |
| 459 | + | /// (ultra-fuzz Perf S2). One persistent thread processes requests serially. The | |
| 460 | + | /// job body is panic-isolated, so a corrupt file surfaces as a `DecodeOutcome` | |
| 461 | + | /// error rather than killing the worker (mirrors the analysis/edit/forge workers). | |
| 462 | + | pub struct InstrumentDecodeWorker { | |
| 463 | + | tx: Option<Sender<DecodeRequest>>, | |
| 464 | + | rx: Receiver<DecodeOutcome>, | |
| 465 | + | handle: Option<JoinHandle<()>>, | |
| 466 | + | next_generation: u64, | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | impl InstrumentDecodeWorker { | |
| 470 | + | /// Spawn the decode thread. | |
| 471 | + | pub fn new() -> Self { | |
| 472 | + | let (req_tx, req_rx) = std::sync::mpsc::channel::<DecodeRequest>(); | |
| 473 | + | let (out_tx, out_rx) = std::sync::mpsc::channel::<DecodeOutcome>(); | |
| 474 | + | let handle = std::thread::Builder::new() | |
| 475 | + | .name("af-instrument-decode".into()) | |
| 476 | + | .spawn(move || { | |
| 477 | + | while let Ok(req) = req_rx.recv() { | |
| 478 | + | let path = req.path.clone(); | |
| 479 | + | let result = match std::panic::catch_unwind(move || decode_to_f32(&path)) { | |
| 480 | + | Ok(r) => r, | |
| 481 | + | Err(_) => Err(PreviewError::Decoder("decoder panicked".to_string())), | |
| 482 | + | }; | |
| 483 | + | if out_tx | |
| 484 | + | .send(DecodeOutcome { | |
| 485 | + | generation: req.generation, | |
| 486 | + | hash: req.hash, | |
| 487 | + | purpose: req.purpose, | |
| 488 | + | result, | |
| 489 | + | }) | |
| 490 | + | .is_err() | |
| 491 | + | { | |
| 492 | + | break; // GUI gone | |
| 493 | + | } | |
| 494 | + | } | |
| 495 | + | }) | |
| 496 | + | .expect("spawn instrument decode worker"); | |
| 497 | + | Self { | |
| 498 | + | tx: Some(req_tx), | |
| 499 | + | rx: out_rx, | |
| 500 | + | handle: Some(handle), | |
| 501 | + | next_generation: 0, | |
| 502 | + | } | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | /// Queue a decode. Returns the generation stamped on this request so the | |
| 506 | + | /// caller can recognise and ignore superseded chromatic auto-loads. | |
| 507 | + | pub fn request(&mut self, hash: String, path: PathBuf, purpose: DecodePurpose) -> u64 { | |
| 508 | + | self.next_generation += 1; | |
| 509 | + | let generation = self.next_generation; | |
| 510 | + | if let Some(tx) = &self.tx { | |
| 511 | + | let _ = tx.send(DecodeRequest { | |
| 512 | + | generation, | |
| 513 | + | hash, | |
| 514 | + | path, | |
| 515 | + | purpose, | |
| 516 | + | }); | |
| 517 | + | } | |
| 518 | + | generation | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | /// Drain all finished decodes (non-blocking). | |
| 522 | + | pub fn drain(&self) -> Vec<DecodeOutcome> { | |
| 523 | + | let mut out = Vec::new(); | |
| 524 | + | // `try_recv` errors on both Empty and Disconnected; either ends the drain. | |
| 525 | + | while let Ok(o) = self.rx.try_recv() { | |
| 526 | + | out.push(o); | |
| 527 | + | } | |
| 528 | + | out | |
| 529 | + | } | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | impl Default for InstrumentDecodeWorker { | |
| 533 | + | fn default() -> Self { | |
| 534 | + | Self::new() | |
| 535 | + | } | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | impl Drop for InstrumentDecodeWorker { | |
| 539 | + | fn drop(&mut self) { | |
| 540 | + | // Close the request channel so the worker loop's recv() returns Err and | |
| 541 | + | // the thread exits, then join it. | |
| 542 | + | self.tx = None; | |
| 543 | + | if let Some(h) = self.handle.take() { | |
| 544 | + | let _ = h.join(); | |
| 545 | + | } | |
| 546 | + | } | |
| 547 | + | } | |
| 548 | + | ||
| 415 | 549 | /// Convert interleaved multi-channel samples to interleaved stereo. | |
| 416 | 550 | fn interleaved_to_stereo( | |
| 417 | 551 | samples: &[f32], | |
| @@ -531,6 +665,58 @@ mod tests { | |||
| 531 | 665 | } | |
| 532 | 666 | ||
| 533 | 667 | #[test] | |
| 668 | + | fn instrument_decode_worker_round_trips_a_file() { | |
| 669 | + | let dir = tempfile::tempdir().unwrap(); | |
| 670 | + | let path = dir.path().join("zone.wav"); | |
| 671 | + | write_wav(&path, 1, 44100, &[0.5, -0.5, 0.25, 0.1]); | |
| 672 | + | ||
| 673 | + | let mut worker = InstrumentDecodeWorker::new(); | |
| 674 | + | let generation = worker.request( | |
| 675 | + | "deadbeef".to_string(), | |
| 676 | + | path, | |
| 677 | + | DecodePurpose::Chromatic { root_note: 60 }, | |
| 678 | + | ); | |
| 679 | + | assert_eq!(generation, 1); | |
| 680 | + | ||
| 681 | + | // Poll for the outcome (worker decodes on its own thread). | |
| 682 | + | let outcome = loop { | |
| 683 | + | let mut drained = worker.drain(); | |
| 684 | + | if let Some(o) = drained.pop() { | |
| 685 | + | break o; | |
| 686 | + | } | |
| 687 | + | std::thread::yield_now(); | |
| 688 | + | }; | |
| 689 | + | assert_eq!(outcome.generation, 1); | |
| 690 | + | assert_eq!(outcome.hash, "deadbeef"); | |
| 691 | + | let buf = outcome.result.expect("decode should succeed"); | |
| 692 | + | assert_eq!(buf.channels, 2); | |
| 693 | + | assert_eq!(buf.data.len(), 8); // 4 mono frames doubled to stereo | |
| 694 | + | } | |
| 695 | + | ||
| 696 | + | #[test] | |
| 697 | + | fn instrument_decode_worker_reports_error_for_missing_file() { | |
| 698 | + | let mut worker = InstrumentDecodeWorker::new(); | |
| 699 | + | worker.request( | |
| 700 | + | "nope".to_string(), | |
| 701 | + | PathBuf::from("/nonexistent/missing.wav"), | |
| 702 | + | DecodePurpose::Zone { | |
| 703 | + | name: "x".into(), | |
| 704 | + | low: 0, | |
| 705 | + | high: 127, | |
| 706 | + | root: 60, | |
| 707 | + | }, | |
| 708 | + | ); | |
| 709 | + | let outcome = loop { | |
| 710 | + | let mut drained = worker.drain(); | |
| 711 | + | if let Some(o) = drained.pop() { | |
| 712 | + | break o; | |
| 713 | + | } | |
| 714 | + | std::thread::yield_now(); | |
| 715 | + | }; | |
| 716 | + | assert!(outcome.result.is_err()); | |
| 717 | + | } | |
| 718 | + | ||
| 719 | + | #[test] | |
| 534 | 720 | fn decode_mono_duplicates_to_stereo() { | |
| 535 | 721 | let dir = tempfile::tempdir().unwrap(); | |
| 536 | 722 | let path = dir.path().join("mono.wav"); |
| @@ -418,9 +418,17 @@ impl BrowserState { | |||
| 418 | 418 | /// Poll all backend workers for events. Returns `true` if any events were processed. | |
| 419 | 419 | /// Called each frame to drain import, analysis, and export progress. | |
| 420 | 420 | pub fn poll_workers(&mut self) -> bool { | |
| 421 | + | // Apply any finished off-thread instrument decodes first (independent of | |
| 422 | + | // the backend worker channels). | |
| 423 | + | let decodes = self.instrument_decode.drain(); | |
| 424 | + | let decode_activity = !decodes.is_empty(); | |
| 425 | + | for outcome in decodes { | |
| 426 | + | self.apply_instrument_decode(outcome); | |
| 427 | + | } | |
| 428 | + | ||
| 421 | 429 | let events = self.backend.poll_events(); | |
| 422 | 430 | if events.is_empty() { | |
| 423 | - | return false; | |
| 431 | + | return decode_activity; | |
| 424 | 432 | } | |
| 425 | 433 | ||
| 426 | 434 | use crate::backend::BackendEvent; |
| @@ -297,6 +297,14 @@ pub struct BrowserState { | |||
| 297 | 297 | // Forge — floating sample-forge window (chop / conform / batch) | |
| 298 | 298 | pub forge: ForgeUiState, | |
| 299 | 299 | ||
| 300 | + | /// Off-thread decoder for instrument loading (chromatic auto-load + zone | |
| 301 | + | /// adds). Keeps the full-file decode off the egui frame thread. | |
| 302 | + | pub instrument_decode: crate::preview::InstrumentDecodeWorker, | |
| 303 | + | /// Generation of the most recently dispatched chromatic auto-load. Outcomes | |
| 304 | + | /// with an older generation are dropped so rapid arrow-key previewing can't | |
| 305 | + | /// load a stale instrument over the newest one. | |
| 306 | + | pub latest_chromatic_gen: u64, | |
| 307 | + | ||
| 300 | 308 | // Display density | |
| 301 | 309 | pub row_height: f32, | |
| 302 | 310 | ||
| @@ -549,6 +557,8 @@ impl BrowserState { | |||
| 549 | 557 | show_collection_create: false, | |
| 550 | 558 | edit: EditUiState::default(), | |
| 551 | 559 | forge: ForgeUiState::default(), | |
| 560 | + | instrument_decode: crate::preview::InstrumentDecodeWorker::new(), | |
| 561 | + | latest_chromatic_gen: 0, | |
| 552 | 562 | row_height, | |
| 553 | 563 | show_vfs_banner: !vfs_explained, | |
| 554 | 564 | show_first_launch_hint: !hints_dismissed, |
| @@ -16,19 +16,16 @@ impl BrowserState { | |||
| 16 | 16 | Ok(path) | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | - | /// Resolve a sample hash and decode it to an interleaved stereo f32 buffer. | |
| 20 | - | pub fn resolve_and_decode(&self, hash: &str) -> Result<crate::preview::PreviewBuffer, crate::error::PreviewError> { | |
| 21 | - | let path = self.resolve_sample_path(hash)?; | |
| 22 | - | crate::preview::decode_to_f32(&path) | |
| 23 | - | } | |
| 24 | - | ||
| 25 | 19 | // --- Preview --- | |
| 26 | 20 | ||
| 27 | 21 | /// Decode a sample by hash and start playback through the shared preview buffer. | |
| 28 | 22 | /// | |
| 29 | - | /// Short files (<=30s or unknown duration) are decoded fully on the GUI thread. | |
| 30 | - | /// Long files use streaming: a background thread decodes while playback starts | |
| 31 | - | /// after a 0.5s pre-fill, avoiding UI freezes. | |
| 23 | + | /// All decoding runs on a background thread via [`start_streaming_decode`]: | |
| 24 | + | /// playback begins after a 0.5s pre-fill (or at decode-end for short files), | |
| 25 | + | /// so the egui thread never blocks on a decode regardless of file length or | |
| 26 | + | /// whether the duration is known (ultra-fuzz Perf S1). | |
| 27 | + | /// | |
| 28 | + | /// [`start_streaming_decode`]: crate::preview::start_streaming_decode | |
| 32 | 29 | pub fn trigger_preview(&mut self, hash: &str) { | |
| 33 | 30 | let path = match self.resolve_sample_path(hash) { | |
| 34 | 31 | Ok(p) => p, | |
| @@ -39,48 +36,24 @@ impl BrowserState { | |||
| 39 | 36 | } | |
| 40 | 37 | }; | |
| 41 | 38 | ||
| 42 | - | let duration = crate::preview::estimate_duration(&path); | |
| 43 | - | let use_streaming = duration.is_some_and(|d| d > crate::preview::STREAMING_THRESHOLD_SECS); | |
| 44 | - | ||
| 45 | 39 | let file_name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); | |
| 46 | - | let ok = if use_streaming { | |
| 47 | - | match crate::preview::start_streaming_decode(&path, &self.shared) { | |
| 48 | - | Ok(()) => { | |
| 49 | - | self.previewing_hash = Some(hash.to_string()); | |
| 50 | - | self.status = format!("Playing: {file_name}"); | |
| 51 | - | true | |
| 52 | - | } | |
| 53 | - | Err(e) => { | |
| 54 | - | self.status = format!("Decode error: {e}"); | |
| 55 | - | self.previewing_hash = None; | |
| 56 | - | false | |
| 57 | - | } | |
| 40 | + | // Set the loop flag the streaming thread will honour once it starts. | |
| 41 | + | self.shared.preview.lock().loop_enabled = self.loop_enabled; | |
| 42 | + | match crate::preview::start_streaming_decode(&path, &self.shared) { | |
| 43 | + | Ok(()) => { | |
| 44 | + | self.previewing_hash = Some(hash.to_string()); | |
| 45 | + | self.status = format!("Playing: {file_name}"); | |
| 58 | 46 | } | |
| 59 | - | } else { | |
| 60 | - | match crate::preview::decode_to_f32(&path) { | |
| 61 | - | Ok(buf) => { | |
| 62 | - | let mut playback = self.shared.preview.lock(); | |
| 63 | - | playback.buffer = Some(buf); | |
| 64 | - | playback.position_frac = 0.0; | |
| 65 | - | playback.playing = true; | |
| 66 | - | playback.loop_enabled = self.loop_enabled; | |
| 67 | - | playback.streaming = false; | |
| 68 | - | playback.decoded_frames = 0; | |
| 69 | - | playback.total_frames_estimate = None; | |
| 70 | - | self.previewing_hash = Some(hash.to_string()); | |
| 71 | - | self.status = format!("Playing: {file_name}"); | |
| 72 | - | true | |
| 73 | - | } | |
| 74 | - | Err(e) => { | |
| 75 | - | self.status = format!("Decode error: {e}"); | |
| 76 | - | self.previewing_hash = None; | |
| 77 | - | false | |
| 78 | - | } | |
| 47 | + | Err(e) => { | |
| 48 | + | self.status = format!("Decode error: {e}"); | |
| 49 | + | self.previewing_hash = None; | |
| 50 | + | return; | |
| 79 | 51 | } | |
| 80 | - | }; | |
| 52 | + | } | |
| 81 | 53 | ||
| 82 | - | // Auto-load previewed sample into the instrument (unless locked) | |
| 83 | - | if ok && !self.instrument_locked { | |
| 54 | + | // Auto-load previewed sample into the instrument (unless locked). The | |
| 55 | + | // decode runs off-thread; the instrument is built when the result arrives. | |
| 56 | + | if !self.instrument_locked { | |
| 84 | 57 | let hash_owned = hash.to_string(); | |
| 85 | 58 | self.load_chromatic_sample(&hash_owned); | |
| 86 | 59 | } | |
| @@ -134,17 +107,20 @@ impl BrowserState { | |||
| 134 | 107 | ||
| 135 | 108 | // --- Instrument --- | |
| 136 | 109 | ||
| 137 | - | /// Load a sample for chromatic instrument playback (pitch-shift across the keyboard). | |
| 110 | + | /// Queue a sample to load for chromatic instrument playback (pitch-shift | |
| 111 | + | /// across the keyboard). The full-file decode runs on the instrument decode | |
| 112 | + | /// worker; the zone is built in [`apply_instrument_decode`] when the result | |
| 113 | + | /// arrives. Newer calls supersede older ones via the generation counter. | |
| 138 | 114 | pub fn load_chromatic_sample(&mut self, hash: &str) { | |
| 139 | - | let buf = match self.resolve_and_decode(hash) { | |
| 140 | - | Ok(b) => b, | |
| 115 | + | let path = match self.resolve_sample_path(hash) { | |
| 116 | + | Ok(p) => p, | |
| 141 | 117 | Err(e) => { | |
| 142 | 118 | self.status = e.to_string(); | |
| 143 | 119 | return; | |
| 144 | 120 | } | |
| 145 | 121 | }; | |
| 146 | 122 | ||
| 147 | - | // Derive root note from analysis, default to C3 (48) | |
| 123 | + | // Derive root note from analysis (cheap DB read), default to C3 (48). | |
| 148 | 124 | let root_note = self | |
| 149 | 125 | .backend | |
| 150 | 126 | .get_analysis(hash) | |
| @@ -154,30 +130,11 @@ impl BrowserState { | |||
| 154 | 130 | .and_then(|k| audiofiles_core::instrument::key_to_root_note(&k)) | |
| 155 | 131 | .unwrap_or(48); | |
| 156 | 132 | ||
| 157 | - | let zone = crate::instrument::LoadedZone { | |
| 158 | - | buffer: buf, | |
| 159 | - | root_note, | |
| 160 | - | low_note: 0, | |
| 161 | - | high_note: 127, | |
| 162 | - | vel_low: 0.0, | |
| 163 | - | vel_high: 1.0, | |
| 164 | - | }; | |
| 165 | - | ||
| 166 | - | let mut inst = self.shared.instrument.lock(); | |
| 167 | - | inst.config.mode = audiofiles_core::instrument::InstrumentMode::Chromatic; | |
| 168 | - | inst.zone_buffers.clear(); | |
| 169 | - | inst.zone_buffers.push(zone); | |
| 170 | - | inst.active = true; | |
| 171 | - | inst.sample_rate = self.sample_rate; | |
| 172 | - | // Kill all voices | |
| 173 | - | for voice in &mut inst.voices { | |
| 174 | - | voice.active = false; | |
| 175 | - | voice.envelope_phase = crate::instrument::EnvelopePhase::Idle; | |
| 176 | - | voice.envelope_level = 0.0; | |
| 177 | - | } | |
| 178 | - | drop(inst); | |
| 179 | - | ||
| 180 | - | self.instrument_root_note = root_note; | |
| 133 | + | self.latest_chromatic_gen = self.instrument_decode.request( | |
| 134 | + | hash.to_string(), | |
| 135 | + | path, | |
| 136 | + | crate::preview::DecodePurpose::Chromatic { root_note }, | |
| 137 | + | ); | |
| 181 | 138 | } | |
| 182 | 139 | ||
| 183 | 140 | /// Toggle instrument mode on/off. | |
| @@ -188,35 +145,98 @@ impl BrowserState { | |||
| 188 | 145 | self.show_midi_window = inst.active; | |
| 189 | 146 | } | |
| 190 | 147 | ||
| 191 | - | /// Add a sample as a new zone in multi-sample instrument mode. | |
| 148 | + | /// Queue a sample to be added as a new zone in multi-sample instrument mode. | |
| 149 | + | /// The decode runs off-thread; the zone is built in [`apply_instrument_decode`]. | |
| 192 | 150 | pub fn add_instrument_zone(&mut self, hash: &str, name: &str, low: u8, high: u8, root: u8) { | |
| 193 | - | let buf = match self.resolve_and_decode(hash) { | |
| 194 | - | Ok(b) => b, | |
| 151 | + | let path = match self.resolve_sample_path(hash) { | |
| 152 | + | Ok(p) => p, | |
| 195 | 153 | Err(e) => { | |
| 196 | 154 | self.status = e.to_string(); | |
| 197 | 155 | return; | |
| 198 | 156 | } | |
| 199 | 157 | }; | |
| 200 | 158 | ||
| 201 | - | let zone = crate::instrument::LoadedZone { | |
| 202 | - | buffer: buf, | |
| 203 | - | root_note: root, | |
| 204 | - | low_note: low, | |
| 205 | - | high_note: high, | |
| 206 | - | vel_low: 0.0, | |
| 207 | - | vel_high: 1.0, | |
| 159 | + | self.instrument_decode.request( | |
| 160 | + | hash.to_string(), | |
| 161 | + | path, | |
| 162 | + | crate::preview::DecodePurpose::Zone { | |
| 163 | + | name: name.to_string(), | |
| 164 | + | low, | |
| 165 | + | high, | |
| 166 | + | root, | |
| 167 | + | }, | |
| 168 | + | ); | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /// Apply a finished instrument decode (called from the poll loop). Builds the | |
| 172 | + | /// instrument zone on the GUI thread from the off-thread-decoded buffer. | |
| 173 | + | pub fn apply_instrument_decode(&mut self, outcome: crate::preview::DecodeOutcome) { | |
| 174 | + | use crate::preview::DecodePurpose; | |
| 175 | + | ||
| 176 | + | let buf = match outcome.result { | |
| 177 | + | Ok(b) => b, | |
| 178 | + | Err(e) => { | |
| 179 | + | self.status = format!("Instrument decode failed: {e}"); | |
| 180 | + | return; | |
| 181 | + | } | |
| 208 | 182 | }; | |
| 209 | 183 | ||
| 210 | - | let mut inst = self.shared.instrument.lock(); | |
| 211 | - | inst.config.mode = audiofiles_core::instrument::InstrumentMode::MultiSample; | |
| 212 | - | inst.zone_buffers.push(zone); | |
| 213 | - | inst.active = true; | |
| 214 | - | inst.sample_rate = self.sample_rate; | |
| 215 | - | drop(inst); | |
| 216 | - | ||
| 217 | - | self.instrument_visible = true; | |
| 218 | - | self.show_midi_window = true; | |
| 219 | - | self.status = format!("Added zone: {name} ({}-{})", low, high); | |
| 184 | + | match outcome.purpose { | |
| 185 | + | DecodePurpose::Chromatic { root_note } => { | |
| 186 | + | // Drop superseded auto-loads (rapid arrow-key previewing). | |
| 187 | + | if outcome.generation != self.latest_chromatic_gen { | |
| 188 | + | return; | |
| 189 | + | } | |
| 190 | + | let zone = crate::instrument::LoadedZone { | |
| 191 | + | buffer: buf, | |
| 192 | + | root_note, | |
| 193 | + | low_note: 0, | |
| 194 | + | high_note: 127, | |
| 195 | + | vel_low: 0.0, | |
| 196 | + | vel_high: 1.0, | |
| 197 | + | }; | |
| 198 | + | let mut inst = self.shared.instrument.lock(); | |
| 199 | + | inst.config.mode = audiofiles_core::instrument::InstrumentMode::Chromatic; | |
| 200 | + | inst.zone_buffers.clear(); | |
| 201 | + | inst.zone_buffers.push(zone); | |
| 202 | + | inst.active = true; | |
| 203 | + | inst.sample_rate = self.sample_rate; | |
| 204 | + | // Kill all voices | |
| 205 | + | for voice in &mut inst.voices { | |
| 206 | + | voice.active = false; | |
| 207 | + | voice.envelope_phase = crate::instrument::EnvelopePhase::Idle; | |
| 208 | + | voice.envelope_level = 0.0; | |
| 209 | + | } | |
| 210 | + | drop(inst); | |
| 211 | + | ||
| 212 | + | self.instrument_root_note = root_note; | |
| 213 | + | } | |
| 214 | + | DecodePurpose::Zone { | |
| 215 | + | name, | |
| 216 | + | low, | |
| 217 | + | high, | |
| 218 | + | root, | |
| 219 | + | } => { | |
| 220 | + | let zone = crate::instrument::LoadedZone { | |
| 221 | + | buffer: buf, | |
| 222 | + | root_note: root, | |
| 223 | + | low_note: low, | |
| 224 | + | high_note: high, | |
| 225 | + | vel_low: 0.0, | |
| 226 | + | vel_high: 1.0, | |
| 227 | + | }; | |
| 228 | + | let mut inst = self.shared.instrument.lock(); | |
| 229 | + | inst.config.mode = audiofiles_core::instrument::InstrumentMode::MultiSample; | |
| 230 | + | inst.zone_buffers.push(zone); | |
| 231 | + | inst.active = true; | |
| 232 | + | inst.sample_rate = self.sample_rate; | |
| 233 | + | drop(inst); | |
| 234 | + | ||
| 235 | + | self.instrument_visible = true; | |
| 236 | + | self.show_midi_window = true; | |
| 237 | + | self.status = format!("Added zone: {name} ({low}-{high})"); | |
| 238 | + | } | |
| 239 | + | } | |
| 220 | 240 | } | |
| 221 | 241 | ||
| 222 | 242 | /// Remove a zone by index and kill any voices using it. |