Skip to main content

music21_rs/alpha/analysis/
search.rs

1//! Runs of notes climbing or falling through a scale: music21's
2//! `alpha.analysis.search`.
3//!
4//! ```
5//! use music21_rs::alpha::analysis::search::{ScaleRunOptions, find_consecutive_scale};
6//! use music21_rs::scale::{Scale, ScaleType};
7//! use music21_rs::tinynotation::from_tiny_notation;
8//! use music21_rs::Pitch;
9//!
10//! let line = from_tiny_notation("4/4 a4 b c'# d' e' f'#")?.flatten();
11//! let a_major = Scale::new(ScaleType::Major, Pitch::from_name("A4")?);
12//! let runs = find_consecutive_scale(&line, &a_major, &ScaleRunOptions::default())?;
13//! assert_eq!(runs.len(), 1);
14//! assert_eq!(runs[0].elements.len(), 6);
15//! # Ok::<(), music21_rs::Error>(())
16//! ```
17
18use std::collections::BTreeSet;
19
20use crate::error::{Error, Result};
21use crate::pitch::Pitch;
22use crate::scale::{DegreeComparison, Scale};
23use crate::stream::{Stream, StreamElement};
24
25/// Which way a run goes through the scale.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
27#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
28pub enum RunDirection {
29    /// Up the scale.
30    Ascending,
31    /// Down the scale.
32    Descending,
33}
34
35/// What two pitches must share to count as the same: music21's
36/// `comparisonAttribute`.
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
38#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39pub enum PitchAttribute {
40    /// The name, octave aside: `name`.
41    #[default]
42    Name,
43    /// The name and the octave: `nameWithOctave`.
44    NameWithOctave,
45    /// The pitch class: `pitchClass`.
46    PitchClass,
47    /// The letter: `step`.
48    Step,
49}
50
51impl PitchAttribute {
52    fn key(self, pitch: &Pitch) -> String {
53        match self {
54            Self::Name => pitch.name(),
55            Self::NameWithOctave => pitch.name_with_octave(),
56            Self::PitchClass => pitch.pitch_class().number().to_string(),
57            Self::Step => pitch.step().as_char().to_string(),
58        }
59    }
60
61    /// How a scale degree is found under this comparison. music21 looks a
62    /// pitch up among the scale's pitches realized about it, octave and
63    /// all, so a name with its octave finds the degree its name does.
64    fn degree_comparison(self) -> DegreeComparison {
65        match self {
66            Self::Name | Self::NameWithOctave => DegreeComparison::Name,
67            Self::PitchClass => DegreeComparison::PitchClass,
68            Self::Step => DegreeComparison::Step,
69        }
70    }
71}
72
73/// music21's keyword arguments to `findConsecutiveScale`.
74#[derive(Clone, Debug, PartialEq, Eq)]
75#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
76pub struct ScaleRunOptions {
77    /// How many different degrees a run must reach: `degreesRequired`.
78    pub degrees_required: usize,
79    /// How many degrees each step of a run moves: `stepSize`.
80    pub step_size: usize,
81    /// What a pitch and a scale pitch must share: `comparisonAttribute`.
82    pub comparison: PitchAttribute,
83    /// Whether a degree repeated keeps a run going: `repeatsAllowed`.
84    pub repeats_allowed: bool,
85}
86
87impl Default for ScaleRunOptions {
88    fn default() -> Self {
89        Self {
90            degrees_required: 5,
91            step_size: 1,
92            comparison: PitchAttribute::Name,
93            repeats_allowed: true,
94        }
95    }
96}
97
98/// A run found: the notes in it, by their place among the stream's own
99/// elements, and the way it goes, where it has gone one.
100#[derive(Clone, Debug, PartialEq, Eq)]
101#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
102pub struct ScaleRun {
103    /// The places of the notes in the stream's own elements, in order.
104    pub elements: Vec<usize>,
105    /// Which way the run went.
106    pub direction: Option<RunDirection>,
107}
108
109/// The degree a pitch stands on, read in a direction, or both where none
110/// is said: music21's `getScaleDegreeFromPitch`.
111fn degree(
112    scale: &Scale,
113    pitch: &Pitch,
114    direction: Option<RunDirection>,
115    comparison: PitchAttribute,
116) -> Result<Option<usize>> {
117    let comparison = comparison.degree_comparison();
118    match direction {
119        Some(RunDirection::Ascending) => scale.degree_of_by(pitch, comparison),
120        Some(RunDirection::Descending) => scale.descending().degree_of_by(pitch, comparison),
121        None => match scale.degree_of_by(pitch, comparison)? {
122            Some(found) => Ok(Some(found)),
123            None => scale.descending().degree_of_by(pitch, comparison),
124        },
125    }
126}
127
128/// Whether `other` is the pitch `steps` degrees from `origin` in a
129/// direction: music21's `isNext`, which is false where there is no other
130/// pitch and refuses where no direction is said.
131fn is_next(
132    scale: &Scale,
133    other: Option<&Pitch>,
134    origin: &Pitch,
135    direction: Option<RunDirection>,
136    steps: usize,
137    comparison: PitchAttribute,
138) -> Result<bool> {
139    let Some(other) = other else {
140        return Ok(false);
141    };
142    let next = match direction {
143        Some(RunDirection::Ascending) => scale.next_pitch_beside(origin, steps as _, true)?,
144        Some(RunDirection::Descending) => scale.descending().next_pitch_beside(
145            origin,
146            -(steps as crate::defaults::IntegerType),
147            false,
148        )?,
149        None => {
150            return Err(Error::Scale(
151                "cannot match direction specification: None".to_string(),
152            ));
153        }
154    };
155    Ok(comparison.key(&next) == comparison.key(other))
156}
157
158/// Every run of notes in a stream going one way through a scale, a degree
159/// at a time, that reaches at least as many different degrees as asked:
160/// music21's `findConsecutiveScale`.
161///
162/// The stream's own notes are read in order, its chords, rests and
163/// streams passed over -- so a rest never breaks a run, whatever
164/// music21's `restsAllowed` says, since it never reads one. A run is
165/// greedy: it goes on as long as the next note continues it. A note off
166/// the scale ends a run; a note on it that does not continue the run ends
167/// it and starts the next.
168///
169/// # Errors
170///
171/// A scale its pitches cannot be read in, or a run asked for of fewer than
172/// two degrees, which music21 cannot tell the way of.
173pub fn find_consecutive_scale(
174    source: &Stream,
175    scale: &Scale,
176    options: &ScaleRunOptions,
177) -> Result<Vec<ScaleRun>> {
178    let notes: Vec<(usize, Pitch)> = source
179        .events()
180        .iter()
181        .enumerate()
182        .filter_map(|(place, event)| match event.element() {
183            StreamElement::Note(note) => Some((place, note.pitch().clone())),
184            _ => None,
185        })
186        .collect();
187    let mut order: Vec<usize> = (0..notes.len()).collect();
188    order.sort_by(|&left, &right| {
189        source.events()[notes[left].0]
190            .offset()
191            .total_cmp(&source.events()[notes[right].0].offset())
192    });
193    let notes: Vec<(usize, Pitch)> = order
194        .into_iter()
195        .map(|index| notes[index].clone())
196        .collect();
197
198    let comparison = options.comparison;
199    let step = options.step_size;
200    let mut degree_last: Option<usize> = None;
201    let mut pitch_last: Option<Pitch> = None;
202    let mut direction_last: Option<RunDirection> = None;
203    let mut degrees: BTreeSet<usize> = BTreeSet::new();
204    let mut elements: Vec<usize> = Vec::new();
205    let mut clear = false;
206    let mut clear_keeping_last = false;
207    let mut runs = Vec::new();
208
209    for (index, (place, pitch)) in notes.iter().enumerate() {
210        let next = notes.get(index + 1).map(|(_, next)| next);
211        let found_degree = degree(scale, pitch, direction_last, comparison)?;
212        match found_degree {
213            None => clear = true,
214            Some(found) => {
215                let collect = match (degree_last, &pitch_last) {
216                    (None, _) | (_, None) => true,
217                    (Some(last), _) if options.repeats_allowed && last == found => true,
218                    (Some(_), Some(previous)) => {
219                        if matches!(direction_last, None | Some(RunDirection::Ascending))
220                            && is_next(
221                                scale,
222                                Some(pitch),
223                                previous,
224                                Some(RunDirection::Ascending),
225                                step,
226                                comparison,
227                            )?
228                        {
229                            direction_last = Some(RunDirection::Ascending);
230                            true
231                        } else if matches!(direction_last, None | Some(RunDirection::Descending))
232                            && is_next(
233                                scale,
234                                Some(pitch),
235                                previous,
236                                Some(RunDirection::Descending),
237                                step,
238                                comparison,
239                            )?
240                        {
241                            direction_last = Some(RunDirection::Descending);
242                            true
243                        } else {
244                            clear_keeping_last = true;
245                            false
246                        }
247                    }
248                };
249                if collect {
250                    degrees.insert(found);
251                    elements.push(*place);
252                }
253            }
254        }
255        degree_last = found_degree;
256        pitch_last = Some(pitch.clone());
257
258        if degrees.len() >= options.degrees_required
259            && !is_next(scale, next, pitch, direction_last, 1, comparison)?
260        {
261            runs.push(ScaleRun {
262                elements: elements.clone(),
263                direction: direction_last,
264            });
265            if !clear && !clear_keeping_last {
266                let continues = is_next(
267                    scale,
268                    next,
269                    pitch,
270                    Some(RunDirection::Descending),
271                    step,
272                    comparison,
273                )? || is_next(
274                    scale,
275                    next,
276                    pitch,
277                    Some(RunDirection::Ascending),
278                    step,
279                    comparison,
280                )?;
281                if continues {
282                    clear_keeping_last = true;
283                } else {
284                    clear = true;
285                }
286            }
287        }
288        if clear {
289            degree_last = None;
290            direction_last = None;
291            degrees.clear();
292            elements.clear();
293            clear = false;
294        }
295        if clear_keeping_last {
296            direction_last = None;
297            degrees.clear();
298            // music21 keeps the degree the note was last found on, which
299            // is nothing where it is off the scale.
300            if let Some(found) = found_degree {
301                degrees.insert(found);
302            }
303            elements = vec![*place];
304            clear_keeping_last = false;
305        }
306    }
307    Ok(runs)
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::note::Note;
314    use crate::scale::ScaleType;
315
316    fn line(names: &[&str]) -> Stream {
317        let mut stream = Stream::new();
318        for name in names {
319            stream.push(Note::from_name(name).unwrap());
320        }
321        stream
322    }
323
324    fn runs(names: &[&str], required: usize, comparison: PitchAttribute) -> Vec<(usize, String)> {
325        let source = line(names);
326        let a_major = Scale::new(ScaleType::Major, Pitch::from_name("A4").unwrap());
327        let options = ScaleRunOptions {
328            degrees_required: required,
329            comparison,
330            ..ScaleRunOptions::default()
331        };
332        find_consecutive_scale(&source, &a_major, &options)
333            .unwrap()
334            .into_iter()
335            .map(|run| {
336                let StreamElement::Note(first) = source.events()[run.elements[0]].element() else {
337                    unreachable!("a run holds notes")
338                };
339                (run.elements.len(), first.pitch().name_with_octave())
340            })
341            .collect()
342    }
343
344    /// music21's testFindConsecutiveScaleA.
345    #[test]
346    fn runs_are_found_as_music21_finds_them() {
347        use PitchAttribute::{Name, NameWithOctave};
348        let scale = ["a4", "b4", "c#4", "d4", "e4", "f#4", "g#4", "a4"];
349        assert_eq!(runs(&scale, 4, Name).len(), 1);
350        assert_eq!(runs(&scale, 4, Name)[0].0, 8);
351        assert_eq!(runs(&scale, 4, NameWithOctave)[0].0, 6);
352        let twice = ["a4", "b4", "c#5", "d5", "e5", "a4", "b4", "c#5", "d5", "e5"];
353        assert_eq!(
354            runs(&twice, 4, NameWithOctave)
355                .iter()
356                .map(|r| r.0)
357                .collect::<Vec<_>>(),
358            [5, 5]
359        );
360        let shifted = [
361            "a4", "b8", "c#3", "d3", "e4", "a4", "b9", "c#2", "d4", "e12",
362        ];
363        assert_eq!(
364            runs(&shifted, 4, Name)
365                .iter()
366                .map(|r| r.0)
367                .collect::<Vec<_>>(),
368            [5, 5]
369        );
370        let thrice = [
371            "a4", "b4", "c#5", "d-3", "a4", "b4", "c#5", "d-3", "a4", "b4", "c#5", "d-3",
372        ];
373        assert!(runs(&thrice, 4, NameWithOctave).is_empty());
374        assert_eq!(
375            runs(&thrice, 3, NameWithOctave)
376                .iter()
377                .map(|r| r.0)
378                .collect::<Vec<_>>(),
379            [3, 3, 3]
380        );
381
382        let up = ["c#5", "d3", "e4", "f#4", "g#4"];
383        let down = ["g#4", "f#4", "e4", "d3", "c#5"];
384        let turning: Vec<&str> = [up, down, up, down, down].concat();
385        assert!(runs(&turning, 5, NameWithOctave).is_empty());
386        let found = runs(&turning, 5, Name);
387        assert_eq!(
388            found.iter().map(|r| r.1.as_str()).collect::<Vec<_>>(),
389            ["C#5", "G#4", "C#5", "G#4", "G#4"]
390        );
391        assert!(found.iter().all(|run| run.0 == 5));
392
393        let mingled: Vec<&str> = [
394            &up[..],
395            &down,
396            &["g2", "e#7"],
397            &up,
398            &["a-2"],
399            &down,
400            &["a", "b"],
401            &down,
402        ]
403        .concat();
404        assert!(runs(&mingled, 5, NameWithOctave).is_empty());
405        let found = runs(&mingled, 5, Name);
406        assert_eq!(
407            found.iter().map(|r| r.1.as_str()).collect::<Vec<_>>(),
408            ["C#5", "G#4", "C#5", "G#4", "G#4"]
409        );
410    }
411}