Lightningbeam/daw-backend/src/audio/recording.rs

658 lines
26 KiB
Rust

/// Audio recording system for capturing microphone input
use crate::audio::{ClipId, MidiClipId, TrackId};
use crate::io::{WavWriter, WaveformPeak};
use crate::time::{Beats, Seconds};
use std::collections::HashMap;
use std::path::PathBuf;
/// Cycle-recording bookkeeping attached to a recording that started with a cycle region armed.
///
/// Takes are sliced **geometrically** at stop, in exact `loop_len_frames` multiples — not at the
/// instant the wrap was detected. The playhead advances before the capture block in `process()`, so
/// the wrap instant isn't sample-exact against the buffer that was just captured, but the geometry
/// is. `wrap_count` therefore only decides *whether* this is a multi-take recording, never where the
/// cuts land.
#[derive(Debug, Clone, Copy)]
pub struct CycleRecordInfo {
/// Where the cycle region starts, in beats. Takes are laid down here, not at the punch-in point.
pub loop_start: Beats,
/// The cycle region's length in beats — what the take folder records as `recorded_loop_beats`.
pub loop_len_beats: Beats,
/// One cycle pass, in frames. The take size.
pub loop_len_frames: usize,
/// Frames between the region start and where capture actually began. Non-zero only for a
/// punch-in (record while already rolling); take 1 gets this much silence prepended so it still
/// spans the whole region.
pub lead_pad_frames: usize,
/// How many times the transport wrapped during this recording. Zero means the user stopped
/// before completing a pass, which stays an ordinary single recording.
pub wrap_count: usize,
}
/// Min/max waveform peaks for a finished buffer of interleaved samples.
///
/// The live recording path builds its peaks incrementally as samples arrive; cycle takes don't
/// exist until the recording is sliced at stop, so they get theirs in one pass here.
pub fn compute_peaks(samples: &[f32], channels: u32, frames_per_peak: usize) -> Vec<WaveformPeak> {
let samples_per_peak = (frames_per_peak * channels.max(1) as usize).max(1);
samples
.chunks(samples_per_peak)
.map(|chunk| {
let mut min = 0.0f32;
let mut max = 0.0f32;
for s in chunk {
min = min.min(*s);
max = max.max(*s);
}
WaveformPeak { min, max }
})
.collect()
}
/// State of an active recording session
pub struct RecordingState {
/// Track being recorded to
pub track_id: TrackId,
/// Clip ID for the intermediate clip
pub clip_id: ClipId,
/// Path to temporary WAV file
pub temp_file_path: PathBuf,
/// WAV file writer (only used at finalization, not during recording)
pub writer: WavWriter,
/// Sample rate of recording
pub sample_rate: u32,
/// Number of channels
pub channels: u32,
/// Timeline start position
pub start_time: Beats,
/// Total frames recorded
pub frames_written: usize,
/// Whether recording is currently paused
pub paused: bool,
/// Number of samples remaining to skip (to discard stale buffer data)
pub samples_to_skip: usize,
/// Waveform peaks generated incrementally during recording
pub waveform: Vec<WaveformPeak>,
/// Temporary buffer for collecting samples for next waveform peak
pub waveform_buffer: Vec<f32>,
/// Number of frames per waveform peak
pub frames_per_peak: usize,
/// All recorded audio data accumulated in memory (written to disk at finalization)
pub audio_data: Vec<f32>,
/// Cycle-recording bookkeeping, when a cycle region was armed at record start.
pub cycle: Option<CycleRecordInfo>,
}
impl RecordingState {
/// Create a new recording state
pub fn new(
track_id: TrackId,
clip_id: ClipId,
temp_file_path: PathBuf,
writer: WavWriter,
sample_rate: u32,
channels: u32,
start_time: Beats,
_flush_interval_seconds: f64, // No longer used - kept for API compatibility
) -> Self {
// Calculate frames per waveform peak
// Target ~300 peaks per second with minimum 1000 samples per peak
let target_peaks_per_second = 300;
let frames_per_peak = (sample_rate / target_peaks_per_second).max(1000) as usize;
Self {
track_id,
clip_id,
temp_file_path,
writer,
sample_rate,
channels,
start_time,
frames_written: 0,
paused: false,
samples_to_skip: 0, // Will be set by engine when it knows buffer size
waveform: Vec::new(),
waveform_buffer: Vec::new(),
frames_per_peak,
audio_data: Vec::new(),
cycle: None,
}
}
/// Slice the recording into cycle takes: one per pass, each spanning the FULL cycle region.
///
/// Partial passes are padded with silence — the head of take 1 for a punch-in, the tail of the
/// last take when the user stops mid-pass — so every take is the same length and aligned to the
/// region. That uniformity is what makes comping-via-split work: take 1 on the left half and
/// take 3 on the right always line up.
///
/// Returns `None` if this wasn't a cycle recording or the transport never wrapped (an ordinary
/// single recording, which keeps the existing path untouched).
pub fn slice_takes(&self) -> Option<Vec<Vec<f32>>> {
let cycle = self.cycle?;
if cycle.wrap_count == 0 || cycle.loop_len_frames == 0 {
return None;
}
let ch = self.channels.max(1) as usize;
let take_len = cycle.loop_len_frames * ch;
let lead = cycle.lead_pad_frames * ch;
// The recording as positioned *within the region*: silence for the gap between the region
// start and the punch-in, then the captured audio. Slicing this at whole-take boundaries is
// the whole trick — take 1 comes out short-by-`lead` at the front, already padded.
let virtual_len = lead + self.audio_data.len();
let take_count = virtual_len.div_ceil(take_len);
let mut takes: Vec<Vec<f32>> = Vec::with_capacity(take_count);
for i in 0..take_count {
let mut take = vec![0.0f32; take_len];
let take_begin = i * take_len;
for slot in 0..take_len {
// Position in the virtual (lead-padded) buffer.
let v = take_begin + slot;
if v < lead {
continue; // still in the punch-in silence
}
match self.audio_data.get(v - lead) {
Some(s) => take[slot] = *s,
None => break, // past the end of capture; the rest stays silent
}
}
takes.push(take);
}
// A final take holding only a sliver of real audio is a stop artifact (the user hit stop a
// moment after the wrap), not a performance. Drop it — but only if it's actually a PARTIAL
// pass, and never the only take. A pass that filled the region is a real take no matter how
// short the region is.
const MIN_TAKE_SECONDS: f64 = 0.05;
if takes.len() > 1 {
let last_real_samples = virtual_len - (takes.len() - 1) * take_len;
let last_real_seconds = (last_real_samples / ch) as f64 / self.sample_rate as f64;
if last_real_samples < take_len && last_real_seconds < MIN_TAKE_SECONDS {
takes.pop();
}
}
Some(takes)
}
/// Add samples to the accumulation buffer
/// Returns true if a flush occurred
pub fn add_samples(&mut self, samples: &[f32]) -> Result<bool, std::io::Error> {
if self.paused {
return Ok(false);
}
// Determine which samples to process
let samples_to_process = if self.samples_to_skip > 0 {
let to_skip = self.samples_to_skip.min(samples.len());
self.samples_to_skip -= to_skip;
if to_skip == samples.len() {
// Skip entire batch
return Ok(false);
}
// Skip partial batch and process the rest
&samples[to_skip..]
} else {
samples
};
// Add to audio data (accumulate in memory - disk write happens at finalization only)
self.audio_data.extend_from_slice(samples_to_process);
// Add to waveform buffer and generate peaks incrementally
self.waveform_buffer.extend_from_slice(samples_to_process);
self.generate_waveform_peaks();
// Track frames for duration calculation (no disk I/O in audio callback!)
let frames_added = samples_to_process.len() / self.channels as usize;
self.frames_written += frames_added;
Ok(false)
}
/// Generate waveform peaks from accumulated samples
/// This is called incrementally as samples arrive
fn generate_waveform_peaks(&mut self) {
let samples_per_peak = self.frames_per_peak * self.channels as usize;
while self.waveform_buffer.len() >= samples_per_peak {
let mut min = 0.0f32;
let mut max = 0.0f32;
// Scan all samples for this peak
for sample in &self.waveform_buffer[..samples_per_peak] {
min = min.min(*sample);
max = max.max(*sample);
}
self.waveform.push(WaveformPeak { min, max });
// Remove processed samples from waveform buffer
self.waveform_buffer.drain(..samples_per_peak);
}
}
/// Get current recording duration
pub fn duration(&self) -> Seconds {
Seconds(self.frames_written as f64 / self.sample_rate as f64)
}
/// Finalize the recording and return the temp file path, waveform, and audio data
pub fn finalize(mut self) -> Result<(PathBuf, Vec<WaveformPeak>, Vec<f32>), std::io::Error> {
// Write all audio data to disk at once (outside audio callback - safe to do I/O)
if !self.audio_data.is_empty() {
self.writer.write_samples(&self.audio_data)?;
}
// Generate final waveform peak from any remaining samples
if !self.waveform_buffer.is_empty() {
let mut min = 0.0f32;
let mut max = 0.0f32;
for sample in &self.waveform_buffer {
min = min.min(*sample);
max = max.max(*sample);
}
self.waveform.push(WaveformPeak { min, max });
}
// Finalize the WAV file
self.writer.finalize()?;
Ok((self.temp_file_path, self.waveform, self.audio_data))
}
/// Pause recording
pub fn pause(&mut self) {
self.paused = true;
}
/// Resume recording
pub fn resume(&mut self) {
self.paused = false;
}
}
/// Active MIDI note waiting for its noteOff event
#[derive(Debug, Clone)]
struct ActiveMidiNote {
note: u8,
velocity: u8,
start_time: Beats,
}
/// State of an active MIDI recording session.
pub struct MidiRecordingState {
pub track_id: TrackId,
pub clip_id: MidiClipId,
pub start_time: Beats,
active_notes: HashMap<u8, ActiveMidiNote>,
/// Completed notes: (time_offset, note, velocity, duration) — all times in beats
pub completed_notes: Vec<(Beats, u8, u8, Beats)>,
/// The cycle region's length in beats, when recording into a cycle.
///
/// A cycle MIDI recording is anchored at the region start (`start_time == loop_start`), which is
/// what makes MERGE fall out for free: the transport always wraps back into the region, so every
/// note's offset already lands inside `[0, loop_len)` and successive passes overdub onto each
/// other with no folding needed. Set only if the transport actually wrapped.
pub cycle_loop_len: Option<Beats>,
/// Which cycle pass is currently being recorded (0-based). Bumped at each wrap.
current_pass: usize,
/// The pass each completed note belongs to, parallel to `completed_notes`.
///
/// Only meaningful in "separate takes" mode, where each pass becomes its own MIDI clip. Merge
/// mode ignores it — all passes fold into one clip, which is the whole point.
note_pass: Vec<usize>,
}
impl MidiRecordingState {
pub fn new(track_id: TrackId, clip_id: MidiClipId, start_time: Beats) -> Self {
Self {
track_id,
clip_id,
start_time,
active_notes: HashMap::new(),
completed_notes: Vec::new(),
cycle_loop_len: None,
current_pass: 0,
note_pass: Vec::new(),
}
}
/// Record a finished note, tagging it with the pass it was played in.
///
/// Every completion goes through here so `completed_notes` and `note_pass` can't drift apart.
fn push_completed(&mut self, note: &ActiveMidiNote, end_time: Beats) {
let note_start = note.start_time.max(self.start_time);
self.completed_notes.push((
note_start - self.start_time,
note.note,
note.velocity,
end_time - note_start,
));
self.note_pass.push(self.current_pass);
}
pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: Beats) {
self.active_notes.insert(note, ActiveMidiNote { note, velocity, start_time: absolute_time });
}
pub fn note_off(&mut self, note: u8, absolute_time: Beats) {
if let Some(active_note) = self.active_notes.remove(&note) {
if absolute_time <= self.start_time {
return;
}
self.push_completed(&active_note, absolute_time);
}
}
/// Completed notes grouped by cycle pass — one bucket per pass, in recording order.
///
/// Used by "separate takes" mode, where each pass becomes its own MIDI clip.
///
/// An *interior* pass in which nothing was played still yields an empty take, so take N in the
/// folder is always pass N on the transport — otherwise the numbering would silently shift and
/// "take 3" would stop meaning "the third time round". A *trailing* empty pass is dropped
/// though: that's what you get by hitting stop shortly after a wrap, and it's a stop artifact
/// rather than a take you played. (Same reasoning as the audio path's short-final-take rule.)
pub fn notes_by_pass(&self, passes: usize) -> Vec<Vec<(Beats, u8, u8, Beats)>> {
let mut buckets = vec![Vec::new(); passes.max(1)];
for (note, &pass) in self.completed_notes.iter().zip(self.note_pass.iter()) {
if let Some(bucket) = buckets.get_mut(pass) {
bucket.push(*note);
}
}
// Never drop the only take.
while buckets.len() > 1 && buckets.last().is_some_and(|b| b.is_empty()) {
buckets.pop();
}
buckets
}
/// How many cycle passes this recording covered (1 if the transport never wrapped).
pub fn pass_count(&self) -> usize {
self.current_pass + 1
}
pub fn get_notes(&self) -> &[(Beats, u8, u8, Beats)] {
&self.completed_notes
}
pub fn note_count(&self) -> usize {
self.completed_notes.len()
}
/// The still-held notes, given a provisional duration running to `current_time`.
///
/// These belong to whatever pass is in progress, so a per-pass view can append them as-is.
pub fn active_notes_with_provisional_end(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> {
self.active_notes
.values()
.map(|active| {
let note_start = active.start_time.max(self.start_time);
(
note_start - self.start_time,
active.note,
active.velocity,
(current_time - note_start).max(Beats::ZERO),
)
})
.collect()
}
/// Get all completed notes plus currently-held notes with a provisional duration.
pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> {
let mut notes = self.completed_notes.clone();
notes.extend(self.active_notes_with_provisional_end(current_time));
notes
}
pub fn active_note_numbers(&self) -> Vec<u8> {
self.active_notes.keys().copied().collect()
}
pub fn close_active_notes(&mut self, end_time: Beats) {
let active_notes: Vec<_> = self.active_notes.drain().collect();
for (_note_num, active_note) in active_notes {
self.push_completed(&active_note, end_time);
}
}
/// Handle a transport cycle wrap during MIDI recording.
///
/// Note times are stored as offsets from `start_time`, and the playhead jumps *backwards* at a
/// wrap — so a note still held across the boundary would otherwise get a nonsensical (negative)
/// duration, or never be closed at all. Write its note-off at `region_end` (exactly as
/// `close_active_notes` does when recording stops), then re-open it at `region_start` so a key
/// the player is still physically holding keeps being captured in the next pass. Mirrors the
/// way `handle_start_midi_recording` re-injects already-held notes at the recording start.
pub fn wrap_at_cycle(&mut self, region_end: Beats, region_start: Beats) {
// Snapshot the held notes (close_active_notes drains them and loses the velocities).
let held: Vec<(u8, u8)> = self
.active_notes
.values()
.map(|n| (n.note, n.velocity))
.collect();
// Close first, so a note held across the boundary has its tail attributed to the pass that's
// ending; then advance, so the re-opened half belongs to the pass that's beginning.
self.close_active_notes(region_end);
self.current_pass += 1;
for (note, velocity) in held {
self.note_on(note, velocity, region_start);
}
// The transport wrapped, so this is a cycle recording: the clip spans the whole region
// rather than however long the user happened to hold the record button.
self.cycle_loop_len = Some(region_end - region_start);
}
}
#[cfg(test)]
mod cycle_tests {
use super::*;
/// A recording state holding `audio_data`, armed for cycle recording. Mono, 100 Hz, so a frame
/// is a sample and 5 frames is 50 ms (exactly the min-take threshold).
fn rec(audio: Vec<f32>, loop_len_frames: usize, lead_pad_frames: usize, wraps: usize) -> RecordingState {
let mut r = RecordingState::new(
0,
0,
PathBuf::from("/dev/null"),
WavWriter::create(&PathBuf::from("/dev/null"), 100, 1).expect("wav writer"),
100,
1,
Beats(0.0),
1.0,
);
r.audio_data = audio;
r.cycle = Some(CycleRecordInfo {
loop_start: Beats(0.0),
loop_len_beats: Beats(4.0),
loop_len_frames,
lead_pad_frames,
wrap_count: wraps,
});
r
}
#[test]
fn no_wrap_is_not_a_cycle_recording() {
// Stopping before the transport ever wraps stays an ordinary single recording — the whole
// point of triggering on the wrap rather than on the cycle region merely existing.
let r = rec(vec![1.0; 10], 4, 0, 0);
assert!(r.slice_takes().is_none());
}
#[test]
fn takes_are_cut_at_exact_loop_multiples() {
// 12 frames of audio, 4-frame loop, started at the region start => 3 clean takes.
let audio: Vec<f32> = (1..=12).map(|i| i as f32).collect();
let takes = rec(audio, 4, 0, 2).slice_takes().expect("cycle takes");
assert_eq!(takes.len(), 3);
assert_eq!(takes[0], vec![1.0, 2.0, 3.0, 4.0]);
assert_eq!(takes[1], vec![5.0, 6.0, 7.0, 8.0]);
assert_eq!(takes[2], vec![9.0, 10.0, 11.0, 12.0]);
}
#[test]
fn punch_in_pads_the_head_of_take_one() {
// Punched in 2 frames into the region: take 1 gets 2 frames of silence at the FRONT so it
// still spans the whole region and lines up with every other take.
let audio: Vec<f32> = (1..=10).map(|i| i as f32).collect();
let takes = rec(audio, 4, 2, 2).slice_takes().expect("cycle takes");
assert_eq!(takes.len(), 3);
assert_eq!(takes[0], vec![0.0, 0.0, 1.0, 2.0]);
assert_eq!(takes[1], vec![3.0, 4.0, 5.0, 6.0]);
assert_eq!(takes[2], vec![7.0, 8.0, 9.0, 10.0]);
}
#[test]
fn stopping_mid_pass_pads_the_tail_of_the_last_take() {
// 13 frames, 8-frame loop => the second take holds 5 real frames (50 ms at 100 Hz, right at
// the keep threshold) and 3 of silence.
let audio: Vec<f32> = (1..=13).map(|i| i as f32).collect();
let takes = rec(audio, 8, 0, 1).slice_takes().expect("cycle takes");
assert_eq!(takes.len(), 2);
assert_eq!(takes[1], vec![9.0, 10.0, 11.0, 12.0, 13.0, 0.0, 0.0, 0.0]);
}
#[test]
fn every_take_is_the_same_length() {
// Uniform length is the invariant comping-via-split depends on.
let audio: Vec<f32> = (1..=23).map(|i| i as f32).collect();
let takes = rec(audio, 8, 3, 3).slice_takes().expect("cycle takes");
assert!(takes.iter().all(|t| t.len() == 8), "takes must be uniform");
}
#[test]
fn a_sliver_of_a_final_take_is_dropped() {
// Stopped 1 frame (10 ms at 100 Hz) after the wrap — below the 50 ms floor, so that stub of
// a take is a stop artifact and goes.
let audio: Vec<f32> = (1..=9).map(|i| i as f32).collect();
let takes = rec(audio, 8, 0, 1).slice_takes().expect("cycle takes");
assert_eq!(takes.len(), 1, "a 10ms tail take should be dropped");
assert_eq!(takes[0].len(), 8);
}
#[test]
fn a_full_final_take_is_never_dropped() {
// Regression: the sliver rule must only fire on a PARTIAL pass. A pass that filled the
// region is a real take however short the region is — an earlier version compared a full
// take's duration to the floor and silently ate it.
let audio: Vec<f32> = (1..=8).map(|i| i as f32).collect();
let takes = rec(audio, 4, 0, 1).slice_takes().expect("cycle takes");
assert_eq!(takes.len(), 2, "both passes filled the region");
assert_eq!(takes[1], vec![5.0, 6.0, 7.0, 8.0]);
}
}
#[cfg(test)]
mod midi_cycle_tests {
use super::*;
/// A MIDI recording anchored at the region start (beat 0), region 4 beats long.
fn rec() -> MidiRecordingState {
MidiRecordingState::new(0, 0, Beats(0.0))
}
/// One pass of the transport around a 4-beat region.
fn wrap(r: &mut MidiRecordingState) {
r.wrap_at_cycle(Beats(4.0), Beats(0.0));
}
#[test]
fn notes_are_bucketed_by_the_pass_they_were_played_in() {
let mut r = rec();
r.note_on(60, 100, Beats(1.0));
r.note_off(60, Beats(2.0)); // pass 0
wrap(&mut r);
r.note_on(62, 100, Beats(1.0));
r.note_off(62, Beats(2.0)); // pass 1
wrap(&mut r);
r.note_on(64, 100, Beats(1.0));
r.note_off(64, Beats(2.0)); // pass 2
assert_eq!(r.pass_count(), 3);
let by_pass = r.notes_by_pass(r.pass_count());
let pitches: Vec<Vec<u8>> = by_pass
.iter()
.map(|p| p.iter().map(|n| n.1).collect())
.collect();
assert_eq!(pitches, vec![vec![60], vec![62], vec![64]]);
}
#[test]
fn a_note_held_across_a_wrap_splits_between_the_two_passes() {
// The key is still down at the boundary: the sounding half belongs to the pass that's
// ending, and the re-opened half to the pass that's beginning. Getting the pass bump on the
// wrong side of close_active_notes would file the whole note under one pass.
let mut r = rec();
r.note_on(60, 100, Beats(3.0));
wrap(&mut r); // still held
r.note_off(60, Beats(1.0)); // released 1 beat into the next pass
assert_eq!(r.pass_count(), 2);
let by_pass = r.notes_by_pass(r.pass_count());
assert_eq!(by_pass[0].len(), 1, "the held half lands in the pass that ended");
assert_eq!(by_pass[1].len(), 1, "the re-opened half lands in the next pass");
// Pass 0's half runs from beat 3 to the region end at 4.
assert_eq!(by_pass[0][0].0, Beats(3.0));
assert_eq!(by_pass[0][0].3, Beats(1.0));
// Pass 1's half starts at the region start and runs to the release.
assert_eq!(by_pass[1][0].0, Beats(0.0));
assert_eq!(by_pass[1][0].3, Beats(1.0));
}
#[test]
fn a_silent_interior_pass_still_yields_an_empty_take() {
// Take N in the folder must be pass N on the transport, even if nothing was played — else
// the take numbering silently shifts under the user.
let mut r = rec();
r.note_on(60, 100, Beats(1.0));
r.note_off(60, Beats(2.0)); // pass 0
wrap(&mut r);
wrap(&mut r); // pass 1: played nothing
r.note_on(64, 100, Beats(1.0));
r.note_off(64, Beats(2.0)); // pass 2
let by_pass = r.notes_by_pass(r.pass_count());
assert_eq!(by_pass.len(), 3);
assert_eq!(by_pass[1].len(), 0, "the silent pass is still take 2");
assert_eq!(by_pass[2][0].1, 64);
}
#[test]
fn a_trailing_empty_pass_is_dropped() {
// Hitting stop shortly after a wrap leaves a pass you never played into. That's a stop
// artifact, not a take — unlike a silent pass in the middle, which was a deliberate rest.
let mut r = rec();
r.note_on(60, 100, Beats(1.0));
r.note_off(60, Beats(2.0)); // pass 0
wrap(&mut r);
r.note_on(62, 100, Beats(1.0));
r.note_off(62, Beats(2.0)); // pass 1
wrap(&mut r); // pass 2 begins... and the user hits stop
assert_eq!(r.pass_count(), 3);
let by_pass = r.notes_by_pass(r.pass_count());
assert_eq!(by_pass.len(), 2, "the empty trailing pass is not a take");
}
#[test]
fn an_empty_recording_still_yields_one_take() {
let mut r = rec();
wrap(&mut r);
wrap(&mut r);
assert_eq!(r.notes_by_pass(r.pass_count()).len(), 1);
}
}