Source code for vis.analyzers.indexers.offset

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# -------------------------------------------------------------------- #
# Program Name:           vis
# Program Description:    Helps analyze music with computers.
#
# Filename:               analyzers/indexers/offset.py
# Purpose:                Indexer to regularize the observed offsets.
#
# Copyright (C) 2013, 2014, 2016 Christopher Antila, Alexander Morgan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public 
# License along with this program.  If not, see 
# <http://www.gnu.org/licenses/>.
# -------------------------------------------------------------------- #
"""
.. codeauthor:: Christopher Antila <christopher@antila.ca>
.. codeauthor:: Alexander Morgan

Indexers that modify the "offset" values (floats stored as the "index" 
of a :class:`pandas.Series`), potentially adding repetitions of or 
removing pre-existing events, without modifying the events
themselves.

"""

import six
import pandas
import numpy
from vis.analyzers import indexer
from multi_key_dict import multi_key_dict as mkd

[docs]class FilterByOffsetIndexer(indexer.Indexer): """ Indexer that regularizes the "offset" values of observations from other indexers. The Indexer regularizes observations from offsets spaced any, possibly irregular, ``quarterLength`` durations apart, so they are instead observed at regular intervals. This has two effects: * events that do not begin at an observed offset will only be included in the output if no other event occurs before the next observed offset * events that last for many observed offsets will be repeated for those offsets Since elements' durations are not recorded, the last observation in a Series will always be included in the results. If it does not start on an observed offset, it will be included as the next observed offset---again, whether or not this is true in the actual music. However, the last observation will only ever be counted once, even if a part ends before others in a piece with many parts. See the doctests for examples. **Examples:** For all, the ``quarterLength`` is ``1.0``. When events in the input already appear at intervals of ``quarterLength``, input and output are identical. +--------+-------+-------+-------+ | offset | 0.0 | 1.0 | 2.0 | +========+=======+=======+=======+ | input | ``a`` | ``b`` | ``c`` | +--------+-------+-------+-------+ | output | ``a`` | ``b`` | ``c`` | +--------+-------+-------+-------+ When events in the input appear at intervals of ``quarterLength``, but there are additional elements between the observed offsets, those additional elements are removed. +--------+-------+-------+-------+-------+ | offset | 0.0 | 0.5 | 1.0 | 2.0 | +========+=======+=======+=======+=======+ | input | ``a`` | ``A`` | ``b`` | ``c`` | +--------+-------+-------+-------+-------+ | output | ``a`` | ``b`` | ``c`` | +--------+---------------+-------+-------+ +--------+-------+-------+-------+-------+-------+ | offset | 0.0 | 0.25 | 0.5 | 1.0 | 2.0 | +========+=======+=======+=======+=======+=======+ | input | ``a`` | ``z`` | ``A`` | ``b`` | ``c`` | +--------+-------+-------+-------+-------+-------+ | output | ``a`` | ``b`` | ``c`` | +--------+-----------------------+-------+-------+ When events in the input appear at intervals of ``quarterLength``, but not at every observed offset, the event from the previous offset is repeated. +--------+-------+-------+-------+ | offset | 0.0 | 1.0 | 2.0 | +========+=======+=======+=======+ | input | ``a`` | ``c`` | +--------+-------+-------+-------+ | output | ``a`` | ``a`` | ``c`` | +--------+-------+-------+-------+ When events in the input appear at offsets other than those observed by the specified ``quarterLength``, the "most recent" event will appear. +--------+-------+-------+-------+-------+-------+ | offset | 0.0 | 0.25 | 0.5 | 1.0 | 2.0 | +========+=======+=======+=======+=======+=======+ | input | ``a`` | ``z`` | ``A`` | ``c`` | +--------+-------+-------+-------+-------+-------+ | output | ``a`` | ``A`` | ``c`` | +--------+-----------------------+-------+-------+ When the final event does not appear at an observed offset, it will be included in the output at the next offset that would be observed, even if this offset does not appear in the score file to which the results correspond. +--------+-------+-------+-------+-------+ | offset | 0.0 | 1.0 | 1.5 | 2.0 | +========+=======+=======+=======+=======+ | input | ``a`` | ``b`` | ``d`` | +--------+-------+-------+-------+-------+ | output | ``a`` | ``b`` | ``d`` | +--------+-------+---------------+-------+ The behaviour in this last example can create a potentially misleading result for some analytic situations that consider meter. It avoids another potentially misleading situation where the final chord of a piece would appear to be dissonant because of a suspension. We chose to lose metric and rythmic precision, which would be more profitably analyzed with indexers built for that purpose. Consider this illustration, where the numbers correspond to scale degrees. +--------+-------+-------+-------+-------+ | offset | 410.0 | 411.0 | 411.5 | 412.0 | +========+=======+=======+=======+=======+ | in-S | 2 | 1 | +--------+-------+-----------------------+ | in-A | 7 | 5 | +--------+-------+-------+---------------+ | in-T | 4 ----------- | 3 | +--------+-------+-------+---------------+ | in-B | 5 | 1 | +--------+-------+---------------+-------+ | out-S | 2 | 1 | 1 | +--------+-------+---------------+-------+ | out-A | 7 | 5 | 5 | +--------+-------+---------------+-------+ | out-T | 4 | 4 | 3 | +--------+-------+---------------+-------+ | out-B | 5 | 1 | 1 | +--------+-------+---------------+-------+ If we left out the note event appear in the ``in-A`` part at offset ``411.5``, the piece would appear to end with a dissonant sonority! ***** Concerning the "dynamic-offset method", this can be accessed by passing the string "dynamic" for the quarterLength setting. This type of analysis is still experimental and comes with no guarantee that it will work accurately. It has the important known limitation that it only applies to Renaissance polyphony in which the contrapuntal rhythm is only ever in duple groupings. For more on contrapuntal rhythm, see: DeFord, Ruth. Tactus Mensuration, and Rhythm in Renaissance Music. Cambridge: Cambridge University Press, 2015. For a more thorough explanation of the experimental dynamic-offset method see (especially chapter 4): Morgan, Alexander. "Renaissance Interval-Succession Theory: Treatises and Analysis." PhD diss., McGill University, 2017. Helper functions have been implemented to facilitate the use of the dynamic-offset method, so you can run analyses in the following way: from vis.models.indexed_piece import Importer ip = Importer('full_path_to_piece_in_symbolic_notation.xml') # assuming you want to apply the offset filter to the noterest # indexer results: nr = ip.get_data('noterest') setts = {'quarterLength': 'dynamic'} filtered_nr = ip.get_data('offset', data=nr, settings=setts) """ required_score_type = 'pandas.Series' "The :class:`FilterByOffsetIndexer` uses :class:`pandas.Series` objects." possible_settings = ['quarterLength', 'dom_data', 'method', 'mp'] """ A ``list`` of possible settings for the :class:`FilterByOffsetIndexer`. :keyword 'quarterLength': The quarterLength duration between observations desired in the output. This value must not have more than three digits to the right of the decimal (i.e. 0.001 is the smallest possible value). For dynamic (i.e. variable) and context-dependent value, pass the string 'dynamic'. :type 'quarterLength': float or string :keyword 'dom_data': A list of DataFrames and one integer is required here if the 'quarterLength' setting is set to 'dynamic'. This list should contain the dissonance, duration, beatstrength, and noterest indexer dataframes and finally the "highest_time" of the piece or movement in that order. The correct information is automatically fetched if this indexer is called on an IndexedPiece object via the get_data method() if the 'data' argument in that method is not passed. :keyword 'method': The value passed as the ``method`` kwarg to :meth:`~pandas.DataFrame.reindex`. The default is ``'ffill'``, which fills in missing indices with the previous value. This is useful for vertical intervals, but not for horizontal, where you should use ``None`` instead. :type 'method': str or None :keyword 'mp': Multiprocesses when True (default) or processes serially when False. :type 'mp': boolean **Examples:** >>> from vis.models.indexed_piece import Importer >>> ip = Importer('path_to_piece.xml') >>> notes = ip.get_data('noterest') >>> setts = {'quarterLength': 2} >>> ip.get_data('offset', data=notes, settings=setts) # Note that other analysis results can be passed to the offset # indexer too, such as the IntervalIndexer results as in the # following example. Also, the original column names (or names of # the series if a list of series was passed) are retained, though # the highest level of the columnar multi-index gets overwritten >>> from vis.models.indexed_piece import Importer >>> ip = Importer('path_to_piece.xml') >>> intervals = ip.get_data('vertical_interval') >>> setts = {'quarterLength': 2} >>> ip.get_data('offset', data=intervals, settings=setts) """ default_settings = {'method': 'ffill', 'mp': True, 'dom_data':[]} _ZERO_PART_ERROR = (u'FilterByOffsetIndexer requires an index ' + 'with at least one part.') _NO_QLENGTH_ERROR = (u'FilterByOffsetIndexer requires a ' + '"quarterLength" setting.') _QLENGTH_TOO_SMALL_ERROR = (u'FilterByOffsetIndexer requires a ' + '"quarterLength" greater than 0.001.') _IMPROPER_DYNAMIC_INPUT = 'FilterByOffsetIndexer requires its score \ parameter to be a list of the dissonance, duration, beatstrength, noterest, \ and timesignature indexers (in that order) if the "quarterLength" setting is \ set to "dynamic"' _UNSUPPORTED_TIME_SIGNATURE = 'FilterByOffsetIndexer only supports \ the following time signatures when the "quarterLength" setting is set to \ "dynamic": {}.' def __init__(self, score, settings=None): """ :param score: A DataFrame or list of Series you wish to filter by offset values, stored in the Index. :type score: :class:`pandas.DataFrame` or ``list`` of :class:`pandas.Series` or ``list`` of :class:`pandas.DataFrame` :param dict settings: There is one required setting. See :const:`possible_settings`. :raises: :exc:`RuntimeError` if ``score`` is the wrong type. :raises: :exc:`RuntimeError` if ``score`` is not a list of the same types. :raises: :exc:`RuntimeError` if the required setting is not present in ``settings``. :raises: :exc:`RuntimeError` if the ``'quarterLength'`` setting has a value less than ``0.001``. """ super(FilterByOffsetIndexer, self).__init__(score, None) # check the settings instance has a u'quarterLength' property. if settings is None or u'quarterLength' not in settings: raise RuntimeError(FilterByOffsetIndexer._NO_QLENGTH_ERROR) elif (type(settings['quarterLength']) != str and settings[u'quarterLength'] < 0.001): raise RuntimeError(FilterByOffsetIndexer._QLENGTH_TOO_SMALL_ERROR) self._settings = FilterByOffsetIndexer.default_settings.copy() self._settings.update(settings) # If self._score is a Stream (subclass), change to a list of # types you want to process. self._types = [] # This Indexer uses pandas magic, not an _indexer_func(). self._indexer_func = None # Ensure the score has at least one part. if len(self._score) == 0: raise RuntimeError(FilterByOffsetIndexer._ZERO_PART_ERROR) if (self._settings['quarterLength'] == 'dynamic' and len(self._settings['dom_data']) != 6): raise RuntimeError(FilterByOffsetIndexer._IMPROPER_DYNAMIC_INPUT) valid_meters = ['2/1', '2/2', '4/2', '4/4'] if (self._settings['quarterLength'] == 'dynamic' and self._settings['dom_data'][4].iloc[0, 0] not in valid_meters): raise RuntimeError(FilterByOffsetIndexer._UNSUPPORTED_TIME_SIGNATURE.format(valid_meters)) def _dynamic_run(self): """ Replacement run method for when the ``quarterLength`` setting is set to 'dynamic'. It assigns context-dependent offset values based on the dissonance types detected in the piece, and its attack density. This setting should only be used for the analysis of Renaissance music with duple divisions in the metric level of the contrapuntal rhythm. For more on contrapuntal rhythm, see Ruth DeFord, 2015. :returns: A :class:`DataFrame` with offset-indexed values for all inputted parts. The pandas indices (holding music21 offsets) start at the first offset at which there is an event in any of the inputted parts. An offset appears at durational intervals equal to the contrapuntal rhythm at that moment in the piece. The value of this contrapuntal rhythm duration is dynamic and can therefore change throughout the course of a piece. :rtype: :class:`pandas.DataFrame` """ dom_data = self._settings['dom_data'] #Remove the upper level of the columnar multi-index. dds = dom_data[0].copy() dds.columns = range(len(dds.columns)) ddr = dom_data[1].copy() ddr.columns = dds.columns bbs = dom_data[2].copy() bbs.columns = dds.columns nnr = dom_data[3].copy() nnr.columns = dds.columns ts = dom_data[4] w = 6 # Remove weak dissonances weaks = ('R', 'D', 'L', 'U', 'E', 'C', 'A') indx, cols = numpy.where(dom_data[0].isin(weaks)) for x in reversed(range(len(indx))): spot = ddr.iloc[:indx[x], cols[x]].last_valid_index() # Add the weak dissonance duration to the note that immediately precedes it. ddr.at[spot, cols[x]] += ddr.iat[indx[x], cols[x]] # Remove strong dissonances other than suspensions strongs = ('Q', 'H') indx, cols = numpy.where(dom_data[0].isin(strongs)) for x in reversed(range(len(indx))): spot = ddr.iloc[indx[x]+1:, cols[x]].first_valid_index() # Add the strong dissonance duration to the note that immediately follows it. ddr.iat[indx[x], cols[x]] += ddr.at[spot, cols[x]] ddr.at[spot, cols[x]] = float('nan') nnr.iat[indx[x], cols[x]] = nnr.at[spot, cols[x]] nnr.at[spot, cols[x]] = float('nan') # Delete the duration entries of weak dissonances ddr[dds.isin(weaks)] = float('nan') nnr[dds.isin(weaks)] = float('nan') # Delete the duration entries of strong dissonances other than suspensions ddr[dds.isin(strongs)] = float('nan') # Delete duration entries for rests ddr = ddr[nnr != 'Rest'] ddr.dropna(how='all', inplace=True) # Attack-density analysis without most dissonances for the whole piece. combined = pandas.Series(ddr.index[1:] - ddr.index[:-1], index=ddr.index[:-1]) comb_roll = combined.rolling(w).mean() # Broadcast any bs value to all columns of a df. cbs = pandas.concat([dom_data[2].T.bfill().iloc[0]]*len(bbs.columns), axis=1, ignore_index=True) diss_levs = mkd({('2/1w', '4/2w'): {.0625: 1, .125: 2, .25: 4, .5: 8, 1: 8}, #NB: things that happen on beats 1 and 3 are treated the same way. ('2/1s', '4/2s'): {.0625: .25, .125: .5, .25: 1, .5: 2, 1: 2}, ('2/2w', '4/4w'): {.0625: .5, .125: 1, .25: 2, .5: 4, 1: 4}, #NB: things that happen on beats 1 and 3 are treated the same way. ('2/2s', '4/4s'): {.0625: .125, .125: .25, .25: .5, .5: 1, 1: 1}, }) # Get the beatstrength of the dissonances diss_cr = bbs[dds.isin(weaks)] time_sig = ts.iloc[0, 0] diss_cr.replace(diss_levs[time_sig + 'w'], inplace=True) swsus = ('S', 'Q', 'H') sbs = cbs[dds.isin(swsus)] sbs.replace(diss_levs[time_sig + 's'], inplace=True) # CR analysis based on dissonance types alone. diss_cr.update(sbs) cr = comb_roll.copy() ccr = cr.copy() # Snap the readings to a reasonable note-value grid. # The CR reading can only be an eighth, quarter, half, or whole note. mlt = 1.25 # top threshhold above which to round CR reading up. ccr[cr < .5*mlt] = .5 ccr[cr > .5*mlt] = 1 ccr[cr > 1*mlt] = 2 ccr[cr > 2*mlt] = 4 for i, val in enumerate(ccr): counts = diss_cr.iloc[i:i+w].stack().value_counts() lvi = ccr.iloc[:i].last_valid_index() if len(counts) > 0 and counts.index[0] == val: # If the most common diss in the window corresponds to the attack-density reading, leave the current val continue elif lvi is not None and ccr.at[lvi] == val: # There is no dissonance in this window, but the IR analysis has not changed, so the reading is valid continue else: # The new level has not been confirmed by a corresponding dissonance ccr.iat[i] = float('nan') ccr.ffill(inplace=True) ccr.bfill(inplace=True) ccr = ccr.loc[ccr.shift() != ccr] # Remove consecutive duplicates # Make the new index end_time = int(dom_data[5]) # "highest time" of first part. spots = list(ccr.index) spots.append(end_time) # Add the index value of the last moment of the piece which usually has no event at it. new_index = [] for i, spot in enumerate(spots[:-1]): if spot % ccr.iat[i] != 0: spot -= (spot % ccr.iat[i]) post = list(numpy.arange(spot, spots[i+1], ccr.iat[i])) # you can't use range() because range can't handle floats if bool(new_index) and bool(post) and post[0] in new_index: del post[0] new_index.extend(post) if isinstance(self._score, list): self._score = pandas.concat(self._score, axis=1) return self._score.reindex(index=pandas.Index(new_index)).ffill()
[docs] def run(self): """ Regularize the observed offsets for the Series input. :returns: A :class:`DataFrame` with offset-indexed values for all inputted parts. The pandas indices (holding music21 offsets) start at the first offset at which there is an event in any of the inputted parts. An offset appears every ``quarterLength`` until the final offset, which is either the last observation in the piece (if it is divisible by the ``quarterLength``) or the next-highest value that is divisible by ``quarterLength``. :rtype: :class:`pandas.DataFrame` """ if self._settings['quarterLength'] == 'dynamic': return self._dynamic_run() # NB: we have to convert all the "offset" values to integers so # we can use the range() function to iterate through # offsets. post = [] start_offset = None try: # usually this finds the first offset in the piece start_offset = int(min([part.index[0] for part in self._score]) * 1000) except (ValueError, IndexError): # if one of the parts has 0 length start_offset = [] for part in self._score: if 0 < len(part.index): start_offset.append(part.index[0]) if 0 == len(start_offset): # all the parts have no length, so we need as many empty parts post = [pandas.Series() for _ in range(len(self._score))] else: start_offset = int(min(start_offset)) if 0 == len(post): for part in self._score: if len(part.index) < 1: post.append(part) else: end_offset = int(part.index[-1] * 1000) step = int(self._settings[u'quarterLength'] * 1000) off_list = list(pandas.Series(range(start_offset, end_offset + step, step)).div(1000.0)) # pylint: disable=C0301 post.append(part.reindex(index=off_list, method=self._settings['method'])) post = self.make_return([ser.name[1] for ser in self._score], post) return post