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) -> Result<PitchClass, PitchScaleError> {
84        self.try_pitch_at_degree(degree)
85    }
86
87    /// Returns the pitch class at the one-based `degree`, wrapping past the octave.
88    pub fn try_pitch_at_degree(&self, degree: usize) -> Result<PitchClass, PitchScaleError> {
89        let index = degree
90            .checked_sub(1)
91            .ok_or(PitchScaleError::InvalidScaleDegree(degree))?;
92        Ok(self
93            .tonic
94            .transpose(i32::from(self.intervals[index % self.intervals.len()])))
95    }
96
97    /// Returns the in-scale pitch nearest to `pitch`, breaking ties upward.
98    pub fn nearest_pitch(&self, pitch: Pitch) -> Pitch {
99        let source = pitch.semitone();
100        self.pitch_classes()
101            .into_iter()
102            .flat_map(|class| {
103                (-1..=1).map(move |octave_offset| Pitch {
104                    class,
105                    octave: pitch.octave + octave_offset,
106                })
107            })
108            .min_by_key(|candidate| {
109                let delta = candidate.semitone() - source;
110                (delta.abs(), if delta >= 0 { 0 } else { 1 })
111            })
112            .unwrap_or(pitch)
113    }
114
115    /// Remaps `pitch` onto the scale by treating its chromatic offset from the
116    /// tonic as a scale degree, preserving the octave.
117    pub fn remap_pitch(&self, pitch: Pitch) -> Pitch {
118        let offset =
119            (i32::from(pitch.class.value()) - i32::from(self.tonic.value())).rem_euclid(12);
120        let index =
121            usize::try_from(offset).expect("mod-12 offset fits usize") % self.intervals.len();
122        Pitch {
123            class: self.tonic.transpose(i32::from(self.intervals[index])),
124            octave: pitch.octave,
125        }
126    }
127}
128
129/// The strategy a [`ScaleLockPlayer`] uses to force pitches onto its scale.
130#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
131pub enum ScaleLockPolicy {
132    /// Snap each pitch to the nearest in-scale pitch.
133    Quantize,
134    /// Drop pitches that are not already in the scale.
135    Filter,
136    /// Reinterpret each pitch's chromatic offset as a scale degree.
137    Remap,
138}
139
140/// Applies a [`ScaleLockPolicy`] to incoming pitches against a [`PlayerScale`].
141#[derive(Clone, Debug, PartialEq, Eq, Hash)]
142pub struct ScaleLockPlayer {
143    /// The scale to lock pitches onto.
144    pub scale: PlayerScale,
145    /// The policy used to handle out-of-scale pitches.
146    pub policy: ScaleLockPolicy,
147}
148
149impl ScaleLockPlayer {
150    /// Constructs a scale-lock player from a [`PlayerScale`] and a policy.
151    pub fn new(scale: PlayerScale, policy: ScaleLockPolicy) -> Self {
152        Self { scale, policy }
153    }
154
155    /// Constructs a scale-lock player directly from a [`Scale`] and a policy.
156    pub fn from_scale(scale: Scale, policy: ScaleLockPolicy) -> Self {
157        Self::new(PlayerScale::from_scale(scale), policy)
158    }
159
160    /// Applies the policy to a single pitch, returning `None` when a
161    /// [`ScaleLockPolicy::Filter`] policy rejects it.
162    pub fn process_pitch(&self, pitch: Pitch) -> Option<Pitch> {
163        match self.policy {
164            ScaleLockPolicy::Quantize => Some(self.scale.nearest_pitch(pitch)),
165            ScaleLockPolicy::Filter => self.scale.contains(pitch.class).then_some(pitch),
166            ScaleLockPolicy::Remap => Some(self.scale.remap_pitch(pitch)),
167        }
168    }
169
170    /// Applies the policy to a sequence of pitches, collecting the surviving
171    /// results.
172    pub fn process_pitches(&self, pitches: impl IntoIterator<Item = Pitch>) -> Vec<Pitch> {
173        pitches
174            .into_iter()
175            .filter_map(|pitch| self.process_pitch(pitch))
176            .collect()
177    }
178}