Skip to main content

music21_rs/
analysis.rs

1use crate::{
2    chord::Chord,
3    defaults::{FloatType, IntegerType},
4    error::{Error, Result},
5    interval::Interval,
6    key::Key,
7    notation::TieType,
8    note::Note,
9    pitch::Pitch,
10    stream::{Stream, StreamElement},
11};
12
13pub mod correlate;
14pub mod elements;
15pub mod enharmonics;
16pub mod floating_key;
17pub mod harmonic_function;
18pub mod metrical;
19pub mod neoriemannian;
20pub mod patel;
21pub mod pitch_analysis;
22pub mod reduce_chords;
23pub mod reduction;
24pub mod segment_by_rests;
25pub mod transposition;
26pub mod windowed;
27
28/// A set of key-finding weights for the Krumhansl-Schmuckler algorithm.
29///
30/// These are the profiles music21's `analysis.discrete` ships, with the
31/// characterisations Craig Sapp gives them in the Humdrum `keycor` manual.
32#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
33#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
34pub enum KeyProfile {
35    /// Krumhansl and Kessler's 1982 probe-tone ratings. Strong tendency to
36    /// name the dominant as the tonic.
37    KrumhanslSchmuckler,
38    /// Aarden's 2003 profile from the Essen folksong collection, music21's
39    /// default. Weak tendency to name the subdominant as the tonic.
40    AardenEssen,
41    /// Sapp's simple weights, most consistent over long stretches of music.
42    SimpleWeights,
43    /// Bellman and Budge's profile, with no particular neighbouring-key bias.
44    BellmanBudge,
45    /// Temperley's Kostka-Payne corpus profile. Strong tendency to name the
46    /// relative major in minor keys.
47    TemperleyKostkaPayne,
48}
49
50impl KeyProfile {
51    /// Every profile, in music21's order.
52    pub const ALL: [KeyProfile; 5] = [
53        Self::KrumhanslSchmuckler,
54        Self::AardenEssen,
55        Self::SimpleWeights,
56        Self::BellmanBudge,
57        Self::TemperleyKostkaPayne,
58    ];
59
60    /// The weights for the twelve pitch classes above a major tonic.
61    pub fn major_weights(self) -> [FloatType; 12] {
62        match self {
63            Self::KrumhanslSchmuckler => [
64                6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88,
65            ],
66            Self::AardenEssen => [
67                17.7661, 0.145624, 14.9265, 0.160186, 19.8049, 11.3587, 0.291248, 22.062, 0.145624,
68                8.15494, 0.232998, 4.95122,
69            ],
70            Self::SimpleWeights => [2.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 2.0, 0.0, 1.0, 0.0, 1.0],
71            Self::BellmanBudge => [
72                16.80, 0.86, 12.95, 1.41, 13.49, 11.93, 1.25, 20.28, 1.80, 8.04, 0.62, 10.57,
73            ],
74            Self::TemperleyKostkaPayne => [
75                0.748, 0.060, 0.488, 0.082, 0.670, 0.460, 0.096, 0.715, 0.104, 0.366, 0.057, 0.400,
76            ],
77        }
78    }
79
80    /// The weights for the twelve pitch classes above a minor tonic.
81    pub fn minor_weights(self) -> [FloatType; 12] {
82        match self {
83            Self::KrumhanslSchmuckler => [
84                6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17,
85            ],
86            Self::AardenEssen => [
87                18.2648, 0.737619, 14.0499, 16.8599, 0.702494, 14.4362, 0.702494, 18.6161, 4.56621,
88                1.93186, 7.37619, 1.75623,
89            ],
90            Self::SimpleWeights => [2.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 2.0, 1.0, 0.0, 0.5, 0.5],
91            Self::BellmanBudge => [
92                18.16, 0.69, 12.99, 13.34, 1.07, 11.15, 1.38, 21.07, 7.49, 1.53, 0.92, 10.21,
93            ],
94            Self::TemperleyKostkaPayne => [
95                0.712, 0.084, 0.474, 0.618, 0.049, 0.460, 0.105, 0.747, 0.404, 0.067, 0.133, 0.330,
96            ],
97        }
98    }
99
100    /// The name music21's class for this profile calls itself by: its
101    /// `name`, which `Stream::analyze` matches a method name against.
102    pub fn music21_name(self) -> &'static str {
103        match self {
104            Self::KrumhanslSchmuckler => "Krumhansl Schmuckler/Kessler Key Analysis",
105            Self::AardenEssen => "Aarden Essen Key Analysis",
106            Self::SimpleWeights => "Simple Weight Key Analysis",
107            Self::BellmanBudge => "Bellman Budge Key Analysis",
108            Self::TemperleyKostkaPayne => "Temperley Kostka Payne Key Analysis",
109        }
110    }
111
112    /// The names a method name may ask for this profile by: music21's
113    /// `identifiers`, which `Stream::analyze` matches loosely.
114    pub fn identifiers(self) -> &'static [&'static str] {
115        match self {
116            Self::KrumhanslSchmuckler => &[
117                "key.krumhansl",
118                "key.schmuckler",
119                "key.krumhansl-schmuckler",
120                "key.krumhanslschmuckler",
121                "krumhansl",
122                "schmuckler",
123                "krumhansl-schmuckler",
124                "krumhanslschmuckler",
125                "key.kessler",
126                "key.krumhansl-kessler",
127                "key.krumhanslkessler",
128                "kessler",
129                "krumhansl-kessler",
130                "krumhanslkessler",
131            ],
132            Self::AardenEssen => &[
133                "key.aarden",
134                "key.essen",
135                "key.aarden-essen",
136                "key.aardenessen",
137                "aarden",
138                "essen",
139                "aarden-essen",
140                "aardenessen",
141                // These make this the analysis a bare `key` asks for.
142                "key",
143                "keyscape",
144            ],
145            Self::SimpleWeights => &[
146                "key.simple",
147                "key.weight",
148                "key.simple-weight",
149                "key.simpleweight",
150                "simple",
151                "weight",
152                "simple-weight",
153                "simpleweight",
154            ],
155            Self::BellmanBudge => &[
156                "key.bellman",
157                "key.budge",
158                "key.bellman-budge",
159                "key.bellmanbudge",
160                "bellman",
161                "budge",
162                "bellman-budge",
163                "bellmanbudge",
164            ],
165            Self::TemperleyKostkaPayne => &[
166                "key.temperley",
167                "key.kostka",
168                "key.payne",
169                "key.temperley-kostka-payne",
170                "key.temperleykostkapayne",
171                "temperley",
172                "kostka",
173                "payne",
174                "temperley-kostka-payne",
175                "temperleykostkapayne",
176            ],
177        }
178    }
179
180    /// The name of the music21 class carrying these weights.
181    pub fn music21_class_name(self) -> &'static str {
182        match self {
183            Self::KrumhanslSchmuckler => "KrumhanslSchmuckler",
184            Self::AardenEssen => "AardenEssen",
185            Self::SimpleWeights => "SimpleWeights",
186            Self::BellmanBudge => "BellmanBudge",
187            Self::TemperleyKostkaPayne => "TemperleyKostkaPayne",
188        }
189    }
190}
191
192/// A ranked key estimate.
193#[derive(Clone, Debug)]
194#[must_use]
195pub struct KeyEstimate {
196    key: Key,
197    score: FloatType,
198}
199
200impl KeyEstimate {
201    /// Returns the estimated key.
202    pub fn key(&self) -> &Key {
203        &self.key
204    }
205
206    /// Returns the correlation score. Higher is a better fit.
207    pub fn score(&self) -> FloatType {
208        self.score
209    }
210}
211
212/// Estimates likely keys from pitches using the Krumhansl-Schmuckler weights.
213pub fn estimate_key_from_pitches(pitches: &[Pitch]) -> Result<Vec<KeyEstimate>> {
214    estimate_key_from_pitches_with(KeyProfile::KrumhanslSchmuckler, pitches)
215}
216
217/// Estimates likely keys from pitches using the given weights, each pitch
218/// counting once.
219pub fn estimate_key_from_pitches_with(
220    profile: KeyProfile,
221    pitches: &[Pitch],
222) -> Result<Vec<KeyEstimate>> {
223    if pitches.is_empty() {
224        return Err(Error::Analysis(
225            "key estimation needs at least one pitch".to_string(),
226        ));
227    }
228    let distribution = pitch_class_distribution(
229        pitches
230            .iter()
231            .map(|pitch| (std::slice::from_ref(pitch), 1.0)),
232    )
233    .expect("there is at least one pitch");
234    Ok(estimate_key_from_distribution(profile, &distribution))
235}
236
237/// Estimates likely keys from chords using the Krumhansl-Schmuckler weights.
238pub fn estimate_key_from_chords(chords: &[Chord]) -> Result<Vec<KeyEstimate>> {
239    estimate_key_from_chords_with(KeyProfile::KrumhanslSchmuckler, chords)
240}
241
242/// Estimates likely keys from chords using the given weights.
243pub fn estimate_key_from_chords_with(
244    profile: KeyProfile,
245    chords: &[Chord],
246) -> Result<Vec<KeyEstimate>> {
247    let pitches = chords.iter().flat_map(Chord::pitches).collect::<Vec<_>>();
248    estimate_key_from_pitches_with(profile, &pitches)
249}
250
251/// How long each pitch class sounds: for each note, its pitches and its
252/// length in quarter notes, every pitch counting for the whole length.
253/// Nothing when there are no notes. music21's
254/// `_getPitchClassDistribution`, a quarter tone rounding to the even class
255/// as music21's `pitchClass` does.
256pub fn pitch_class_distribution<'a>(
257    notes: impl IntoIterator<Item = (&'a [Pitch], FloatType)>,
258) -> Option<[FloatType; 12]> {
259    let mut distribution = [0.0; 12];
260    let mut any = false;
261    for (pitches, length) in notes {
262        any = true;
263        for pitch in pitches {
264            let class = (pitch.ps().round_ties_even() as IntegerType).rem_euclid(12) as usize;
265            distribution[class] += length;
266        }
267    }
268    any.then_some(distribution)
269}
270
271/// How long each pitch class sounds in a stream, nested streams included:
272/// music21's `_getPitchClassDistribution` over the stream's notes, each
273/// pitch counting for its note's whole length. A note is music21's: a note,
274/// a chord, a chord symbol or the pitched members of a percussion chord,
275/// so a stream of chord symbols lasting nothing has a distribution of
276/// nothing but noughts. Nothing for a stream with no notes at all.
277pub fn stream_distribution(stream: &Stream) -> Option<[FloatType; 12]> {
278    let notes: Vec<(Vec<Pitch>, FloatType)> = stream
279        .flatten()
280        .events()
281        .iter()
282        .filter_map(|event| {
283            let element = event.element();
284            let pitches = match element {
285                StreamElement::Note(_)
286                | StreamElement::Chord(_)
287                | StreamElement::PercussionChord(_) => element.pitches(),
288                StreamElement::ChordSymbol(symbol) => symbol.pitches().unwrap_or_default(),
289                _ => return None,
290            };
291            Some((pitches, element.quarter_length()))
292        })
293        .collect();
294    pitch_class_distribution(
295        notes
296            .iter()
297            .map(|(pitches, length)| (pitches.as_slice(), *length)),
298    )
299}
300
301/// The keys a stream is likely in, best first, by `profile`'s weights over
302/// its duration-weighted pitch classes: music21's `KeyWeightKeyAnalysis`
303/// run on a stream. Nothing for a stream with no notes.
304pub fn estimate_key_of_stream(profile: KeyProfile, stream: &Stream) -> Option<Vec<KeyEstimate>> {
305    stream_distribution(stream)
306        .map(|distribution| estimate_key_from_distribution(profile, &distribution))
307}
308
309/// The lowest and highest pitch sounding anywhere in a stream: music21's
310/// `Ambitus.getPitchSpan`, which leaves chord symbols out.
311pub fn stream_pitch_span(stream: &Stream) -> Option<(Pitch, Pitch)> {
312    pitch_span(&stream.pitches())
313}
314
315/// The melodic lines of a stream, as music21's `MelodicIntervalDiversity`
316/// reads them: each part-like stream it holds on its own, or the stream as
317/// one line where it holds none; the notes of each in order, chords and
318/// rests left out, and a note continuing a tie heard once.
319pub fn melodic_lines(stream: &Stream) -> Vec<Vec<Pitch>> {
320    let leaves = stream.leaves();
321    melodic_line_positions(stream)
322        .into_iter()
323        .map(|line| {
324            line.into_iter()
325                .filter_map(|position| match leaves.get(position).map(|leaf| leaf.1) {
326                    Some(StreamElement::Note(note)) => Some(note.pitch().clone()),
327                    _ => None,
328                })
329                .collect()
330        })
331        .collect()
332}
333
334/// The same lines as positions in [`Stream::leaves`], which is the by-position
335/// twin [`melodic_lines`] reads its own answer off: the note a count was
336/// taken from is the note the stream holds, so a caller handing back objects
337/// can hand back its own.
338///
339/// ```
340/// use music21_rs::analysis::melodic_line_positions;
341/// use music21_rs::stream::StreamElement;
342/// use music21_rs::tinynotation::from_tiny_notation;
343///
344/// let line = from_tiny_notation("4/4 c4 r d e")?;
345/// let leaves = line.leaves();
346/// // The clef, the meter and the rest are leaves too; only the notes
347/// // make a line.
348/// let names: Vec<Vec<String>> = melodic_line_positions(&line)
349///     .into_iter()
350///     .map(|line| {
351///         line.into_iter()
352///             .map(|position| match leaves[position].1 {
353///                 StreamElement::Note(note) => note.pitch().name(),
354///                 _ => unreachable!(),
355///             })
356///             .collect()
357///     })
358///     .collect();
359/// assert_eq!(names, [vec!["C", "D", "E"]]);
360/// # Ok::<(), music21_rs::Error>(())
361/// ```
362pub fn melodic_line_positions(stream: &Stream) -> Vec<Vec<usize>> {
363    let leaves = stream.leaves();
364    let tops: Option<Vec<usize>> = stream.has_part_like_streams().then(|| {
365        stream
366            .events()
367            .iter()
368            .enumerate()
369            .filter(|(_, event)| event.element().as_stream().is_some())
370            .map(|(index, _)| index)
371            .collect()
372    });
373    // A line is read in the order a flattening reads it, as music21 reads
374    // each part flattened.
375    let order = Stream::flatten_order(&leaves);
376    let under = stream.leaves_under_top();
377    let of_line = |top: Option<usize>| -> Vec<usize> {
378        order
379            .iter()
380            .copied()
381            .filter(|position| match (top, under.get(*position)) {
382                (Some(top), Some((held, _, _))) => *held == top,
383                (None, _) => true,
384                _ => false,
385            })
386            .filter(|position| {
387                matches!(
388                    leaves.get(*position).map(|leaf| leaf.1),
389                    Some(StreamElement::Note(note)) if !continues_tie(note)
390                )
391            })
392            .collect()
393    };
394    match tops {
395        Some(tops) => tops.into_iter().map(|top| of_line(Some(top))).collect(),
396        None => vec![of_line(None)],
397    }
398}
399
400/// Whether a note carries on a tie from the one before, as music21's
401/// `stripTies` folds into it.
402fn continues_tie(note: &Note) -> bool {
403    note.tie().is_some_and(|tie| {
404        matches!(
405            tie.tie_type(),
406            TieType::Stop | TieType::Continue | TieType::ContinueLetRing
407        )
408    })
409}
410
411/// The key names music21's key analysis spells a major tonic with.
412const VALID_MAJOR: [&str; 15] = [
413    "C", "C#", "Cb", "Db", "D", "Eb", "E", "F", "F#", "Gb", "G", "Ab", "A", "Bb", "B",
414];
415
416/// The key names music21's key analysis spells a minor tonic with.
417const VALID_MINOR: [&str; 15] = [
418    "C", "C#", "D", "D#", "Eb", "E", "F", "F#", "G", "G#", "Ab", "A", "A#", "Bb", "B",
419];
420
421/// Every key ranked by how well `profile` fits `distribution`, best first:
422/// music21's key-weight analysis.
423///
424/// Each of the 24 keys is scored by the correlation between the
425/// distribution and the profile turned to that tonic, summed in music21's
426/// order and averaged as Python sums, so a score is music21's to within the
427/// last digit -- music21 squares through the platform's `pow`, which is not
428/// always exact, where this multiplies. Ties are broken as
429/// music21 breaks them, by the higher tonic and then minor before major, and
430/// each tonic is spelled as music21 spells it: the pitch class's usual name,
431/// respelled where that is no key in the mode, so a major key on the eighth
432/// class is `A-` and a minor one `G#`.
433pub fn estimate_key_from_distribution(
434    profile: KeyProfile,
435    distribution: &[FloatType; 12],
436) -> Vec<KeyEstimate> {
437    let histogram_average = crate::statistics::python_sum(distribution) / 12.0;
438    let mut ranked: Vec<(FloatType, usize, &'static str)> = Vec::with_capacity(24);
439    for (mode, weights) in [
440        ("major", profile.major_weights()),
441        ("minor", profile.minor_weights()),
442    ] {
443        let profile_average = crate::statistics::python_sum(&weights) / 12.0;
444        for tonic in 0..12 {
445            let mut top = 0.0;
446            let mut bottom_right = 0.0;
447            let mut bottom_left = 0.0;
448            for (class, value) in distribution.iter().enumerate() {
449                let weight = weights[(class + 12 - tonic) % 12];
450                top += (weight - profile_average) * (value - histogram_average);
451                bottom_right += (weight - profile_average) * (weight - profile_average);
452                bottom_left += (value - histogram_average) * (value - histogram_average);
453            }
454            let score = if bottom_right == 0.0 || bottom_left == 0.0 {
455                0.0
456            } else {
457                top / (bottom_right * bottom_left).sqrt()
458            };
459            ranked.push((score, tonic, mode));
460        }
461    }
462    ranked.sort_by(|a, b| b.0.total_cmp(&a.0).then(b.1.cmp(&a.1)).then(b.2.cmp(a.2)));
463    ranked
464        .into_iter()
465        .map(|(score, tonic, mode)| KeyEstimate {
466            key: key_on(tonic, mode),
467            score,
468        })
469        .collect()
470}
471
472/// The key on pitch class `tonic` in `mode`, spelled as music21's analysis
473/// spells it.
474fn key_on(tonic: usize, mode: &str) -> Key {
475    const USUAL: [&str; 12] = [
476        "C", "C#", "D", "Eb", "E", "F", "F#", "G", "G#", "A", "Bb", "B",
477    ];
478    const RESPELLED: [&str; 12] = [
479        "B#", "Db", "C##", "D#", "Fb", "E#", "Gb", "F##", "Ab", "Bbb", "A#", "Cb",
480    ];
481    let valid: &[&str] = if mode == "major" {
482        &VALID_MAJOR
483    } else {
484        &VALID_MINOR
485    };
486    let name = if valid.contains(&USUAL[tonic]) {
487        USUAL[tonic]
488    } else {
489        RESPELLED[tonic]
490    };
491    Key::from_tonic_mode(name, mode).expect("every analysis tonic names a key")
492}
493
494/// The lowest and the highest pitch among `pitches`, the first of each
495/// where several share a place: music21's `Ambitus.getPitchSpan`. Nothing
496/// for no pitches.
497pub fn pitch_span(pitches: &[Pitch]) -> Option<(Pitch, Pitch)> {
498    let first = pitches.first()?;
499    let mut lowest = first;
500    let mut highest = first;
501    for pitch in &pitches[1..] {
502        if pitch.ps() < lowest.ps() {
503            lowest = pitch;
504        }
505        if pitch.ps() > highest.ps() {
506            highest = pitch;
507        }
508    }
509    Some((lowest.clone(), highest.clone()))
510}
511
512/// Every melodic interval between neighbouring notes of each line, with how
513/// often it comes, in the order each is first met: music21's
514/// `MelodicIntervalDiversity.countMelodicIntervals`. With `ignore_direction`
515/// a falling interval counts as the rising one, and with `ignore_unison` a
516/// repeated note does not count. Intervals are told apart by their directed
517/// name, and the first of each is the one kept.
518///
519/// # Errors
520///
521/// Two neighbouring pitches no interval can be spelled between.
522pub fn melodic_interval_counts(
523    lines: &[Vec<Pitch>],
524    ignore_direction: bool,
525    ignore_unison: bool,
526) -> Result<Vec<(Interval, usize)>> {
527    let mut found: Vec<(String, Interval, usize)> = Vec::new();
528    for line in lines {
529        for pair in line.windows(2) {
530            let mut interval = Interval::between_pitches(&pair[0], &pair[1])?;
531            let semitones = interval.semitones();
532            if ignore_unison && semitones == 0.0 {
533                continue;
534            }
535            if ignore_direction && semitones < 0.0 {
536                interval = interval.reversed()?;
537            }
538            let name = interval.directed_name();
539            match found.iter_mut().find(|(known, _, _)| *known == name) {
540                Some((_, _, count)) => *count += 1,
541                None => found.push((name, interval, 1)),
542            }
543        }
544    }
545    Ok(found
546        .into_iter()
547        .map(|(_, interval, count)| (interval, count))
548        .collect())
549}
550
551/// How decisively the first of a ranked list of key estimates wins: music21's
552/// `Key.tonalCertainty` for a key that came out of analysis. It is the
553/// leader's score plus twice its lead over the next positive score; with no
554/// positive runner-up it is the leader's score, floored at zero.
555pub fn tonal_certainty(estimates: &[KeyEstimate]) -> FloatType {
556    let scores: Vec<FloatType> = estimates.iter().map(KeyEstimate::score).collect();
557    tonal_certainty_from_scores(&scores)
558}
559
560/// The same measure over the scores alone, for a ranking that came from
561/// somewhere else — music21 hands its own analysis results around as keys
562/// carrying a `correlationCoefficient` rather than as estimates.
563pub fn tonal_certainty_from_scores(scores: &[FloatType]) -> FloatType {
564    let Some(leader) = scores.first().copied() else {
565        return 0.0;
566    };
567    match scores[1..].iter().copied().find(|score| *score > 0.0) {
568        Some(second) => leader + 2.0 * (leader - second),
569        None => leader.max(0.0),
570    }
571}
572
573#[cfg(test)]
574mod tests {
575
576    #[test]
577    fn a_stream_is_weighed_as_music21_weighs_it() {
578        use super::*;
579        use crate::{Duration, Note, Stream, StreamKind};
580
581        // music21's own example: C for three quarters, F# for two, then a
582        // chord of D, E and B- for one and a half.
583        let note = |name: &str, length: FloatType| {
584            let mut note = Note::from_name(name).unwrap();
585            note.set_duration(Duration::new(length).unwrap());
586            note
587        };
588        let mut stream = Stream::new();
589        stream.push(note("C4", 3.0));
590        stream.push(note("F#4", 2.0));
591        assert_eq!(
592            stream_distribution(&stream),
593            Some([3.0, 0.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0])
594        );
595        let mut chord = Chord::new("D4 E4 B-4").unwrap();
596        chord.set_duration(Duration::new(1.5).unwrap());
597        stream.push(chord);
598        assert_eq!(
599            stream_distribution(&stream),
600            Some([3.0, 0.0, 1.5, 0.0, 1.5, 0.0, 2.0, 0.0, 0.0, 0.0, 1.5, 0.0])
601        );
602        assert!(estimate_key_of_stream(KeyProfile::AardenEssen, &stream).is_some());
603        assert_eq!(stream_distribution(&Stream::new()), None);
604
605        // Ambitus: the lowest and highest pitch anywhere in the nesting.
606        let (low, high) = stream_pitch_span(&stream).unwrap();
607        assert_eq!(
608            (low.name_with_octave(), high.name_with_octave()),
609            ("C4".into(), "Bb4".into())
610        );
611
612        // Two parts, each its own line; a tied note is heard once.
613        let mut upper = Stream::with_kind(StreamKind::Part);
614        let mut tied = note("E4", 1.0);
615        tied.set_tie(Some(crate::Tie::new(crate::TieType::Start)));
616        let mut held = note("E4", 1.0);
617        held.set_tie(Some(crate::Tie::new(crate::TieType::Stop)));
618        for element in [note("C4", 1.0), tied, held, note("G4", 1.0)] {
619            upper.push(element);
620        }
621        let mut lower = Stream::with_kind(StreamKind::Part);
622        lower.push(note("C3", 2.0));
623        lower.push(note("G2", 2.0));
624        let mut score = Stream::with_kind(StreamKind::Score);
625        score.insert(0.0, upper);
626        score.insert(0.0, lower);
627        let lines: Vec<Vec<String>> = melodic_lines(&score)
628            .iter()
629            .map(|line| line.iter().map(Pitch::name_with_octave).collect())
630            .collect();
631        assert_eq!(lines, [vec!["C4", "E4", "G4"], vec!["C3", "G2"]]);
632    }
633
634    #[test]
635    fn a_profile_names_its_music21_class() {
636        assert_eq!(KeyProfile::AardenEssen.music21_class_name(), "AardenEssen");
637        assert_eq!(
638            KeyProfile::KrumhanslSchmuckler.music21_class_name(),
639            "KrumhanslSchmuckler"
640        );
641    }
642
643    #[test]
644    fn tonal_certainty_rewards_a_clear_leader() {
645        let scale: Vec<Pitch> = ["C4", "D4", "E4", "F4", "G4", "A4", "B4", "C5"]
646            .iter()
647            .map(|name| Pitch::from_name(*name).unwrap())
648            .collect();
649        let ranked = estimate_key_from_pitches(&scale).unwrap();
650        let leader = ranked[0].score();
651        let second = ranked[1].score();
652        assert!(second > 0.0);
653        assert!((tonal_certainty(&ranked) - (leader + 2.0 * (leader - second))).abs() < 1e-12);
654        assert_eq!(tonal_certainty(&ranked[..1]), leader.max(0.0));
655        assert_eq!(tonal_certainty(&[]), 0.0);
656    }
657    use super::*;
658
659    #[test]
660    fn estimates_c_major_from_tonic_triad_material() {
661        let pitches = ["C4", "E4", "G4", "C5", "E5", "G5"]
662            .into_iter()
663            .map(Pitch::from_name)
664            .collect::<Result<Vec<_>>>()
665            .unwrap();
666        let estimates = estimate_key_from_pitches(&pitches).unwrap();
667        assert_eq!(estimates[0].key().tonic().name(), "C");
668        assert_eq!(estimates[0].key().mode(), "major");
669    }
670
671    #[test]
672    fn estimates_from_chords() {
673        let chords = [Chord::new("C E G").unwrap(), Chord::new("F A C").unwrap()];
674        let estimates = estimate_key_from_chords(&chords).unwrap();
675        assert!(!estimates.is_empty());
676    }
677
678    #[test]
679    fn every_profile_agrees_on_unambiguous_material() {
680        let pitches = ["C4", "D4", "E4", "F4", "G4", "A4", "B4", "C5", "G4", "C4"]
681            .into_iter()
682            .map(Pitch::from_name)
683            .collect::<Result<Vec<_>>>()
684            .unwrap();
685        for profile in KeyProfile::ALL {
686            let estimates = estimate_key_from_pitches_with(profile, &pitches).unwrap();
687            assert_eq!(estimates[0].key().tonic().name(), "C", "{profile:?}");
688            assert_eq!(estimates[0].key().mode(), "major", "{profile:?}");
689        }
690    }
691}