nooise 2.2.0

Ambient music generator for the terminal
//! The Arp voice: cycles the Pad's current chord tones on its own grid.

use super::*;

pub(crate) const ARP_RATE_BEATS_MIN: f32 = 0.125;
pub(crate) const ARP_RATE_BEATS_MAX: f32 = 4.0;
pub(crate) const ARP_OCTAVES_MIN: f32 = 1.0;
pub(crate) const ARP_OCTAVES_MAX: f32 = 3.0;
pub(crate) const ARP_CHORD_TONES: usize = 4;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ArpPattern {
    Up,
    Down,
    UpDown,
    Random,
}

/// Patterns in stored-value order; `arp.pattern` wraps into this table.
const ARP_PATTERNS: [ArpPattern; 4] = [
    ArpPattern::Up,
    ArpPattern::Down,
    ArpPattern::UpDown,
    ArpPattern::Random,
];

pub(crate) fn arp_pattern_from_control(value: f32) -> ArpPattern {
    ARP_PATTERNS[wrapped_index(value, ARP_PATTERNS.len())]
}

pub(crate) fn arp_pattern_label(value: f32) -> &'static str {
    match arp_pattern_from_control(value) {
        ArpPattern::Up => "Up",
        ArpPattern::Down => "Down",
        ArpPattern::UpDown => "Up-Down",
        ArpPattern::Random => "Random",
    }
}

pub(crate) fn arp_octave_span(value: f32) -> usize {
    (value.round() as i32).clamp(ARP_OCTAVES_MIN as i32, ARP_OCTAVES_MAX as i32) as usize
}

/// Build the cycled tone list for a chord: the 4 chord tones duplicated up
/// whole octaves (+12, +24 semitones) for each extra octave of span, sorted
/// ascending. Span 1 keeps just the 4 chord tones; span 3 yields 12 tones.
pub(crate) fn arp_cycle_notes(chord: [i32; ARP_CHORD_TONES], octaves: usize) -> Vec<i32> {
    let mut notes = Vec::with_capacity(ARP_CHORD_TONES * octaves.max(1));
    for octave in 0..octaves.max(1) {
        for tone in chord {
            notes.push(tone + 12 * octave as i32);
        }
    }
    notes.sort_unstable();
    notes
}

/// Advance the ping-pong (up-down) cursor by one step without repeating
/// either endpoint: bounces at 0 and `len - 1`, reversing direction there.
pub(crate) fn arp_ping_pong_advance(pos: usize, dir: i32, len: usize) -> (usize, i32) {
    if len <= 1 {
        return (0, dir);
    }
    let proposed = pos as i32 + dir;
    if proposed < 0 {
        (1, 1)
    } else if proposed >= len as i32 {
        (len - 2, -1)
    } else {
        (proposed as usize, dir)
    }
}

/// Compute the next cycle position (and, for Up-Down, the next travel
/// direction) after emitting the tone at `pos`. `len` is the current cycle
/// list length; `rng` is only consumed by the Random pattern. Shared by
/// `ArpEngine::next` and its tests so pattern-sequencing logic is exercised
/// exactly as production runs it.
pub(crate) fn arp_advance(
    pos: usize,
    pattern: ArpPattern,
    len: usize,
    dir: i32,
    rng: &mut StdRng,
) -> (usize, i32) {
    match pattern {
        ArpPattern::Up => ((pos + 1) % len, dir),
        ArpPattern::Down => ((pos + len - 1) % len, dir),
        ArpPattern::UpDown => arp_ping_pong_advance(pos, dir, len),
        ArpPattern::Random => (rng.gen_range(0..len), dir),
    }
}

pub(crate) struct ArpEngine {
    pub(crate) sample_rate: f32,
    pub(crate) progression: ProgressionFollower,
    pub(crate) note_trigger: GridTrigger,
    pub(crate) cycle_pos: usize,
    pub(crate) ping_pong_dir: i32,
    pub(crate) voices: Vec<TonalVoice>,
    pub(crate) rng: StdRng,
}

impl ArpEngine {
    pub(crate) fn new(sample_rate: f32) -> Self {
        Self {
            sample_rate,
            progression: ProgressionFollower::new(),
            note_trigger: GridTrigger::new(),
            cycle_pos: 0,
            ping_pong_dir: 1,
            voices: Vec::with_capacity(8),
            rng: StdRng::from_entropy(),
        }
    }

    pub(crate) fn next(
        &mut self,
        c: &ArpControls,
        pad: &PadControls,
        tune: f32,
        timing: TimingContext,
    ) -> (f32, f32) {
        // `pad_chord_tones` is the same chord-source path Pad and Bass
        // resolve through, so a custom progression drives all three
        // identically.
        let (progression, step) = self.progression.follow(pad, timing);

        let rate_beats = c.rate_beats.clamp(ARP_RATE_BEATS_MIN, ARP_RATE_BEATS_MAX);
        if self
            .note_trigger
            .pop_swung(timing, rate_beats, c.offset_beats, c.swing)
        {
            let chord = pad_chord_tones(pad, progression, step);
            let octaves = arp_octave_span(c.octaves);
            let notes = arp_cycle_notes(chord, octaves);
            let len = notes.len().max(1);
            // Chord/octave changes never reset the cycle position — just
            // clamp it into the (possibly resized) list so there's no click.
            self.cycle_pos = self.cycle_pos.min(len - 1);

            let pattern = arp_pattern_from_control(c.pattern);
            let note = notes[self.cycle_pos];

            let (next_pos, next_dir) = arp_advance(
                self.cycle_pos,
                pattern,
                len,
                self.ping_pong_dir,
                &mut self.rng,
            );
            self.cycle_pos = next_pos;
            self.ping_pong_dir = next_dir;

            let hz = note_hz(note, tune);
            // A note sounds for `attack + decay`, decoupled from the step grid,
            // so a longer decay lets consecutive notes overlap and ring out
            // instead of being cut at the step. `rate_beats` only sets the
            // trigger spacing below.
            let pan = self.rng.gen_range(-0.4f32..0.4);
            // A silent layer still triggers nothing: skipping keeps a Vol of
            // exactly 0 (the default) from accumulating inaudible voices.
            // Every RNG draw above still happens, keeping seeded renders
            // byte-identical.
            if c.gain != 0.0 {
                self.voices.push(TonalVoice::new(
                    wrapped_index(c.voice_type, TONAL_SYNTH_TYPES.len()),
                    TonalNote {
                        midi: note,
                        hz,
                        pan,
                        sample_rate: self.sample_rate,
                        attack_time: c.attack,
                        decay_time: c.decay,
                    },
                ));
            }
        }

        // Applied to the summed voices, not captured per note, so the fader
        // reaches notes that are already sounding. Pre-smoothed by
        // `GainSmoothers`.
        let (dry_l, dry_r) =
            mix_and_retain(&mut self.voices, TonalVoice::next, TonalVoice::is_done);
        (dry_l * c.gain, dry_r * c.gain)
    }
}