Skip to main content

maolan_engine/midi/
mpe.rs

1//! MIDI Polyphonic Expression (MPE) v1.1 state and message parsing.
2//!
3//! MPE divides MIDI channels into Zones. Each Zone has one Manager Channel
4//! (global controllers) and one or more Member Channels (per-note expression).
5//! This module tracks zone configuration from MPE Configuration Messages
6//! (MCM, RPN #6) and Pitch Bend Sensitivity (RPN #0).
7
8use std::ops::RangeInclusive;
9
10/// A single MPE Zone.
11#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
12pub struct MpeZone {
13    /// The Manager Channel for this zone (0-based MIDI channel number).
14    pub manager_channel: u8,
15    /// Inclusive range of Member Channels (0-based MIDI channel numbers).
16    pub member_channels: RangeInclusive<u8>,
17    /// Pitch bend sensitivity in semitones for the Manager Channel.
18    pub manager_pitch_bend_semitones: u8,
19    /// Pitch bend sensitivity in semitones for Member Channels.
20    pub member_pitch_bend_semitones: u8,
21}
22
23impl MpeZone {
24    /// Default per MPE v1.1: 48 semitones on members, 2 on manager.
25    pub const DEFAULT_MEMBER_PITCH_BEND_SEMITONES: u8 = 48;
26    pub const DEFAULT_MANAGER_PITCH_BEND_SEMITONES: u8 = 2;
27
28    /// Create a zone from a manager channel and a member-channel count.
29    /// Returns `None` if the count is zero or the resulting range is invalid.
30    pub fn new(manager_channel: u8, member_count: u8) -> Option<Self> {
31        let manager_channel = manager_channel.min(15);
32        let member_count = member_count.min(15);
33        if member_count == 0 {
34            return None;
35        }
36        let member_channels = if manager_channel == 0 {
37            // Lower Zone: members start at channel 2 (index 1) and ascend.
38            let end = 1_u8.saturating_add(member_count).min(15);
39            1..=end
40        } else if manager_channel == 15 {
41            // Upper Zone: members start at channel 15 (index 14) and descend.
42            let start = 15_u8.saturating_sub(member_count).max(1);
43            start..=14
44        } else {
45            // Only channels 1 and 16 can be Manager Channels.
46            return None;
47        };
48        Some(Self {
49            manager_channel,
50            member_channels,
51            manager_pitch_bend_semitones: Self::DEFAULT_MANAGER_PITCH_BEND_SEMITONES,
52            member_pitch_bend_semitones: Self::DEFAULT_MEMBER_PITCH_BEND_SEMITONES,
53        })
54    }
55
56    /// Returns true if `channel` is the Manager Channel of this zone.
57    pub fn is_manager(&self, channel: u8) -> bool {
58        self.manager_channel == channel.min(15)
59    }
60
61    /// Returns true if `channel` is a Member Channel of this zone.
62    pub fn is_member(&self, channel: u8) -> bool {
63        self.member_channels.contains(&channel.min(15))
64    }
65
66    /// Returns true if `channel` belongs to this zone in any role.
67    pub fn contains(&self, channel: u8) -> bool {
68        let channel = channel.min(15);
69        self.is_manager(channel) || self.is_member(channel)
70    }
71}
72
73/// MPE state for one MIDI input stream (typically one track).
74#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
75pub struct MpeState {
76    lower: Option<MpeZone>,
77    upper: Option<MpeZone>,
78    #[serde(skip)]
79    pending_rpn_msb: [Option<u8>; 16],
80    #[serde(skip)]
81    pending_rpn_lsb: [Option<u8>; 16],
82}
83
84impl MpeState {
85    /// Create a fresh, inactive MPE state.
86    pub fn new() -> Self {
87        Self::default()
88    }
89
90    /// Returns the Lower Zone if configured.
91    pub fn lower(&self) -> Option<&MpeZone> {
92        self.lower.as_ref()
93    }
94
95    /// Returns the Upper Zone if configured.
96    pub fn upper(&self) -> Option<&MpeZone> {
97        self.upper.as_ref()
98    }
99
100    /// Returns the zone (lower or upper) that `channel` belongs to, if any.
101    pub fn zone_for_channel(&self, channel: u8) -> Option<&MpeZone> {
102        let channel = channel.min(15);
103        if let Some(ref lower) = self.lower
104            && lower.contains(channel)
105        {
106            return Some(lower);
107        }
108        if let Some(ref upper) = self.upper
109            && upper.contains(channel)
110        {
111            return Some(upper);
112        }
113        None
114    }
115
116    /// Returns true if `channel` is a Manager Channel in any configured zone.
117    pub fn is_manager_channel(&self, channel: u8) -> bool {
118        let channel = channel.min(15);
119        self.lower.as_ref().is_some_and(|z| z.is_manager(channel))
120            || self.upper.as_ref().is_some_and(|z| z.is_manager(channel))
121    }
122
123    /// Returns true if `channel` is a Member Channel in any configured zone.
124    pub fn is_member_channel(&self, channel: u8) -> bool {
125        let channel = channel.min(15);
126        self.lower.as_ref().is_some_and(|z| z.is_member(channel))
127            || self.upper.as_ref().is_some_and(|z| z.is_member(channel))
128    }
129
130    /// Returns true if any MPE zone is active.
131    pub fn is_active(&self) -> bool {
132        self.lower.is_some() || self.upper.is_some()
133    }
134
135    /// Returns the active zone to use for voice allocation. When both zones
136    /// are configured the Lower Zone is preferred because it is the most
137    /// common setup; callers that need both zones can inspect [`lower`] and
138    /// [`upper`] directly.
139    pub fn active_zone(&self) -> Option<&MpeZone> {
140        self.lower.as_ref().or(self.upper.as_ref())
141    }
142
143    /// Configure a zone from an MPE Configuration Message.
144    ///
145    /// `manager_channel` is the 0-based channel on which the MCM was received
146    /// (must be 0 for Lower Zone or 15 for Upper Zone). `member_count` is the
147    /// value sent with CC#6 (0..=15). A count of zero deactivates the zone.
148    ///
149    /// If the new zone overlaps an existing zone, the new zone steals the
150    /// channels and the old zone is deactivated if it is left with no members,
151    /// per MPE v1.1 Appendix B.
152    pub fn configure_zone(&mut self, manager_channel: u8, member_count: u8) {
153        let manager_channel = manager_channel.min(15);
154        if manager_channel != 0 && manager_channel != 15 {
155            return;
156        }
157
158        if member_count == 0 {
159            if manager_channel == 0 {
160                self.lower = None;
161            } else {
162                self.upper = None;
163            }
164            return;
165        }
166
167        let new_zone = match MpeZone::new(manager_channel, member_count) {
168            Some(z) => z,
169            None => return,
170        };
171
172        // If the new zone steals channels from the other zone, shrink the
173        // other zone to remove the stolen channels and deactivate it if no
174        // members remain.
175        let other = if manager_channel == 0 {
176            &mut self.upper
177        } else {
178            &mut self.lower
179        };
180        if let Some(other_zone) = other.as_mut() {
181            let remaining_members: Vec<u8> = other_zone
182                .member_channels
183                .clone()
184                .filter(|ch| !new_zone.member_channels.contains(ch))
185                .collect();
186            if remaining_members.is_empty() {
187                *other = None;
188            } else {
189                let start = *remaining_members.first().unwrap();
190                let end = *remaining_members.last().unwrap();
191                other_zone.member_channels = start..=end;
192            }
193        }
194
195        if manager_channel == 0 {
196            self.lower = Some(new_zone);
197        } else {
198            self.upper = Some(new_zone);
199        }
200    }
201
202    /// Set Pitch Bend Sensitivity from an RPN #0 message.
203    ///
204    /// `channel` is the 0-based channel on which the RPN was received.
205    /// `semitones` is the MSB value (CC#6). The cent value (CC#38) is ignored
206    /// for now; MPE recommends integer semitones.
207    pub fn set_pitch_bend_sensitivity(&mut self, channel: u8, semitones: u8) {
208        let channel = channel.min(15);
209        let semitones = semitones.min(96);
210
211        if let Some(ref mut lower) = self.lower {
212            if lower.is_manager(channel) {
213                lower.manager_pitch_bend_semitones = semitones;
214            } else if lower.is_member(channel) {
215                lower.member_pitch_bend_semitones = semitones;
216            }
217        }
218        if let Some(ref mut upper) = self.upper {
219            if upper.is_manager(channel) {
220                upper.manager_pitch_bend_semitones = semitones;
221            } else if upper.is_member(channel) {
222                upper.member_pitch_bend_semitones = semitones;
223            }
224        }
225    }
226
227    /// Feed one raw MIDI event into the MPE state machine.
228    ///
229    /// Returns the channel that was affected if the event was an MCM or RPN
230    /// message that changed state. This is mostly useful for tests and logging.
231    pub fn feed(&mut self, data: &[u8]) -> Option<u8> {
232        if data.len() < 3 {
233            return None;
234        }
235        let status = data[0];
236        let channel = status & 0x0F;
237        let msg_type = status & 0xF0;
238
239        // Controller messages are needed for RPN/MCM parsing.
240        if msg_type != 0xB0 {
241            return None;
242        }
243
244        let cc = data[1];
245        let value = data[2];
246
247        match cc {
248            101 => {
249                // RPN MSB. Any new RPN selection aborts the previous sequence.
250                self.pending_rpn_msb[channel as usize] = Some(value);
251                self.pending_rpn_lsb[channel as usize] = None;
252            }
253            100 => {
254                if self.pending_rpn_msb[channel as usize].is_some() {
255                    self.pending_rpn_lsb[channel as usize] = Some(value);
256                }
257            }
258            6 => {
259                if let (Some(msb), Some(lsb)) = (
260                    self.pending_rpn_msb[channel as usize],
261                    self.pending_rpn_lsb[channel as usize],
262                ) {
263                    if msb == 0x00 && lsb == 0x00 {
264                        // RPN #0: Pitch Bend Sensitivity.
265                        self.set_pitch_bend_sensitivity(channel, value);
266                        self.clear_rpn_state(channel);
267                        return Some(channel);
268                    } else if msb == 0x00 && lsb == 0x06 {
269                        // RPN #6: MPE Configuration Message.
270                        self.configure_zone(channel, value);
271                        self.clear_rpn_state(channel);
272                        return Some(channel);
273                    }
274                }
275            }
276            38 => {
277                // Data Entry LSB — ignored for now.
278            }
279            _ => {
280                // Any other CC on the channel aborts the pending RPN sequence.
281                self.clear_rpn_state(channel);
282            }
283        }
284
285        None
286    }
287
288    fn clear_rpn_state(&mut self, channel: u8) {
289        let idx = channel.min(15) as usize;
290        self.pending_rpn_msb[idx] = None;
291        self.pending_rpn_lsb[idx] = None;
292    }
293}
294
295/// Voice allocator that spreads note-ons across the Member Channels of an
296/// active MPE zone.
297///
298/// The allocator is intended for playback-time conversion: a piano roll or
299/// incoming MIDI stream that is not yet channelised is rewritten so that each
300/// note lives on its own Member Channel, allowing per-note expression.
301#[derive(Debug, Clone)]
302pub struct MpeVoiceAllocator {
303    /// Active MPE zone used for allocation.
304    zone: MpeZone,
305    /// Active note voices indexed by pitch. Because the same pitch can be
306    /// triggered while still sounding, each pitch keeps a stack of allocated
307    /// channels (LIFO).
308    active: std::collections::HashMap<u8, Vec<u8>>,
309    /// Running count of active notes on each Member Channel so we can place
310    /// new notes on the least-loaded channel.
311    channel_load: std::collections::HashMap<u8, usize>,
312}
313
314impl MpeVoiceAllocator {
315    /// Create an allocator from an active zone.
316    pub fn new(zone: MpeZone) -> Self {
317        let mut channel_load = std::collections::HashMap::new();
318        for ch in zone.member_channels.clone() {
319            channel_load.insert(ch, 0);
320        }
321        Self {
322            zone,
323            active: std::collections::HashMap::new(),
324            channel_load,
325        }
326    }
327
328    /// Process one raw MIDI event. Returns zero or more raw MIDI events.
329    ///
330    /// * Note-on events on non-member channels are allocated to a Member
331    ///   Channel and rewritten.
332    /// * Note-off events look up the previously allocated channel for the
333    ///   pitch and are rewritten to the same channel.
334    /// * All other events pass through unchanged. This includes events that
335    ///   are already on Member Channels (e.g. per-note expression generated by
336    ///   the editor) and Manager Channel global controllers.
337    pub fn feed(&mut self, data: &[u8]) -> Vec<Vec<u8>> {
338        if data.is_empty() {
339            return Vec::new();
340        }
341        let status = data[0];
342        let msg_type = status & 0xF0;
343        let channel = status & 0x0F;
344        let pitch = data.get(1).copied().unwrap_or(0);
345
346        match msg_type {
347            0x90 => {
348                // Note-on. Velocity zero is treated as note-off by many
349                // devices, but we still allocate for consistent handling.
350                if data.len() < 2 {
351                    return vec![data.to_vec()];
352                }
353                let allocated = if self.zone.is_member(channel) {
354                    // Already on a member channel: track it but do not move it.
355                    channel
356                } else {
357                    self.allocate(pitch)
358                };
359                self.active.entry(pitch).or_default().push(allocated);
360                *self.channel_load.entry(allocated).or_insert(0) += 1;
361                // Per MPE v1.1, reset per-note controllers on the member channel
362                // before the note-on so the new note does not inherit stale
363                // expression from a previous note on the same channel.
364                vec![
365                    vec![0xE0 | allocated, 0x00, 0x40], // pitch bend center
366                    vec![0xD0 | allocated, 0x00],       // channel pressure zero
367                    vec![0xB0 | allocated, 74, 0x00],   // CC74 (timbre) zero
368                    Self::with_channel(data, allocated),
369                ]
370            }
371            0x80 => {
372                // Note-off.
373                if data.len() < 2 {
374                    return vec![data.to_vec()];
375                }
376                let allocated = self.release(pitch);
377                let out_channel = allocated.unwrap_or(channel);
378                vec![Self::with_channel(data, out_channel)]
379            }
380            _ => {
381                // Pass through unchanged.
382                vec![data.to_vec()]
383            }
384        }
385    }
386
387    /// Process a slice of events in order.
388    pub fn feed_many(&mut self, data: &[Vec<u8>]) -> Vec<Vec<u8>> {
389        data.iter().flat_map(|d| self.feed(d)).collect()
390    }
391
392    fn allocate(&mut self, _pitch: u8) -> u8 {
393        // Pick the member channel with the smallest load. Ties are resolved by
394        // channel number, which keeps allocation deterministic.
395        self.zone
396            .member_channels
397            .clone()
398            .min_by_key(|ch| self.channel_load.get(ch).copied().unwrap_or(0))
399            .unwrap_or(*self.zone.member_channels.start())
400    }
401
402    fn release(&mut self, pitch: u8) -> Option<u8> {
403        let stack = self.active.get_mut(&pitch)?;
404        let ch = stack.pop();
405        if stack.is_empty() {
406            self.active.remove(&pitch);
407        }
408        if let Some(ch) = ch
409            && let Some(load) = self.channel_load.get_mut(&ch)
410        {
411            *load = load.saturating_sub(1);
412        }
413        ch
414    }
415
416    fn with_channel(data: &[u8], channel: u8) -> Vec<u8> {
417        let mut out = data.to_vec();
418        out[0] = (data[0] & 0xF0) | (channel & 0x0F);
419        out
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    fn cc(channel: u8, cc: u8, value: u8) -> Vec<u8> {
428        vec![0xB0 | (channel & 0x0F), cc, value]
429    }
430
431    #[test]
432    fn lower_zone_with_15_members() {
433        let mut state = MpeState::new();
434        // MCM on channel 1 (index 0) with 15 member channels.
435        state.feed(&cc(0, 101, 0));
436        state.feed(&cc(0, 100, 6));
437        state.feed(&cc(0, 6, 15));
438
439        let lower = state.lower().unwrap();
440        assert_eq!(lower.manager_channel, 0);
441        assert_eq!(lower.member_channels, 1..=15);
442        assert_eq!(lower.member_pitch_bend_semitones, 48);
443        assert_eq!(lower.manager_pitch_bend_semitones, 2);
444        assert!(state.upper().is_none());
445    }
446
447    #[test]
448    fn upper_zone_with_7_members() {
449        let mut state = MpeState::new();
450        // MCM on channel 16 (index 15) with 7 member channels.
451        state.feed(&cc(15, 101, 0));
452        state.feed(&cc(15, 100, 6));
453        state.feed(&cc(15, 6, 7));
454
455        let upper = state.upper().unwrap();
456        assert_eq!(upper.manager_channel, 15);
457        assert_eq!(upper.member_channels, 8..=14);
458        assert!(state.lower().is_none());
459    }
460
461    #[test]
462    fn zero_member_count_deactivates_zone() {
463        let mut state = MpeState::new();
464        state.feed(&cc(0, 101, 0));
465        state.feed(&cc(0, 100, 6));
466        state.feed(&cc(0, 6, 5));
467        assert!(state.lower().is_some());
468
469        state.feed(&cc(0, 101, 0));
470        state.feed(&cc(0, 100, 6));
471        state.feed(&cc(0, 6, 0));
472        assert!(state.lower().is_none());
473    }
474
475    #[test]
476    fn overlapping_zone_steals_channels() {
477        let mut state = MpeState::new();
478        // Lower zone uses channels 1..=8 (member count 7, channels 2-8).
479        state.feed(&cc(0, 101, 0));
480        state.feed(&cc(0, 100, 6));
481        state.feed(&cc(0, 6, 7));
482        assert!(state.lower().is_some());
483        assert!(state.upper().is_none());
484
485        // Upper zone uses channels 6..=15 (member count 10).
486        // This overlaps with lower zone members 2..=8; lower zone keeps the
487        // non-overlapping members 2..=5.
488        state.feed(&cc(15, 101, 0));
489        state.feed(&cc(15, 100, 6));
490        state.feed(&cc(15, 6, 10));
491        let lower = state.lower().unwrap();
492        assert_eq!(lower.member_channels, 1..=4);
493        let upper = state.upper().unwrap();
494        assert_eq!(upper.member_channels, 5..=14);
495    }
496
497    #[test]
498    fn rpn_zero_sets_pitch_bend_sensitivity() {
499        let mut state = MpeState::new();
500        state.feed(&cc(0, 101, 0));
501        state.feed(&cc(0, 100, 6));
502        state.feed(&cc(0, 6, 7));
503
504        // Manager channel pitch bend sensitivity to 5 semitones.
505        state.feed(&cc(0, 101, 0));
506        state.feed(&cc(0, 100, 0));
507        state.feed(&cc(0, 6, 5));
508        assert_eq!(state.lower().unwrap().manager_pitch_bend_semitones, 5);
509
510        // Member channel pitch bend sensitivity to 24 semitones.
511        state.feed(&cc(2, 101, 0));
512        state.feed(&cc(2, 100, 0));
513        state.feed(&cc(2, 6, 24));
514        assert_eq!(state.lower().unwrap().member_pitch_bend_semitones, 24);
515    }
516
517    #[test]
518    fn invalid_manager_channels_are_ignored() {
519        let mut state = MpeState::new();
520        for ch in 1..=14 {
521            state.feed(&cc(ch, 101, 0));
522            state.feed(&cc(ch, 100, 6));
523            state.feed(&cc(ch, 6, 5));
524        }
525        assert!(!state.is_active());
526    }
527
528    fn note_on(channel: u8, pitch: u8, velocity: u8) -> Vec<u8> {
529        vec![0x90 | (channel & 0x0F), pitch, velocity]
530    }
531
532    fn note_off(channel: u8, pitch: u8, velocity: u8) -> Vec<u8> {
533        vec![0x80 | (channel & 0x0F), pitch, velocity]
534    }
535
536    fn note_on_event(events: &[Vec<u8>]) -> Option<&Vec<u8>> {
537        events
538            .iter()
539            .find(|e| matches!(e.first().copied().unwrap_or(0) & 0xF0, 0x90))
540    }
541
542    #[test]
543    fn allocator_spreads_notes_across_member_channels() {
544        let zone = MpeZone::new(0, 3).unwrap();
545        let mut alloc = MpeVoiceAllocator::new(zone);
546
547        let out = alloc.feed_many(&[
548            note_on(0, 60, 100),
549            note_on(0, 64, 100),
550            note_on(0, 67, 100),
551        ]);
552
553        assert_eq!(note_on_event(&out[0..4]).unwrap()[0] & 0x0F, 1);
554        assert_eq!(note_on_event(&out[4..8]).unwrap()[0] & 0x0F, 2);
555        assert_eq!(note_on_event(&out[8..12]).unwrap()[0] & 0x0F, 3);
556    }
557
558    #[test]
559    fn allocator_reuses_released_channel() {
560        let zone = MpeZone::new(0, 2).unwrap();
561        let mut alloc = MpeVoiceAllocator::new(zone);
562
563        let _ = alloc.feed(&note_on(0, 60, 100)); // channel 1
564        let _ = alloc.feed(&note_off(0, 60, 100)); // release channel 1
565        let out = alloc.feed(&note_on(0, 64, 100));
566
567        assert_eq!(note_on_event(&out).unwrap()[0] & 0x0F, 1);
568    }
569
570    #[test]
571    fn allocator_matches_note_off_to_note_on_channel() {
572        let zone = MpeZone::new(0, 3).unwrap();
573        let mut alloc = MpeVoiceAllocator::new(zone);
574
575        let _ = alloc.feed(&note_on(0, 60, 100));
576        let _ = alloc.feed(&note_on(0, 64, 100));
577        let off = alloc.feed(&note_off(0, 60, 100));
578
579        assert_eq!(off[0][0] & 0x0F, 1);
580    }
581
582    #[test]
583    fn allocator_resets_per_note_controllers_before_note_on() {
584        let zone = MpeZone::new(0, 3).unwrap();
585        let mut alloc = MpeVoiceAllocator::new(zone);
586
587        let out = alloc.feed(&note_on(0, 60, 100));
588
589        assert_eq!(out.len(), 4);
590        assert_eq!(out[0], vec![0xE1, 0x00, 0x40]); // pitch bend center
591        assert_eq!(out[1], vec![0xD1, 0x00]); // pressure zero
592        assert_eq!(out[2], vec![0xB1, 74, 0x00]); // CC74 zero
593        assert_eq!(out[3], vec![0x91, 60, 100]); // note-on
594    }
595
596    #[test]
597    fn allocator_keeps_member_channel_events() {
598        let zone = MpeZone::new(0, 3).unwrap();
599        let mut alloc = MpeVoiceAllocator::new(zone);
600
601        let pb = vec![0xE0 | 2, 0x00, 0x40];
602        let out = alloc.feed(&pb);
603        assert_eq!(out[0][0] & 0x0F, 2);
604    }
605
606    #[test]
607    fn allocator_leaves_manager_controllers_unchanged() {
608        let zone = MpeZone::new(0, 3).unwrap();
609        let mut alloc = MpeVoiceAllocator::new(zone);
610
611        let cc_msg = vec![0xB0, 74, 64];
612        let out = alloc.feed(&cc_msg);
613        assert_eq!(out[0][0] & 0x0F, 0);
614    }
615}