//! CLAP audio effect. Audio passes through unchanged; the input is recorded continuously. use crucible_core::{Player, Queues, Recorder, Ring, Settings, Transport, HISTORY}; use nih_plug::prelude::*; use nih_plug_egui::{create_egui_editor, egui, EguiState}; use std::sync::{Arc, Mutex, RwLock}; #[derive(Params)] pub struct CrucibleParams { #[persist = "editor"] pub editor: Arc, #[persist = "crucible-settings"] pub settings: RwLock, #[id = "bypass"] pub bypass: BoolParam, } impl Default for CrucibleParams { fn default() -> Self { Self { editor: EguiState::from_size(crucible_ui::WIDTH, crucible_ui::HEIGHT), settings: RwLock::new(Settings::default()), bypass: BoolParam::new("Bypass", false).make_bypass(), } } } struct Shared { /// Replaced on activation; the editor clones the Arc briefly, never the audio thread. ring: Mutex>>, queues: Arc, } pub struct Crucible { params: Arc, shared: Arc, ring: Option>, recorder: Recorder, player: Player, } impl Default for Crucible { fn default() -> Self { Self { params: Arc::new(CrucibleParams::default()), shared: Arc::new(Shared { ring: Mutex::new(None), queues: Arc::new(Queues::default()), }), ring: None, recorder: Recorder::new(), player: Player::default(), } } } impl Plugin for Crucible { const NAME: &'static str = "Crucible"; const VENDOR: &'static str = "Rivet & Patina"; const URL: &'static str = ""; const EMAIL: &'static str = ""; const VERSION: &'static str = env!("CARGO_PKG_VERSION"); const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[ AudioIOLayout { main_input_channels: NonZeroU32::new(2), main_output_channels: NonZeroU32::new(2), ..AudioIOLayout::const_default() }, AudioIOLayout { main_input_channels: NonZeroU32::new(1), main_output_channels: NonZeroU32::new(1), ..AudioIOLayout::const_default() }, ]; const SAMPLE_ACCURATE_AUTOMATION: bool = false; type SysExMessage = (); type BackgroundTask = (); fn params(&self) -> Arc { self.params.clone() } fn editor(&mut self, _: AsyncExecutor) -> Option> { let params = self.params.clone(); let shared = self.shared.clone(); create_egui_editor( params.editor.clone(), crucible_ui::State::default(), |ctx, _| crucible_ui::style(ctx), move |ctx, _setter, state| { let link = crucible_ui::Link { ring: shared.ring.lock().ok().and_then(|r| r.clone()), queues: shared.queues.clone(), }; let mut settings = params .settings .read() .map(|s| s.clone()) .unwrap_or_default(); let before = settings.clone(); nih_plug_egui::resizable_window::ResizableWindow::new("crucible-editor") .min_size(egui::vec2( crucible_ui::WIDTH as f32 - 80., crucible_ui::HEIGHT as f32 - 40., )) .show(ctx, ¶ms.editor, |ui| { crucible_ui::show(ui, &mut settings, state, &link) }); if settings != before { if let Ok(mut s) = params.settings.write() { *s = settings; } } // Meters and the transport readout follow the audio. ctx.request_repaint_after(std::time::Duration::from_millis(50)); }, ) } fn initialize( &mut self, _: &AudioIOLayout, config: &BufferConfig, _: &mut impl InitContext, ) -> bool { if let Ok(mut s) = self.params.settings.write() { s.sanitize(); } let ring = Arc::new(Ring::new(config.sample_rate, HISTORY)); self.ring = Some(ring.clone()); self.recorder = Recorder::new(); self.player = Player::default(); if let Ok(mut slot) = self.shared.ring.lock() { *slot = Some(ring); } true } fn reset(&mut self) { self.player = Player::default(); } fn process( &mut self, buffer: &mut Buffer, _: &mut AuxiliaryBuffers, context: &mut impl ProcessContext, ) -> ProcessStatus { let Some(ring) = self.ring.as_deref() else { return ProcessStatus::Normal; }; let bypass = self.params.bypass.value(); let t = context.transport(); let transport = match (t.tempo, t.pos_beats()) { (Some(tempo), Some(beats)) => Some(Transport { playing: t.playing, tempo, beats, numerator: t.time_sig_numerator.unwrap_or(4).clamp(1, 64) as u32, denominator: t.time_sig_denominator.unwrap_or(4).clamp(1, 64) as u32, }), _ => None, }; if !bypass { self.recorder.block(ring, transport); } self.player.block(&self.shared.queues); let channels = buffer.channels(); for mut sample in buffer.iter_samples() { let left = *sample.get_mut(0).unwrap(); let right = if channels > 1 { *sample.get_mut(1).unwrap() } else { left }; if bypass { continue; } self.recorder.push(ring, [left, right]); // Auditioned slices are added to the output, never recorded. let a = self.player.next(&self.shared.queues); if self.player.playing() || a != [0.; 2] { *sample.get_mut(0).unwrap() += if channels > 1 { a[0] } else { 0.5 * (a[0] + a[1]) }; if channels > 1 { *sample.get_mut(1).unwrap() += a[1]; } } } self.recorder.publish(ring); ProcessStatus::Normal } } impl ClapPlugin for Crucible { const CLAP_ID: &'static str = "io.storyboard.resampler"; const CLAP_DESCRIPTION: Option<&'static str> = Some("Capture the last bars, slice them and export clean, key-labelled samples"); const CLAP_MANUAL_URL: Option<&'static str> = None; const CLAP_SUPPORT_URL: Option<&'static str> = None; const CLAP_FEATURES: &'static [ClapFeature] = &[ ClapFeature::AudioEffect, ClapFeature::Utility, ClapFeature::Stereo, ClapFeature::Mono, ]; } nih_export_clap!(Crucible);