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