Skip to main content

sim_lib_pitch_scale/
player.rs

1use sim_lib_pitch_core::{Pitch, PitchClass};
2
3use crate::{Mode, PitchScaleError, Scale};
4
5/// A performance-oriented scale that owns its interval list, supporting both the
6/// built-in [`Mode`]s and arbitrary custom scales.
7///
8/// Unlike [`Scale`], which is a fixed mode plus tonic, a `PlayerScale` can hold a
9/// caller-supplied set of intervals and provides quantization and remapping for
10/// live input.
11#[derive(Clone, Debug, PartialEq, Eq, Hash)]
12pub struct PlayerScale {
13    /// The tonic pitch class.
14    pub tonic: PitchClass,
15    intervals: Vec<u8>,
16}
17
18impl PlayerScale {
19    /// Builds a player scale from a fixed [`Scale`].
20    pub fn from_scale(scale: Scale) -> Self {
21        Self {
22            tonic: scale.tonic,
23            intervals: scale.mode.intervals().to_vec(),
24        }
25    }
26
27    /// Builds a player scale from a `tonic` and [`Mode`].
28    pub fn from_key(tonic: PitchClass, mode: Mode) -> Self {
29        Self::from_scale(Scale::new(tonic, mode))
30    }
31
32    /// Builds a player scale from a custom interval list (semitone offsets from the
33    /// tonic), which is sorted and deduplicated.
34    ///
35    /// Returns [`PitchScaleError::EmptyScale`] if no intervals are given, or
36    /// [`PitchScaleError::InvalidScaleInterval`] if any interval is 12 or more.
37    pub fn custom(
38        tonic: PitchClass,
39        intervals: impl Into<Vec<u8>>,
40    ) -> Result<Self, PitchScaleError> {
41        let mut intervals = intervals.into();
42        if intervals.is_empty() {
43            return Err(PitchScaleError::EmptyScale);
44        }
45        for interval in &intervals {
46            if *interval >= 12 {
47                return Err(PitchScaleError::InvalidScaleInterval(*interval));
48            }
49        }
50        intervals.sort_unstable();
51        intervals.dedup();
52        Ok(Self { tonic, intervals })
53    }
54
55    /// Returns the scale's semitone offsets from the tonic, in ascending order.
56    pub fn intervals(&self) -> &[u8] {
57        &self.intervals
58    }
59
60    /// Returns the scale's pitch classes in ascending degree order.
61    pub fn pitch_classes(&self) -> Vec<PitchClass> {
62        self.intervals
63            .iter()
64            .map(|interval| self.tonic.transpose(i32::from(*interval)))
65            .collect()
66    }
67
68    /// Returns `true` if `class` is a member of the scale.
69    pub fn contains(&self, class: PitchClass) -> bool {
70        self.degree_of(class).is_some()
71    }
72
73    /// Returns the one-based scale degree of `class`, or `None` if it is not in the
74    /// scale.
75    pub fn degree_of(&self, class: PitchClass) -> Option<usize> {
76        self.pitch_classes()
77            .iter()
78            .position(|candidate| *candidate == class)
79            .map(|index| index + 1)
80    }
81
82    /// Returns the pitch class at the one-based `degree`, wrapping past the octave.
83    pub fn pitch_at_degree(&self, degree: usize) -> PitchClass {
84        let index = degree.saturating_sub(1) % self.intervals.len();
85        self.tonic.transpose(i32::from(self.intervals[index]))
86    }
87
88    /// Returns the in-scale pitch nearest to `pitch`, breaking ties upward.
89    pub fn nearest_pitch(&self, pitch: Pitch) -> Pitch {
90        let source = pitch.semitone();
91        self.pitch_classes()
92            .into_iter()
93            .flat_map(|class| {
94                (-1..=1).map(move |octave_offset| Pitch {
95                    class,
96                    octave: pitch.octave + octave_offset,
97                })
98            })
99            .min_by_key(|candidate| {
100                let delta = candidate.semitone() - source;
101                (delta.abs(), if delta >= 0 { 0 } else { 1 })
102            })
103            .unwrap_or(pitch)
104    }
105
106    /// Remaps `pitch` onto the scale by treating its chromatic offset from the
107    /// tonic as a scale degree, preserving the octave.
108    pub fn remap_pitch(&self, pitch: Pitch) -> Pitch {
109        let offset = (i32::from(pitch.class.0) - i32::from(self.tonic.0)).rem_euclid(12);
110        let degree = offset as usize % self.intervals.len() + 1;
111        Pitch {
112            class: self.pitch_at_degree(degree),
113            octave: pitch.octave,
114        }
115    }
116}
117
118/// The strategy a [`ScaleLockPlayer`] uses to force pitches onto its scale.
119#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
120pub enum ScaleLockPolicy {
121    /// Snap each pitch to the nearest in-scale pitch.
122    Quantize,
123    /// Drop pitches that are not already in the scale.
124    Filter,
125    /// Reinterpret each pitch's chromatic offset as a scale degree.
126    Remap,
127}
128
129/// Applies a [`ScaleLockPolicy`] to incoming pitches against a [`PlayerScale`].
130#[derive(Clone, Debug, PartialEq, Eq, Hash)]
131pub struct ScaleLockPlayer {
132    /// The scale to lock pitches onto.
133    pub scale: PlayerScale,
134    /// The policy used to handle out-of-scale pitches.
135    pub policy: ScaleLockPolicy,
136}
137
138impl ScaleLockPlayer {
139    /// Constructs a scale-lock player from a [`PlayerScale`] and a policy.
140    pub fn new(scale: PlayerScale, policy: ScaleLockPolicy) -> Self {
141        Self { scale, policy }
142    }
143
144    /// Constructs a scale-lock player directly from a [`Scale`] and a policy.
145    pub fn from_scale(scale: Scale, policy: ScaleLockPolicy) -> Self {
146        Self::new(PlayerScale::from_scale(scale), policy)
147    }
148
149    /// Applies the policy to a single pitch, returning `None` when a
150    /// [`ScaleLockPolicy::Filter`] policy rejects it.
151    pub fn process_pitch(&self, pitch: Pitch) -> Option<Pitch> {
152        match self.policy {
153            ScaleLockPolicy::Quantize => Some(self.scale.nearest_pitch(pitch)),
154            ScaleLockPolicy::Filter => self.scale.contains(pitch.class).then_some(pitch),
155            ScaleLockPolicy::Remap => Some(self.scale.remap_pitch(pitch)),
156        }
157    }
158
159    /// Applies the policy to a sequence of pitches, collecting the surviving
160    /// results.
161    pub fn process_pitches(&self, pitches: impl IntoIterator<Item = Pitch>) -> Vec<Pitch> {
162        pitches
163            .into_iter()
164            .filter_map(|pitch| self.process_pitch(pitch))
165            .collect()
166    }
167}