Skip to main content

sim_lib_music_counterpoint/
rule.rs

1use sim_lib_music_core::{Pitch, Time};
2use thiserror::Error;
3
4/// Conventional species label attached to a rule set.
5#[derive(Copy, Clone, Debug, PartialEq, Eq)]
6pub enum Species {
7    /// One note against one note.
8    First,
9    /// Two notes against one note.
10    Second,
11    /// Four notes against one note.
12    Third,
13    /// Suspensions and tied syncopation.
14    Fourth,
15    /// Caller-authored non-species policy.
16    Open,
17}
18
19/// Allowed pitch range, inclusive at both ends.
20#[derive(Copy, Clone, Debug, PartialEq, Eq)]
21pub struct PitchRange {
22    /// Lowest allowed pitch.
23    pub low: Pitch,
24    /// Highest allowed pitch.
25    pub high: Pitch,
26}
27
28impl PitchRange {
29    /// Full MIDI pitch range.
30    pub fn midi() -> Self {
31        Self {
32            low: Pitch::from_midi(0),
33            high: Pitch::from_midi(127),
34        }
35    }
36
37    /// Returns whether `pitch` lies in this inclusive range.
38    pub fn contains(self, pitch: Pitch) -> bool {
39        self.low <= pitch && pitch <= self.high
40    }
41}
42
43/// Interval constraints for melodic and simultaneous motion.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct IntervalRules {
46    /// Largest allowed absolute melodic leap in semitones.
47    pub max_melodic_semitones: u8,
48    /// Explicitly forbidden absolute melodic intervals.
49    pub forbidden_melodic_semitones: Vec<u8>,
50    /// Accepted harmonic interval classes in `0..=6`.
51    pub consonant_harmonic_classes: Vec<u8>,
52    /// Harmonic classes treated as perfect for motion checks.
53    pub perfect_harmonic_classes: Vec<u8>,
54}
55
56/// Relative-motion policy.
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct MotionRules {
59    /// Reject similar motion between repeated perfect interval classes.
60    pub forbid_parallel_perfects: bool,
61    /// Reject similar motion into a perfect interval when either voice leaps.
62    pub forbid_direct_perfects: bool,
63    /// Semitone distance above which a voice movement is a leap.
64    pub leap_threshold: u8,
65}
66
67/// Register, crossing, and overlap policy.
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub struct VoiceRules {
70    /// Default range for voices without an indexed override.
71    pub default_range: PitchRange,
72    /// Per-voice range overrides by source index.
73    pub ranges: Vec<PitchRange>,
74    /// Whether lower source indices are expected to remain at higher pitches.
75    pub highest_voice_first: bool,
76    /// Whether voices may exchange vertical order while sounding.
77    pub allow_crossing: bool,
78    /// Whether a voice may move beyond the other voice's previous pitch.
79    pub allow_overlap: bool,
80}
81
82/// Exact-duration policy expressed as ratios of one caller-visible pulse.
83#[derive(Clone, Debug, PartialEq, Eq)]
84pub struct DurationRules {
85    /// Exact reference pulse.
86    pub pulse: Time,
87    /// Allowed note durations divided by `pulse`; empty means unrestricted.
88    pub allowed_pulse_ratios: Vec<Time>,
89}
90
91/// Recognized context in which a dissonant class may be accepted.
92#[derive(Copy, Clone, Debug, PartialEq, Eq)]
93pub enum DissonanceContext {
94    /// Stepwise motion continues in the same direction.
95    Passing,
96    /// Stepwise motion leaves and returns to the same pitch.
97    Neighbor,
98    /// A prepared held note resolves downward by step.
99    Suspension,
100}
101
102/// Preparation and resolution policy for non-consonant intervals.
103#[derive(Clone, Debug, PartialEq, Eq)]
104pub struct DissonanceRules {
105    /// Contexts that may legalize a non-consonant interval.
106    pub allowed_contexts: Vec<DissonanceContext>,
107    /// Maximum absolute semitone distance considered stepwise.
108    pub max_step_semitones: u8,
109    /// Whether passing and neighboring dissonances must begin off the pulse.
110    pub require_weak_attack: bool,
111}
112
113/// Complete inspectable counterpoint rule set.
114#[derive(Clone, Debug, PartialEq, Eq)]
115pub struct RuleSet {
116    /// Stable rule-set identifier.
117    pub id: String,
118    /// Species label.
119    pub species: Species,
120    /// Melodic and harmonic interval data.
121    pub intervals: IntervalRules,
122    /// Relative-motion data.
123    pub motion: MotionRules,
124    /// Range, crossing, and overlap data.
125    pub voices: VoiceRules,
126    /// Exact duration data.
127    pub durations: DurationRules,
128    /// Dissonance preparation and resolution data.
129    pub dissonance: DissonanceRules,
130}
131
132/// Invalid caller-authored rule data.
133#[derive(Clone, Debug, Error, PartialEq, Eq)]
134pub enum RuleError {
135    /// Rule-set id was empty.
136    #[error("counterpoint rule-set id cannot be empty")]
137    EmptyId,
138    /// Duration pulse was not positive.
139    #[error("counterpoint duration pulse must be positive")]
140    InvalidPulse,
141    /// A pitch range was reversed.
142    #[error("counterpoint pitch range {index} is reversed")]
143    ReversedRange {
144        /// Index in the effective range list.
145        index: usize,
146    },
147    /// An interval class was outside `0..=6`.
148    #[error("counterpoint harmonic interval class {value} is outside 0..=6")]
149    InvalidIntervalClass {
150        /// Invalid class.
151        value: u8,
152    },
153}
154
155impl RuleSet {
156    /// First-species rules with whole-pulse durations.
157    pub fn species_one(pulse: Time) -> Self {
158        species_rules(
159            "species-one",
160            Species::First,
161            pulse,
162            vec![Time::from_integer(1)],
163            vec![],
164        )
165    }
166
167    /// Second-species rules with weak-beat passing and neighbor tones.
168    pub fn species_two(pulse: Time) -> Self {
169        species_rules(
170            "species-two",
171            Species::Second,
172            pulse,
173            vec![Time::new(1, 2), Time::from_integer(1)],
174            vec![DissonanceContext::Passing, DissonanceContext::Neighbor],
175        )
176    }
177
178    /// Third-species rules with quarter-pulse motion.
179    pub fn species_three(pulse: Time) -> Self {
180        species_rules(
181            "species-three",
182            Species::Third,
183            pulse,
184            vec![Time::new(1, 4), Time::new(1, 2), Time::from_integer(1)],
185            vec![DissonanceContext::Passing, DissonanceContext::Neighbor],
186        )
187    }
188
189    /// Fourth-species rules allowing prepared suspensions.
190    pub fn species_four(pulse: Time) -> Self {
191        species_rules(
192            "species-four",
193            Species::Fourth,
194            pulse,
195            vec![Time::new(1, 2), Time::from_integer(1)],
196            vec![DissonanceContext::Suspension],
197        )
198    }
199
200    /// Open rule set that accepts all pitch classes, durations, crossings, and motion.
201    pub fn open() -> Self {
202        Self {
203            id: "open".to_owned(),
204            species: Species::Open,
205            intervals: IntervalRules {
206                max_melodic_semitones: u8::MAX,
207                forbidden_melodic_semitones: Vec::new(),
208                consonant_harmonic_classes: (0..=6).collect(),
209                perfect_harmonic_classes: vec![0, 5],
210            },
211            motion: MotionRules {
212                forbid_parallel_perfects: false,
213                forbid_direct_perfects: false,
214                leap_threshold: u8::MAX,
215            },
216            voices: VoiceRules {
217                default_range: PitchRange::midi(),
218                ranges: Vec::new(),
219                highest_voice_first: true,
220                allow_crossing: true,
221                allow_overlap: true,
222            },
223            durations: DurationRules {
224                pulse: Time::from_integer(1),
225                allowed_pulse_ratios: Vec::new(),
226            },
227            dissonance: DissonanceRules {
228                allowed_contexts: Vec::new(),
229                max_step_semitones: 2,
230                require_weak_attack: false,
231            },
232        }
233    }
234
235    /// Checks caller-authored rule data before analysis.
236    pub fn validate(&self) -> Result<(), RuleError> {
237        if self.id.trim().is_empty() {
238            return Err(RuleError::EmptyId);
239        }
240        if self.durations.pulse <= Time::from_integer(0) {
241            return Err(RuleError::InvalidPulse);
242        }
243        for (index, range) in std::iter::once(&self.voices.default_range)
244            .chain(&self.voices.ranges)
245            .enumerate()
246        {
247            if range.low > range.high {
248                return Err(RuleError::ReversedRange { index });
249            }
250        }
251        for value in self
252            .intervals
253            .consonant_harmonic_classes
254            .iter()
255            .chain(&self.intervals.perfect_harmonic_classes)
256        {
257            if *value > 6 {
258                return Err(RuleError::InvalidIntervalClass { value: *value });
259            }
260        }
261        Ok(())
262    }
263
264    /// Effective pitch range for one source voice.
265    pub fn range_for_voice(&self, index: usize) -> PitchRange {
266        self.voices
267            .ranges
268            .get(index)
269            .copied()
270            .unwrap_or(self.voices.default_range)
271    }
272}
273
274fn species_rules(
275    id: &str,
276    species: Species,
277    pulse: Time,
278    allowed_pulse_ratios: Vec<Time>,
279    allowed_contexts: Vec<DissonanceContext>,
280) -> RuleSet {
281    RuleSet {
282        id: id.to_owned(),
283        species,
284        intervals: IntervalRules {
285            max_melodic_semitones: 12,
286            forbidden_melodic_semitones: vec![6, 10, 11],
287            consonant_harmonic_classes: vec![0, 3, 4, 5],
288            perfect_harmonic_classes: vec![0, 5],
289        },
290        motion: MotionRules {
291            forbid_parallel_perfects: true,
292            forbid_direct_perfects: true,
293            leap_threshold: 2,
294        },
295        voices: VoiceRules {
296            default_range: PitchRange::midi(),
297            ranges: Vec::new(),
298            highest_voice_first: true,
299            allow_crossing: false,
300            allow_overlap: false,
301        },
302        durations: DurationRules {
303            pulse,
304            allowed_pulse_ratios,
305        },
306        dissonance: DissonanceRules {
307            allowed_contexts,
308            max_step_semitones: 2,
309            require_weak_attack: !matches!(species, Species::Fourth),
310        },
311    }
312}