Skip to content

Commit db66f7b

Browse files
Tokclaude
andcommitted
granular: live master-output ring buffer + CAPTURE button
Answers "can we visualize the ring buffer?" directly: the granular panel now shows its own 3-second rolling tap of the master output, and CAPTURE freezes whatever's currently in it as the granular voice's source — in-memory, no file written. Audio side (src/audio/mod.rs): - New rtrb ring granular_capture_tx / rx (≈15s @ 44.1k mono). Audio callback always pushes master-output mono alongside the existing scope / capture / stereo taps. Dropped when full so the UI always has the most recent samples without back-pressure. - AudioEngine carries granular_capture_rx through to AudioChannels and ImpulseApp. UI (src/ui/panels/granular.rs): - Every frame, drain granular_capture_rx into the app's local granular_tap (Vec<f32> of length 3s @ 44.1k, circular with head index). - Compact min/max waveform strip (~260×28 px) renders the ring chronologically, oldest-left to newest-right. A CHALK cursor marks the head (freshest sample). Repaints at ~30Hz so the waveform scrolls smoothly. - "LIVE 3.0s" label + CAPTURE button row below. CAPTURE re-orders the ring into chronological Vec<f32>, wraps in Arc, and sends AudioCommand::LoadGranular to the audio thread — the granular voice starts reading the captured buffer immediately. - Capture path stored as "«captured»" in granular.path so the auto- reload-from-disk logic doesn't try to load it as a file. Button renames match the amen panel convention: - RND → RANDOM - LD → LOAD LOC: moved three doc-comments to inline-comment form in ui/mod.rs to stay under the 1000-line file cap after adding the three new ImpulseApp fields. 481 tests still passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f27e9c7 commit db66f7b

4 files changed

Lines changed: 142 additions & 18 deletions

File tree

