Skip to main content

music21_rs/chord/
resolution.rs

1//! Where a chord goes next: the resolutions of dominants, leading-tone
2//! sonorities and augmented sixths, in a key and out of one.
3
4use super::*;
5
6impl Chord {
7    /// Returns the first likely tonal resolution chord in the given key.
8    ///
9    /// This is intentionally conservative rather than a universal harmonic
10    /// oracle. It covers the resolution families that music21 exposes most
11    /// directly: dominant-function sonorities, leading-tone diminished
12    /// sonorities, and contextual augmented-sixth sonorities. Unsupported
13    /// chords return `Ok(None)`.
14    pub fn resolution_chord(&self, tonic: &str, mode: Option<&str>) -> Result<Option<Self>> {
15        Ok(self.resolution_chords(tonic, mode)?.into_iter().next())
16    }
17
18    /// Returns likely tonal resolution chords in the given key.
19    ///
20    /// Dominant-function chords resolve by root motion up a perfect fourth to
21    /// a diatonic triad in the supplied key, so secondary dominants such as
22    /// `D7` in C major resolve to the G-major triad. Leading-tone diminished
23    /// sonorities resolve up by semitone to a diatonic triad. Italian, French,
24    /// German, and Swiss-style augmented-sixth sonorities in context resolve to
25    /// the dominant triad.
26    pub fn resolution_chords(&self, tonic: &str, mode: Option<&str>) -> Result<Vec<Self>> {
27        let key = Key::from_tonic_mode(tonic, mode)?;
28        self.resolution_chords_in_key(&key)
29    }
30
31    /// Returns likely tonal resolution chords in the supplied key.
32    pub fn resolution_chords_in_key(&self, key: &Key) -> Result<Vec<Self>> {
33        if self.is_contextual_augmented_sixth(key)? {
34            return Ok(vec![
35                self.place_resolution_near_source(key.triad_from_degree(5)?)?,
36            ]);
37        }
38
39        let mut resolutions = Vec::new();
40
41        let dominant_resolution = if self.is_dominant_function_sonority() {
42            self.resolve_by_root_motion(key, 5)?
43        } else {
44            None
45        };
46        if let Some(chord) = dominant_resolution {
47            resolutions.push(chord);
48        }
49
50        let leading_tone_resolution = if self.is_leading_tone_function_sonority() {
51            self.resolve_by_root_motion(key, 1)?
52        } else {
53            None
54        };
55        if let Some(chord) = leading_tone_resolution {
56            resolutions.push(chord);
57        }
58
59        Ok(Self::deduplicate_resolution_chords(resolutions))
60    }
61
62    /// Returns likely tonal resolution suggestions in the supplied key.
63    pub fn resolution_suggestions_in_key(
64        &self,
65        key: &Key,
66    ) -> Result<Vec<ChordResolutionSuggestion>> {
67        let mut suggestions = Vec::new();
68        let mut seen = std::collections::BTreeSet::new();
69        let key_name = Self::display_key_name(key);
70
71        if self.is_contextual_augmented_sixth(key)? {
72            Self::push_resolution_suggestion(
73                key.triad_from_degree(5)?,
74                format!("augmented-sixth resolution in {key_name}"),
75                &mut suggestions,
76                &mut seen,
77            );
78            return Ok(suggestions);
79        }
80
81        if self.is_dominant_function_sonority()
82            && let Some(chord) = self.resolve_by_root_motion(key, 5)?
83        {
84            Self::push_resolution_suggestion(
85                chord,
86                format!("dominant resolution in {key_name}"),
87                &mut suggestions,
88                &mut seen,
89            );
90        }
91
92        if self.is_leading_tone_function_sonority()
93            && let Some(chord) = self.resolve_by_root_motion(key, 1)?
94        {
95            Self::push_resolution_suggestion(
96                chord,
97                format!("leading-tone resolution in {key_name}"),
98                &mut suggestions,
99                &mut seen,
100            );
101        }
102
103        Ok(suggestions)
104    }
105
106    /// Returns likely tonal resolution chords with inferred key contexts.
107    ///
108    /// This is a convenience wrapper around [`Self::resolution_chords`] for
109    /// exploratory tools: dominant-function sonorities are tested against the
110    /// key a perfect fourth above their root, leading-tone sonorities against
111    /// the key a semitone above their root, and augmented-sixth sonorities
112    /// against all built-in major/minor tonic spellings.
113    pub fn resolution_suggestions(&self) -> Result<Vec<ChordResolutionSuggestion>> {
114        let mut suggestions = Vec::new();
115        let mut seen = std::collections::BTreeSet::new();
116
117        let augmented_contexts = self.augmented_sixth_contexts()?;
118        if !augmented_contexts.is_empty() {
119            for (tonic, mode) in augmented_contexts {
120                let context = format!(
121                    "augmented-sixth resolution in {} {mode}",
122                    Self::display_tonic_name(tonic)
123                );
124                self.add_resolution_suggestions_for_key(
125                    tonic,
126                    mode,
127                    context,
128                    &mut suggestions,
129                    &mut seen,
130                )?;
131            }
132            return Ok(suggestions);
133        }
134
135        if let Some(root_pc) = self.find_root_pitch().map(root::pitch_class) {
136            if self.is_dominant_function_sonority() {
137                let tonic = Self::pitch_class_name((root_pc + 5) % 12);
138                for mode in ["major", "minor"] {
139                    let context = format!(
140                        "dominant resolution to {} {mode}",
141                        Self::display_tonic_name(tonic)
142                    );
143                    self.add_resolution_suggestions_for_key(
144                        tonic,
145                        mode,
146                        context,
147                        &mut suggestions,
148                        &mut seen,
149                    )?;
150                }
151            }
152
153            if self.is_leading_tone_function_sonority() {
154                let tonic = Self::pitch_class_name((root_pc + 1) % 12);
155                for mode in ["major", "minor"] {
156                    let context = format!(
157                        "leading-tone resolution to {} {mode}",
158                        Self::display_tonic_name(tonic)
159                    );
160                    self.add_resolution_suggestions_for_key(
161                        tonic,
162                        mode,
163                        context,
164                        &mut suggestions,
165                        &mut seen,
166                    )?;
167                }
168            }
169        }
170
171        Ok(suggestions)
172    }
173
174    /// Returns whether the chord could serve as a dominant: a major triad or
175    /// a dominant seventh.
176    pub fn can_be_dominant_v(&self) -> bool {
177        self.is_major_triad() || self.is_dominant_seventh()
178    }
179
180    /// Returns whether the chord could serve as a tonic: a major or minor
181    /// triad.
182    pub fn can_be_tonic(&self) -> bool {
183        self.is_major_triad() || self.is_minor_triad()
184    }
185
186    pub(super) fn resolve_by_root_motion(&self, key: &Key, semitones: u8) -> Result<Option<Self>> {
187        let Some(root_pitch) = self.find_root_pitch() else {
188            return Ok(None);
189        };
190        let target_pc = (root::pitch_class(root_pitch) + semitones) % 12;
191        Self::triad_for_key_pitch_class(key, target_pc)?
192            .map(|chord| self.place_resolution_near_source(chord))
193            .transpose()
194    }
195
196    pub(super) fn triad_for_key_pitch_class(key: &Key, target_pc: u8) -> Result<Option<Self>> {
197        for degree in 1..=7 {
198            let degree_pitch = key.pitch_from_degree(degree)?;
199            if root::pitch_class(&degree_pitch) == target_pc {
200                return Ok(Some(key.triad_from_degree(degree)?));
201            }
202        }
203        Ok(None)
204    }
205
206    pub(super) fn place_resolution_near_source(&self, resolution: Self) -> Result<Self> {
207        let Some(source_center) = Self::pitch_center(&self.pitches()) else {
208            return Ok(resolution);
209        };
210        let Some(resolution_center) = Self::pitch_center(&resolution.pitches()) else {
211            return Ok(resolution);
212        };
213
214        let octave_shift = ((source_center - resolution_center) / 12.0).round() as IntegerType;
215        if octave_shift == 0 {
216            return Ok(resolution);
217        }
218
219        let pitches = resolution
220            .pitches()
221            .into_iter()
222            .map(|pitch| {
223                let octave = pitch
224                    .octave()
225                    .unwrap_or_else(|| (pitch.ps().round() as IntegerType).div_euclid(12) - 1);
226                Pitch::from_name_and_octave(pitch.name(), octave + octave_shift)
227            })
228            .collect::<Result<Vec<_>>>()?;
229
230        Chord::new(pitches.as_slice())
231    }
232
233    pub(super) fn pitch_center(pitches: &[Pitch]) -> Option<FloatType> {
234        if pitches.is_empty() {
235            return None;
236        }
237
238        Some(pitches.iter().map(Pitch::ps).sum::<FloatType>() / pitches.len() as FloatType)
239    }
240
241    pub(super) fn deduplicate_resolution_chords(chords: Vec<Self>) -> Vec<Self> {
242        let mut seen = std::collections::BTreeSet::new();
243        let mut deduped = Vec::new();
244
245        for chord in chords {
246            if seen.insert(chord.pitch_classes()) {
247                deduped.push(chord);
248            }
249        }
250
251        deduped
252    }
253
254    pub(super) fn augmented_sixth_contexts(&self) -> Result<Vec<(&'static str, &'static str)>> {
255        if !self.has_augmented_sixth_spelling() {
256            return Ok(Vec::new());
257        }
258
259        let mut contexts = Vec::new();
260        for tonic in CANDIDATE_TONICS {
261            for mode in ["major", "minor"] {
262                let key = Key::from_tonic_mode(tonic, Some(mode))?;
263                if self.is_contextual_augmented_sixth(&key)? {
264                    contexts.push((tonic, mode));
265                }
266            }
267        }
268        Ok(contexts)
269    }
270
271    pub(super) fn push_resolution_suggestion(
272        chord: Chord,
273        key_context: String,
274        suggestions: &mut Vec<ChordResolutionSuggestion>,
275        seen: &mut std::collections::BTreeSet<(String, String)>,
276    ) {
277        let pitched_common_name = chord.pitched_common_name();
278        if seen.insert((pitched_common_name, key_context.clone())) {
279            suggestions.push(ChordResolutionSuggestion { chord, key_context });
280        }
281    }
282
283    pub(super) fn has_augmented_sixth_spelling(&self) -> bool {
284        for (index, lower) in self.notes.iter().enumerate() {
285            for upper in self.notes.iter().skip(index + 1) {
286                if Self::is_directed_augmented_sixth(&lower.pitch, &upper.pitch)
287                    || Self::is_directed_augmented_sixth(&upper.pitch, &lower.pitch)
288                {
289                    return true;
290                }
291            }
292        }
293        false
294    }
295
296    pub(super) fn is_directed_augmented_sixth(lower: &Pitch, upper: &Pitch) -> bool {
297        let generic_interval = (root::step_num(upper) - root::step_num(lower)).rem_euclid(7) + 1;
298        let semitones = ((upper.ps().round() as IntegerType) - (lower.ps().round() as IntegerType))
299            .rem_euclid(12);
300        generic_interval == 6 && semitones == 10
301    }
302
303    pub(super) fn add_resolution_suggestions_for_key(
304        &self,
305        tonic: &str,
306        mode: &str,
307        key_context: String,
308        suggestions: &mut Vec<ChordResolutionSuggestion>,
309        seen: &mut std::collections::BTreeSet<(String, String)>,
310    ) -> Result<()> {
311        for chord in self.resolution_chords(tonic, Some(mode))? {
312            Self::push_resolution_suggestion(chord, key_context.clone(), suggestions, seen);
313        }
314        Ok(())
315    }
316
317    pub(super) fn is_dominant_function_sonority(&self) -> bool {
318        let names = self.common_names_with_primary();
319        let has_explicit_dominant_name = names.iter().any(|name| {
320            matches!(
321                name.as_str(),
322                "dominant seventh chord"
323                    | "major minor seventh chord"
324                    | "incomplete dominant-seventh chord"
325            )
326        });
327        let has_dominant_family_name = names
328            .iter()
329            .any(|name| name.contains("dominant") || name == "major-minor");
330
331        has_explicit_dominant_name
332            || (has_dominant_family_name && self.has_intervals_above_root(&[4, 10]))
333    }
334
335    pub(super) fn is_leading_tone_function_sonority(&self) -> bool {
336        let names = self.common_names_with_primary();
337        let has_explicit_leading_tone_name = names.iter().any(|name| {
338            matches!(
339                name.as_str(),
340                "diminished triad"
341                    | "diminished seventh chord"
342                    | "half-diminished seventh chord"
343                    | "incomplete half-diminished seventh chord"
344            )
345        });
346        let has_diminished_family_name = names.iter().any(|name| name.contains("diminished"));
347
348        has_explicit_leading_tone_name
349            || (has_diminished_family_name && self.has_intervals_above_root(&[3, 6]))
350    }
351
352    pub(super) fn is_contextual_augmented_sixth(&self, key: &Key) -> Result<bool> {
353        let chord_pcs = self.pitch_class_set();
354        if chord_pcs.len() < 3 || chord_pcs.len() > 4 {
355            return Ok(false);
356        }
357
358        let tonic_pc = root::pitch_class(&key.pitch_from_degree(1)?);
359        let second_pc = root::pitch_class(&key.pitch_from_degree(2)?);
360        let third_pc = root::pitch_class(&key.pitch_from_degree(3)?);
361        let fourth_pc = root::pitch_class(&key.pitch_from_degree(4)?);
362        let sixth_pc = root::pitch_class(&key.pitch_from_degree(6)?);
363
364        let raised_fourth_pc = (fourth_pc + 1) % 12;
365        let lowered_sixth_pc = if (sixth_pc + 12 - tonic_pc) % 12 == 9 {
366            (sixth_pc + 11) % 12
367        } else {
368            sixth_pc
369        };
370
371        if !chord_pcs.contains(&lowered_sixth_pc) || !chord_pcs.contains(&raised_fourth_pc) {
372            return Ok(false);
373        }
374
375        if self
376            .common_names_with_primary()
377            .iter()
378            .any(|name| name.contains("augmented sixth chord"))
379        {
380            return Ok(true);
381        }
382
383        let lowered_third_pc = if (third_pc + 12 - tonic_pc) % 12 == 4 {
384            (third_pc + 11) % 12
385        } else {
386            third_pc
387        };
388        let raised_second_pc = (second_pc + 1) % 12;
389        let allowed_pcs = [
390            lowered_sixth_pc,
391            raised_fourth_pc,
392            tonic_pc,
393            second_pc,
394            lowered_third_pc,
395            raised_second_pc,
396        ];
397
398        Ok(chord_pcs.contains(&tonic_pc)
399            && chord_pcs
400                .iter()
401                .all(|pc| allowed_pcs.iter().any(|allowed| allowed == pc)))
402    }
403}
404
405#[derive(Debug, Clone)]
406/// A likely tonal resolution for a chord, including the key context used.
407#[must_use]
408pub struct ChordResolutionSuggestion {
409    /// The suggested resolution chord.
410    pub chord: Chord,
411    /// Human-readable harmonic context for the suggestion.
412    pub key_context: String,
413}
414
415pub(super) const CANDIDATE_TONICS: [&str; 12] = [
416    "C", "D-", "D", "E-", "E", "F", "F#", "G", "A-", "A", "B-", "B",
417];