Skip to main content

sim_lib_pitch_set/
model.rs

1use thiserror::Error;
2
3use sim_lib_pitch_core::{Pitch, PitchClass};
4
5use crate::{SetClass, SetEquivalence, classify_set, conventional};
6
7/// Error returned when a pitch-set value cannot be constructed, encoded, or decoded.
8#[derive(Debug, Error, Clone, PartialEq, Eq)]
9pub enum PitchSetError {
10    /// A MIDI key outside the valid `0..128` range was supplied.
11    #[error("invalid MIDI key {0}")]
12    InvalidMidiKey(u8),
13    /// A pitch-class mask used bits outside the low twelve pitch-class bits.
14    #[error("invalid pitch-class mask {0}")]
15    InvalidPitchClassMask(u16),
16    /// A third-stack bit pattern could not be decoded into a valid signature.
17    #[error("invalid third stack encoding")]
18    InvalidThirdStackEncoding,
19    /// A third-stack signature violated the run-length constraints on its steps.
20    #[error("invalid third stack signature")]
21    InvalidThirdStack,
22}
23
24/// A bitmask over the twelve pitch classes, with bit `n` set when pitch class `n`
25/// is present.
26///
27/// The low twelve bits represent pitch classes C through B.
28///
29/// # Examples
30///
31/// ```
32/// use sim_lib_pitch_core::PitchClass;
33/// use sim_lib_pitch_set::PitchClassMask;
34///
35/// let triad = PitchClassMask::from_pitch_classes(&[PitchClass::C, PitchClass::E, PitchClass::G]);
36/// assert_eq!(triad.count_bits(), 3);
37/// ```
38#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Default)]
39pub struct PitchClassMask(u16);
40
41impl PitchClassMask {
42    const VALID_BITS: u16 = 0x0fff;
43
44    /// Builds a pitch-class mask, rejecting any bits outside the low twelve.
45    pub fn new(bits: u16) -> Result<Self, PitchSetError> {
46        if bits & !Self::VALID_BITS == 0 {
47            Ok(Self(bits))
48        } else {
49            Err(PitchSetError::InvalidPitchClassMask(bits))
50        }
51    }
52
53    /// Returns the raw low-twelve pitch-class bits.
54    pub const fn bits(self) -> u16 {
55        self.0
56    }
57
58    /// Builds a mask from a slice of pitch classes; duplicates collapse to one bit.
59    pub fn from_pitch_classes(pitch_classes: &[PitchClass]) -> Self {
60        let mut bits = 0u16;
61        for pitch_class in pitch_classes {
62            bits |= 1u16 << pitch_class.value();
63        }
64        Self(bits)
65    }
66
67    /// Returns the set pitch classes in ascending order.
68    pub fn pitch_classes(self) -> Vec<PitchClass> {
69        (0..12)
70            .filter(|bit| self.0 & (1u16 << bit) != 0)
71            .map(|bit| PitchClass::new(bit).expect("mask iteration yields valid pitch classes"))
72            .collect()
73    }
74
75    /// Returns this mask transposed by `semitones`, wrapping within the octave.
76    pub fn rotate(self, semitones: i32) -> Self {
77        let shift = semitones.rem_euclid(12) as u32;
78        let bits = self.0;
79        Self(((bits << shift) | (bits >> (12 - shift))) & Self::VALID_BITS)
80    }
81
82    /// Returns this mask inverted about `axis`.
83    pub fn invert(self, axis: PitchClass) -> Self {
84        let mut out = 0u16;
85        for pitch_class in self.pitch_classes() {
86            out |= 1u16 << pitch_class.invert(axis).value();
87        }
88        Self(out)
89    }
90
91    /// Returns the conventional set-theory inversion `TnI`, where each pitch
92    /// class `p` maps to `index - p (mod 12)`.
93    ///
94    /// Unlike [`PitchClassMask::invert`], this accepts every integer inversion
95    /// index, including odd indices whose geometric axis lies between pitch
96    /// classes.
97    pub fn invert_tni(self, index: u8) -> Self {
98        let index = i32::from(index % 12);
99        let classes: Vec<_> = self
100            .pitch_classes()
101            .into_iter()
102            .map(|pitch_class| {
103                PitchClass::new((index - i32::from(pitch_class.value())).rem_euclid(12) as u8)
104                    .expect("TnI folds to a valid pitch class")
105            })
106            .collect();
107        Self::from_pitch_classes(&classes)
108    }
109
110    /// Returns the rotation of this mask with the smallest numeric value, a
111    /// transposition-invariant normal form.
112    pub fn normalize(self) -> Self {
113        (0..12)
114            .map(|shift| self.rotate(-shift))
115            .min_by_key(|mask| mask.bits())
116            .unwrap_or(self)
117    }
118
119    /// Returns the conventional normal order for this set.
120    ///
121    /// The result preserves cardinality and is transposed so the first pitch
122    /// class is C. Use [`PitchClassMask::normalize`] when the older numeric mask
123    /// identity is required instead.
124    pub fn normal_order(self) -> Vec<PitchClass> {
125        conventional::normal_order(self.pitch_classes())
126    }
127
128    /// Classifies this mask using the requested conventional equivalence policy.
129    pub fn classify(self, equivalence: SetEquivalence) -> SetClass {
130        classify_set(self, equivalence)
131    }
132
133    /// Returns `true` when every pitch class in `self` is also in `other`.
134    pub fn is_subset_of(self, other: Self) -> bool {
135        self.0 & !other.0 == 0
136    }
137
138    /// Returns `true` when every pitch class in `other` is also in `self`.
139    pub fn is_superset_of(self, other: Self) -> bool {
140        other.is_subset_of(self)
141    }
142
143    /// Returns the complement of this set in the twelve pitch-class universe.
144    pub fn complement(self) -> Self {
145        Self(!self.0 & Self::VALID_BITS)
146    }
147
148    /// Returns the union of this set and `other`.
149    pub fn union(self, other: Self) -> Self {
150        Self(self.0 | other.0)
151    }
152
153    /// Returns the pitch classes shared by this set and `other`.
154    pub fn intersection(self, other: Self) -> Self {
155        Self(self.0 & other.0)
156    }
157
158    /// Returns the pitch classes in this set but not in `other`.
159    pub fn difference(self, other: Self) -> Self {
160        Self(self.0 & !other.0)
161    }
162
163    /// Returns the pitch classes belonging to exactly one of the two sets.
164    pub fn symmetric_difference(self, other: Self) -> Self {
165        Self(self.0 ^ other.0)
166    }
167
168    /// Returns `true` when this set and `other` share no pitch classes.
169    pub fn is_disjoint_from(self, other: Self) -> bool {
170        self.intersection(other).bits() == 0
171    }
172
173    /// Returns transpositions that map this set onto itself.
174    pub fn transpositional_symmetries(self) -> Vec<u8> {
175        (0..12)
176            .filter(|shift| self.rotate(i32::from(*shift)) == self)
177            .collect()
178    }
179
180    /// Returns inversion axes that map this set onto itself.
181    pub fn inversional_symmetries(self) -> Vec<PitchClass> {
182        (0..12)
183            .filter_map(|axis| {
184                let pitch_class =
185                    PitchClass::new(axis).expect("symmetry axis iteration yields pitch classes");
186                (self.invert(pitch_class) == self).then_some(pitch_class)
187            })
188            .collect()
189    }
190
191    /// Returns likely tonal roots, derived from third-stack interpretations.
192    pub fn roots(self) -> Vec<PitchClass> {
193        let mut roots = Vec::new();
194        for root in self.pitch_classes() {
195            let contains = |semitones| self.0 & (1u16 << root.transpose(semitones).value()) != 0;
196            if contains(7) && (contains(3) || contains(4)) {
197                roots.push(root);
198            }
199        }
200        roots
201    }
202
203    /// Returns `true` when both masks have the same interval vector but distinct
204    /// transposition-inversion prime forms.
205    pub fn is_z_related_to(self, other: Self) -> bool {
206        self.count_bits() == other.count_bits()
207            && self.interval_vector() == other.interval_vector()
208            && classify_set(self, SetEquivalence::TranspositionInversion).prime
209                != classify_set(other, SetEquivalence::TranspositionInversion).prime
210    }
211
212    /// Returns the number of pitch classes in the set (the population count).
213    pub fn count_bits(self) -> u32 {
214        self.0.count_ones()
215    }
216
217    /// Returns the [`IntervalVector`] tallying interval classes among the set's
218    /// pitch classes.
219    pub fn interval_vector(self) -> IntervalVector {
220        let pitch_classes = self.pitch_classes();
221        let mut bins = [0u16; 6];
222        for (index, a) in pitch_classes.iter().enumerate() {
223            for b in pitch_classes.iter().skip(index + 1) {
224                let class = a.interval_class(*b);
225                if class > 0 {
226                    bins[(class - 1) as usize] += 1;
227                }
228            }
229        }
230        IntervalVector(bins)
231    }
232}
233
234/// A bitmask over the 128 MIDI keys, with bit `n` set when MIDI key `n` is present.
235///
236/// Unlike [`PitchClassMask`], this preserves octave, so it represents a concrete
237/// set of sounding pitches rather than pitch classes.
238#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Default)]
239pub struct PitchRangeMask {
240    /// The packed key bits, with bit `n` corresponding to MIDI key `n`.
241    pub bits: u128,
242}
243
244impl PitchRangeMask {
245    /// Adds `midi_key` to the set.
246    pub fn set(&mut self, midi_key: u8) {
247        self.bits |= 1u128 << midi_key;
248    }
249
250    /// Removes `midi_key` from the set.
251    pub fn clear(&mut self, midi_key: u8) {
252        self.bits &= !(1u128 << midi_key);
253    }
254
255    /// Returns `true` if `midi_key` is present in the set.
256    pub fn contains(self, midi_key: u8) -> bool {
257        self.bits & (1u128 << midi_key) != 0
258    }
259
260    /// Returns the union of this mask with `other`.
261    pub fn union(self, other: Self) -> Self {
262        Self {
263            bits: self.bits | other.bits,
264        }
265    }
266
267    /// Returns the intersection of this mask with `other`.
268    pub fn intersection(self, other: Self) -> Self {
269        Self {
270            bits: self.bits & other.bits,
271        }
272    }
273
274    /// Returns the keys present in this mask but not in `other`.
275    pub fn difference(self, other: Self) -> Self {
276        Self {
277            bits: self.bits & !other.bits,
278        }
279    }
280
281    /// Returns the set keys as concrete [`Pitch`] values in ascending order.
282    pub fn to_pitches(self) -> Vec<Pitch> {
283        (0..128u8)
284            .filter(|key| self.contains(*key))
285            .map(Pitch::from_midi)
286            .collect()
287    }
288}
289
290/// An interval-class vector: counts of each of the six interval classes (1..=6)
291/// occurring among a set's pitch classes.
292#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
293pub struct IntervalVector(pub [u16; 6]);
294
295/// A chord represented as a [`PitchClassMask`] with an optional designated root.
296///
297/// When `root` is `None` the chord is rootless and can be reduced to its
298/// transposition-invariant normal form via [`BitChord::canonical`].
299#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
300pub struct BitChord {
301    /// The pitch classes that make up the chord.
302    pub mask: PitchClassMask,
303    /// The chord root, or `None` for a rootless chord.
304    pub root: Option<PitchClass>,
305}
306
307impl BitChord {
308    /// Returns a canonical form: rooted chords are returned unchanged, while
309    /// rootless chords are normalized to their lowest-valued rotation.
310    pub fn canonical(self) -> Self {
311        if self.root.is_some() {
312            self
313        } else {
314            Self {
315                mask: self.mask.normalize(),
316                root: None,
317            }
318        }
319    }
320}
321
322/// A single step in a stack of thirds: a minor third (3 semitones) or major third
323/// (4 semitones).
324#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
325pub enum ThirdStep {
326    /// A minor third (3 semitones).
327    Minor,
328    /// A major third (4 semitones).
329    Major,
330}
331
332/// A chord described as a root pitch class plus an ordered stack of third [`ThirdStep`]s.
333///
334/// This tertian encoding captures triads, sevenths, and extended chords as a
335/// sequence of stacked thirds, with run-length limits that reject implausible
336/// stacks.
337#[derive(Clone, Debug, PartialEq, Eq, Hash)]
338pub struct ThirdStackSignature {
339    /// The pitch class at the bottom of the stack.
340    pub root: PitchClass,
341    /// The ordered thirds stacked above the root.
342    pub steps: Vec<ThirdStep>,
343    /// A guard bit reserved by the bit encoding to mark the end of the step run.
344    pub guard: bool,
345}
346
347impl ThirdStackSignature {
348    /// Validates the step run, rejecting four consecutive minor thirds or three
349    /// consecutive major thirds.
350    pub fn validate(&self) -> Result<(), PitchSetError> {
351        let mut minor_run = 0usize;
352        let mut major_run = 0usize;
353        for step in &self.steps {
354            match step {
355                ThirdStep::Minor => {
356                    minor_run += 1;
357                    major_run = 0;
358                }
359                ThirdStep::Major => {
360                    major_run += 1;
361                    minor_run = 0;
362                }
363            }
364            if minor_run >= 4 || major_run >= 3 {
365                return Err(PitchSetError::InvalidThirdStack);
366            }
367        }
368        Ok(())
369    }
370
371    /// Encodes this signature into a compact `u32`, validating it first.
372    pub fn encode(&self) -> Result<u32, PitchSetError> {
373        self.validate()?;
374        let mut encoded = u32::from(self.root.value());
375        for (index, step) in self.steps.iter().enumerate() {
376            let bit = if matches!(step, ThirdStep::Major) {
377                1u32
378            } else {
379                0u32
380            };
381            encoded |= bit << (4 + index);
382        }
383        if self.guard {
384            encoded |= 1u32 << (4 + self.steps.len());
385        }
386        Ok(encoded)
387    }
388
389    /// Decodes a `u32` produced by [`ThirdStackSignature::encode`] back into a
390    /// validated signature.
391    pub fn decode(encoded: u32) -> Result<Self, PitchSetError> {
392        let root =
393            PitchClass::new(u8::try_from(encoded & 0x0f).expect("third-stack root nibble fits u8"))
394                .map_err(|_| PitchSetError::InvalidThirdStackEncoding)?;
395        let mut steps = Vec::new();
396        let mut index = 4u32;
397        let mut guard = false;
398        while index < 31 {
399            let bit = (encoded >> index) & 1;
400            if ((encoded >> (index + 1)) & 1) == 0 && bit == 1 && index > 4 {
401                guard = true;
402                break;
403            }
404            steps.push(if bit == 0 {
405                ThirdStep::Minor
406            } else {
407                ThirdStep::Major
408            });
409            index += 1;
410            if steps.len() >= 8 {
411                break;
412            }
413        }
414        let signature = Self { root, steps, guard };
415        signature.validate()?;
416        Ok(signature)
417    }
418
419    /// Returns a single-character family tag classifying the stack by its count of
420    /// major thirds.
421    pub fn family_tag(&self) -> char {
422        let majors = self
423            .steps
424            .iter()
425            .filter(|step| matches!(step, ThirdStep::Major))
426            .count();
427        match majors {
428            0..=2 => 'w',
429            3 => 'x',
430            4 => 'y',
431            _ => 'z',
432        }
433    }
434
435    /// Realizes the stacked thirds into the [`PitchClassMask`] of the chord's
436    /// pitch classes.
437    pub fn to_mask(&self) -> PitchClassMask {
438        let mut pitch_classes = vec![self.root];
439        let mut current = self.root;
440        for step in &self.steps {
441            current = current.transpose(match step {
442                ThirdStep::Minor => 3,
443                ThirdStep::Major => 4,
444            });
445            pitch_classes.push(current);
446        }
447        PitchClassMask::from_pitch_classes(&pitch_classes)
448    }
449}