Module music_df.harmony.modulation
Functions
def assert_range_index(df: pandas.DataFrame)-
Expand source code
def assert_range_index(df: pd.DataFrame): try: assert isinstance(df.index, pd.RangeIndex) except AssertionError: assert (df.index == range(len(df))).all() return assert df.index.start == 0 assert df.index.stop == len(df) assert df.index.step == 1 def expand_tonicizations(df: pandas.DataFrame, quality_col: str | None = None)-
Expand source code
def expand_tonicizations(df: pd.DataFrame, quality_col: str | None = None): """ If we have a sequence of annotations like "vi V/vi vi ii/vi V/vi vi", for the purposes of determining the length of tonicizations, we want to treat it as "i/vi V/vi i/vi ii/vi V/vi i/vi". Thus this function looks for tonicizations that are preceded/followed by the tonicized degree and then "expands" them by treating that degree as "i/...". We make a special case for dominant chords (i.e., chords that have degree "V/I" or "V/i"). "V/V V I" is such an ubiquitous pattern that it seems like "expanding" it isn't appropriate (in particular since V is very often a V7 chord and thus not a plausible tonic). Therefore, we only expand dominant chords that are both preceded and followed by tonicization (e.g., "V/V V V/V" but not "V/V V I"). `replace_spurious_tonics` is a quasi-inverse of this function. If `quality_col` is provided, we only expand tonicized chords that are not dominant sevenths, augmented sixths, or diminished or augmented triads (i.e., quality doesn't contain "Mm7", "aug6", "o", or "+"). >>> df = pd.read_csv( ... io.StringIO( ... ''' ... primary_degree,secondary_degree,key ... vi,i,C ... V,vi,C ... vi,i,C ... ii,vi,C ... V,vi,C ... vi,i,C ... ''' ... ) ... ) >>> expand_tonicizations(df) primary_degree secondary_degree key 0 i vi C 1 V vi C 2 i vi C 3 ii vi C 4 V vi C 5 i vi C Across key-change >>> df = pd.read_csv( ... io.StringIO( ... ''' ... primary_degree,secondary_degree,key ... vi,i,C ... V,vi,G ... ''' ... ) ... ) >>> expand_tonicizations(df) primary_degree secondary_degree key 0 vi i C 1 V vi G >>> df = pd.read_csv( ... io.StringIO( ... ''' ... primary_degree,secondary_degree,key ... IV,ii,Ab ... ii,I,c ... ''' ... ) ... ) >>> expand_tonicizations(df) primary_degree secondary_degree key 0 IV ii Ab 1 ii I c >>> df = pd.read_csv( ... io.StringIO( ... ''' ... primary_degree,secondary_degree,key ... vi,i,C ... V,vi,C ... vi,i,C ... ii,VI,C ... V,VI,C ... VI,i,C ... ''' ... ) ... ) >>> expand_tonicizations(df) primary_degree secondary_degree key 0 i vi C 1 V vi C 2 i vi C 3 ii VI C 4 V VI C 5 I VI C Dominant chord behavior >>> df = pd.read_csv( ... io.StringIO( ... ''' ... primary_degree,secondary_degree,key ... V,V,Ab ... V,I,Ab ... V,V,Ab ... ''' ... ) ... ) When V is preceded and followed by tonicization, it is expanded >>> expand_tonicizations(df.copy()) primary_degree secondary_degree key 0 V V Ab 1 I V Ab 2 V V Ab When V is only preceded or only followed by tonicization, it is not expanded >>> expand_tonicizations(df.iloc[1:].copy()) primary_degree secondary_degree key 1 V I Ab 2 V V Ab >>> expand_tonicizations(df.iloc[:-1].copy()) primary_degree secondary_degree key 0 V V Ab 1 V I Ab >>> df = pd.read_csv( ... io.StringIO( ... ''' ... primary_degree,secondary_degree,key,quality ... V,V,Ab,M ... V,I,Ab,Mm7 ... V,V,Ab,M ... V,I,Ab,aug6 ... V,V,Ab,M ... V,I,Ab,o ... V,V,Ab,M ... V,I,Ab,+ ... V,V,Ab,M ... ''' ... ) ... ) >>> expand_tonicizations(df, quality_col="quality") primary_degree secondary_degree key quality 0 V V Ab M 1 V I Ab Mm7 2 V V Ab M 3 V I Ab aug6 4 V V Ab M 5 V I Ab o 6 V V Ab M 7 V I Ab + 8 V V Ab M """ non_dominant_mask = ( (df["primary_degree"] != "V") & (df["secondary_degree"].isin(["i", "I"])) & ( ( (df["primary_degree"] == df["secondary_degree"].shift(-1)) & (df["key"] == df["key"].shift(-1)) ) | ( (df["primary_degree"] == df["secondary_degree"].shift(1)) & (df["key"] == df["key"].shift(1)) ) ) ) dominant_mask = ( (df["primary_degree"] == "V") & (df["secondary_degree"].isin(["i", "I"])) & ( (df["primary_degree"] == df["secondary_degree"].shift(-1)) & (df["key"] == df["key"].shift(-1)) & (df["primary_degree"] == df["secondary_degree"].shift(1)) & (df["key"] == df["key"].shift(1)) ) ) if quality_col is not None: non_dominant_mask &= ~df[quality_col].str.contains("Mm7|aug6|o|\\+") dominant_mask &= ~df[quality_col].str.contains("Mm7|aug6|o|\\+") def _apply_mask(mask): df.loc[mask, "secondary_degree"] = df.loc[mask, "primary_degree"] lower_case_indices = df.loc[mask].index[ df.loc[mask, "primary_degree"].str.slice(start=-1).str.islower() ] df.loc[mask, "primary_degree"] = "I" df.loc[lower_case_indices, "primary_degree"] = "i" _apply_mask(non_dominant_mask) _apply_mask(dominant_mask) return dfIf we have a sequence of annotations like "vi V/vi vi ii/vi V/vi vi", for the purposes of determining the length of tonicizations, we want to treat it as "i/vi V/vi i/vi ii/vi V/vi i/vi".
Thus this function looks for tonicizations that are preceded/followed by the tonicized degree and then "expands" them by treating that degree as "i/…".
We make a special case for dominant chords (i.e., chords that have degree "V/I" or "V/i"). "V/V V I" is such an ubiquitous pattern that it seems like "expanding" it isn't appropriate (in particular since V is very often a V7 chord and thus not a plausible tonic). Therefore, we only expand dominant chords that are both preceded and followed by tonicization (e.g., "V/V V V/V" but not "V/V V I").
replace_spurious_tonics()is a quasi-inverse of this function.If
quality_colis provided, we only expand tonicized chords that are not dominant sevenths, augmented sixths, or diminished or augmented triads (i.e., quality doesn't contain "Mm7", "aug6", "o", or "+").>>> df = pd.read_csv( ... io.StringIO( ... ''' ... primary_degree,secondary_degree,key ... vi,i,C ... V,vi,C ... vi,i,C ... ii,vi,C ... V,vi,C ... vi,i,C ... ''' ... ) ... ) >>> expand_tonicizations(df) primary_degree secondary_degree key 0 i vi C 1 V vi C 2 i vi C 3 ii vi C 4 V vi C 5 i vi CAcross key-change
>>> df = pd.read_csv( ... io.StringIO( ... ''' ... primary_degree,secondary_degree,key ... vi,i,C ... V,vi,G ... ''' ... ) ... ) >>> expand_tonicizations(df) primary_degree secondary_degree key 0 vi i C 1 V vi G >>> df = pd.read_csv( ... io.StringIO( ... ''' ... primary_degree,secondary_degree,key ... IV,ii,Ab ... ii,I,c ... ''' ... ) ... ) >>> expand_tonicizations(df) primary_degree secondary_degree key 0 IV ii Ab 1 ii I c>>> df = pd.read_csv( ... io.StringIO( ... ''' ... primary_degree,secondary_degree,key ... vi,i,C ... V,vi,C ... vi,i,C ... ii,VI,C ... V,VI,C ... VI,i,C ... ''' ... ) ... ) >>> expand_tonicizations(df) primary_degree secondary_degree key 0 i vi C 1 V vi C 2 i vi C 3 ii VI C 4 V VI C 5 I VI CDominant chord behavior
>>> df = pd.read_csv( ... io.StringIO( ... ''' ... primary_degree,secondary_degree,key ... V,V,Ab ... V,I,Ab ... V,V,Ab ... ''' ... ) ... )When V is preceded and followed by tonicization, it is expanded
>>> expand_tonicizations(df.copy()) primary_degree secondary_degree key 0 V V Ab 1 I V Ab 2 V V AbWhen V is only preceded or only followed by tonicization, it is not expanded
>>> expand_tonicizations(df.iloc[1:].copy()) primary_degree secondary_degree key 1 V I Ab 2 V V Ab >>> expand_tonicizations(df.iloc[:-1].copy()) primary_degree secondary_degree key 0 V V Ab 1 V I Ab>>> df = pd.read_csv( ... io.StringIO( ... ''' ... primary_degree,secondary_degree,key,quality ... V,V,Ab,M ... V,I,Ab,Mm7 ... V,V,Ab,M ... V,I,Ab,aug6 ... V,V,Ab,M ... V,I,Ab,o ... V,V,Ab,M ... V,I,Ab,+ ... V,V,Ab,M ... ''' ... ) ... ) >>> expand_tonicizations(df, quality_col="quality") primary_degree secondary_degree key quality 0 V V Ab M 1 V I Ab Mm7 2 V V Ab M 3 V I Ab aug6 4 V V Ab M 5 V I Ab o 6 V V Ab M 7 V I Ab + 8 V V Ab M def modulation_census(chord_df: pandas.DataFrame, last_chord_duration: float | None = None)-
Expand source code
def modulation_census(chord_df: pd.DataFrame, last_chord_duration: float | None = None): """ Args: chord_df: A DataFrame with columns "onset", "degree", and "key" and possibly "release". last_chord_duration: The duration of the last chord. If "release" is in the DataFrame, this is ignored. >>> modulation_with_tonicization = pd.read_csv( ... io.StringIO( ... ''' ... onset,release,degree,key ... 0.0,1.0,I,b ... 1.0,2.0,V/V,G ... 2.0,3.0,V,G ... 3.0,4.0,VI,b ... ''' ... ) ... ) >>> modulation_census(modulation_with_tonicization) chord_df_index onset key n_chords duration 0 0 0.0 b 1 1.0 1 1 1.0 G 2 2.0 2 3 3.0 b 1 1.0 >>> modulation_census( ... modulation_with_tonicization.drop(columns=["release"]), ... last_chord_duration=4.0, ... ) chord_df_index onset key n_chords duration 0 0 0.0 b 1 1.0 1 1 1.0 G 2 2.0 2 3 3.0 b 1 4.0 """ key_series = chord_df["key"].ffill() key_changes = key_series != key_series.shift(1) key_changes_df = pd.DataFrame(chord_df.loc[key_changes, ["onset", "key"]]) key_changes_df["n_chords"] = np.diff( np.concatenate([key_changes_df.index, [len(chord_df)]]) ) key_changes_df = key_changes_df.reset_index(names="chord_df_index") key_changes_df.loc[range(0, len(key_changes_df) - 1), "duration"] = np.diff( key_changes_df["onset"] ) last_onset = chord_df.iloc[-1]["onset"] last_change_onset = key_changes_df.iloc[-1]["onset"] if "release" in chord_df.columns: last_release = chord_df.iloc[-1]["release"] last_duration = last_release - last_onset else: assert last_chord_duration is not None last_duration = last_chord_duration key_changes_df.loc[len(key_changes_df) - 1, "duration"] = ( last_onset - last_change_onset + last_duration ) return key_changes_dfArgs
chord_df- A DataFrame with columns "onset", "degree", and "key" and possibly "release".
last_chord_duration- The duration of the last chord. If "release" is in the DataFrame, this is ignored.
>>> modulation_with_tonicization = pd.read_csv( ... io.StringIO( ... ''' ... onset,release,degree,key ... 0.0,1.0,I,b ... 1.0,2.0,V/V,G ... 2.0,3.0,V,G ... 3.0,4.0,VI,b ... ''' ... ) ... ) >>> modulation_census(modulation_with_tonicization) chord_df_index onset key n_chords duration 0 0 0.0 b 1 1.0 1 1 1.0 G 2 2.0 2 3 3.0 b 1 1.0>>> modulation_census( ... modulation_with_tonicization.drop(columns=["release"]), ... last_chord_duration=4.0, ... ) chord_df_index onset key n_chords duration 0 0 0.0 b 1 1.0 1 1 1.0 G 2 2.0 2 3 3.0 b 1 4.0 def remove_long_tonicizations(chord_df: pandas.DataFrame,
inplace: bool = False,
max_tonicization_duration: float | None = None,
min_removal_duration: float | None = None,
max_tonicization_num_chords: int | None = None,
min_removal_num_chords: int | None = None,
tonicization_cache: CacheDict[tuple[str, str], str] | None = None,
case_matters: bool = False,
simplify_enharmonics: bool = True) ‑> pandas.DataFrame-
Expand source code
def remove_long_tonicizations( chord_df: pd.DataFrame, inplace: bool = False, max_tonicization_duration: float | None = None, min_removal_duration: float | None = None, max_tonicization_num_chords: int | None = None, min_removal_num_chords: int | None = None, tonicization_cache: CacheDict[tuple[str, str], str] | None = None, case_matters: bool = False, simplify_enharmonics: bool = True, ) -> pd.DataFrame: """ Remove long tonicizations from a chord dataframe. Note that we assume that all tonicizations are "normalized" in the sense that they only use a single slash and secondary RN. (E.g., we don't have RNs like "V/V/V".) Args: chord_df: A dataframe containing either of the following sets of columns: - "onset", "primary_degree", "secondary_degree", and "key" - "onset", "degree", and "key" In the former case, if the dataframe has an "degree" column, it will be overwritten. If a "quality" column is provided, it is used when determining whether to expand tonicizations. inplace: Whether to modify the dataframe in place. max_tonicization_duration: The maximum duration of a tonicization. At least one of max_tonicization_duration or max_tonicization_num_chords must be provided. min_removal_duration: If provided, a tonicization must have at least this duration to be removed. This argument must be <= max_tonicization_duration if both are provided. max_tonicization_num_chords: The maximum number of chords in a tonicization. At least one of max_tonicization_duration or max_tonicization_num_chords must be provided. Note that repetitions of the same chord count as multiple chords. (If you wish for a different behavior, you should remove chord repetitions in advance.) min_removal_num_chords: If provided, a tonicization must have at least this number of consecutive chords to be removed. Concerning chord repetitions, see the note above about max_tonicization_num_chords. This argument must be <= max_tonicization_num_chords if both are provided. tonicization_cache: A cache for tonicizations to save compute if running this function many times. case_matters: Whether to consider case when determining the key of the tonicization. Ignored if tonicization_cache is provided. simplify_enharmonics: Whether to simplify enharmonic spellings of the key of the tonicization. Ignored if tonicization_cache is provided. >>> no_tonicizations = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,C ... ''' ... ) ... ) >>> remove_long_tonicizations(no_tonicizations, max_tonicization_num_chords=1) onset degree key 0 0.0 I C >>> consecutive_tonicizations = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,Db ... 1.0,V/#IV,Db ... 2.0,IV/#IV,Db ... 3.0,I/#IV,Db ... 4.0,V/bVI,Db ... 5.0,I/bVI,Db ... 6.0,I,Db ... ''' ... ) ... ) >>> remove_long_tonicizations( ... consecutive_tonicizations, max_tonicization_num_chords=1 ... ) onset degree key 0 0.0 I Db 1 1.0 V G 2 2.0 IV G 3 3.0 I G 4 4.0 V A 5 5.0 I A 6 6.0 I Db >>> tonicization_with_key_change = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,a ... 1.0,IV/III,a ... 2.0,V/III,a ... 3.0,V/III,d ... 4.0,IV/III,d ... 5.0,I,d ... ''' ... ) ... ) >>> remove_long_tonicizations( ... tonicization_with_key_change, max_tonicization_num_chords=1 ... ) onset degree key 0 0.0 I a 1 1.0 IV C 2 2.0 V C 3 3.0 V F 4 4.0 IV F 5 5.0 I d >>> tonicization_and_key_change = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,bb ... 1.0,ii/iv,bb ... 2.0,V/iv,bb ... 3.0,ii/III,eb ... 4.0,V/III,eb ... 5.0,I,eb ... ''' ... ) ... ) >>> remove_long_tonicizations( ... tonicization_and_key_change, max_tonicization_num_chords=1 ... ) onset degree key 0 0.0 I bb 1 1.0 ii eb 2 2.0 V eb 3 3.0 ii Gb 4 4.0 V Gb 5 5.0 I eb >>> tonicization_at_beginning_and_end = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,V/V,C ... 1.0,viio/V,C ... 2.0,I,C ... 3.0,iv/ii,C ... 4.0,V/ii,C ... ''' ... ) ... ) >>> remove_long_tonicizations( ... tonicization_at_beginning_and_end, max_tonicization_num_chords=1 ... ) onset degree key 0 0.0 V G 1 1.0 viio G 2 2.0 I C 3 3.0 iv d 4 4.0 V d >>> single_chord_tonicization = pd.read_csv( ... io.StringIO( ... ''' ... onset,release,degree,key ... 0.0,1.0,V/V,C ... ''' ... ) ... ) >>> remove_long_tonicizations( ... single_chord_tonicization, max_tonicization_num_chords=0 ... ) onset release degree key 0 0.0 1.0 V G Note that tonicizations are first "expanded", and then "spurious" tonicizations are removed. This means that we treat chords that are adjacent to their tonicizations (e.g., "V" next to "V/V") as belonging to the tonicized region, but before returning, we remove any "spurious" tonicizations (e.g., replacing "I/V" with "V"). >>> chord_df = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,C ... 0.5,V,C ... 1,V/V,C ... 2,I/V,C ... 3,V/V,C ... 4,I/V,C ... 5,V/V,C ... 6,I/V,C ... 7,V/V,C ... 8,V,C ... 9,I,C ... ''' ... ) ... ) >>> remove_long_tonicizations(chord_df, max_tonicization_num_chords=9) onset degree key 0 0.0 I C 1 0.5 V C 2 1.0 V/V C 3 2.0 V C 4 3.0 V/V C 5 4.0 V C 6 5.0 V/V C 7 6.0 V C 8 7.0 V/V C 9 8.0 V C 10 9.0 I C >>> remove_long_tonicizations(chord_df, max_tonicization_num_chords=6) onset degree key 0 0.0 I C 1 0.5 V C 2 1.0 V G 3 2.0 I G 4 3.0 V G 5 4.0 I G 6 5.0 V G 7 6.0 I G 8 7.0 V G 9 8.0 V C 10 9.0 I C >>> remove_long_tonicizations(chord_df, max_tonicization_duration=9.0) onset degree key 0 0.0 I C 1 0.5 V C 2 1.0 V/V C 3 2.0 V C 4 3.0 V/V C 5 4.0 V C 6 5.0 V/V C 7 6.0 V C 8 7.0 V/V C 9 8.0 V C 10 9.0 I C >>> remove_long_tonicizations(chord_df, max_tonicization_duration=6.9) onset degree key 0 0.0 I C 1 0.5 V C 2 1.0 V G 3 2.0 I G 4 3.0 V G 5 4.0 I G 6 5.0 V G 7 6.0 I G 8 7.0 V G 9 8.0 V C 10 9.0 I C >>> long_single_chord_tonicization = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,C ... 1.0,V/V,C ... 9.0,I,C ... ''' ... ) ... ) >>> remove_long_tonicizations( ... long_single_chord_tonicization, max_tonicization_duration=7.9 ... ) onset degree key 0 0.0 I C 1 1.0 V G 2 9.0 I C >>> remove_long_tonicizations( ... long_single_chord_tonicization, ... max_tonicization_duration=7.9, ... min_removal_num_chords=3, ... ) onset degree key 0 0.0 I C 1 1.0 V/V C 2 9.0 I C >>> multiple_brief_chords = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,C ... 1.0,ii/V,C ... 1.125,V/V,C ... 1.25,ii/V,C ... 1.375,V/V,C ... 1.5,V,C ... ''' ... ) ... ) >>> remove_long_tonicizations(multiple_brief_chords, max_tonicization_num_chords=1) onset degree key 0 0.000 I C 1 1.000 ii G 2 1.125 V G 3 1.250 ii G 4 1.375 V G 5 1.500 V C >>> remove_long_tonicizations( ... multiple_brief_chords, ... max_tonicization_num_chords=1, ... min_removal_duration=1, ... ) onset degree key 0 0.000 I C 1 1.000 ii/V C 2 1.125 V/V C 3 1.250 ii/V C 4 1.375 V/V C 5 1.500 V C >>> consecutive_chords = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,V/V,C ... 1.0,V/V,C ... 2.0,V/V,C ... 3.0,V/V,C ... ''' ... ) ... ) >>> remove_long_tonicizations(consecutive_chords, max_tonicization_num_chords=3) onset degree key 0 0.0 V G 1 1.0 V G 2 2.0 V G 3 3.0 V G """ assert ( max_tonicization_duration is not None or max_tonicization_num_chords is not None ) if min_removal_num_chords is not None and max_tonicization_num_chords is not None: assert min_removal_num_chords <= max_tonicization_num_chords if min_removal_duration is not None and max_tonicization_duration is not None: assert min_removal_duration <= max_tonicization_duration if not inplace: chord_df = chord_df.copy() assert_range_index(chord_df) split_columns = ["primary_degree", "secondary_degree", "key", "onset"] joined_columns = [ "onset", "degree", "key", ] if all(k in chord_df.columns for k in split_columns): had_split_columns = True elif all(k in chord_df.columns for k in joined_columns): had_split_columns = False chord_df = split_degree_into_primary_and_secondary(chord_df) else: raise ValueError( f"chord_df must have columns {split_columns} or {joined_columns}" ) # We begin by filling the key column so that if we change the key during a # tonicization, it will revert to the original key after the tonicized passage. chord_df["key"] = chord_df["key"].ffill() # We fill the secondary_degree column with "I" chord_df["secondary_degree"] = chord_df["secondary_degree"].fillna("I") chord_df = expand_tonicizations( chord_df, quality_col="quality" if "quality" in chord_df.columns else None ) tonicization_changes = ( chord_df["secondary_degree"] != chord_df["secondary_degree"].shift(1) ) | (chord_df["key"] != chord_df["key"].shift(1)) indices = tonicization_changes.index[tonicization_changes].tolist() indices.append(len(chord_df)) def get_tonicized_key(secondary_degree: str, key: str): if tonicization_cache is None: return tonicization_to_key( secondary_degree, key, case_matters, simplify_enharmonics ) else: return tonicization_cache[(secondary_degree, key)] def remove_tonicization(tonicization: str, start_i: int, end_i: int): new_key = get_tonicized_key(tonicization, chord_df.iloc[start_i]["key"]) chord_df.loc[start_i : end_i - 1, "key"] = new_key chord_df.loc[start_i : end_i - 1, "secondary_degree"] = "I" for start_i, end_i in zip(indices[:-1], indices[1:]): assert chord_df.iloc[start_i:end_i]["secondary_degree"].nunique() == 1 assert chord_df.iloc[start_i:end_i]["key"].nunique() == 1 tonicization = chord_df.iloc[start_i]["secondary_degree"] if tonicization == "I": continue if end_i == len(chord_df): # Length of tonicization is undefined tonicization_duration = float("inf") else: tonicization_onset = chord_df.iloc[start_i]["onset"] tonicization_release = chord_df.iloc[end_i]["onset"] tonicization_duration = tonicization_release - tonicization_onset if ( max_tonicization_num_chords is not None and end_i - start_i > max_tonicization_num_chords ): if ( min_removal_duration is None or tonicization_duration >= min_removal_duration ): remove_tonicization(tonicization, start_i, end_i) continue if ( max_tonicization_duration is not None and tonicization_duration > max_tonicization_duration ): if ( min_removal_num_chords is None or end_i - start_i >= min_removal_num_chords ): remove_tonicization(tonicization, start_i, end_i) chord_df = replace_spurious_tonics(chord_df) chord_df["secondary_degree"] = "/" + chord_df["secondary_degree"] chord_df["secondary_degree"] = chord_df["secondary_degree"].replace("/I", "") chord_df["degree"] = chord_df["primary_degree"] + chord_df["secondary_degree"] if not had_split_columns: chord_df = chord_df.drop(columns=["primary_degree", "secondary_degree"]) return chord_dfRemove long tonicizations from a chord dataframe.
Note that we assume that all tonicizations are "normalized" in the sense that they only use a single slash and secondary RN. (E.g., we don't have RNs like "V/V/V".)
Args
chord_df- A dataframe containing either of the following sets of columns: - "onset", "primary_degree", "secondary_degree", and "key" - "onset", "degree", and "key" In the former case, if the dataframe has an "degree" column, it will be overwritten. If a "quality" column is provided, it is used when determining whether to expand tonicizations.
inplace- Whether to modify the dataframe in place.
max_tonicization_duration- The maximum duration of a tonicization. At least one of max_tonicization_duration or max_tonicization_num_chords must be provided.
min_removal_duration- If provided, a tonicization must have at least this duration to be removed. This argument must be <= max_tonicization_duration if both are provided.
max_tonicization_num_chords- The maximum number of chords in a tonicization. At least one of max_tonicization_duration or max_tonicization_num_chords must be provided. Note that repetitions of the same chord count as multiple chords. (If you wish for a different behavior, you should remove chord repetitions in advance.)
min_removal_num_chords- If provided, a tonicization must have at least this number of consecutive chords to be removed. Concerning chord repetitions, see the note above about max_tonicization_num_chords. This argument must be <= max_tonicization_num_chords if both are provided.
tonicization_cache- A cache for tonicizations to save compute if running this function many times.
case_matters- Whether to consider case when determining the key of the tonicization. Ignored if tonicization_cache is provided.
simplify_enharmonics- Whether to simplify enharmonic spellings of the key of the tonicization. Ignored if tonicization_cache is provided.
>>> no_tonicizations = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,C ... ''' ... ) ... ) >>> remove_long_tonicizations(no_tonicizations, max_tonicization_num_chords=1) onset degree key 0 0.0 I C>>> consecutive_tonicizations = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,Db ... 1.0,V/#IV,Db ... 2.0,IV/#IV,Db ... 3.0,I/#IV,Db ... 4.0,V/bVI,Db ... 5.0,I/bVI,Db ... 6.0,I,Db ... ''' ... ) ... ) >>> remove_long_tonicizations( ... consecutive_tonicizations, max_tonicization_num_chords=1 ... ) onset degree key 0 0.0 I Db 1 1.0 V G 2 2.0 IV G 3 3.0 I G 4 4.0 V A 5 5.0 I A 6 6.0 I Db>>> tonicization_with_key_change = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,a ... 1.0,IV/III,a ... 2.0,V/III,a ... 3.0,V/III,d ... 4.0,IV/III,d ... 5.0,I,d ... ''' ... ) ... ) >>> remove_long_tonicizations( ... tonicization_with_key_change, max_tonicization_num_chords=1 ... ) onset degree key 0 0.0 I a 1 1.0 IV C 2 2.0 V C 3 3.0 V F 4 4.0 IV F 5 5.0 I d>>> tonicization_and_key_change = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,bb ... 1.0,ii/iv,bb ... 2.0,V/iv,bb ... 3.0,ii/III,eb ... 4.0,V/III,eb ... 5.0,I,eb ... ''' ... ) ... ) >>> remove_long_tonicizations( ... tonicization_and_key_change, max_tonicization_num_chords=1 ... ) onset degree key 0 0.0 I bb 1 1.0 ii eb 2 2.0 V eb 3 3.0 ii Gb 4 4.0 V Gb 5 5.0 I eb>>> tonicization_at_beginning_and_end = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,V/V,C ... 1.0,viio/V,C ... 2.0,I,C ... 3.0,iv/ii,C ... 4.0,V/ii,C ... ''' ... ) ... ) >>> remove_long_tonicizations( ... tonicization_at_beginning_and_end, max_tonicization_num_chords=1 ... ) onset degree key 0 0.0 V G 1 1.0 viio G 2 2.0 I C 3 3.0 iv d 4 4.0 V d>>> single_chord_tonicization = pd.read_csv( ... io.StringIO( ... ''' ... onset,release,degree,key ... 0.0,1.0,V/V,C ... ''' ... ) ... ) >>> remove_long_tonicizations( ... single_chord_tonicization, max_tonicization_num_chords=0 ... ) onset release degree key 0 0.0 1.0 V GNote that tonicizations are first "expanded", and then "spurious" tonicizations are removed. This means that we treat chords that are adjacent to their tonicizations (e.g., "V" next to "V/V") as belonging to the tonicized region, but before returning, we remove any "spurious" tonicizations (e.g., replacing "I/V" with "V").
>>> chord_df = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,C ... 0.5,V,C ... 1,V/V,C ... 2,I/V,C ... 3,V/V,C ... 4,I/V,C ... 5,V/V,C ... 6,I/V,C ... 7,V/V,C ... 8,V,C ... 9,I,C ... ''' ... ) ... ) >>> remove_long_tonicizations(chord_df, max_tonicization_num_chords=9) onset degree key 0 0.0 I C 1 0.5 V C 2 1.0 V/V C 3 2.0 V C 4 3.0 V/V C 5 4.0 V C 6 5.0 V/V C 7 6.0 V C 8 7.0 V/V C 9 8.0 V C 10 9.0 I C >>> remove_long_tonicizations(chord_df, max_tonicization_num_chords=6) onset degree key 0 0.0 I C 1 0.5 V C 2 1.0 V G 3 2.0 I G 4 3.0 V G 5 4.0 I G 6 5.0 V G 7 6.0 I G 8 7.0 V G 9 8.0 V C 10 9.0 I C >>> remove_long_tonicizations(chord_df, max_tonicization_duration=9.0) onset degree key 0 0.0 I C 1 0.5 V C 2 1.0 V/V C 3 2.0 V C 4 3.0 V/V C 5 4.0 V C 6 5.0 V/V C 7 6.0 V C 8 7.0 V/V C 9 8.0 V C 10 9.0 I C >>> remove_long_tonicizations(chord_df, max_tonicization_duration=6.9) onset degree key 0 0.0 I C 1 0.5 V C 2 1.0 V G 3 2.0 I G 4 3.0 V G 5 4.0 I G 6 5.0 V G 7 6.0 I G 8 7.0 V G 9 8.0 V C 10 9.0 I C>>> long_single_chord_tonicization = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,C ... 1.0,V/V,C ... 9.0,I,C ... ''' ... ) ... ) >>> remove_long_tonicizations( ... long_single_chord_tonicization, max_tonicization_duration=7.9 ... ) onset degree key 0 0.0 I C 1 1.0 V G 2 9.0 I C >>> remove_long_tonicizations( ... long_single_chord_tonicization, ... max_tonicization_duration=7.9, ... min_removal_num_chords=3, ... ) onset degree key 0 0.0 I C 1 1.0 V/V C 2 9.0 I C>>> multiple_brief_chords = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,C ... 1.0,ii/V,C ... 1.125,V/V,C ... 1.25,ii/V,C ... 1.375,V/V,C ... 1.5,V,C ... ''' ... ) ... ) >>> remove_long_tonicizations(multiple_brief_chords, max_tonicization_num_chords=1) onset degree key 0 0.000 I C 1 1.000 ii G 2 1.125 V G 3 1.250 ii G 4 1.375 V G 5 1.500 V C >>> remove_long_tonicizations( ... multiple_brief_chords, ... max_tonicization_num_chords=1, ... min_removal_duration=1, ... ) onset degree key 0 0.000 I C 1 1.000 ii/V C 2 1.125 V/V C 3 1.250 ii/V C 4 1.375 V/V C 5 1.500 V C>>> consecutive_chords = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,V/V,C ... 1.0,V/V,C ... 2.0,V/V,C ... 3.0,V/V,C ... ''' ... ) ... ) >>> remove_long_tonicizations(consecutive_chords, max_tonicization_num_chords=3) onset degree key 0 0.0 V G 1 1.0 V G 2 2.0 V G 3 3.0 V G def remove_short_modulations(chord_df: pandas.DataFrame,
inplace: bool = False,
min_modulation_duration: float | None = None,
max_removal_duration: float | None = None,
min_modulation_num_chords: int | None = None,
max_removal_num_chords: int | None = None,
spelled_pitch_to_rn_cache: CacheDict[tuple[str, str], str] | None = None,
handle_nested_secondary_rns_cache: CacheDict[tuple[str, str], str] | None = None)-
Expand source code
def remove_short_modulations( chord_df: pd.DataFrame, inplace: bool = False, min_modulation_duration: float | None = None, max_removal_duration: float | None = None, min_modulation_num_chords: int | None = None, max_removal_num_chords: int | None = None, spelled_pitch_to_rn_cache: CacheDict[tuple[str, str], str] | None = None, handle_nested_secondary_rns_cache: CacheDict[tuple[str, str], str] | None = None, ): """ Replace short modulations with the equivalent tonicization. Note that we only replace modulations that are preceded and followed by the same key. >>> modulation_with_tonicization = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,b ... 1.0,V/V,G ... 2.0,V,G ... 3.0,VI,b ... ''' ... ) ... ) >>> remove_short_modulations( ... modulation_with_tonicization, min_modulation_num_chords=3 ... ) onset degree key 0 0.0 I b 1 1.0 V/III b 2 2.0 V/VI b 3 3.0 VI b >>> one_chord_modulation = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,C ... 1.0,I,G ... 2.0,I,C ... ''' ... ) ... ) >>> remove_short_modulations(one_chord_modulation, min_modulation_num_chords=2) onset degree key 0 0.0 I C 1 1.0 V C 2 2.0 I C >>> brief_modulation = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,Ab ... 1.0,I,C ... 1.25,viio6,C ... 1.5,I,C ... 2.0,I,Ab ... ''' ... ) ... ) >>> remove_short_modulations(brief_modulation, min_modulation_duration=2.0) onset degree key 0 0.00 I Ab 1 1.00 III Ab 2 1.25 viio6/III Ab 3 1.50 III Ab 4 2.00 I Ab >>> modulation_at_end = pd.read_csv( # Should remain unchanged ... # Of course, you could also interpret this as a modulation at the beginning ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,F# ... 1.0,V,G ... 2.0,I,G ... ''' ... ) ... ) >>> remove_short_modulations(modulation_at_end, min_modulation_num_chords=3) onset degree key 0 0.0 I F# 1 1.0 V G 2 2.0 I G """ assert ( min_modulation_duration is not None or min_modulation_num_chords is not None ), ( "At least one of min_modulation_duration or min_modulation_num_chords must be provided" ) if min_modulation_num_chords is not None and max_removal_num_chords is not None: assert min_modulation_num_chords <= max_removal_num_chords if min_modulation_duration is not None and max_removal_duration is not None: assert min_modulation_duration <= max_removal_duration if not inplace: chord_df = chord_df.copy() assert_range_index(chord_df) split_columns = ["primary_degree", "secondary_degree", "key", "onset"] joined_columns = [ "onset", "degree", "key", ] if all(k in chord_df.columns for k in split_columns): had_split_columns = True elif all(k in chord_df.columns for k in joined_columns): had_split_columns = False split_result = chord_df["degree"].str.split("/", expand=True) if split_result.shape[1] == 2: chord_df[["primary_degree", "secondary_degree"]] = split_result chord_df["secondary_degree"] = chord_df["secondary_degree"].fillna("I") else: chord_df["primary_degree"] = split_result[0] chord_df["secondary_degree"] = "I" else: raise ValueError( f"chord_df must have columns {split_columns} or {joined_columns}" ) chord_df["key"] = chord_df["key"].ffill() key_changes = chord_df["key"] != chord_df["key"].shift(1) indices = key_changes.index[key_changes].tolist() indices.append(len(chord_df)) def get_secondary_rn(inner_key: str, outer_key: str): inner_key_mode = "M" if inner_key[0].isupper() else "m" if spelled_pitch_to_rn_cache is None: return spelled_pitch_to_rn(inner_key, outer_key, inner_key_mode) else: return spelled_pitch_to_rn_cache[(inner_key, outer_key, inner_key_mode)] def nested_handler(row: pd.Series, secondary_degree: str): if handle_nested_secondary_rns_cache is None: return handle_nested_secondary_rns( row["secondary_degree"] + "/" + secondary_degree, row["key"] ) else: return handle_nested_secondary_rns_cache[ (row["secondary_degree"] + "/" + secondary_degree, row["key"]) ] def remove_modulation_if_possible(start_i: int, end_i: int): if start_i == 0: # At start of chord_df, we can't remove the modulation, because it's not # between two keys. return if end_i == len(chord_df): # At end of chord_df, we can't remove the modulation, because it's not # between two keys. return if chord_df.loc[start_i - 1, "key"] != chord_df.loc[end_i, "key"]: # The modulation between two different keys. return outer_key = chord_df.loc[start_i - 1, "key"] inner_key = chord_df.loc[start_i, "key"] secondary_degree = get_secondary_rn(inner_key, outer_key) # Handle nested secondary RNs nested_secondary_mask = ( chord_df.loc[start_i : end_i - 1, "secondary_degree"] != "I" ) nested_secondary_index = nested_secondary_mask.index[nested_secondary_mask] chord_df.loc[nested_secondary_index, "secondary_degree"] = chord_df.loc[ nested_secondary_index ].apply(lambda row: nested_handler(row, secondary_degree), axis=1) # Handle simple secondary RNs simple_secondary_index = nested_secondary_mask.index[~nested_secondary_mask] chord_df.loc[simple_secondary_index, "secondary_degree"] = secondary_degree chord_df.loc[start_i : end_i - 1, "key"] = outer_key for start_i, end_i in zip(indices[:-1], indices[1:]): assert chord_df.iloc[start_i:end_i]["key"].nunique() == 1 if end_i == len(chord_df): # Length of modulation is undefined modulation_duration = float("inf") else: modulation_onset = chord_df.iloc[start_i]["onset"] modulation_release = chord_df.iloc[end_i]["onset"] modulation_duration = modulation_release - modulation_onset if ( min_modulation_num_chords is not None and end_i - start_i < min_modulation_num_chords ): if ( max_removal_duration is None or modulation_duration <= max_removal_duration ): remove_modulation_if_possible(start_i, end_i) continue if ( min_modulation_duration is not None and modulation_duration < min_modulation_duration ): if ( max_removal_num_chords is None or end_i - start_i <= max_removal_num_chords ): remove_modulation_if_possible(start_i, end_i) replace_spurious_tonics(chord_df, inplace=True) chord_df["secondary_degree"] = "/" + chord_df["secondary_degree"] chord_df["secondary_degree"] = chord_df["secondary_degree"].replace("/I", "") chord_df["degree"] = chord_df["primary_degree"] + chord_df["secondary_degree"] if not had_split_columns: chord_df = chord_df.drop(columns=["primary_degree", "secondary_degree"]) return chord_dfReplace short modulations with the equivalent tonicization.
Note that we only replace modulations that are preceded and followed by the same key.
>>> modulation_with_tonicization = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,b ... 1.0,V/V,G ... 2.0,V,G ... 3.0,VI,b ... ''' ... ) ... ) >>> remove_short_modulations( ... modulation_with_tonicization, min_modulation_num_chords=3 ... ) onset degree key 0 0.0 I b 1 1.0 V/III b 2 2.0 V/VI b 3 3.0 VI b>>> one_chord_modulation = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,C ... 1.0,I,G ... 2.0,I,C ... ''' ... ) ... ) >>> remove_short_modulations(one_chord_modulation, min_modulation_num_chords=2) onset degree key 0 0.0 I C 1 1.0 V C 2 2.0 I C>>> brief_modulation = pd.read_csv( ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,Ab ... 1.0,I,C ... 1.25,viio6,C ... 1.5,I,C ... 2.0,I,Ab ... ''' ... ) ... ) >>> remove_short_modulations(brief_modulation, min_modulation_duration=2.0) onset degree key 0 0.00 I Ab 1 1.00 III Ab 2 1.25 viio6/III Ab 3 1.50 III Ab 4 2.00 I Ab>>> modulation_at_end = pd.read_csv( # Should remain unchanged ... # Of course, you could also interpret this as a modulation at the beginning ... io.StringIO( ... ''' ... onset,degree,key ... 0.0,I,F# ... 1.0,V,G ... 2.0,I,G ... ''' ... ) ... ) >>> remove_short_modulations(modulation_at_end, min_modulation_num_chords=3) onset degree key 0 0.0 I F# 1 1.0 V G 2 2.0 I G def replace_spurious_tonics(chord_df: pandas.DataFrame, inplace: bool = False)-
Expand source code
def replace_spurious_tonics(chord_df: pd.DataFrame, inplace: bool = False): """ Replace chords like "I/V" with chords like "V". Used by remove_short_modulations(). >>> spurious_tonic = pd.read_csv( ... io.StringIO( ... ''' ... onset,primary_degree,secondary_degree,key ... 0.0,I,I,C ... 1.0,I,V,C ... ''' ... ) ... ) >>> replace_spurious_tonics(spurious_tonic) onset primary_degree secondary_degree key 0 0.0 I I C 1 1.0 V I C """ assert_range_index(chord_df) split_columns = ["primary_degree", "secondary_degree", "key", "onset"] assert all(k in chord_df.columns for k in split_columns) if not inplace: chord_df = chord_df.copy() chord_df["secondary_degree"] = chord_df["secondary_degree"].fillna("I") spurious_tonic_mask = (chord_df["primary_degree"] == "I") & ( chord_df["secondary_degree"] != "I" ) chord_df.loc[spurious_tonic_mask, "primary_degree"] = chord_df.loc[ spurious_tonic_mask, "secondary_degree" ] chord_df.loc[spurious_tonic_mask, "secondary_degree"] = "I" return chord_dfReplace chords like "I/V" with chords like "V".
Used by remove_short_modulations().
>>> spurious_tonic = pd.read_csv( ... io.StringIO( ... ''' ... onset,primary_degree,secondary_degree,key ... 0.0,I,I,C ... 1.0,I,V,C ... ''' ... ) ... ) >>> replace_spurious_tonics(spurious_tonic) onset primary_degree secondary_degree key 0 0.0 I I C 1 1.0 V I C def split_degree_into_primary_and_secondary(chord_df: pandas.DataFrame,
degree_col: str = 'degree',
primary_degree_col: str = 'primary_degree',
secondary_degree_col: str = 'secondary_degree',
inplace: bool = True)-
Expand source code
def split_degree_into_primary_and_secondary( chord_df: pd.DataFrame, degree_col: str = "degree", primary_degree_col: str = "primary_degree", secondary_degree_col: str = "secondary_degree", inplace: bool = True, ): if not inplace: chord_df = chord_df.copy() split_result = chord_df[degree_col].str.split("/", expand=True) if split_result.shape[1] == 2: chord_df[[primary_degree_col, secondary_degree_col]] = split_result chord_df[secondary_degree_col] = chord_df[secondary_degree_col].fillna("I") else: chord_df[primary_degree_col] = split_result[0] chord_df[secondary_degree_col] = "I" return chord_df def tonicization_census(chord_df: pandas.DataFrame,
degree_col: str = 'degree',
primary_degree_col: str = 'primary_degree',
secondary_degree_col: str = 'secondary_degree',
key_col: str = 'key',
last_chord_duration: float | None = None,
drop_I_or_i: bool = True)-
Expand source code
def tonicization_census( chord_df: pd.DataFrame, degree_col: str = "degree", primary_degree_col: str = "primary_degree", secondary_degree_col: str = "secondary_degree", key_col: str = "key", last_chord_duration: float | None = None, drop_I_or_i: bool = True, ): """ Args: last_chord_duration: The duration of the last chord. If "release" is in the DataFrame, this is ignored. >>> df = pd.read_csv( ... io.StringIO( ... ''' ... onset,release,degree,key ... 0.0,0.5,I,C ... 0.5,1.0,V,C ... 1,2.0,V/V,C ... 2,3.0,I/V,C ... 3,4.0,V/V,C ... 4,5.0,I/V,C ... 5,6.0,V/V,C ... 6,7.0,I/V,C ... 7,8.0,V/V,C ... 8,9.0,V/vi,C ... 9,10.0,I/vi,C ... ''' ... ) ... ) >>> tonicization_census(df) chord_df_index onset secondary_degree n_chords duration 1 2 1.0 V 7 7.0 2 9 8.0 vi 2 2.0 >>> tonicization_census(df.drop(columns=["release"]), last_chord_duration=4.0) chord_df_index onset secondary_degree n_chords duration 1 2 1.0 V 7 7.0 2 9 8.0 vi 2 5.0 """ if secondary_degree_col not in chord_df.columns: chord_df = split_degree_into_primary_and_secondary( chord_df, degree_col=degree_col, primary_degree_col=primary_degree_col, secondary_degree_col=secondary_degree_col, ) tonicization_changes = ( chord_df[secondary_degree_col] != chord_df[secondary_degree_col].shift(1) ) | (chord_df[key_col] != chord_df[key_col].shift(1)) changes_df = pd.DataFrame( chord_df.loc[tonicization_changes, ["onset", secondary_degree_col]] ) changes_df["n_chords"] = np.diff( np.concatenate([changes_df.index, [len(chord_df)]]) ) # We need to reset the index *after* computing the number of chords changes_df = changes_df.reset_index(names="chord_df_index") changes_df.loc[range(0, len(changes_df) - 1), "duration"] = np.diff( changes_df["onset"] ) last_onset = chord_df.iloc[-1]["onset"] last_change_onset = changes_df.iloc[-1]["onset"] if "release" in chord_df.columns: last_release = chord_df.iloc[-1]["release"] last_duration = last_release - last_onset else: assert last_chord_duration is not None last_duration = last_chord_duration changes_df.loc[len(changes_df) - 1, "duration"] = ( last_onset - last_change_onset + last_duration ) if drop_I_or_i: changes_df = changes_df[~changes_df[secondary_degree_col].isin(["I", "i"])] return changes_dfArgs
last_chord_duration- The duration of the last chord. If "release" is in the DataFrame, this is ignored.
>>> df = pd.read_csv( ... io.StringIO( ... ''' ... onset,release,degree,key ... 0.0,0.5,I,C ... 0.5,1.0,V,C ... 1,2.0,V/V,C ... 2,3.0,I/V,C ... 3,4.0,V/V,C ... 4,5.0,I/V,C ... 5,6.0,V/V,C ... 6,7.0,I/V,C ... 7,8.0,V/V,C ... 8,9.0,V/vi,C ... 9,10.0,I/vi,C ... ''' ... ) ... ) >>> tonicization_census(df) chord_df_index onset secondary_degree n_chords duration 1 2 1.0 V 7 7.0 2 9 8.0 vi 2 2.0 >>> tonicization_census(df.drop(columns=["release"]), last_chord_duration=4.0) chord_df_index onset secondary_degree n_chords duration 1 2 1.0 V 7 7.0 2 9 8.0 vi 2 5.0