Skip to main content

sim_lib_midi_core/
player.rs

1use std::convert::TryFrom;
2
3use crate::{Channel, ChannelMessage, MidiEvent, MidiPayload, TickTime, U7, synthetic_origin};
4
5/// Controls whether echoed notes are snapped to a scale.
6#[derive(Clone, Debug, PartialEq, Eq)]
7pub enum NoteEchoScaleSnap {
8    /// No snapping; the drifted key is used as-is.
9    Off,
10    /// Snap to the nearest key whose pitch class is in this set (each value is
11    /// reduced modulo 12).
12    PitchClasses(Vec<u8>),
13}
14
15impl NoteEchoScaleSnap {
16    /// Builds a major-scale snap for the given `tonic` pitch class.
17    pub fn major(tonic: u8) -> Self {
18        Self::PitchClasses(
19            [0, 2, 4, 5, 7, 9, 11]
20                .into_iter()
21                .map(|class| (class + tonic) % 12)
22                .collect(),
23        )
24    }
25
26    fn snap(&self, key: i16) -> Option<u8> {
27        if !(0..=127).contains(&key) {
28            return None;
29        }
30        match self {
31            Self::Off => Some(key as u8),
32            Self::PitchClasses(classes) => nearest_key_in_classes(key, classes),
33        }
34    }
35}
36
37/// Controls which channel each echoed note is emitted on.
38#[derive(Copy, Clone, Debug, PartialEq, Eq)]
39pub enum NoteEchoChannelPolicy {
40    /// Keep the source note's channel.
41    Preserve,
42    /// Force every echo onto a fixed channel.
43    Fixed(Channel),
44    /// Shift the channel by `offset` per repeat, wrapping within `0..=15`.
45    Offset(i8),
46}
47
48impl NoteEchoChannelPolicy {
49    fn apply(self, channel: Channel, repeat: u8) -> Channel {
50        match self {
51            Self::Preserve => channel,
52            Self::Fixed(channel) => channel,
53            Self::Offset(offset) => Channel(
54                (i16::from(channel.0) + i16::from(offset) * i16::from(repeat)).rem_euclid(16) as u8,
55            ),
56        }
57    }
58}
59
60/// Configuration for the [`NoteEchoPlayer`] echo/delay transform.
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct NoteEchoConfig {
63    /// Number of echo repeats to emit per source note.
64    pub repeats: u8,
65    /// Cap on the number of repeats actually rendered (`0` means no cap).
66    pub feedback_count: u8,
67    /// Time delay added per repeat.
68    pub time_offset: TickTime,
69    /// Note length used when no matching note-off is found in the input.
70    pub fallback_gate: TickTime,
71    /// Velocity reduction applied per repeat.
72    pub velocity_decay: u8,
73    /// Semitone pitch shift applied per repeat before snapping.
74    pub pitch_offset: i8,
75    /// Scale-snapping policy for shifted pitches.
76    pub scale_snap: NoteEchoScaleSnap,
77    /// Channel-assignment policy for echoes.
78    pub channel_policy: NoteEchoChannelPolicy,
79    /// Whether the original input events are included in the render.
80    pub include_source: bool,
81}
82
83impl NoteEchoConfig {
84    /// Creates a default single-repeat config delayed by `time_offset`.
85    pub fn new(time_offset: TickTime) -> Self {
86        Self {
87            repeats: 1,
88            feedback_count: 1,
89            time_offset,
90            fallback_gate: time_offset,
91            velocity_decay: 0,
92            pitch_offset: 0,
93            scale_snap: NoteEchoScaleSnap::Off,
94            channel_policy: NoteEchoChannelPolicy::Preserve,
95            include_source: true,
96        }
97    }
98
99    /// Returns the effective number of repeats after applying the
100    /// [`feedback_count`](Self::feedback_count) cap.
101    pub fn repeat_count(&self) -> u8 {
102        if self.feedback_count == 0 {
103            self.repeats
104        } else {
105            self.repeats.min(self.feedback_count)
106        }
107    }
108}
109
110/// A record of a single emitted echo, linking it back to its source note.
111#[derive(Clone, Debug, PartialEq, Eq)]
112pub struct NoteEchoTrace {
113    /// Index of the source note in the input slice.
114    pub source_index: usize,
115    /// Which repeat (1-based) produced this echo.
116    pub repeat: u8,
117    /// Time of the echoed note-on.
118    pub time: TickTime,
119    /// Echoed note number.
120    pub key: U7,
121    /// Echoed velocity.
122    pub velocity: U7,
123    /// Echoed channel.
124    pub channel: Channel,
125}
126
127/// The result of a [`NoteEchoPlayer`] render: emitted events plus per-echo
128/// traces.
129#[derive(Clone, Debug, Default, PartialEq, Eq)]
130pub struct NoteEchoRender {
131    /// Rendered events in stable time order.
132    pub events: Vec<MidiEvent>,
133    /// One trace per emitted echo.
134    pub traces: Vec<NoteEchoTrace>,
135}
136
137/// Renders rhythmic note echoes from an input event stream per a
138/// [`NoteEchoConfig`].
139#[derive(Clone, Debug, PartialEq, Eq)]
140pub struct NoteEchoPlayer {
141    /// The echo configuration driving this player.
142    pub config: NoteEchoConfig,
143}
144
145impl NoteEchoPlayer {
146    /// Creates a player from `config`.
147    pub fn new(config: NoteEchoConfig) -> Self {
148        Self { config }
149    }
150
151    /// Renders echoes for `input`, returning the emitted events and traces.
152    pub fn render(&self, input: &[MidiEvent]) -> NoteEchoRender {
153        let mut render = NoteEchoRender::default();
154        if self.config.include_source {
155            render.events.extend(input.iter().cloned());
156        }
157
158        for (source_index, event) in input.iter().enumerate() {
159            let Some((channel, key, velocity)) = note_on(event) else {
160                continue;
161            };
162            let duration =
163                note_duration(input, source_index, channel, key, self.config.fallback_gate);
164            for repeat in 1..=self.config.repeat_count() {
165                let Some((echo_key, echo_velocity, echo_channel)) =
166                    self.echo_values(key, velocity, channel, repeat)
167                else {
168                    continue;
169                };
170                let time = event.time + self.config.time_offset.mul_int(i64::from(repeat));
171                let off_time = time + duration;
172                render
173                    .events
174                    .push(note_on_event(time, echo_channel, echo_key, echo_velocity));
175                render
176                    .events
177                    .push(note_off_event(off_time, echo_channel, echo_key));
178                render.traces.push(NoteEchoTrace {
179                    source_index,
180                    repeat,
181                    time,
182                    key: echo_key,
183                    velocity: echo_velocity,
184                    channel: echo_channel,
185                });
186            }
187        }
188        stable_midi_event_order(&mut render.events);
189        render.traces.sort_by(|left, right| {
190            left.time
191                .cmp(&right.time)
192                .then_with(|| left.source_index.cmp(&right.source_index))
193                .then_with(|| left.repeat.cmp(&right.repeat))
194        });
195        render
196    }
197
198    /// Renders `input` and returns the result; an alias for
199    /// [`render`](Self::render) reading as a commit-to-output step.
200    pub fn freeze(&self, input: &[MidiEvent]) -> NoteEchoRender {
201        self.render(input)
202    }
203
204    fn echo_values(
205        &self,
206        key: U7,
207        velocity: U7,
208        channel: Channel,
209        repeat: u8,
210    ) -> Option<(U7, U7, Channel)> {
211        let drifted_key =
212            i16::from(key.0) + i16::from(self.config.pitch_offset) * i16::from(repeat);
213        let snapped_key = self.config.scale_snap.snap(drifted_key)?;
214        let decayed = velocity
215            .0
216            .saturating_sub(self.config.velocity_decay.saturating_mul(repeat))
217            .max(1);
218        Some((
219            U7::try_from(u16::from(snapped_key)).ok()?,
220            U7(decayed),
221            self.config.channel_policy.apply(channel, repeat),
222        ))
223    }
224}
225
226fn note_on(event: &MidiEvent) -> Option<(Channel, U7, U7)> {
227    match event.payload {
228        MidiPayload::Channel(ChannelMessage::NoteOn { ch, key, vel }) if vel.0 > 0 => {
229            Some((ch, key, vel))
230        }
231        _ => None,
232    }
233}
234
235fn is_note_off(event: &MidiEvent, channel: Channel, key: U7) -> bool {
236    matches!(
237        event.payload,
238        MidiPayload::Channel(ChannelMessage::NoteOff { ch, key: off_key, .. })
239            if ch == channel && off_key == key
240    ) || matches!(
241        event.payload,
242        MidiPayload::Channel(ChannelMessage::NoteOn { ch, key: off_key, vel })
243            if ch == channel && off_key == key && vel.0 == 0
244    )
245}
246
247fn note_duration(
248    input: &[MidiEvent],
249    source_index: usize,
250    channel: Channel,
251    key: U7,
252    fallback: TickTime,
253) -> TickTime {
254    let start = input[source_index].time;
255    input
256        .iter()
257        .skip(source_index + 1)
258        .find(|event| event.time > start && is_note_off(event, channel, key))
259        .map(|event| event.time - start)
260        .unwrap_or(fallback)
261}
262
263fn nearest_key_in_classes(key: i16, classes: &[u8]) -> Option<u8> {
264    if classes.is_empty() {
265        return Some(key as u8);
266    }
267    (-12..=12)
268        .filter_map(|delta| {
269            let candidate = key + delta;
270            if !(0..=127).contains(&candidate) {
271                return None;
272            }
273            let class = candidate.rem_euclid(12) as u8;
274            classes
275                .iter()
276                .any(|allowed| allowed % 12 == class)
277                .then_some((delta.abs(), candidate as u8))
278        })
279        .min_by_key(|(distance, candidate)| (*distance, *candidate))
280        .map(|(_, candidate)| candidate)
281}
282
283fn note_on_event(time: TickTime, ch: Channel, key: U7, vel: U7) -> MidiEvent {
284    MidiEvent {
285        time,
286        origin: synthetic_origin(),
287        payload: MidiPayload::Channel(ChannelMessage::NoteOn { ch, key, vel }),
288    }
289}
290
291fn note_off_event(time: TickTime, ch: Channel, key: U7) -> MidiEvent {
292    MidiEvent {
293        time,
294        origin: synthetic_origin(),
295        payload: MidiPayload::Channel(ChannelMessage::NoteOff {
296            ch,
297            key,
298            vel: U7(0),
299        }),
300    }
301}
302
303/// Sorts events into a stable, deterministic order by time and then by a
304/// payload tiebreak (note-offs before note-ons at the same tick).
305pub fn stable_midi_event_order(events: &mut [MidiEvent]) {
306    events.sort_by(|left, right| {
307        left.time
308            .cmp(&right.time)
309            .then_with(|| event_sort_key(left).cmp(&event_sort_key(right)))
310    });
311}
312
313fn event_sort_key(event: &MidiEvent) -> (u8, u8, u8, u8) {
314    match event.payload {
315        MidiPayload::Channel(ChannelMessage::NoteOff { ch, key, vel }) => (0, ch.0, key.0, vel.0),
316        MidiPayload::Channel(ChannelMessage::NoteOn { ch, key, vel }) => (1, ch.0, key.0, vel.0),
317        MidiPayload::Channel(_) => (2, 0, 0, 0),
318        MidiPayload::Meta(_) => (3, 0, 0, 0),
319        MidiPayload::SysEx(_) => (4, 0, 0, 0),
320        MidiPayload::Raw(_) => (5, 0, 0, 0),
321    }
322}