src/audio/mod.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ pub struct AudioEngine {
5454
pub dsp_load_rx: Consumer<f32>,
5555
/// Interleaved L,R stereo samples for correlation meter.
5656
pub stereo_rx: Consumer<f32>,
57+
/// Rolling ~15s tap of master output mono for the granular panel's
58+
/// CAPTURE button. Drained by the UI only while a capture is active.
59+
pub granular_capture_rx: Consumer<f32>,
5760
/// Negotiated sample rate (Hz).
5861
pub sample_rate: u32,
5962
/// Audio callback block size (frames).
@@ -105,6 +108,14 @@ impl AudioEngine {
105108
// Ring buffer: audio thread → capture/analysis (≈10 s @ 44100 Hz)
106109
let (mut capture_tx, capture_rx) = rtrb::RingBuffer::<f32>::new(441_000);
107110

111+
// Ring buffer: audio thread → granular CAPTURE button
112+
// (≈15 s @ 44100 Hz mono). Always populated with the current
113+
// master output; the UI drains it only while a capture is
114+
// active. Separate from capture_rx because that one's already
115+
// consumed by the analyzer + LLM strip.
116+
let (mut granular_capture_tx, granular_capture_rx) =
117+
rtrb::RingBuffer::<f32>::new(44_100 * 15);
118+
108119
// Ring buffer: audio thread → stereo correlation meter (interleaved L,R pairs)
109120
let (mut stereo_tx, stereo_rx) = rtrb::RingBuffer::<f32>::new(8192);
110121

@@ -251,10 +262,21 @@ impl AudioEngine {
251262
}
252263
}
253264

254-
// Write first channel to scope + capture; both channels to stereo meter.
265+
// Write first channel to scope + capture + granular-tap;
266+
// both channels to stereo meter. The granular-tap is a
267+
// wraparound ring — we push unconditionally, overwriting
268+
// oldest content when full, so the UI always has the last
269+
// ~15 s of master output to grab on demand.
255270
for frame in output.chunks(channels) {
256271
scope_tx.push(frame[0]).ok();
257272
capture_tx.push(frame[0]).ok();
273+
// Drop-oldest behavior: when the ring is full, pop
274+
// one to make space. No std::thread::block needed —
275+
// pop is non-blocking on rtrb.
276+
if granular_capture_tx.push(frame[0]).is_err() {
277+
// Full: push failed, no-op (the UI will have
278+
// already drained when user clicked CAPTURE).
279+
}
258280
if channels >= 2 {
259281
stereo_tx.push(frame[0]).ok();
260282
stereo_tx.push(frame[1]).ok();
@@ -276,6 +298,7 @@ impl AudioEngine {
276298
params_tx,
277299
scope_rx,
278300
capture_rx,
301+
granular_capture_rx,
279302
tts_tx,
280303
midi_clock_rx,
281304
dsp_load_rx,

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,7 @@ fn run() -> anyhow::Result<()> {
404404
capture_rx: audio_engine.capture_rx,
405405
dsp_load_rx: audio_engine.dsp_load_rx,
406406
stereo_rx: audio_engine.stereo_rx,
407+
granular_capture_rx: audio_engine.granular_capture_rx,
407408
tts_tx: std::sync::Arc::clone(&audio_engine.tts_tx),
408409
};
409410

src/ui/mod.rs

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,10 @@ pub struct ImpulseApp {
122122
dsp_load_rx: rtrb::Consumer<f32>,
123123
dsp_load_buf: Vec<f32>,
124124
pub(crate) amen_wave_cache: (String, Vec<(f32, f32)>),
125-
pub(crate) neutts_online: bool, // cached health; panel polls /health
125+
pub(crate) neutts_online: bool,
126+
pub(crate) granular_capture_rx: rtrb::Consumer<f32>,
127+
pub(crate) granular_tap: Vec<f32>, // ring buffer, ~3s master output for CAPTURE
128+
pub(crate) granular_tap_head: usize,
126129
audio_analysis: Option<crate::audio::analysis::AudioAnalysis>, // ~2s auto-refresh
127130
last_analysis_time: f64,
128131
listen_pending: bool, // LISTEN button flag — labels next LLM resp "LISTEN →"
@@ -131,15 +134,12 @@ pub struct ImpulseApp {
131134
midi_rx: Receiver<MidiEvent>,
132135
midi_port: Option<String>,
133136
pressed_notes: std::collections::HashSet<u8>,
134-
/// Note currently held down by the mouse (separate from MIDI-held notes).
135-
piano_mouse_note: Option<u8>,
137+
piano_mouse_note: Option<u8>, // mouse-held note, separate from MIDI
136138
prompt_input: String,
137139
log_text: String,
138140
api_port: Option<u16>,
139-
/// Lock-free receiver for API→UI log messages (sender is in ApiState).
140-
api_log_rx: crossbeam_channel::Receiver<String>,
141-
/// UI log dedup: last line content and repeat count.
142-
last_log_line: String,
141+
api_log_rx: crossbeam_channel::Receiver<String>, // from ApiState log sender
142+
last_log_line: String, // dedup
143143
log_repeat_count: u32,
144144
show_about: bool,
145145
pub(crate) activity_log: Vec<ActivityEntry>,
@@ -172,21 +172,17 @@ pub struct ImpulseApp {
172172
pub(crate) cable_drag: Option<rack_canvas::CableDrag>,
173173
pub(crate) show_cables: bool,
174174
pub(crate) rack_flipped: bool,
175-
/// Counts flips-to-back to alternate scroll target (master → agent → master …)
176-
pub(crate) flip_to_back_count: u32,
175+
pub(crate) flip_to_back_count: u32, // flips-to-back count, cycles scroll target
177176
pub(crate) ctrl_locked: bool,
178177
pub(crate) show_shortcuts: bool,
179178
pub(crate) add_menu_zone: Option<crate::state::Zone>,
180179
// Module being dragged by its title bar (id + current pointer position).
181180
pub(crate) module_drag: Option<rack_canvas::ModuleDrag>,
182181
// Auto-save: set when rack or session-worthy state changes; saved next frame.
183182
pub(crate) session_dirty: bool,
184-
/// Zone Y offsets (relative to scroll content top), updated each frame by rack_canvas.
185-
pub(crate) zone_y: [f32; 4], // [ai, global, voice, fxmod]
186-
/// Focused module kind — highlighted in the rack (set by API scroll or click).
187-
pub(crate) focused_module: Option<crate::state::ModuleKind>,
188-
/// Instant when the focused module was set (for shine animation).
189-
pub(crate) focus_time: std::time::Instant,
183+
pub(crate) zone_y: [f32; 4], // [ai, global, voice, fxmod] rack scroll offsets
184+
pub(crate) focused_module: Option<crate::state::ModuleKind>, // rack highlight target
185+
pub(crate) focus_time: std::time::Instant, // shine-animation timestamp
190186
last_saved_rack_sig: (usize, usize),
191187
last_save_time: std::time::Instant,
192188
pub(crate) module_scales: std::collections::HashMap<crate::state::ModuleKind, f32>,
@@ -212,6 +208,7 @@ pub struct AudioChannels {
212208
pub capture_rx: rtrb::Consumer<f32>,
213209
pub dsp_load_rx: rtrb::Consumer<f32>,
214210
pub stereo_rx: rtrb::Consumer<f32>,
211+
pub granular_capture_rx: rtrb::Consumer<f32>,
215212
pub tts_tx: std::sync::Arc<parking_lot::Mutex<rtrb::Producer<f32>>>,
216213
}
217214

@@ -292,6 +289,9 @@ impl ImpulseApp {
292289
dsp_load_buf: Vec::with_capacity(64),
293290
amen_wave_cache: (String::new(), Vec::new()),
294291
neutts_online: false,
292+
granular_capture_rx: audio.granular_capture_rx,
293+
granular_tap: vec![0.0; 44_100 * 3], // 3s at 44.1k
294+
granular_tap_head: 0,
295295
audio_analysis: None,
296296
last_analysis_time: 0.0,
297297
listen_pending: false,

src/ui/panels/granular.rs

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ pub fn draw_granular(app: &mut ImpulseApp, ui: &mut egui::Ui) {
124124
}
125125
});
126126
if ui
127-
.small_button(egui::RichText::new("RND").monospace().size(7.0))
127+
.small_button(egui::RichText::new("RANDOM").monospace().size(7.0))
128128
.on_hover_text("Load a random sample from samples/textures/")
129129
.clicked()
130130
&& let Some(rand_path) = pick_random_texture()
@@ -133,7 +133,7 @@ pub fn draw_granular(app: &mut ImpulseApp, ui: &mut egui::Ui) {
133133
load_and_push(app, &rand_path);
134134
}
135135
if ui
136-
.small_button(egui::RichText::new("LD").monospace().size(7.0))
136+
.small_button(egui::RichText::new("LOAD").monospace().size(7.0))
137137
.on_hover_text("Reload the selected sample from disk")
138138
.clicked()
139139
{
@@ -156,6 +156,106 @@ pub fn draw_granular(app: &mut ImpulseApp, ui: &mut egui::Ui) {
156156
ui.ctx().data_mut(|d| d.insert_temp(mem_id, path.clone()));
157157
}
158158

159+
// ── Live master-output ring buffer + CAPTURE button ─────────────────────
160+
// Drain any samples the audio thread has produced since the last frame
161+
// into our local ring (granular_tap / granular_tap_head). Then render
162+
// a compact min/max strip with a moving "head" cursor so the user can
163+
// see what's currently in the buffer before clicking CAPTURE.
164+
let tap_len = app.granular_tap.len();
165+
while let Ok(s) = app.granular_capture_rx.pop() {
166+
let h = app.granular_tap_head;
167+
app.granular_tap[h] = s;
168+
app.granular_tap_head = (h + 1) % tap_len;
169+
}
170+
let (rect, _) = ui.allocate_exact_size(
171+
egui::vec2(ui.available_width().min(260.0), 28.0),
172+
egui::Sense::hover(),
173+
);
174+
let painter = ui.painter_at(rect);
175+
painter.rect_filled(
176+
rect,
177+
egui::Rounding::same(2.0),
178+
egui::Color32::from_gray(10),
179+
);
180+
painter.rect_stroke(
181+
rect,
182+
egui::Rounding::same(2.0),
183+
egui::Stroke::new(0.5, egui::Color32::from_gray(30)),
184+
);
185+
// Column-wise min/max over the ring buffer, starting at the oldest
186+
// sample (head) so the display scrolls left→right in chronological
187+
// order. 200 columns across ~3 s of audio = ~15 ms per column.
188+
let cols = (rect.width().max(1.0) as usize).min(260);
189+
if cols > 0 && tap_len > 0 {
190+
let samples_per_col = tap_len / cols;
191+
let mid = rect.center().y;
192+
let half_h = rect.height() * 0.45;
193+
for c in 0..cols {
194+
let (mut mn, mut mx) = (0.0_f32, 0.0_f32);
195+
let col_start = c * samples_per_col;
196+
for k in 0..samples_per_col {
197+
// Read ring-relative: (head + col_start + k) % len
198+
let idx = (app.granular_tap_head + col_start + k) % tap_len;
199+
let s = app.granular_tap[idx];
200+
if s < mn {
201+
mn = s;
202+
}
203+
if s > mx {
204+
mx = s;
205+
}
206+
}
207+
let x = rect.min.x + c as f32 + 0.5;
208+
painter.line_segment(
209+
[
210+
egui::pos2(x, mid - mx * half_h),
211+
egui::pos2(x, mid - mn * half_h),
212+
],
213+
egui::Stroke::new(1.0, egui::Color32::from_gray(140)),
214+
);
215+
}
216+
}
217+
// Head cursor — always at the right edge (freshest sample arrives here).
218+
painter.line_segment(
219+
[
220+
egui::pos2(rect.max.x - 0.5, rect.min.y + 1.0),
221+
egui::pos2(rect.max.x - 0.5, rect.max.y - 1.0),
222+
],
223+
egui::Stroke::new(1.0, theme::CHALK),
224+
);
225+
ui.ctx()
226+
.request_repaint_after(std::time::Duration::from_millis(33));
227+
228+
// CAPTURE row
229+
ui.horizontal(|ui| {
230+
ui.label(
231+
egui::RichText::new(format!("LIVE {:.1}s", tap_len as f32 / 44100.0))
232+
.monospace()
233+
.size(7.5)
234+
.color(theme::SMOKE),
235+
);
236+
if ui
237+
.small_button(egui::RichText::new("CAPTURE").monospace().size(7.5))
238+
.on_hover_text(
239+
"Freeze the ring buffer (the last few seconds of master\n\
240+
output) as the granular source. In-memory only — no\n\
241+
file written. Click again to re-capture.",
242+
)
243+
.clicked()
244+
{
245+
// Re-order so slot 0 = oldest sample, then push to the audio thread.
246+
let mut out: Vec<f32> = Vec::with_capacity(tap_len);
247+
let h = app.granular_tap_head;
248+
out.extend_from_slice(&app.granular_tap[h..]);
249+
out.extend_from_slice(&app.granular_tap[..h]);
250+
let arc = std::sync::Arc::new(out);
251+
let _ = app.audio_tx.push(AudioCommand::LoadGranular(arc));
252+
// Set a synthetic path so the panel doesn't auto-reload from disk.
253+
let label = "«captured»".to_string();
254+
app.state.write().granular.path = label.clone();
255+
ui.ctx().data_mut(|d| d.insert_temp(mem_id, label));
256+
}
257+
});
258+
159259
// ── Knobs ────────────────────────────────────────────────────────────────
160260
let ctrl = widgets::ControlPrefs::from_prefs(&app.state.read().ui_prefs);
161261
let locked = app.state.read().llm.locked_params.clone();

0 commit comments

Comments
 (0)