diff --git a/accompanion/__init__.py b/accompanion/__init__.py index a940266..0514a2b 100644 --- a/accompanion/__init__.py +++ b/accompanion/__init__.py @@ -2,9 +2,10 @@ """ Top level of the package """ +import importlib.util import platform + import pkg_resources -import importlib.util # OS: Linux, Mac or Windows PLATFORM = platform.system() diff --git a/accompanion/accompanist/__init__.py b/accompanion/accompanist/__init__.py index bb229e5..40a96af 100644 --- a/accompanion/accompanist/__init__.py +++ b/accompanion/accompanist/__init__.py @@ -1,2 +1 @@ # -*- coding: utf-8 -*- -from .accompanist import ACCompanion diff --git a/accompanion/accompanist/accompaniment_decoder.py b/accompanion/accompanist/accompaniment_decoder.py index bb41104..6cd4d00 100644 --- a/accompanion/accompanist/accompaniment_decoder.py +++ b/accompanion/accompanist/accompaniment_decoder.py @@ -2,120 +2,16 @@ """ Decode the performance from the accompaniment """ -import numpy as np -from accompanion.utils.expression_tools import friberg_sundberg_rit -from accompanion.config import CONFIG - - -class Accompanist(object): - """ - The Accompanist class is responsible for decoding the performance - from the accompaniment. - - Parameters - ---------- - accompaniment_score : AccompanimentScore - The accompaniment score. - performance_codec : PerformanceCodec - The performance codec. - """ - - def __init__(self, accompaniment_score, performance_codec): - - self.acc_score = accompaniment_score - - self.pc = performance_codec - self.prev_eq_onsets = np.zeros( - len(self.acc_score.ssc.unique_onsets), dtype=float - ) - self.step_counter = 0 - self.bp_prev = None - self.rit_curve = friberg_sundberg_rit(CONFIG["RIT_LEN"], CONFIG["RIT_W"]) - self.rit_counter = 0 - # self.num_acc_onsets = len(self.acc_score.ssc.unique_onsets) - - self.tempo_change_curve = dict() - - j = 0 - all_chords = self.acc_score.solo_score_dict[ - list(self.acc_score.solo_score_dict.keys())[0] - ][0] - self.num_chords = len(all_chords) - for i, so in enumerate(all_chords): - self.tempo_change_curve[so] = 1.0 - if self.num_chords - i <= CONFIG["RIT_LEN"]: - self.tempo_change_curve[so] = self.rit_curve[j] - j += 1 - - def accompaniment_step(self, solo_s_onset, solo_p_onset, tempo_expectations=None): - - # Get next accompaniment onsets and their - # respective score iois with respect to the current - # solo score onset - - next_acc_onsets, next_iois, _, _, suix = self.acc_score.solo_score_dict[ - solo_s_onset - ] - - # if self.step_counter > 0: - # beat_period, eq_onset = self.pc.tempo_model(solo_p_onset, - # solo_s_onset) - # else: - # beat_period = self.pc.bp_ave - # eq_onset = solo_p_onset - - beat_period, eq_onset = self.pc.tempo_model(solo_p_onset, solo_s_onset) - - # print(self.step_counter, beat_period) - - velocity, articulation = self.pc.encode_step() - - # if solo_p_onset is not None: - self.prev_eq_onsets[suix] = eq_onset - prev_eq_onset = self.prev_eq_onsets[suix] - - if next_acc_onsets is not None: - - for i, (so, ioi) in enumerate(zip(next_acc_onsets, next_iois)): - - if tempo_expectations is not None and i != 0: - bp_ave = tempo_expectations(so.onset) - else: - bp_ave = beat_period - - t_factor = self.tempo_change_curve[so] - bp_ave *= t_factor +from typing import Callable, Dict, Optional, Tuple, Union - ( - perf_onset, - so.p_duration, - so.velocity, - prev_eq_onset, - ) = self.pc.decode_step( - ioi=ioi, - dur=so.duration, - # TODO: This will need to be changed when - # the expressive parameters are computed in - # in real time. - vt=self.acc_score.velocity_trend[so], - vd=self.acc_score.velocity_dev[so], - lbpr=self.acc_score.log_bpr[so], - tim=self.acc_score.timing[so], - lart=self.acc_score.log_articulation[so], - bp_ave=bp_ave, - vel_a=velocity, - art_a=articulation, - prev_eq_onset=prev_eq_onset, - ) - - # if i == 0: - # print(so.onset, perf_onset, ioi, bp_ave, solo_p_onset) - - if ioi != 0 or self.step_counter == 0: - # print('accompaniment step') - so.p_onset = perf_onset +import numpy as np +from partitura.score import Part - self.step_counter += 1 +from accompanion.accompanist.score import AccompanimentScore +from accompanion.accompanist.tempo_models import SyncModel +from accompanion.config import CONFIG +from accompanion.score_follower.note_tracker import NoteTracker +from accompanion.utils.expression_tools import friberg_sundberg_rit class OnlinePerformanceCodec(object): @@ -124,7 +20,7 @@ class OnlinePerformanceCodec(object): Parameters ---------- - pass: partitura.score.Part (optional) + part: partitura.score.Part (optional) The solo part. note_tracker : NoteTracker (optional) The note tracker. @@ -141,13 +37,14 @@ class OnlinePerformanceCodec(object): init_eq_onset : float (optional) The initial equalized onset. mechanical_delay : float (optional) - The mechanical delay of the accompaniment (to be used with a mechnaical piano). - This refers to the delay of the message arriving to the piano and the mechanical piano actually producing the sound. - tempo_model : TempoModel (optional) + The mechanical delay of the accompaniment (to be used with a mechanical piano). + This refers to the delay of the message arriving to the piano and the mechanical + piano actually producing the sound. + tempo_model : SyncModel (optional) The tempo model to be used from available models in accompanist tempo_models.py vel_prev : int (optional) The previous velocity. - articulation_prev : int (optional) + articulation_prev : float (optional) The previous articulation. articulation_ma_alpha: float (optional) The alpha parameter for the moving average of the articulation. @@ -155,38 +52,37 @@ class OnlinePerformanceCodec(object): def __init__( self, - part=None, - note_tracker=None, - beat_period_ave=0.5, - velocity_ave=45, - vel_min=20, - vel_max=90, - velocity_ma_alpha=0.6, - init_eq_onset=0.0, - mechanical_delay=0.0, - tempo_model=None, - vel_prev=60, - articulation_prev=1, - articulation_ma_alpha=0.4, + part: Optional[Part] = None, + note_tracker: Optional[NoteTracker] = None, + beat_period_ave: float = 0.5, + velocity_ave: Union[int, float] = 45, + vel_min: int = 20, + vel_max: int = 90, + velocity_ma_alpha: float = 0.6, + init_eq_onset: float = 0.0, + mechanical_delay: float = 0.0, + tempo_model: Optional[SyncModel] = None, + vel_prev: float = 60, + articulation_prev: float = 1.0, + articulation_ma_alpha: float = 0.4, **kwargs - ): - - self.velocity_ave = float(velocity_ave) - self.bp_ave = float(beat_period_ave) - self.vel_min = vel_min - self.vel_max = vel_max - self.velocity_ma_alpha = velocity_ma_alpha - self.articulation_ma_alpha = articulation_ma_alpha - self.mechanical_delay = mechanical_delay - self.prev_eq_onset = init_eq_onset - self.part = part - self.note_tracker = note_tracker - self.tempo_model = tempo_model - self.vel_prev = vel_prev - self.articulation_prev = articulation_prev - self.kwargs = kwargs - - def encode_step(self): + ) -> None: + self.velocity_ave: float = float(velocity_ave) + self.bp_ave: float = float(beat_period_ave) + self.vel_min: int = vel_min + self.vel_max: int = vel_max + self.velocity_ma_alpha: float = velocity_ma_alpha + self.articulation_ma_alpha: float = articulation_ma_alpha + self.mechanical_delay: float = mechanical_delay + self.prev_eq_onset: float = init_eq_onset + self.part: Optional[Part] = part + self.note_tracker: Optional[NoteTracker] = note_tracker + self.tempo_model: Optional[SyncModel] = tempo_model + self.vel_prev: float = vel_prev + self.articulation_prev: float = articulation_prev + self.kwargs: dict = kwargs + + def encode_step(self) -> Tuple[float, float]: try: self.vel_prev = moving_average_online( np.max(self.note_tracker.velocities[-1]), @@ -229,19 +125,23 @@ def encode_step(self): def decode_step( self, - ioi, - dur, - vt, - vd, - lbpr, - tim, - lart, - bp_ave, - vel_a, - art_a, - prev_eq_onset=None, - ): - + ioi: float, + dur: Union[np.ndarray, float], + vt: Union[np.ndarray, float], + vd: Union[np.ndarray, float], + lbpr: Union[np.ndarray, float], + tim: Union[np.ndarray, float], + lart: Union[np.ndarray, float], + bp_ave: float, + vel_a: float, + art_a: float, + prev_eq_onset: Optional[float] = None, + ) -> Tuple[ + Union[np.ndarray, float], + Union[np.ndarray, float], + Union[np.ndarray, int], + float, + ]: self.bp_ave = bp_ave self.velocity_ave = vel_a # Compute equivalent onsets @@ -260,28 +160,196 @@ def decode_step( return perf_onset, perf_duration, perf_vel, eq_onset - def decode_velocity(self, vt, vd, vel_ave): + def decode_velocity( + self, + vt: Union[np.ndarray, float], + vd: Union[np.ndarray, float], + vel_ave: float, + ) -> np.ndarray: # Add options for normalization - perf_vel = np.clip(vt * vel_ave - vd, self.vel_min, self.vel_max).astype(np.int) + perf_vel = np.clip(vt * vel_ave - vd, self.vel_min, self.vel_max).astype(int) return perf_vel - def decode_duration(self, dur, lart, lbpr, art_a, bp_ave): + def decode_duration( + self, + dur: Union[np.ndarray, float], + lart: Union[np.ndarray, float], + lbpr: Union[np.ndarray, float], + art_a: float, + bp_ave: float, + ) -> np.ndarray: # TODO: check expected articulation in solo (legato, staccato), # and only use it if it is the "same" as notated in the # accompaniment score art_a = np.clip(art_a, 0, 1) - perf_duration = (2 ** lart) * ((2 ** lbpr) * bp_ave) * dur * art_a + perf_duration = (2**lart) * ((2**lbpr) * bp_ave) * dur * art_a np.clip(perf_duration, a_min=0.01, a_max=4, out=perf_duration) return perf_duration -def moving_average_online(param_new, param_old, alpha=0.5): +class Accompanist(object): + """ + The Accompanist class is responsible for decoding the performance + from the accompaniment. + + Parameters + ---------- + accompaniment_score : AccompanimentScore + The accompaniment score. + performance_codec : PerformanceCodec + The performance codec. + """ + + acc_score: AccompanimentScore + pc: OnlinePerformanceCodec + + def __init__( + self, + accompaniment_score: AccompanimentScore, + performance_codec: OnlinePerformanceCodec, + decoder_kwargs: Optional[Dict] = None, + ) -> None: + self.acc_score = accompaniment_score + + self.pc = performance_codec + + self.decoder_kwargs = decoder_kwargs if decoder_kwargs is not None else {} + + self.prev_eq_onsets = np.zeros( + len(self.acc_score.ssc.unique_onsets), dtype=float + ) + self.step_counter = 0 + self.bp_prev = None + self.rit_curve = friberg_sundberg_rit( + len_c=self.decoder_kwargs.get("rit_len", CONFIG["RIT_LEN"]), + r_w=self.decoder_kwargs.get("rit_w", CONFIG["RIT_W"]), + r_q=self.decoder_kwargs.get("rit_q", CONFIG["RIT_Q"]), + ) + self.rit_counter = 0 + + self.tempo_change_curve = dict() + + j = 0 + all_chords = self.acc_score.solo_score_dict[ + list(self.acc_score.solo_score_dict.keys())[0] + ][0] + self.num_chords = len(all_chords) + for i, so in enumerate(all_chords): + self.tempo_change_curve[so] = 1.0 + if self.num_chords - i <= self.decoder_kwargs.get( + "rit_len", CONFIG["RIT_LEN"] + ): + self.tempo_change_curve[so] = self.rit_curve[j] + j += 1 + + def accompaniment_step( + self, + solo_s_onset: float, + solo_p_onset: float, + tempo_expectations: Optional[Callable[[float], float]] = None, + ) -> None: + """ + Update the performance of the accompaniment part given the latest + information from the solo performance. This method does not + return the parameters of the performance, but updates the + notes in the accompaniment score sequencer directly. + + Parameters + ---------- + solo_s_onset : float + Currently performed score onset (in beats) + solo_p_onset : float + Current performed score onset time (in seconds) + tempo_expectations: Callable + A callable method that outputs the tempo expectations + given the corresponding score onset position. + """ + # Get next accompaniment onsets and their + # respective score iois with respect to the current + # solo score onset + + next_acc_onsets, next_iois, _, _, suix = self.acc_score.solo_score_dict[ + solo_s_onset + ] + + # if self.step_counter > 0: + # beat_period, eq_onset = self.pc.tempo_model(solo_p_onset, + # solo_s_onset) + # else: + # beat_period = self.pc.bp_ave + # eq_onset = solo_p_onset + + beat_period, eq_onset = self.pc.tempo_model(solo_p_onset, solo_s_onset) + + # print(self.step_counter, beat_period) + + velocity, articulation = self.pc.encode_step() + + # if solo_p_onset is not None: + self.prev_eq_onsets[suix] = eq_onset + prev_eq_onset = self.prev_eq_onsets[suix] + + if next_acc_onsets is not None: + for i, (so, ioi) in enumerate(zip(next_acc_onsets, next_iois)): + if tempo_expectations is not None and i != 0: + bp_ave = tempo_expectations(so.onset) + else: + bp_ave = beat_period + + t_factor = self.tempo_change_curve[so] + bp_ave *= t_factor + + ( + perf_onset, + so.p_duration, + so.velocity, + prev_eq_onset, + ) = self.pc.decode_step( + ioi=ioi, + dur=so.duration, + # TODO: This will need to be changed when + # the expressive parameters are computed in + # in real time. + vt=self.acc_score.velocity_trend[so], + vd=self.acc_score.velocity_dev[so], + lbpr=self.acc_score.log_bpr[so], + tim=self.acc_score.timing[so], + lart=self.acc_score.log_articulation[so], + bp_ave=bp_ave, + vel_a=velocity, + art_a=articulation, + prev_eq_onset=prev_eq_onset, + ) + + # if i == 0: + # print(so.onset, perf_onset, ioi, bp_ave, solo_p_onset) + + if ioi != 0 or self.step_counter == 0: + so.p_onset = perf_onset + # if i == 0: + # print( + # "accompaniment step", + # so.onset, + # ioi, + # so.p_onset, + # perf_onset, + # perf_onset - solo_p_onset, + # ) + + self.step_counter += 1 + + +def moving_average_online( + param_new: Union[np.ndarray, float], + param_old: Union[np.ndarray, float], + alpha: float = 0.5, +) -> Union[np.ndarray, float]: return alpha * param_old + (1 - alpha) * param_new -def moving_average_offline(parameter, alpha=0.5): +def moving_average_offline(parameter: np.ndarray, alpha: float = 0.5) -> np.ndarray: ma = np.zeros_like(parameter) ma[0] = parameter[0] diff --git a/accompanion/accompanist/accompanist.py b/accompanion/accompanist/accompanist.py deleted file mode 100644 index 97652cb..0000000 --- a/accompanion/accompanist/accompanist.py +++ /dev/null @@ -1,642 +0,0 @@ -# -*- coding: utf-8 -*- -""" -ACCompanion! -""" -import multiprocessing -import threading -import time - -import numpy as np -import partitura - -from basismixer.performance_codec import get_performance_codec -from basismixer.utils.music import onsetwise_to_notewise, notewise_to_onsetwise -from scipy.interpolate import interp1d - -from accompanion.midi_handler.midi_input import create_midi_poll, POLLING_PERIOD -from accompanion.midi_handler.midi_file_player import get_midi_file_player -from accompanion.midi_handler.midi_sequencing_threads import ScoreSequencer -from accompanion.midi_handler.midi_routing import MidiRouter - -from accompanion.mtchmkr.features_midi import PianoRollProcessor -from accompanion.mtchmkr.alignment_online_oltw import ( - OnlineTimeWarping, -) - -from accompanion.mtchmkr.utils_generic import SequentialOutputProcessor - -from accompanion.accompanist.score import ( - part_to_score, - alignment_to_score, - AccompanimentScore, -) - -from accompanion.accompanist.accompaniment_decoder import ( - OnlinePerformanceCodec, - Accompanist, - moving_average_offline, -) -import accompanion.accompanist.tempo_models as tempo_models -from accompanion.config import CONFIG -from accompanion.utils.partitura_utils import ( - get_time_maps_from_alignment, - partitura_to_framed_midi_custom as partitura_to_framed_midi) - -from accompanion.midi_handler.ceus_mediator import CeusMediator -from accompanion.score_follower.note_tracker import NoteTracker -from accompanion.score_follower.onset_tracker import OnsetTracker -from accompanion.score_follower.trackers import MultiDTWScoreFollower -from accompanion.midi_handler.fluid import FluidsynthPlayer - - -ACC_PROCESS = True -ACC_PARENT = multiprocessing.Process if ACC_PROCESS else threading.Thread -USE_THREADS = True - - -class ACCompanion(ACC_PARENT): - def __init__( - self, - solo_fn, - acc_fn, - midi_fn=None, - init_bpm=60, - init_velocity=60, - polling_period=POLLING_PERIOD, - follower="OnlineTimeWarping", - follower_kwargs={"window_size": 80, "step_size": 10}, - ground_truth_match=None, - router_kwargs={}, - tempo_model=tempo_models.LSM, - tempo_model_kwargs={}, - accompaniment_match=None, - pipe=None, - use_ceus_mediator=False, - performance_codec_kwargs={ - "velocity_trend_ma_alpha": 0.6, - "articulation_ma_alpha": 0.4, - "velocity_dev_scale": 70, - "velocity_min": 20, - "velocity_max": 100, - "velocity_solo_scale": 0.85, - "timing_scale": 0.001, - "log_articulation_scale": 0.1, - "mechanical_delay": 0.0, - }, - adjust_following_rate=0.1, - bypass_audio=False, # bypass fluidsynth audio - ): - super(ACCompanion, self).__init__() - - self.solo_fn = solo_fn - self.acc_fn = acc_fn - self.midi_fn = midi_fn - # Matchfile with ground truth alignment (to - # test the accuracy of the score follower) - self.ground_truth_match = ground_truth_match - # Matchfile with precomputed performance from the - # Basis Mixer - self.accompaniment_match = accompaniment_match - - self.router = None - self.router_kwargs = router_kwargs - - self.init_bpm = init_bpm - self.init_bp = 60 / self.init_bpm - self.init_velocity = init_velocity - - # Parameters for following - self.polling_period = polling_period - - self.follower_type = follower - self.follower_kwargs = follower_kwargs - - self.tempo_model_kwargs = tempo_model_kwargs - self.tempo_model = tempo_model - self.performance_codec_kwargs = performance_codec_kwargs - - self.solo_parts = [] - self.solo_spart, self.solo_score, self.acc_score = None, None, None - - # MIDI communication - self.pipe_out, self.queue, self.midi_input_process = None, None, None - - # reference features and frame index in reference for visualization - self.reference_features = None - self.score_idx = 0 - self.perf_frame = None - - self.score_follower = None - self.note_tracker = None - - self.accompanist = None - self.seq = None - self.mediator = None - self.use_mediator = use_ceus_mediator - - self.dummy_solo = None - - self.reference_is_performance = False - self.play_accompanion = False - - self.beat_period = self.init_bp - - self.prev_score_onset = None - - # self.tap_tempo = tap_tempo - # self.tempo_tapping = tempo_tapping - self.tempo_counter = 0 - self.prev_metro_time = None - self.tempo_sum = 0 - self.pipe = pipe - self.first_score_onset = None - self.adjust_following_rate = adjust_following_rate - # Rate in "loops_without_update" for adjusting the score - # follower with expected position at the - # current tempo - self.afr = np.round(1 / self.polling_period * self.adjust_following_rate) - - # bypass fluidsynth audio - self.bypass_audio = bypass_audio - - @property - def beat_period(self): - return self.beat_period_ - - @beat_period.setter - def beat_period(self, beat_period): - self.beat_period_ = beat_period - - if self.accompanist is not None: - self.accompanist.pc.bp_ave = beat_period - - @property - def velocity(self): - return self.velocity_ - - @velocity.setter - def velocity(self, velocity): - self.velocity_ = velocity - - def setup_scores(self): - """ - Load scores and prepare the accompaniment - """ - - self.solo_parts = [] - - for i, fn in enumerate(self.solo_fn): - - if fn.endswith(".match"): - if i == 0: - solo_ppart, alignment, self.solo_spart = partitura.load_match( - fn=fn, create_part=True, first_note_at_zero=True - ) - else: - solo_ppart, alignment = partitura.load_match( - fn=fn, create_part=False, first_note_at_zero=True - ) - - ptime_to_stime_map, stime_to_ptime_map = get_time_maps_from_alignment( - ppart_or_note_array=solo_ppart, - spart_or_note_array=self.solo_spart, - alignment=alignment, - ) - self.solo_parts.append( - (solo_ppart, ptime_to_stime_map, stime_to_ptime_map) - ) - else: - solo_spart = partitura.load_musicxml(fn) - - if i == 0: - self.solo_spart = solo_spart - - self.solo_parts.append((solo_spart, None, None)) - - self.solo_score = part_to_score(self.solo_spart, bpm=self.init_bpm) - - if self.accompaniment_match is None: - acc_spart = partitura.load_musicxml(self.acc_fn) - acc_notes = list(part_to_score(acc_spart, bpm=self.init_bpm).notes) - velocity_trend = None - velocity_dev = None - timing = None - log_articulation = None - log_bpr = None - - else: - acc_ppart, acc_alignment, acc_spart = partitura.load_match( - fn=self.accompaniment_match, - first_note_at_zero=True, - create_part=True, - ) - acc_notes = list( - alignment_to_score( - fn_or_spart=acc_spart, ppart=acc_ppart, alignment=acc_alignment - ).notes - ) - pc = get_performance_codec( - [ - "velocity_trend", - "velocity_dev", - "beat_period", - "timing", - "articulation_log", - ] - ) - bm_params, _, u_onset_idx = pc.encode( - part=acc_spart, - ppart=acc_ppart, - alignment=acc_alignment, - return_u_onset_idx=True, - ) - - bm_params_onsetwise = notewise_to_onsetwise(bm_params, u_onset_idx) - - # TODO Use the solo part to compute the moving average - vt_ma = moving_average_offline( - parameter=bm_params_onsetwise["velocity_trend"], - alpha=self.performance_codec_kwargs.get("velocity_trend_ma_alpha", 0.6), - ) - - velocity_trend = onsetwise_to_notewise( - bm_params_onsetwise["velocity_trend"] / vt_ma, u_onset_idx - ) - - if self.tempo_model.has_tempo_expectations: - # get iterable of the tempo expectations - self.tempo_model.tempo_expectations_func = interp1d( - np.unique(acc_spart.note_array()["onset_beat"]), - bm_params_onsetwise["beat_period"], - bounds_error=False, - kind="previous", - fill_value=( - bm_params_onsetwise["beat_period"][0], - bm_params_onsetwise["beat_period"][-1], - ), - ) - self.init_bp = bm_params_onsetwise["beat_period"][0] - - vd_scale = self.performance_codec_kwargs.get("velocity_dev_scale", 90) - velocity_dev = bm_params["velocity_dev"] * vd_scale - - timing_scale = self.performance_codec_kwargs.get("timing_scale", 1.0) - timing = bm_params["timing"] * timing_scale - print(np.mean(np.abs(timing))) - lart_scale = self.performance_codec_kwargs.get( - "log_articulation_scale", 1.0 - ) - log_articulation = bm_params["articulation_log"] * lart_scale - # log_articulation = None - log_bpr = None - - self.acc_score = AccompanimentScore( - notes=acc_notes, - solo_score=self.solo_score, - velocity_trend=velocity_trend, - velocity_dev=velocity_dev, - timing=timing, - log_articulation=log_articulation, - log_bpr=log_bpr, - ) - - pc = OnlinePerformanceCodec( - beat_period_ave=self.init_bp, - velocity_ave=64, - init_eq_onset=0.0, - tempo_model=self.tempo_model, - **self.performance_codec_kwargs, - ) - - self.accompanist = Accompanist( - accompaniment_score=self.acc_score, performance_codec=pc - ) - - if self.use_mediator: - self.mediator = CeusMediator() - - self.seq = ScoreSequencer( - score_or_notes=self.acc_score, - outport=self.router.acc_output_to_sound_port, - mediator=self.mediator, - ) - self.seq.panic_button() - - # Update tempo model - self.tempo_model.beat_period = self.init_bp - self.prev_score_onset = self.solo_score.unique_onsets.min() - self.first_score_onset = self.solo_score.unique_onsets.min() - - # initialize note tracker - self.note_tracker = NoteTracker(self.solo_spart.note_array()) - self.accompanist.pc.note_tracker = self.note_tracker - - def setup_score_follower(self): - pipeline = SequentialOutputProcessor([PianoRollProcessor(piano_range=True)]) - - state_to_ref_time_maps = [] - ref_to_state_time_maps = [] - score_followers = [] - - # reference score for visualization - self.reference_features = None - - for part, state_to_ref_time_map, ref_to_state_time_map in self.solo_parts: - - if state_to_ref_time_map is not None: - ref_frames = partitura_to_framed_midi( - part_or_notearray_or_filename=part, - is_performance=True, - pipeline=pipeline, - polling_period=self.polling_period, - )[0] - - else: - raise NotImplementedError - - state_to_ref_time_maps.append(state_to_ref_time_map) - ref_to_state_time_maps.append(ref_to_state_time_map) - ref_features = np.array(ref_frames).astype(float) - - if self.reference_features is None: - self.reference_features = ref_features - - # setup score follower - if self.follower_type == "OnlineTimeWarping": - score_follower = OnlineTimeWarping( - reference_features=ref_features, **self.follower_kwargs - ) - else: - raise NotImplementedError - - score_followers.append(score_follower) - - self.score_follower = MultiDTWScoreFollower( - score_followers, - state_to_ref_time_maps, - ref_to_state_time_maps, - self.polling_period, - ) - - self.pipe_out, self.queue, self.midi_input_process = create_midi_poll( - port=self.router.solo_input_to_accompaniment_port, - polling_period=self.polling_period, - # velocities only for visualization purposes - pipeline=SequentialOutputProcessor( - [PianoRollProcessor(piano_range=True, use_velocity=True)] - ), - return_midi_messages=True, - thread=USE_THREADS, - mediator=self.mediator, - ) - - def get_reference_features(self): - return self.reference_features - - def get_performance_frame(self): - return self.perf_frame - - def get_accompaniment_frame(self): - return self.seq.get_midi_frame() - - def get_score_index(self): - return self.score_idx - - def stop_playing(self): - self.play_accompanion = False - - if self.dummy_solo is not None: - self.dummy_solo.stop_playing() - - self.midi_input_process.stop_listening() - self.seq.stop_playing() - self.router.close_ports() - # self.join() - - def terminate(self): - - if hasattr(super(ACCompanion, self), "terminate"): - super(ACCompanion, self).terminate() - else: - self.stop_playing() - - def get_tempo(self): - return 60 / self.beat_period - - def run(self): - - if self.router_kwargs.get("acc_output_to_sound_port_name", None) is not None: - try: - # For SynthPorts - self.router_kwargs[ - "acc_output_to_sound_port_name" - ] = self.router_kwargs["acc_output_to_sound_port_name"]() - except TypeError: - pass - - if self.router_kwargs.get("MIDIPlayer_to_sound_port_name", None) is not None: - try: - self.router_kwargs[ - "MIDIPlayer_to_sound_port_name" - ] = self.router_kwargs["MIDIPlayer_to_sound_port_name"]() - except TypeError: - pass - - self.router = MidiRouter(**self.router_kwargs) - self.tempo_model = self.tempo_model(**self.tempo_model_kwargs) - self.setup_scores() - self.setup_score_follower() - self.tempo_model.prev_score_onset = self.solo_score.min_onset - - if self.pipe is not None: - self.pipe.send(self.get_reference_features()) - - self.play_accompanion = True - solo_starts = True - sequencer_start = False - start_time = None - if self.acc_score.min_onset < self.solo_score.min_onset: - self.seq.init_time = time.time() - self.accompanist.pc.prev_eq_onset = 0 - self.seq.start() - sequencer_start = True - solo_starts = False - start_time = self.seq.init_time - - # intialize beat period - # perf_start = False - - onset_tracker = OnsetTracker(self.solo_score.unique_onsets) - # Initialize on-line Basis Mixer here - # expression_model = BasisMixer() - self.midi_input_process.start() - print("Start listening") - - self.perf_frame = None - self.score_idx = 0 - - if self.midi_fn is not None: - print("Start playing MIDI file") - self.dummy_solo = get_midi_file_player( - port=self.router.MIDIPlayer_to_accompaniment_port, - file_name=self.midi_fn, - player_class=FluidsynthPlayer, - thread=USE_THREADS, - bypass_audio=self.bypass_audio, - ) - self.dummy_solo.start() - - # dummy start time (see below) - if start_time is None: - start_time = time.time() - if not sequencer_start: - self.seq.init_time = start_time - expected_position = self.first_score_onset - loops_without_update = 0 - empty_loops = 0 - prev_solo_p_onset = None - adjusted_sf = False - decay = np.ones(88) - - pioi = self.polling_period - - try: - while self.play_accompanion and not self.seq.end_of_piece: - - if self.queue.poll(): - output = self.queue.recv() - # CC: moved solo_p_onset here because of the delays... - # perhaps it would be good to take the time from - # the MIDI messages? - solo_p_onset = time.time() - start_time - - input_midi_messages, output = output - - # listen to metronome notes for tempo - # copy output to perf_frame - # (with velocities for visualization) - self.perf_frame = output.copy() - # overwrite velocities to 1 for tracking - # TODO think about nicer solution - output[output > 0] = 1.0 - - # # start playing the performance - # if not perf_start and (output > 0).any(): - # # Ignore messages after the tapping - # if np.all( - # np.where(output > 0)[0] + 21 - # == self.solo_score.getitem_indexwise(0).pitch - # ): - # perf_start = True - # print("start following!") - - # Use these onset times? - onset_times = [ - msg[1] - for msg in input_midi_messages - if msg[0].type in ("note_on", "note_off") - ] - onset_time = np.mean(onset_times) if len(onset_times) > 0 else 0 - new_midi_messages = False - decay *= CONFIG["DECAY_VALUE"] - for msg, msg_time in input_midi_messages: - if msg.type in ("note_on", "note_off"): - - if msg.type == "note_on" and msg.velocity > 0: - new_midi_messages = True - midi_msg = (msg.type, msg.note, msg.velocity, onset_time) - self.note_tracker.track_note(midi_msg) - - decay[msg.note - 21] = 1.0 - - output *= decay - - if sum(output) == 0: - empty_loops += 1 - else: - empty_loops == 0 - - # if perf_start: - self.score_idx, score_position = self.score_follower(output) - solo_s_onset, onset_index, acc_update = onset_tracker( - score_position, - expected_position - # self.seq.performed_score_onsets[-1] - ) - - pioi = ( - solo_p_onset - prev_solo_p_onset - if prev_solo_p_onset is not None - else self.polling_period - ) - prev_solo_p_onset = solo_p_onset - expected_position = expected_position + pioi / self.beat_period - - if solo_s_onset is not None: - - print( - f"performed onset {solo_s_onset}", - f"expected onset {expected_position}", - f"beat_period {self.beat_period}", - f"adjusted {acc_update or adjusted_sf}", - ) - - - - - if not acc_update: - asynch = expected_position - solo_s_onset - # print('asynchrony', asynch) - expected_position = expected_position - 0.6 * asynch - - - loops_without_update = 0 - adjusted_sf = False - else: - loops_without_update += 1 - - if new_midi_messages: - self.note_tracker.update_alignment(solo_s_onset) - # start accompaniment if it starts at the - # same time as the solo - if solo_starts and onset_index == 0: - if not sequencer_start: - print("Start accompaniment") - sequencer_start = True - self.accompanist.accompaniment_step( - solo_s_onset=solo_s_onset, - solo_p_onset=solo_p_onset, - ) - self.seq.start() - - if ( - solo_s_onset > self.first_score_onset - and not acc_update - and not adjusted_sf - ): - self.accompanist.accompaniment_step( - solo_s_onset=solo_s_onset, solo_p_onset=solo_p_onset - ) - self.beat_period = self.accompanist.pc.bp_ave - else: - loops_without_update += 1 - - if loops_without_update % self.afr == 0: - # only allow forward updates - if self.score_follower.current_position < expected_position: - self.score_follower.update_position(expected_position) - adjusted_sf = True - - if self.pipe is not None: - self.pipe.send( - ( - self.perf_frame, - self.get_accompaniment_frame(), - self.score_idx, - self.get_tempo(), - ) - ) - except Exception: - pass - finally: - self.stop_playing() diff --git a/accompanion/accompanist/score.py b/accompanion/accompanist/score.py index 515d138..10667bb 100644 --- a/accompanion/accompanist/score.py +++ b/accompanion/accompanist/score.py @@ -2,37 +2,72 @@ """ Objects for representing score information """ -from typing import Iterable -import numpy as np import warnings +from typing import Iterable, Optional, Union, Callable, List, Tuple, Dict -# from matchmaker.io.symbolic import load_score -from partitura import load_score -from mido import Message +import numpy as np + +# from numpy.typing import NDArray import partitura -from partitura.score import Part, Score as PtScore -from partitura.performance import PerformedPart, Performance +from mido import Message +from partitura import load_score +from partitura.performance import Performance, PerformedPart +from partitura.score import Part +from partitura.score import Score as PtScore from partitura.utils.music import performance_from_part +class ACCNoteError(Exception): + pass + + class Note(object): """ - Class for representing notes + Class for representing notes. + + Parameters + ---------- + pitch: int + MIDI Pitch of the note + onset: float + Score onset of the note (in beats) + duration: float + Score duration of the note (in beats) + p_onset: Optional[float] + Performed onset time (in seconds) + p_duration: Optional[float] + Performed duration time (in seconds) + velocity: int + Performed MIDI velocity + id: Optional[str] + Note ID + channel: int + MIDI channel """ + pitch: int + onset: float + duration: float + _p_onset: Optional[float] + p_duration: Optional[float] + _velocity: int + id: Optional[str] + _note_on: Message + _note_off: Message + already_performed: bool + def __init__( self, - pitch, - onset, - duration, - p_onset=None, - p_duration=None, - velocity=64, - id=None, - channel=0, - ): - + pitch: int, + onset: float, + duration: float, + p_onset: Optional[float] = None, + p_duration: Optional[float] = None, + velocity: int = 64, + id: Optional[str] = None, + channel: int = 0, + ) -> None: self.pitch = pitch self.onset = onset self.duration = duration @@ -57,40 +92,40 @@ def __init__( channel=channel, ) - def __string__(self): + def __string__(self) -> str: out_string = f"Note({self.pitch}, {self.onset}, {self.p_onset})" return out_string @property - def p_onset(self): + def p_onset(self) -> float: return self._p_onset @p_onset.setter - def p_onset(self, onset): + def p_onset(self, onset: float) -> None: self._p_onset = onset @property - def note_on(self): + def note_on(self) -> Message: self._note_on.velocity = self.velocity self._note_on.time = self.p_onset return self._note_on @property - def note_off(self): + def note_off(self) -> Message: self._note_off.velocity = self.velocity self._note_off.time = self.p_offset return self._note_off @property - def p_offset(self): + def p_offset(self) -> float: return self.p_onset + self.p_duration @property - def velocity(self): + def velocity(self) -> int: return self._velocity @velocity.setter - def velocity(self, velocity): + def velocity(self, velocity: Union[float, int]) -> None: self._velocity = int(velocity) @@ -99,11 +134,20 @@ class Chord(object): Class for representing Score onsets or "chords". """ - def __init__(self, notes): + notes: Iterable[Note] + num_notes: int + onset: float + pitch: np.ndarray + duration: np.ndarray + def __init__(self, notes: Iterable[Note]) -> None: if not isinstance(notes, Iterable): notes = [notes] - assert all([n.onset == notes[0].onset for n in notes]) + + if not all([n.onset == notes[0].onset for n in notes]): + raise ACCNoteError( + "The score onset in beats of all notes in a chord should be the same" + ) self.notes = notes self.num_notes = len(notes) @@ -112,25 +156,24 @@ def __init__(self, notes): self.pitch = np.array([n.pitch for n in self.notes]) self.duration = np.array([n.duration for n in self.notes]) - def __getitem__(self, index): + def __getitem__(self, index: int) -> Note: return self.notes[index] - def __len__(self): + def __len__(self) -> int: return self.num_notes @property - def p_onset(self): - + def p_onset(self) -> float: if any([n.p_onset is None for n in self.notes]): return None else: return np.mean([n.p_onset for n in self.notes]) @p_onset.setter - def p_onset(self, p_onset): + def p_onset(self, p_onset: Union[np.ndarray, float, int]) -> None: if isinstance(p_onset, (float, int)): for n in self.notes: - n.p_onset = p_onset + n.p_onset = float(p_onset) else: # Assume that self.notes and p_onset # have the same length (this makes a little bit @@ -143,14 +186,14 @@ def p_onset(self, p_onset): # n.p_onset = po @property - def p_duration(self): + def p_duration(self) -> np.ndarray: if any([n.p_duration is None for n in self.notes]): return None else: return np.mean([n.p_duration for n in self.notes]) @p_duration.setter - def p_duration(self, p_duration): + def p_duration(self, p_duration: np.ndarray) -> None: if isinstance(p_duration, (float, int)): for n in self.notes: n.p_duration = p_duration @@ -160,14 +203,14 @@ def p_duration(self, p_duration): n.p_duration = po @property - def velocity(self): + def velocity(self) -> Optional[np.ndarray]: if any([n.velocity is None for n in self.notes]): return None else: return np.max([n.velocity for n in self.notes]) @velocity.setter - def velocity(self, velocity): + def velocity(self, velocity: Union[float, int, np.ndarray]) -> None: if isinstance(velocity, (float, int)): for n in self.notes: n.velocity = velocity @@ -178,14 +221,42 @@ def velocity(self, velocity): class Score(object): + """ + Main object to represent a musical score + + Parameters + ---------- + notes: Iterable[Note] + The notes in the score + time_signature_map: callable or None + A map that relates score time (in beats) to the time signature at + at that position. + access_mode: str + "indexwise" or "timewise" + note_array: np.ndarray (optional) + The structured note array of the score. If not given, + it will be computed from the `notes`. + """ + + notes: Iterable[Note] + chords: List[Chord] + time_signature_map: Callable[[float], Tuple[int, int]] + _access_mode: str + unique_onsets: np.ndarray + min_onset: float + max_onset: float + unique_onset_idxs: List[np.ndarray] + chords: np.ndarray + chord_dict: Dict[float, Chord] + note_array: np.ndarray + def __init__( self, - notes, - time_signature_map=None, - access_mode="indexwise", - note_array=None, + notes: Iterable[Note], + time_signature_map: Optional[Callable] = None, + access_mode: str = "indexwise", + note_array: Optional[np.ndarray] = None, ): - # TODO: Seconday sort by pitch self.notes = np.array(sorted(notes, key=lambda x: x.pitch)) self.time_signature_map = time_signature_map @@ -206,11 +277,6 @@ def __init__( # Very weird numpy behavior... # See https://stackoverflow.com/a/72036793 self.chords[:] = [Chord(self.notes[ui]) for ui in self.unique_onset_idxs] - # self.chords = np.array( - # [Chord(self.notes[ui]) for ui in self.unique_onset_idxs], dtype=object - # ) - - # assert(all([isinstance(c, Chord) for c in self.chords])) self.chord_dict = dict( [(u, c) for u, c in zip(self.unique_onsets, self.chords)] @@ -240,13 +306,12 @@ def note_array_from_notes(self) -> None: self.note_array = note_array - @property - def access_mode(self): + def access_mode(self) -> str: return self._access_mode @access_mode.setter - def access_mode(self, access_mode): + def access_mode(self, access_mode: str) -> None: if access_mode not in ("indexwise", "timewise"): raise ValueError( '`access_mode` should be "indexwise" or "timewise". ' @@ -259,25 +324,19 @@ def access_mode(self, access_mode): elif self.access_mode == "timewise": self._getitem_ = self.getitem_timewise - def getitem_indexwise(self, index): + def getitem_indexwise(self, index: int) -> Chord: return self.chords[index] - def getitem_timewise(self, index): + def getitem_timewise(self, index: float) -> Chord: return self.chord_dict[index] - def __getitem__(self, index): + def __getitem__(self, index: Union[int, float]) -> Chord: return self._getitem_(index) - # def __getitem__(self, index): - # if self.access_mode == 'indexwise': - # return self.chords[index] - # elif self.access_mode == 'timewise': - # return self.chord_dict[index] - - def __len__(self): + def __len__(self) -> int: return len(self.unique_onsets) - def export_midi(self, out_fn): + def export_midi(self, out_fn) -> None: # create note_array note_array = np.zeros( len(self.notes), @@ -290,7 +349,6 @@ def export_midi(self, out_fn): ) for i, note in enumerate(self.notes): - note_array["pitch"][i] = note.pitch note_array["onset_sec"][i] = note.p_onset note_array["duration_sec"][i] = note.p_duration @@ -302,19 +360,53 @@ def export_midi(self, out_fn): class AccompanimentScore(Score): + """ + Representation of the score of the accompaniment part + + Parameters + ---------- + notes: Iterable[Note] + The notes of the accompaniment part + solo_score: Score + The score of the solo part + mode: str + Mode of access. + velocity_trend: Optional[np.ndarray] + The onset-level trend in MIDI velocity + velocity_dev: Optional[np.ndarray] + The deviation in MIDI velocity + log_bpr: Optional[np.ndarray] + Log beat period ratio + timing: Optional[np.ndarray] + The (micro-)timing deviations + log_articulation: Optional[np.ndarray] + The log articulation parameter + note_array: Optional[np.ndarray] + The structured note array of the score. If not given, + it will be computed from the `notes`. + """ + + ssc: Score + mode: str + solo_score_dict: Dict[float, Tuple[np.ndarray, np.ndarray, np.ndarray, int, int]] + velocity_trend: Dict[Chord, float] + velocity_dev: Dict[Chord, np.ndarray] + log_bpr: Dict[Chord, float] + timing: Dict[Chord, np.ndarray] + log_articulation: Dict[Chord, np.ndarray] + def __init__( self, - notes, - solo_score, - mode="iter_solo", - velocity_trend=None, - velocity_dev=None, - log_bpr=None, - timing=None, - log_articulation=None, - note_array=None, + notes: Iterable[Note], + solo_score: Score, + mode: str = "iter_solo", + velocity_trend: Optional[np.ndarray] = None, + velocity_dev: Optional[np.ndarray] = None, + log_bpr: Optional[np.ndarray] = None, + timing: Optional[np.ndarray] = None, + log_articulation: Optional[np.ndarray] = None, + note_array: Optional[np.ndarray] = None, ): - assert isinstance(solo_score, Score) super().__init__( @@ -346,8 +438,6 @@ def __init__( log_bpr = np.zeros(len(self.notes)) if timing is None: timing = np.zeros(len(self.notes)) - # hack - # timing = 0.1 * (np.random.rand(len(self.notes)) - 0.5) if log_articulation is None: log_articulation = np.zeros(len(self.notes)) @@ -359,7 +449,6 @@ def __init__( self.log_articulation[chord] = log_articulation[uix] for i, on in enumerate(self.ssc.unique_onsets): - acc_idx = np.where(self.unique_onsets >= on)[0] if len(acc_idx) > 0: @@ -368,10 +457,9 @@ def __init__( ioi_init = next_acc_onsets.min() - on next_iois = np.r_[ioi_init, np.diff(next_acc_onsets)] - # next_onsets = np.cumsum(next_iois) - # This information seems to be redundant... - # self.next_onsets[on] = (self.chords[acc_idx:], next_iois) + # if i == 0: + # print("next_iois", np.cumsum(next_iois), on) self.solo_score_dict[on] = ( self.chords[acc_idx:], @@ -381,11 +469,14 @@ def __init__( i, ) else: - # self.next_onsets[on] = (None, None) self.solo_score_dict[on] = (None, None, None, None, i) -def part_to_score(fn_spart_or_ppart, bpm=100, velocity=64): +def part_to_score( + fn_spart_or_ppart: Union[str, Part, PerformedPart, Score], + bpm: float = 100, + velocity: int = 64, +) -> Score: """ Get a accompanion `Score` instance from a partitura `Part`, `Score` or `PerformedPart` @@ -445,7 +536,11 @@ def part_to_score(fn_spart_or_ppart, bpm=100, velocity=64): return score -def alignment_to_score(fn_or_spart, ppart, alignment): +def alignment_to_score( + fn_or_spart: Union[str, Part, PtScore], + ppart: Union[Performance, PerformedPart], + alignment: List[Dict], +) -> Score: """ Get a `Score` instance from a partitura `Part` or `PerformedPart` @@ -474,7 +569,8 @@ def alignment_to_score(fn_or_spart, ppart, alignment): part = fn_or_spart[0] else: raise ValueError( - "`fn_or_spart` must be a `Part` or a filename, " f"but is {type(fn_or_spart)}." + "`fn_or_spart` must be a `Part` or a filename, " + f"but is {type(fn_or_spart)}." ) if not isinstance(ppart, (PerformedPart, Performance)): @@ -518,14 +614,3 @@ def alignment_to_score(fn_or_spart, ppart, alignment): score = Score(notes, time_signature_map=part.time_signature_map) return score - - -if __name__ == "__main__": - - import partitura - - fn = "../demo_data/twinkle_twinkle_little_star_score.musicxml" - - spart = partitura.load_musicxml(fn) - - score = Score(spart) diff --git a/accompanion/accompanist/tempo_models.py b/accompanion/accompanist/tempo_models.py index 9210aef..3995e0f 100644 --- a/accompanion/accompanist/tempo_models.py +++ b/accompanion/accompanist/tempo_models.py @@ -2,7 +2,8 @@ """ Tempo Models """ -from typing import ClassVar, Tuple +from typing import Tuple + import numpy as np from scipy.interpolate import interp1d @@ -28,13 +29,13 @@ class SyncModel(object): purposes. """ - beat_period: ClassVar[float] - prev_score_onset: ClassVar[float] - prev_perf_onset: ClassVar[float] - est_onset: ClassVar[float] - asynchrony: ClassVar[float] - has_tempo_expectations: ClassVar[bool] - counter: ClassVar[int] + beat_period: float + prev_score_onset: float + prev_perf_onset: float + est_onset: float + asynchrony: float + has_tempo_expectations: bool + counter: int def __init__( self, @@ -144,7 +145,7 @@ def update_beat_period(self, performed_onset, score_onset): self.beat_period = ( self.alpha * self.beat_period + (1 - self.alpha) * beat_period ) - print(self.beat_period) + # print(self.beat_period) else: self.est_onset = performed_onset @@ -174,6 +175,7 @@ class LinearSyncModel(SyncModel): Learning rate for the onset """ + def __init__( self, init_beat_period=0.5, @@ -204,6 +206,7 @@ def update_beat_period(self, performed_onset, score_onset, *args, **kwargs): tempo_correction_term = ( self.asynchrony if self.asynchrony != 0 and s_ioi != 0 else 0 ) + self.prev_perf_onset = performed_onset self.prev_score_onset = score_onset @@ -219,10 +222,12 @@ def update_beat_period(self, performed_onset, score_onset, *args, **kwargs): # Alias LSM = LinearSyncModel + class JointAdaptationAnticipationSyncModel(SyncModel): """ Tempo Model with Joint Adaptation and Anticipation. """ + def __init__( self, init_beat_period=0.5, @@ -424,6 +429,7 @@ def update_beat_period(self, performed_onset, score_onset, *args, **kwargs): tempo_correction_term = ( self.asynchrony if self.asynchrony != 0 and s_ioi != 0 else 0 ) + print(f"tempo_correction_term {tempo_correction_term}") self.prev_perf_onset = performed_onset self.prev_score_onset = score_onset @@ -500,13 +506,13 @@ def update_beat_period(self, performed_onset: float, score_onset: float) -> None self.prev_perf_onset = performed_onset # First, compute the prediction step: period_pred = self.beat_period * self.trans_par - var_pred = (self.trans_par ** 2) * self.var_est + self.trans_var + var_pred = (self.trans_par**2) * self.var_est + self.trans_var # Compute the error (innovation), between the estimation and obs: err = performed_ioi - score_ioi * period_pred # Compute the Kalman gain with the new predictions: kalman_gain = float(var_pred * score_ioi) / ( - (score_ioi ** 2) * var_pred + self.obs_var + (score_ioi**2) * var_pred + self.obs_var ) # Compute the estimations after the update step: self.beat_period = period_pred + kalman_gain * err diff --git a/accompanion/accompanist/tempo_models_evaluation.py b/accompanion/accompanist/tempo_models_evaluation.py index 5148029..c9c0622 100644 --- a/accompanion/accompanist/tempo_models_evaluation.py +++ b/accompanion/accompanist/tempo_models_evaluation.py @@ -1,7 +1,7 @@ - -from misc.partitura_utils import partitura_to_framed_midi_custom import partitura -if __name__ == '__main__': +from misc.partitura_utils import partitura_to_framed_midi_custom + +if __name__ == "__main__": # See main.py to see how to load performances, # set up the score follower and all initializations @@ -9,15 +9,16 @@ # load scores and performances (see `setup_scores` method in main.py) # This is the midi file containing the performance of the solo part - midi_fn = '' + midi_fn = "" # the "object" holding the accompaniment score, which is an instance of # `AccompanimentScore` acc_score = None # Also, load the ground truth performance of the accompaniment - accompaniment_match = '' - accompaniment_ppart, accompaniment_alignment = partitura.load_match(accompaniment_match_fn, - first_note_at_zero=True) + accompaniment_match = "" + accompaniment_ppart, accompaniment_alignment = partitura.load_match( + accompaniment_match_fn, first_note_at_zero=True + ) # create score follower pipeline = None score_follower = None @@ -25,15 +26,13 @@ polling_period = 0.01 # generate frames - frames = partitura_to_framed_midi_custom(midi_fn, - pipeline=pipeline, - polling_period=polling_period, - is_performance=True) - + frames = partitura_to_framed_midi_custom( + midi_fn, pipeline=pipeline, polling_period=polling_period, is_performance=True + ) # This code was copied and sliglty adapted from main.py, a few # things might need to be set up. for it to work - + perf_start = False for ix, output in enumerate(frames): # change to corresponding time of the frames @@ -49,21 +48,22 @@ if solo_s_onset is not None: if onset_index == 0: - expected_solo_onset = 0 + expected_solo_onset = 0 else: # this are the tempo models that we want # to test - beat_period, expected_solo_onset = \ - tempo_model(solo_p_onset, - solo_s_onset) + beat_period, expected_solo_onset = tempo_model( + solo_p_onset, solo_s_onset + ) accompanist.accompaniment_step( solo_s_onset=solo_s_onset, solo_p_onset=expected_solo_onset, velocity=60, beat_period=beat_period, - articulation=1.0) + articulation=1.0, + ) """ TODO: diff --git a/accompanion/base.py b/accompanion/base.py index 46ac34f..c0237ea 100644 --- a/accompanion/base.py +++ b/accompanion/base.py @@ -3,39 +3,35 @@ ACCompanion! """ import multiprocessing +import os import threading import time -import numpy as np -import os -from accompanion.config import CONFIG from typing import Optional -from accompanion.midi_handler.midi_input import create_midi_poll, POLLING_PERIOD -from accompanion.midi_handler.midi_file_player import get_midi_file_player -from accompanion.midi_handler.midi_sequencing_threads import ScoreSequencer -from accompanion.midi_handler.midi_routing import ( - MidiRouter, - DummyRouter, - RecordingRouter, -) -from accompanion.midi_handler.midi_utils import midi_file_from_midi_msg # TODO: del, import is never used - -from accompanion.mtchmkr.utils_generic import SequentialOutputProcessor # TODO: del - -from accompanion.accompanist.score import AccompanimentScore, Score +import numpy as np from accompanion.accompanist.accompaniment_decoder import ( - OnlinePerformanceCodec, Accompanist, + OnlinePerformanceCodec, ) - -from accompanion.accompanist.tempo_models import SyncModel # TODO: del +from accompanion.accompanist.score import AccompanimentScore, Score +from accompanion.config import CONFIG from accompanion.midi_handler.ceus_mediator import CeusMediator -from accompanion.score_follower.note_tracker import NoteTracker -from accompanion.score_follower.onset_tracker import OnsetTracker, DiscreteOnsetTracker -from accompanion.score_follower.trackers import AccompanimentScoreFollower from accompanion.midi_handler.fluid import FluidsynthPlayer - +from accompanion.midi_handler.midi_file_player import get_midi_file_player +from accompanion.midi_handler.midi_input import POLLING_PERIOD, create_midi_poll +from accompanion.midi_handler.midi_routing import ( + DummyRouter, + MidiRouter, + RecordingRouter, +) +from accompanion.midi_handler.midi_sequencing_threads import ScoreSequencer +from accompanion.score_follower.note_tracker import NoteTracker +from accompanion.score_follower.onset_tracker import DiscreteOnsetTracker, OnsetTracker +from accompanion.score_follower.trackers import ( + AccompanimentScoreFollower, + ExpectedPositionTracker, +) ACC_PARENT = multiprocessing.Process if CONFIG["ACC_PROCESS"] else threading.Thread @@ -95,10 +91,12 @@ def __init__( polling_period: float = POLLING_PERIOD, use_ceus_mediator: bool = False, adjust_following_rate: float = 0.1, + expected_position_weight: float = 0.6, onset_tracker_type: str = "continuous", bypass_audio: bool = False, # bypass fluidsynth audio test: bool = False, # switch to Dummy MIDI ROuter for test environment record_midi: bool = False, + accompanist_decoder_kwargs: Optional[dict] = None, ) -> None: super(ACCompanion, self).__init__() @@ -108,6 +106,7 @@ def __init__( self.tempo_model_kwargs = tempo_model_kwargs self.solo_score: Optional[Score] = None self.acc_score: Optional[AccompanimentScore] = None + self.accompanist_decoder_kwargs: Optional[dict] = accompanist_decoder_kwargs self.accompanist = None self.time_delays = list() self.alignment = list() @@ -131,8 +130,12 @@ def __init__( self.tempo_model = None self.bypass_audio: bool = True if test else bypass_audio self.play_accompanion: bool = False + + # Expected position tracker + self.expected_position_tracker: Optional[ExpectedPositionTracker] = None # Rate in "loops_without_update" for adjusting the score self.adjust_following_rate: float = adjust_following_rate + self.expected_position_weight: float = expected_position_weight # follower with expected position at the current tempo. self.afr: float = np.round(1 / self.polling_period * self.adjust_following_rate) self.input_pipeline = None @@ -146,6 +149,8 @@ def __init__( self.test = test self.onset_tracker_type = onset_tracker_type + print("expected_position_weight", self.expected_position_weight) + def setup_scores(self) -> None: """Method to be overwritten by the child classes.""" raise NotImplementedError @@ -185,6 +190,7 @@ def setup_process(self): self.setup_scores() self.setup_score_follower() + self.performance_codec = OnlinePerformanceCodec( beat_period_ave=self.init_bp, velocity_ave=self.velocity, @@ -196,6 +202,7 @@ def setup_process(self): self.accompanist: Accompanist = Accompanist( accompaniment_score=self.acc_score, performance_codec=self.performance_codec, + decoder_kwargs=self.accompanist_decoder_kwargs, ) if self.use_mediator: @@ -234,6 +241,11 @@ def setup_process(self): self.note_tracker: NoteTracker = NoteTracker(self.solo_score.note_array) self.accompanist.pc.note_tracker = self.note_tracker + self.expected_position_tracker = ExpectedPositionTracker( + tempo_model=self.tempo_model, + first_onset=self.first_score_onset, + ) + self.pipe_out, self.queue, self.midi_input_process = create_midi_poll( port=self.router.solo_input_to_accompaniment_port, polling_period=self.polling_period, @@ -305,6 +317,8 @@ def run(self): solo_starts = True sequencer_start = False start_time = None + # For debugging + acc_step_counter = 0 # start the accompaniment if the solo part starts afterwards if self.acc_score.min_onset < self.solo_score.min_onset: @@ -330,7 +344,7 @@ def run(self): if self.midi_fn is not None: print("Start playing MIDI file") self.dummy_solo = get_midi_file_player( - port= self.router.MIDIPlayer_to_accompaniment_port, + port=self.router.MIDIPlayer_to_accompaniment_port, file_name=self.midi_fn, player_class=FluidsynthPlayer, thread=CONFIG["USE_THREADS"], @@ -338,13 +352,12 @@ def run(self): ) self.dummy_solo.start() - - # dummy start time (see below) if start_time is None: start_time = time.time() if not sequencer_start: self.seq.init_time = start_time + expected_position = self.first_score_onset loops_without_update = 0 empty_loops = 0 @@ -361,10 +374,10 @@ def run(self): # vs. "self.queue.poll() is not None" # (NV) actually, this should be "have a CORRECT branch (non-blocking MIDI) # vs. no branch (blocking MIDI)" - - #if self.queue.poll() is not None: - - #this version of recv uses the quasi-blocking version with periodic timeouts + + # if self.queue.poll() is not None: + + # this version of recv uses the quasi-blocking version with periodic timeouts output = self.queue.recv() solo_p_onset = time.time() - start_time input_midi_messages, output = output @@ -385,7 +398,7 @@ def run(self): # if perf_start: score_position = self.score_follower(output) - + # print(f"score_position {score_position}") solo_s_onset, onset_index, acc_update = onset_tracker( score_position, expected_position @@ -404,15 +417,19 @@ def run(self): print( f"performed onset {solo_s_onset}", - f"expected onset {expected_position}", + f"expected onset {self.expected_position_tracker.expected_position}", f"beat_period {self.beat_period}", f"adjusted {acc_update or adjusted_sf}", ) - self.time_delays.append([solo_s_onset, solo_p_onset, self.beat_period]) + + self.time_delays.append( + [solo_s_onset, solo_p_onset, self.beat_period] + ) if not acc_update: + self.expected_position_tracker.expected_position = solo_s_onset asynch = expected_position - solo_s_onset - expected_position = expected_position - 0.6 * asynch + expected_position = expected_position - self.expected_position_weight * asynch loops_without_update = 0 adjusted_sf = False else: @@ -437,17 +454,21 @@ def run(self): and not acc_update and not adjusted_sf ): + print(f"step {acc_step_counter} {solo_s_onset}") self.accompanist.accompaniment_step( solo_s_onset=solo_s_onset, solo_p_onset=solo_p_onset ) self.beat_period = self.accompanist.pc.bp_ave + acc_step_counter += 1 else: loops_without_update += 1 if loops_without_update % self.afr == 0: # only allow forward updates if self.score_follower.current_position < expected_position: + # old_sf_position = float(self.score_follower.current_position) self.score_follower.update_position(expected_position) + # self.score_follower.update_position(self.expected_position_tracker.expected_position) adjusted_sf = True self.alignment = self.note_tracker.alignment except Exception as e: diff --git a/accompanion/config/__init__.py b/accompanion/config/__init__.py index 529b429..377c3a1 100644 --- a/accompanion/config/__init__.py +++ b/accompanion/config/__init__.py @@ -8,6 +8,8 @@ "RIT_LEN": 24, # Ritenuto Window Length found in Accompanist in Accopanist decoder "RIT_W": 0.75, + # Ritenuto curvature found in Accompanist decoder + "RIT_Q": 2.0, # I/O MIDI "BACKEND": "mido", "POLLING_PERIOD": 0.02, diff --git a/accompanion/hmm_accompanion.py b/accompanion/hmm_accompanion.py index 8bcbcb2..2a72b6e 100644 --- a/accompanion/hmm_accompanion.py +++ b/accompanion/hmm_accompanion.py @@ -5,31 +5,29 @@ This module contains the HMMAccompanion class, which is the main class for following scores using an HMM. It mainly works when the soloist plays monophonic melodies. """ +from os import PathLike from typing import Optional + import numpy as np import partitura - from basismixer.performance_codec import get_performance_codec -from basismixer.utils.music import onsetwise_to_notewise, notewise_to_onsetwise +from basismixer.utils.music import notewise_to_onsetwise, onsetwise_to_notewise from scipy.interpolate import interp1d -from accompanion.mtchmkr.utils_generic import SequentialOutputProcessor - -from accompanion.base import ACCompanion -from accompanion.midi_handler.midi_input import POLLING_PERIOD -from accompanion.config import CONFIG +from accompanion.accompanist import tempo_models +from accompanion.accompanist.accompaniment_decoder import moving_average_offline from accompanion.accompanist.score import ( AccompanimentScore, alignment_to_score, part_to_score, ) -from accompanion.accompanist.accompaniment_decoder import ( - moving_average_offline, -) +from accompanion.base import ACCompanion +from accompanion.config import CONFIG +from accompanion.midi_handler.midi_input import POLLING_PERIOD +from accompanion.mtchmkr import score_hmm from accompanion.mtchmkr.features_midi import PitchIOIProcessor +from accompanion.mtchmkr.utils_generic import SequentialOutputProcessor from accompanion.score_follower.trackers import HMMScoreFollower -from accompanion.accompanist import tempo_models -from accompanion.mtchmkr import score_hmm class HMMACCompanion(ACCompanion): @@ -73,13 +71,14 @@ class HMMACCompanion(ACCompanion): record_midi : bool, optional Whether to record the MIDI, by default False. """ + def __init__( self, - solo_fn, - acc_fn, + solo_fn: PathLike, + acc_fn: PathLike, midi_router_kwargs: dict, # this is just a workaround for now accompaniment_match: Optional[str] = None, - midi_fn: Optional[str] = None, + midi_fn: Optional[PathLike] = None, score_follower_kwargs: dict = { "score_follower": "PitchIOIHMM", # For the future! @@ -108,9 +107,11 @@ def __init__( polling_period: float = POLLING_PERIOD, use_ceus_mediator: bool = False, adjust_following_rate: float = 0.1, + expected_position_weight: float = 0.6, bypass_audio: bool = False, # bypass fluidsynth audio - test: bool = False, # bypass MIDIRouter - record_midi : str = None, + test: bool = False, # bypass MIDIRouter + record_midi: Optional[str] = None, + accompanist_decoder_kwargs: Optional[dict] = None, ) -> None: score_kwargs = dict( @@ -130,11 +131,13 @@ def __init__( polling_period=polling_period, use_ceus_mediator=use_ceus_mediator, adjust_following_rate=adjust_following_rate, + expected_position_weight=expected_position_weight, bypass_audio=bypass_audio, tempo_model_kwargs=tempo_model_kwargs, test=test, onset_tracker_type="continuous", record_midi=record_midi, + accompanist_decoder_kwargs=accompanist_decoder_kwargs, ) def setup_scores(self): @@ -268,16 +271,21 @@ def setup_score_follower(self): score_follower_type = self.score_follower_kwargs.pop("score_follower") try: - score_follower_kwargs = self.score_follower_kwargs.pop("score_follower_kwargs") + score_follower_kwargs = self.score_follower_kwargs.pop( + "score_follower_kwargs" + ) except KeyError: score_follower_kwargs = {} chord_pitches = [chord.pitch for chord in self.solo_score.chords] pitch_profiles = score_hmm.compute_pitch_profiles( - chord_pitches, piano_range=piano_range, inserted_states=inserted_states, + chord_pitches, + piano_range=piano_range, + inserted_states=inserted_states, ) ioi_matrix = score_hmm.compute_ioi_matrix( - unique_onsets=self.solo_score.unique_onsets, inserted_states=inserted_states, + unique_onsets=self.solo_score.unique_onsets, + inserted_states=inserted_states, ) state_space = ioi_matrix[0] n_states = len(state_space) @@ -295,7 +303,7 @@ def setup_score_follower(self): ) initial_probabilities = score_hmm.gumbel_init_dist(n_states=n_states) - if score_follower_type == 'PitchIOIHMM': + if score_follower_type == "PitchIOIHMM": score_follower = score_hmm.PitchIOIHMM( transition_matrix=transition_matrix, pitch_profiles=pitch_profiles, diff --git a/accompanion/midi_handler/ceus_mediator.py b/accompanion/midi_handler/ceus_mediator.py index eaa8f44..dd3d0fc 100644 --- a/accompanion/midi_handler/ceus_mediator.py +++ b/accompanion/midi_handler/ceus_mediator.py @@ -40,8 +40,8 @@ import threading -class ThreadMediator(): - ''' +class ThreadMediator(object): + """ Mediator class for communications between ACCompanion modules running in concurrent threads or processes. The class ensures thread safety. @@ -51,21 +51,21 @@ class ThreadMediator(): A buffer to receive the output from the one process and send it to another when promped. Follows LIFO (Last In, First Out) logic. For the buffer a deque object is used as this ensures thread safety. - ''' + """ - def __init__(self, **kwds): - ''' + def __init__(self, **kwargs): + """ The initialization method. - ''' + """ # Define the comms buffer: self._comms_buffer = collections.deque(maxlen=200) # A name variable to store the type of the mediator: - self._mediator_type = 'default' + self._mediator_type = "default" # Call the superconstructor: - super().__init__(**kwds) + super().__init__(**kwargs) def is_empty(self): - ''' + """ Returns True if the comms buffer is empty. False if it has at least one element. @@ -74,7 +74,7 @@ def is_empty(self): empty : Boolean True if the comms buffer is empty. False if it has at least one element. - ''' + """ # Check if the buffer is empty: if len(self._comms_buffer) == 0: return True @@ -82,7 +82,7 @@ def is_empty(self): return False def get_message(self): - ''' + """ Get the first from the previously sent messages from an ACCompanion module. Returns IndexError if there is no element in the buffer. This should only be called by the ACCompanion accompaniment production @@ -92,12 +92,12 @@ def get_message(self): ------- message : collections.namedtuple The message to be returned. - ''' + """ # Return the first element: return self._comms_buffer.popleft() def put_message(self, message): - ''' + """ Put a message into the comms buffer. This should only be called by ACCompanion's score matching/following module. @@ -106,19 +106,19 @@ def put_message(self, message): ---------- message : collections.namedtuple The message to be put into the buffer. - ''' + """ self._comms_buffer.append(message) @property def mediator_type(self): - ''' + """ Property method to return the value of the mediator_type variable. - ''' + """ return self._mediator_type class CeusMediator(ThreadMediator): - ''' + """ Encapsulates the ACCompanion trans-module communication in the context of a Ceus System. It also filters notes (MIDI pitches) in the accompaniment part that are played by Ceus. These notes are fed back to the matcher (MAPS) and @@ -133,25 +133,25 @@ class CeusMediator(ThreadMediator): A buffer to receive the output from the one process and send it to another when promped. Follows LIFO (Last In, First Out) logic. For the buffer a deque object is used as this ensures thread safety. - ''' + """ - def __init__(self, **kwds): - ''' + def __init__(self, **kwargs): + """ The initialization method. - ''' + """ # A lock to ensure thread safety of the Ceus filter: self._ceus_lock = threading.RLock() # Define the Ceus filter: self._ceus_filter = collections.deque(maxlen=10) # Call the superconstructor: - super().__init__(**kwds) + super().__init__(**kwargs) # A name variable to store the type of the mediator: - self._mediator_type = 'ceus' + self._mediator_type = "ceus" def filter_check(self, midi_pitch, delete_entry=True): - ''' + """ Check if the midi pitch is in the Ceus filter. Return True if yes, False if it is not present. Delete the filter entry if specified by delete_entry. @@ -169,7 +169,7 @@ def filter_check(self, midi_pitch, delete_entry=True): ------- indicate : Boolean True if pitch is in the filter, False if it is not. - ''' + """ with self._ceus_lock: # print("\t", self._ceus_filter) @@ -187,7 +187,7 @@ def filter_check(self, midi_pitch, delete_entry=True): return False def filter_append_pitch(self, midi_pitch): - ''' + """ Append a MIDI pitch to the Ceus filter. This should only be called by the ACCompanion's accompaniment production module. @@ -195,13 +195,13 @@ def filter_append_pitch(self, midi_pitch): ---------- midi_pitch : int The midi pitch to be appended to the filter. - ''' + """ with self._ceus_lock: # Append the midi pitch to be filtered: self._ceus_filter.append(midi_pitch) def filter_remove_pitch(self, midi_pitch): - ''' + """ Remove a MIDI pitch from the Ceus filter. This should only be called by the ACCompanion's score matching/following module. @@ -209,7 +209,7 @@ def filter_remove_pitch(self, midi_pitch): ---------- midi_pitch : int The midi pitch to be removed from the filter. - ''' + """ with self._ceus_lock: # Remove the pitch from filter: self._ceus_filter.remove(midi_pitch) diff --git a/accompanion/midi_handler/fluid.py b/accompanion/midi_handler/fluid.py index 72ee6f2..55f55a7 100644 --- a/accompanion/midi_handler/fluid.py +++ b/accompanion/midi_handler/fluid.py @@ -9,8 +9,7 @@ """ import warnings -from accompanion import PLATFORM, HAS_FLUIDSYNTH - +from accompanion import HAS_FLUIDSYNTH, PLATFORM if PLATFORM == "Linux": MIDI_DRIVER = "alsa" @@ -24,6 +23,7 @@ if PLATFORM in ("Linux", "Darwin") and HAS_FLUIDSYNTH: import fluidsynth + from accompanion import SOUNDFONT class FluidsynthPlayer(object): @@ -60,7 +60,6 @@ def panic(self): for mp in range(128): self.fs.noteoff(0, mp) - else: class FluidsynthPlayer(object): diff --git a/accompanion/midi_handler/midi_file_player.py b/accompanion/midi_handler/midi_file_player.py index a76db97..cfb46a6 100644 --- a/accompanion/midi_handler/midi_file_player.py +++ b/accompanion/midi_handler/midi_file_player.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- -import mido import multiprocessing import threading +import mido + class MidiFilePlayerThread(threading.Thread): def __init__(self, port, filename, player_class, bypass_audio=False): diff --git a/accompanion/midi_handler/midi_input.py b/accompanion/midi_handler/midi_input.py index 8b1a7d5..053e4f6 100644 --- a/accompanion/midi_handler/midi_input.py +++ b/accompanion/midi_handler/midi_input.py @@ -1,12 +1,13 @@ # -*- coding: utf-8 -*- -import mido import multiprocessing -import time +import os +import tempfile import threading +import time from multiprocessing import Pipe -from queue import Queue, Empty -import tempfile -import os +from queue import Empty, Queue + +import mido # Default polling period (in seconds) POLLING_PERIOD = 0.02 @@ -88,7 +89,6 @@ def run(self): else: self.pipe.send(output) - @property def current_time(self): """ @@ -117,9 +117,9 @@ def stop_listening(self): # close port # TODO!!! - #self.terminate() + # self.terminate() # Join thread - #self.join() + # self.join() def save_midi(self): # sort MIDI messages @@ -154,8 +154,7 @@ def __init__( def run(self): self.start_listening() while self.listen: - - + msg = self.midi_in.poll() if msg is not None: if ( @@ -199,10 +198,10 @@ def stop_listening(self): # reset init time self.init_time = None - #self.terminate() + # self.terminate() # Join thread - #self.join() + # self.join() class Buffer(object): @@ -238,6 +237,7 @@ def __len__(self): def __str__(self): return str(self.frame) + class FramedMidiInputProcess(MidiInputProcess): def __init__( self, @@ -292,6 +292,7 @@ def run(self): self.pipe.send(output) frame.reset(c_time) + class FramedMidiInputThread(MidiInputThread): def __init__( self, @@ -356,6 +357,7 @@ def run(self): # self.queue.put(output) frame.reset(c_time) + def create_midi_poll( port, polling_period, diff --git a/accompanion/midi_handler/midi_routing.py b/accompanion/midi_handler/midi_routing.py index 499eb6c..148fcb0 100644 --- a/accompanion/midi_handler/midi_routing.py +++ b/accompanion/midi_handler/midi_routing.py @@ -4,24 +4,22 @@ real time. This is a copy from matchmaker/io/midi.py, so that it can be updated without requiring to re-install matchmaker """ -import time import datetime +import os import queue - -# import sys - -from typing import Optional, Iterable, Union +import time +from typing import Iterable, Optional, Union import mido - from mido.ports import BaseOutput from accompanion.midi_handler.fluid import FluidsynthPlayer from accompanion.midi_handler.midi_utils import ( - midi_file_from_midi_msg, OUTPUT_MIDI_FOLDER, + midi_file_from_midi_msg, ) -import os + +# import sys class BasePort(object): diff --git a/accompanion/midi_handler/midi_utils.py b/accompanion/midi_handler/midi_utils.py index 719afbd..13aa52e 100644 --- a/accompanion/midi_handler/midi_utils.py +++ b/accompanion/midi_handler/midi_utils.py @@ -1,15 +1,12 @@ - # -*- coding: utf-8 -*- """ MIDI utilities """ import os - +from os.path import dirname, realpath import mido -from os.path import dirname, realpath - filepath = realpath(__file__) dir_of_file = dirname(filepath) acc_pack_dir = dirname(dir_of_file) @@ -33,7 +30,9 @@ def midi_file_from_midi_msg(midi_msg_list, output_path): mid.tracks.append(track) # filter out all messages that are not note related (e.g. pedal messages) - midi_msg_list = [msg for msg in midi_msg_list if msg[0].type in ("note_on", "note_off")] + midi_msg_list = [ + msg for msg in midi_msg_list if msg[0].type in ("note_on", "note_off") + ] # TODO: Add support for pedal messages # setting starting time starting_time = midi_msg_list[0][1] diff --git a/accompanion/mtchmkr/alignment_online_oltw.py b/accompanion/mtchmkr/alignment_online_oltw.py index 8e4cf6b..efe3cd2 100644 --- a/accompanion/mtchmkr/alignment_online_oltw.py +++ b/accompanion/mtchmkr/alignment_online_oltw.py @@ -2,20 +2,20 @@ """ On-line Dynamic Time Warping """ +from typing import Callable, List, Tuple, Union + import numpy as np +from numpy.typing import NDArray -from accompanion.mtchmkr.base import OnlineAlignment from accompanion.mtchmkr import distances -from accompanion.mtchmkr.distances import vdist, Metric -from accompanion.mtchmkr.dtw_loop import ( - dtw_loop, - reset_cost_matrix, -) +from accompanion.mtchmkr.base import OnlineAlignment +from accompanion.mtchmkr.distances import Metric, vdist +from accompanion.mtchmkr.dtw_loop import dtw_loop, reset_cost_matrix -DEFAULT_LOCAL_COST = "Manhattan" -WINDOW_SIZE = 100 -STEP_SIZE = 5 -START_WINDOW_SIZE = 60 +DEFAULT_LOCAL_COST: str = "Manhattan" +WINDOW_SIZE: int = 100 +STEP_SIZE: int = 5 +START_WINDOW_SIZE: int = 60 class OnlineTimeWarping(OnlineAlignment): @@ -27,12 +27,15 @@ class OnlineTimeWarping(OnlineAlignment): reference_features : np.ndarray A 2D array with dimensions (n_timesteps, n_features) containing the features of the reference the input is going to be aligned to. - window_size : int (optional) + window_size : int Size of the window for searching the optimal path in the cumulative cost matrix - step_size : int (optional) + step_size : int Size of the step - + local_cost_fun : Union[str, Callable] + Local metric for computing pairwise distances. + start_window_size: int + Size of the starting window size Attributes ---------- reference_features : np.ndarray @@ -49,18 +52,19 @@ class OnlineTimeWarping(OnlineAlignment): List of the positions for each input. """ + local_cost_fun: Callable[[NDArray[np.float64]], NDArray[np.float64]] + def __init__( self, - reference_features, - window_size=WINDOW_SIZE, - step_size=STEP_SIZE, - local_cost_fun=DEFAULT_LOCAL_COST, - start_window_size=START_WINDOW_SIZE, - ): - + reference_features: NDArray[np.float64], + window_size: int = WINDOW_SIZE, + step_size: int = STEP_SIZE, + local_cost_fun: Union[str, Callable] = DEFAULT_LOCAL_COST, + start_window_size: int = START_WINDOW_SIZE, + ) -> None: super().__init__(reference_features=reference_features) # self.reference_features = reference_features - self.input_features = [] + self.input_features: List[NDArray[np.float64]] = [] # Set local cost function if isinstance(local_cost_fun, str): @@ -84,27 +88,26 @@ def __init__( else: self.vdist = lambda X, y, lcf: lcf(X, y) - self.N_ref = self.reference_features.shape[0] - self.window_size = window_size - self.step_size = step_size - self.start_window_size = start_window_size - - self.current_position = 0 - self.positions = [] - self.warping_path = [] - self.global_cost_matrix = ( + self.N_ref: int = self.reference_features.shape[0] + self.window_size: int = window_size + self.step_size: int = step_size + self.start_window_size: int = start_window_size + self.current_position: int = 0 + self.positions: List[int] = [] + self.warping_path: List = [] + self.global_cost_matrix: NDArray[np.float64] = ( np.ones((reference_features.shape[0] + 1, 2)) * np.infty ) - self.input_index = 0 - self.go_backwards = False - self.update_window_index = False - self.restart = False + self.input_index: int = 0 + self.go_backwards: bool = False + self.update_window_index: bool = False + self.restart: bool = False - def __call__(self, input): + def __call__(self, input: NDArray[np.float64]) -> int: self.step(input) return self.current_position - def get_window(self): + def get_window(self) -> Tuple[int, int]: w_size = self.window_size if self.window_index < self.start_window_size: w_size = self.start_window_size @@ -113,10 +116,10 @@ def get_window(self): return window_start, window_end @property - def window_index(self): + def window_index(self) -> int: return self.current_position - def step(self, input_features): + def step(self, input_features: NDArray[np.float64]) -> None: """ Update the current position and the warping path. """ @@ -124,8 +127,6 @@ def step(self, input_features): min_index = max(self.window_index - self.step_size, 0) window_start, window_end = self.get_window() - # window_start = max(self.window_index - self.window_size, 0) - # window_end = min(self.window_index + self.window_size, self.N_ref) # compute local cost beforehand as it is much faster (~twice as fast) window_cost = self.vdist( self.reference_features[window_start:window_end], @@ -161,22 +162,6 @@ def step(self, input_features): # update input index self.input_index += 1 - # # TODO review that update_position method is not needed. - # def update_position(self, input_features, position): - # """ - # Restart following from a new position. - # This method "forgets" the pasts and starts from - # scratch form a new position. - # """ - # self.current_position = int(position) - # window_start, window_end = self.get_window() - # window_cost = self.vdist( - # self.reference_features[window_start:window_end], - # input_features, - # self.local_cost_fun - # ) - if __name__ == "__main__": - pass diff --git a/accompanion/mtchmkr/base.py b/accompanion/mtchmkr/base.py index 388abd7..32d7125 100644 --- a/accompanion/mtchmkr/base.py +++ b/accompanion/mtchmkr/base.py @@ -12,7 +12,7 @@ class OnlineAlignment(object): Features of the music we want to align our online input to. """ - def __init__(self, reference_features: Any): + def __init__(self, reference_features: Any) -> None: super().__init__() self.reference_features: Any = reference_features diff --git a/accompanion/mtchmkr/distances.pxd b/accompanion/mtchmkr/distances.pxd index 77f5d74..0ac8803 100644 --- a/accompanion/mtchmkr/distances.pxd +++ b/accompanion/mtchmkr/distances.pxd @@ -1,7 +1,10 @@ # -*- coding: utf-8 -*- cimport numpy + import numpy as np + cimport cython + cdef class Metric: cdef double distance(self, double[:] X, double[:] Y) except? 0.0 diff --git a/accompanion/mtchmkr/distances.pyx b/accompanion/mtchmkr/distances.pyx index 9281afa..1e81f70 100644 --- a/accompanion/mtchmkr/distances.pyx +++ b/accompanion/mtchmkr/distances.pyx @@ -4,10 +4,11 @@ Cythonized methods for computing distances """ cimport numpy as np + import numpy as np -cimport cython -from libc.math cimport sqrt, abs +cimport cython +from libc.math cimport abs, sqrt cdef class Metric: diff --git a/accompanion/mtchmkr/dtw_loop.pyx b/accompanion/mtchmkr/dtw_loop.pyx index cb119bd..7006659 100644 --- a/accompanion/mtchmkr/dtw_loop.pyx +++ b/accompanion/mtchmkr/dtw_loop.pyx @@ -4,10 +4,14 @@ Cythonized main loop for online time warping. """ cimport cython cimport numpy as np + import numpy as np + # from numpy.math cimport INFINITY + from libc.math cimport INFINITY + @cython.boundscheck(False) @cython.wraparound(False) @cython.cdivision(True) diff --git a/accompanion/mtchmkr/features_midi.py b/accompanion/mtchmkr/features_midi.py index 2420c77..53b46c4 100644 --- a/accompanion/mtchmkr/features_midi.py +++ b/accompanion/mtchmkr/features_midi.py @@ -2,7 +2,16 @@ """ Features from symbolic files """ +from typing import Dict, List, Optional, Tuple + import numpy as np +from mido import Message + +# Type hint for Input MIDI frame. A frame is a tuple +# consisting of a list with the MIDI messages corresponding +# to the frame (List[Tuple[Message, float]]) and the +# time associated to the frame +InputMIDIFrame = Tuple[List[Tuple[Message, float]], float] class PitchIOIProcessor(object): @@ -15,13 +24,17 @@ class PitchIOIProcessor(object): If True, the pitch range will be limited to the piano range (21-108). """ - def __init__(self, piano_range=False): - self.prev_time = 0 + def __init__(self, piano_range: bool = False): + self.prev_time: float = 0 self.piano_range = piano_range self.pitch_bias = 21 if piano_range else 0 - def __call__(self, frame, kwargs={}): + def __call__( + self, + frame: InputMIDIFrame, + kwargs: Dict = {}, + ) -> Tuple[Optional[np.ndarray], Dict]: data, f_time = frame pitch_obs = [] @@ -40,7 +53,7 @@ def __call__(self, frame, kwargs={}): else: return None, {} - def reset(self): + def reset(self) -> None: pass @@ -60,16 +73,23 @@ class PianoRollProcessor(object): The data type of the piano roll. Default is float. """ - def __init__(self, use_velocity=False, piano_range=False, dtype=float): - self.active_notes = dict() - self.piano_roll_slices = [] - self.use_velocity = use_velocity - self.piano_range = piano_range - self.dtype = dtype - - def __call__(self, frame, kwargs={}): + def __init__( + self, + use_velocity: bool = False, + piano_range: bool = False, + dtype: type = float, + ): + self.active_notes: Dict = dict() + self.piano_roll_slices: List[np.ndarray] = [] + self.use_velocity: bool = use_velocity + self.piano_range: bool = piano_range + self.dtype: type = dtype + + def __call__( + self, frame: InputMIDIFrame, kwargs: Dict = {} + ) -> Tuple[np.ndarray, Dict]: # initialize piano roll - piano_roll_slice = np.zeros(128, dtype=self.dtype) + piano_roll_slice: np.ndarray = np.zeros(128, dtype=self.dtype) data, f_time = frame for msg, m_time in data: if msg.type in ("note_on", "note_off"): @@ -93,25 +113,45 @@ def __call__(self, frame, kwargs={}): return piano_roll_slice, {} - def reset(self): + def reset(self) -> None: self.piano_roll_slices = [] self.active_notes = dict() class CumSumPianoRollProcessor(object): """ - A class to convert a MIDI file time slice to a piano roll representation. - TODO: del, never called? - """ + A class to convert a MIDI file time slice to a cumulative sum piano roll + representation. - def __init__(self, use_velocity=False, piano_range=False, dtype=float): - self.active_notes = dict() - self.piano_roll_slices = [] - self.use_velocity = use_velocity - self.piano_range = piano_range - self.dtype = dtype + Parameters + ---------- + use_velocity : bool + If True, the velocity of the note is used as the value in the piano + roll. Otherwise, the value is 1. + piano_range : bool + If True, the piano roll will only contain the notes in the piano. + Otherwise, the piano roll will contain all 128 MIDI notes. + dtype : type + The data type of the piano roll. Default is float. + """ - def __call__(self, frame, kwargs={}): + def __init__( + self, + use_velocity: bool = False, + piano_range: bool = False, + dtype: type = float, + ) -> None: + self.active_notes: Dict = dict() + self.piano_roll_slices: List[np.ndarray] = [] + self.use_velocity: bool = use_velocity + self.piano_range: bool = piano_range + self.dtype: type = dtype + + def __call__( + self, + frame: InputMIDIFrame, + kwargs: Dict = {}, + ) -> Tuple[np.ndarray, Dict]: # initialize piano roll piano_roll_slice = np.zeros(128, dtype=self.dtype) data, f_time = frame @@ -137,6 +177,6 @@ def __call__(self, frame, kwargs={}): return piano_roll_slice, {} - def reset(self): + def reset(self) -> None: self.piano_roll_slices = [] self.active_notes = dict() diff --git a/accompanion/mtchmkr/score_hmm.py b/accompanion/mtchmkr/score_hmm.py index 0b2c216..0abf330 100644 --- a/accompanion/mtchmkr/score_hmm.py +++ b/accompanion/mtchmkr/score_hmm.py @@ -5,14 +5,12 @@ from typing import Optional import numpy as np - -from hiddenmarkov import ObservationModel, ConstantTransitionModel, HiddenMarkovModel -from scipy.stats import gumbel_l import scipy.spatial.distance as sp_dist - -from accompanion.mtchmkr.base import OnlineAlignment +from hiddenmarkov import ConstantTransitionModel, HiddenMarkovModel, ObservationModel +from scipy.stats import gumbel_l from accompanion.accompanist.tempo_models import SyncModel +from accompanion.mtchmkr.base import OnlineAlignment class PitchIOIObservationModel(ObservationModel): @@ -88,7 +86,7 @@ def compute_pitch_observation_probability(self, pitch_obs): # Use Bernouli distribution to compute the prob: # Binary piano-roll observation: pitch_prof_obs = np.zeros((1, 128)) - pitch_prof_obs[0, pitch_obs.astype(np.int)] = 1 + pitch_prof_obs[0, pitch_obs.astype(int)] = 1 # Compute Bernoulli probability: pitch_prob = (self._pitch_profiles**pitch_prof_obs) * ( diff --git a/accompanion/mtchmkr/utils_generic.py b/accompanion/mtchmkr/utils_generic.py index 70c1147..4bf24d2 100644 --- a/accompanion/mtchmkr/utils_generic.py +++ b/accompanion/mtchmkr/utils_generic.py @@ -5,6 +5,7 @@ This module contains all processor related functionality. """ from platform import processor + import partitura diff --git a/accompanion/oltw_accompanion.py b/accompanion/oltw_accompanion.py index b8b6fcf..7c6a799 100644 --- a/accompanion/oltw_accompanion.py +++ b/accompanion/oltw_accompanion.py @@ -5,35 +5,64 @@ This module contains the main class for the Online Time Warping ACCompanion. It works as a follower for complicated pieces usually for four hands. """ -from typing import Optional, Iterable +import os +from typing import Any, Dict, Iterable, List, Optional, Union + import numpy as np -import partitura +import partitura as pt from basismixer.performance_codec import get_performance_codec -from partitura.utils.music import get_time_maps_from_alignment -from basismixer.utils.music import onsetwise_to_notewise, notewise_to_onsetwise -from scipy.interpolate import interp1d -from accompanion.mtchmkr.alignment_online_oltw import ( - OnlineTimeWarping, +from partitura.musicanalysis.performance_codec import ( + get_matched_notes, + get_time_maps_from_alignment, + notewise_to_onsetwise, + onsetwise_to_notewise, ) -from accompanion.mtchmkr.utils_generic import SequentialOutputProcessor +from partitura.performance import PerformedPart -from accompanion.base import ACCompanion -from accompanion.midi_handler.midi_input import POLLING_PERIOD +# from basismixer.utils.music import onsetwise_to_notewise, notewise_to_onsetwise +from scipy.interpolate import interp1d +from accompanion.accompanist import tempo_models +from accompanion.accompanist.accompaniment_decoder import moving_average_offline from accompanion.accompanist.score import ( AccompanimentScore, alignment_to_score, part_to_score, ) -from accompanion.accompanist.accompaniment_decoder import ( - moving_average_offline, -) +from accompanion.base import ACCompanion +from accompanion.midi_handler.midi_input import POLLING_PERIOD +from accompanion.mtchmkr.alignment_online_oltw import OnlineTimeWarping from accompanion.mtchmkr.features_midi import PianoRollProcessor +from accompanion.mtchmkr.utils_generic import SequentialOutputProcessor +from accompanion.score_follower.trackers import MultiDTWScoreFollower from accompanion.utils.partitura_utils import ( partitura_to_framed_midi_custom as partitura_to_framed_midi, ) -from accompanion.score_follower.trackers import MultiDTWScoreFollower -from accompanion.accompanist import tempo_models +from accompanion.utils.partitura_utils import performance_notearray_from_score_notearray + +SCORE_FOLLOWER_DEFAULT_KWARGS = { + "score_follower": "OnlineTimeWarping", + "window_size": 80, + "step_size": 10, + "input_processor": { + "processor": "PianoRollProcessor", + "processor_kwargs": {"piano_range": True}, + }, +} + +TEMPO_MODEL_DEFAULT_KWARGS = {"tempo_model": tempo_models.LSM} + +PERFORMANCE_CODEC_DEFAULT_KWARGS = { + "velocity_trend_ma_alpha": 0.6, + "articulation_ma_alpha": 0.4, + "velocity_dev_scale": 70, + "velocity_min": 20, + "velocity_max": 100, + "velocity_solo_scale": 0.85, + "timing_scale": 0.001, + "log_articulation_scale": 0.1, + "mechanical_delay": 0.0, +} class OLTWACCompanion(ACCompanion): @@ -80,44 +109,38 @@ class OLTWACCompanion(ACCompanion): def __init__( self, - solo_fn, - acc_fn, - midi_router_kwargs: dict, # this is just a workaround for now + solo_fn: str, + acc_fn: str, + midi_router_kwargs: Dict[str, Any], # this is just a workaround for now accompaniment_match: Optional[str] = None, midi_fn: Optional[str] = None, - score_follower_kwargs: dict = { - "score_follower": "OnlineTimeWarping", - "window_size": 80, - "step_size": 10, - "input_processor": { - "processor": "PianoRollProcessor", - "processor_kwargs": {"piano_range": True}, - }, - }, - tempo_model_kwargs={"tempo_model": tempo_models.LSM}, - performance_codec_kwargs={ - "velocity_trend_ma_alpha": 0.6, - "articulation_ma_alpha": 0.4, - "velocity_dev_scale": 70, - "velocity_min": 20, - "velocity_max": 100, - "velocity_solo_scale": 0.85, - "timing_scale": 0.001, - "log_articulation_scale": 0.1, - "mechanical_delay": 0.0, - }, + score_follower_kwargs: Dict[ + str, Union[str, float, int, dict] + ] = SCORE_FOLLOWER_DEFAULT_KWARGS, + tempo_model_kwargs: Dict[ + str, Union[str, float, int, dict, tempo_models.SyncModel] + ] = TEMPO_MODEL_DEFAULT_KWARGS, + performance_codec_kwargs: Dict[ + str, Union[float, int, str] + ] = PERFORMANCE_CODEC_DEFAULT_KWARGS, init_bpm: float = 60, init_velocity: int = 60, polling_period: float = POLLING_PERIOD, use_ceus_mediator: bool = False, adjust_following_rate: float = 0.1, + expected_position_weight: float = 0.6, bypass_audio: bool = False, # bypass fluidsynth audio test: bool = False, # bypass MIDIRouter record_midi: bool = False, + accompanist_decoder_kwargs: Optional[ + Dict[str, Union[float, int, str, dict]] + ] = None, ) -> None: - + # Remember that strings are also iterables ;) score_kwargs = dict( - solo_fn=solo_fn if isinstance(solo_fn, Iterable) else [solo_fn], + solo_fn=solo_fn + if (isinstance(solo_fn, Iterable) and not isinstance(solo_fn, str)) + else [solo_fn], acc_fn=acc_fn, accompaniment_match=accompaniment_match, ) @@ -132,13 +155,15 @@ def __init__( polling_period=polling_period, use_ceus_mediator=use_ceus_mediator, adjust_following_rate=adjust_following_rate, + expected_position_weight=expected_position_weight, bypass_audio=bypass_audio, tempo_model_kwargs=tempo_model_kwargs, test=test, record_midi=record_midi, + accompanist_decoder_kwargs=accompanist_decoder_kwargs, ) - self.solo_parts = None + self.solo_parts: Optional[List] = None def setup_scores(self): """ @@ -156,9 +181,10 @@ def setup_scores(self): self.solo_parts = [] for i, fn in enumerate(self.score_kwargs["solo_fn"]): - if fn.endswith(".match"): + fn_ext = os.path.splitext(fn)[-1] + if fn_ext == ".match": if i == 0: - solo_perf, alignment, solo_score = partitura.load_match( + solo_perf, alignment, solo_score = pt.load_match( filename=fn, create_score=True, first_note_at_zero=True, @@ -166,7 +192,7 @@ def setup_scores(self): solo_ppart = solo_perf[0] solo_spart = solo_score[0] else: - solo_perf, alignment = partitura.load_match( + solo_perf, alignment = pt.load_match( filename=fn, create_score=False, first_note_at_zero=True, @@ -182,17 +208,36 @@ def setup_scores(self): (solo_ppart, ptime_to_stime_map, stime_to_ptime_map) ) else: - solo_spart = partitura.load_score(fn)[0] - + # if the score is a score format supported by partitura + # it will only load the first one if i == 0: - solo_spart = solo_spart + solo_spart = pt.load_score(fn)[0] - self.solo_parts.append((solo_spart, None, None)) + solo_pna, alignment = performance_notearray_from_score_notearray( + snote_array=solo_spart.note_array(), + bpm=self.init_bpm, + return_alignment=True, + ) + + solo_ppart = PerformedPart.from_note_array(solo_pna) + + ( + ptime_to_stime_map, + stime_to_ptime_map, + ) = get_time_maps_from_alignment( + ppart_or_note_array=solo_pna, + spart_or_note_array=solo_spart, + alignment=alignment, + ) + + self.solo_parts.append( + (solo_ppart, ptime_to_stime_map, stime_to_ptime_map) + ) self.solo_score = part_to_score(solo_spart, bpm=self.init_bpm) if self.score_kwargs["accompaniment_match"] is None: - acc_spart = partitura.load_score(self.score_kwargs["acc_fn"])[0] + acc_spart = pt.load_score(self.score_kwargs["acc_fn"])[0] acc_notes = list(part_to_score(acc_spart, bpm=self.init_bpm).notes) velocity_trend = None velocity_dev = None @@ -201,13 +246,17 @@ def setup_scores(self): log_bpr = None else: - acc_perf, acc_alignment, acc_score = partitura.load_match( + acc_perf, acc_alignment, acc_score = pt.load_match( filename=self.score_kwargs["accompaniment_match"], first_note_at_zero=True, create_score=True, ) acc_ppart = acc_perf[0] acc_spart = acc_score[0] + + acc_pnote_array = acc_ppart.note_array() + acc_snote_array = acc_spart.note_array() + acc_notes = list( alignment_to_score( fn_or_spart=acc_spart, ppart=acc_ppart, alignment=acc_alignment @@ -243,9 +292,13 @@ def setup_scores(self): if self.tempo_model.has_tempo_expectations: # get iterable of the tempo expectations + unique_acc_score_onsets = np.array( + [np.mean(acc_snote_array["onset_beat"][ui]) for ui in u_onset_idx] + ) self.tempo_model.tempo_expectations_func = interp1d( - np.unique(acc_spart.note_array()["onset_beat"]), - bm_params_onsetwise["beat_period"], + x=unique_acc_score_onsets, + # np.unique(acc_spart.note_array()["onset_beat"]), + y=bm_params_onsetwise["beat_period"], bounds_error=False, kind="previous", fill_value=( @@ -283,8 +336,9 @@ def setup_score_follower(self): This method initializes arguments used in the accompanion Base Class. """ - # NOTE: pipeline_kwargs and score_follower_type are not used. - pipeline_kwargs = self.score_follower_kwargs.pop("input_processor") + input_pipeline_kwargs = self.score_follower_kwargs.pop("input_processor") + input_processor_type = input_pipeline_kwargs.pop("processor") + input_processor_kwargs = input_pipeline_kwargs.pop("processor_kwargs") score_follower_type = self.score_follower_kwargs.pop("score_follower") pipeline = SequentialOutputProcessor([PianoRollProcessor(piano_range=True)]) @@ -293,7 +347,6 @@ def setup_score_follower(self): score_followers = [] for part, state_to_ref_time_map, ref_to_state_time_map in self.solo_parts: - if state_to_ref_time_map is not None: ref_frames = partitura_to_framed_midi( part_or_notearray_or_filename=part, @@ -310,9 +363,11 @@ def setup_score_follower(self): ref_features = np.array(ref_frames).astype(float) # setup score follower - score_follower = OnlineTimeWarping( - reference_features=ref_features, **self.score_follower_kwargs - ) + if score_follower_type == "OnlineTimeWarping": + score_follower = OnlineTimeWarping( + reference_features=ref_features, + **self.score_follower_kwargs, + ) score_followers.append(score_follower) @@ -323,9 +378,12 @@ def setup_score_follower(self): self.polling_period, ) - self.input_pipeline = SequentialOutputProcessor( - [PianoRollProcessor(piano_range=True)] - ) + if input_processor_type == "PianoRollProcessor": + self.input_pipeline = SequentialOutputProcessor( + [PianoRollProcessor(**input_processor_kwargs)] + ) + else: + raise NotImplementedError(f"Unknown input pipeline: {input_processor_type}") def check_empty_frames(self, frame): """ diff --git a/accompanion/score_follower/note_tracker.py b/accompanion/score_follower/note_tracker.py index d1eb5fb..ad89040 100644 --- a/accompanion/score_follower/note_tracker.py +++ b/accompanion/score_follower/note_tracker.py @@ -1,7 +1,8 @@ import math -import numpy as np from collections import defaultdict +import numpy as np + class TrackedNote(object): def __init__(self, pitch=None, onset=None, duration=None, id=None): @@ -28,7 +29,6 @@ class NoteTracker(object): "midi_id", ] - def __init__(self, score): self.score = score self.open_notes = dict() @@ -51,13 +51,15 @@ def setup_tracked_notes(self): # 0: pitch, 1: duration_beat, 2: onset, 3: durations, # 3: index in notes, # 4: index of the note in sublist - self.note_dict[note['id']] += [ - note['pitch'], - max(note['duration_beat'], 1 / 32), - None, None, None, None + self.note_dict[note["id"]] += [ + note["pitch"], + max(note["duration_beat"], 1 / 32), + None, + None, + None, + None, ] - def track_note(self, midi_msg): # time now is the absolute time of the message, not @@ -142,14 +144,23 @@ def update_alignment(self, score_time): if score_id is None: print("No match for", note) - self.alignment.append({"label": "insertion", "onset": self.onset[idx], "performance_id": self.midi_id}) + self.alignment.append( + { + "label": "insertion", + "onset": self.onset[idx], + "performance_id": self.midi_id, + } + ) else: self.note_dict[score_id][2] = self.onset[idx] - self.alignment.append({ - "label": "match", - "score_id": score_id, - "onset": self.onset[idx], - "performance_id": self.midi_id}) + self.alignment.append( + { + "label": "match", + "score_id": score_id, + "onset": self.onset[idx], + "performance_id": self.midi_id, + } + ) matched_ids.append(score_id) # store matches for particular notes @@ -167,14 +178,4 @@ def export_midi(self, out_fn): # ] # ) - ninfo = ( - self.notes, - self.onset, - self.durations, - self.velocities - ) - - - - - + ninfo = (self.notes, self.onset, self.durations, self.velocities) diff --git a/accompanion/score_follower/onset_tracker.py b/accompanion/score_follower/onset_tracker.py index 39ddc73..2d5b00d 100644 --- a/accompanion/score_follower/onset_tracker.py +++ b/accompanion/score_follower/onset_tracker.py @@ -1,4 +1,5 @@ -from typing import Tuple, List, Optional +from typing import List, Optional, Tuple + import numpy as np @@ -90,6 +91,7 @@ class DiscreteOnsetTracker(object): unique_onsets : np.ndarray The unique score onsets in beats. """ + def __init__(self, unique_onsets: np.ndarray, *args, **kwargs) -> None: print("Using discrete onset tracker") self.unique_onsets = unique_onsets @@ -121,6 +123,6 @@ def __call__( self.performed_onsets.append(score_time) onset_index = self.current_idx - print(f'onset tracker {score_time}') + print(f"onset tracker {score_time}") return solo_s_onset, onset_index, acc_update diff --git a/accompanion/score_follower/trackers.py b/accompanion/score_follower/trackers.py index b1da07d..44cf25e 100644 --- a/accompanion/score_follower/trackers.py +++ b/accompanion/score_follower/trackers.py @@ -4,8 +4,17 @@ ---- * Update the MultiDTWScoreFollower for HMM? """ +import time +from typing import Callable, List, Optional, Union + import numpy as np +from accompanion.accompanist.tempo_models import SyncModel +from accompanion.mtchmkr.alignment_online_oltw import OnlineTimeWarping +from accompanion.mtchmkr.score_hmm import PitchIOIHMM, PitchIOIKHMM + +HMM_SF_Types = Union[PitchIOIKHMM, PitchIOIHMM] + class AccompanimentScoreFollower(object): """ @@ -15,10 +24,10 @@ class AccompanimentScoreFollower(object): def __init__(self): super().__init__() - def __call__(self, frame): + def __call__(self, frame: Optional[np.ndarray]) -> Optional[float]: raise NotImplementedError - def update_position(self, ref_time): + def update_position(self, ref_time: float) -> None: pass @@ -28,17 +37,16 @@ class HMMScoreFollower(AccompanimentScoreFollower): Parameters ---------- - score : partitura.score.Part - The score to be followed. + score_followers: Union[PitchIOIKHMM, PitchIOIKHMM] + The score follower to be used. """ - def __init__(self, score_follower, update_sf_positions=False): + def __init__(self, score_follower: HMM_SF_Types, **kwargs) -> None: super().__init__() - self.score_follower = score_follower - self.current_position = 0 - - def __call__(self, frame): + self.score_follower: HMM_SF_Types = score_follower + self.current_position: int = 0 + def __call__(self, frame: Optional[np.ndarray]) -> Optional[float]: if frame is not None: current_position = self.score_follower(frame) @@ -72,32 +80,45 @@ class MultiDTWScoreFollower(AccompanimentScoreFollower): def __init__( self, - score_followers, - state_to_ref_time_maps, - ref_to_state_time_maps, - polling_period, - update_sf_positions=False, - ): + score_followers: List[OnlineTimeWarping], + state_to_ref_time_maps: List[ + Callable[[Union[float, int, np.ndarray]], Union[float, int, np.ndarray]] + ], + ref_to_state_time_maps: List[ + Callable[[Union[float, int, np.ndarray]], Union[float, int, np.ndarray]] + ], + polling_period: float, + update_sf_positions: bool = False, + *kwargs, + ) -> None: super().__init__() - self.score_followers = score_followers - self.state_to_ref_time_maps = state_to_ref_time_maps - self.ref_to_state_time_maps = ref_to_state_time_maps - self.polling_period = polling_period - self.inv_polling_period = 1 / polling_period - self.update_sf_positions = update_sf_positions - self.current_position = 0 - - def __call__(self, frame): + self.score_followers: List[OnlineTimeWarping] = score_followers + self.state_to_ref_time_maps: List[ + Callable[[Union[float, int, np.ndarray]], Union[float, int, np.ndarray]] + ] = state_to_ref_time_maps + self.ref_to_state_time_maps: List[ + Callable[[Union[float, int, np.ndarray]], Union[float, int, np.ndarray]] + ] = ref_to_state_time_maps + self.polling_period: float = polling_period + self.inv_polling_period: float = 1 / polling_period + self.update_sf_positions: bool = update_sf_positions + self.current_position: int = 0 + + def __call__(self, frame: Optional[np.ndarray]) -> float: """ Get score position by aggregating the predicted position of all followers in the ensemble """ score_positions = [] + predicted_frames = [] for sf, strm in zip(self.score_followers, self.state_to_ref_time_maps): st = sf(frame) sp = float(strm(st * self.polling_period)) score_positions.append(sp) + predicted_frames.append(st) score_position = np.median(score_positions) + # print("predicted_frames", predicted_frames) + # print("score_positions", score_positions) self.current_position = score_position if self.update_sf_positions: @@ -106,7 +127,7 @@ def __call__(self, frame): return score_position - def update_position(self, ref_time): + def update_position(self, ref_time: float) -> None: """ Update the current position in each of the score followers @@ -118,3 +139,34 @@ def update_position(self, ref_time): for sf, rtsm in zip(self.score_followers, self.ref_to_state_time_maps): st = rtsm(ref_time) * self.inv_polling_period sf.current_position = int(np.round(st)) + + +class ExpectedPositionTracker(object): + tempo_model: SyncModel + prev_position: float = None + prev_time: Optional[float] = None + + def __init__(self, tempo_model: SyncModel, first_onset: float) -> None: + self.tempo_model = tempo_model + self.prev_position = first_onset + + @property + def expected_position(self) -> float: + current_time = time.time() + + if self.prev_time is None: + self.prev_time = current_time + return self.prev_position + else: + # inter-event-interval + iei = current_time - self.prev_time + expected_position = self.prev_position + iei / max( + self.tempo_model.beat_period, 1e-6 + ) + self.prev_position = expected_position + return expected_position + + @expected_position.setter + def expected_position(self, score_position: float) -> None: + self.prev_position = score_position + self.prev_time = time.time() diff --git a/accompanion/utils/expression_tools.py b/accompanion/utils/expression_tools.py index d9ff47f..71aca15 100644 --- a/accompanion/utils/expression_tools.py +++ b/accompanion/utils/expression_tools.py @@ -1,32 +1,52 @@ # -*- coding: utf-8 -*- -import numpy as np +from typing import Union + import matplotlib.pyplot as plt +import numpy as np -def melody_lead(pitch, velocity, lead=0.02): +def melody_lead( + pitch: Union[np.ndarray, int], + velocity: int, + lead: float = 0.02, +) -> Union[np.ndarray, float]: """ Compute melody lead """ - return (np.exp((pitch - 127) / 127.) * - np.exp(-(velocity - 127) / 127) * lead) + return np.exp((pitch - 127) / 127.0) * np.exp(-(velocity - 127) / 127) * lead -def friberg_sundberg_rit(len_c, r_w=0.5, r_q=2.0): +def friberg_sundberg_rit( + len_c: int, + r_w: float = 0.5, + r_q: float = 2.0, +) -> Union[np.ndarray, float]: """ Compute ritard_curve """ if len_c > 0: - ritard_curve = (1 + (r_w ** r_q - 1) * - np.linspace(0, 1, len_c)) ** (1. / r_q) + ritard_curve = (1 + (r_w**r_q - 1) * np.linspace(0, 1, len_c)) ** (1.0 / r_q) return 1.0 / ritard_curve else: return 1.0 -if __name__ == '__main__': +if __name__ == "__main__": + for rw in np.linspace(0.1, 1, 10): + rit = friberg_sundberg_rit(10, rw, 2) + + plt.plot(rit, label=f"{rw:.2f}") + + plt.legend(loc="best") + plt.show() + plt.clf() + plt.close() + + for rq in np.linspace(0.1, 3, 10): + rit = friberg_sundberg_rit(10, 0.5, rq) - rit = friberg_sundberg_rit(10) + plt.plot(rit, label=f"{rq:.2f}") - plt.plot(rit) + plt.legend(loc="best") plt.show() diff --git a/accompanion/utils/partitura_utils.py b/accompanion/utils/partitura_utils.py index d756cb2..e998089 100644 --- a/accompanion/utils/partitura_utils.py +++ b/accompanion/utils/partitura_utils.py @@ -7,18 +7,20 @@ * Replace utilities with the latest version from Partitura """ +from typing import Callable, Dict, List, Tuple, Union + import mido -import partitura import numpy as np +import partitura from basismixer.performance_codec import get_performance_codec from basismixer.utils import get_unique_onset_idxs, notewise_to_onsetwise -from partitura import load_score, load_performance -from partitura.utils.music import performance_from_part -from accompanion.config import CONFIG -from partitura.score import Part +from partitura import load_performance, load_score from partitura.performance import PerformedPart +from partitura.score import Part +from partitura.utils.music import performance_from_part from scipy.interpolate import interp1d +from accompanion.config import CONFIG PPART_FIELDS = [ ("onset_sec", "f4"), @@ -411,6 +413,158 @@ def get_beat_conversion(note_duration, beat_type): return quarter_to_beat(duration_quarters, beat_type) +def performance_notearray_from_score_notearray( + snote_array: np.ndarray, + bpm: Union[float, np.ndarray, Callable] = 100.0, + velocity: Union[int, np.ndarray, Callable] = 64, + return_alignment: bool = False, +) -> Union[np.ndarray, Tuple[np.ndarray, List[Dict[str, str]]]]: + """ + Generate a performance note array from a score note array + + Parameters + ---------- + snote_array : np.ndarray + A score note array. + bpm : float, np.ndarray or callable + Beats per minute to generate the performance. If a the value is a float, + the performance will be generated with a constant tempo. If the value is + a np.ndarray, it has to be an array with two columns where the first + column is score time in beats and the second column is the tempo. If a + callable is given, the function is assumed to map score onsets in beats + to tempo values. Default is 100 bpm. + velocity: int, np.ndarray or callable + MIDI velocity of the performance. If a the value is an int, the + performance will be generated with a constant MIDI velocity. If the + value is a np.ndarray, it has to be an array with two columns where + the first column is score time in beats and the second column is the + MIDI velocity. If a callable is given, the function is assumed to map + score time in beats to MIDI velocity. Default is 64. + return_alignment: bool + Return alignment between the score and the generated performance. + + Returns + ------- + pnote_array : np.ndarray + A performance note array based on the score with the specified tempo + and velocity. + alignment : List[Dict[str, str]] + If `return_alignment` is True, return the alignment between performance + and score. + + Notes + ----- + * This method should be deleted when the function is updated in partitura. + """ + + ppart_fields = [ + ("onset_sec", "f4"), + ("duration_sec", "f4"), + ("pitch", "i4"), + ("velocity", "i4"), + ("track", "i4"), + ("channel", "i4"), + ("id", "U256"), + ] + + pnote_array = np.zeros(len(snote_array), dtype=ppart_fields) + + if isinstance(velocity, np.ndarray): + if velocity.ndim == 2: + velocity_fun = interp1d( + x=velocity[:, 0], + y=velocity[:, 1], + kind="previous", + bounds_error=False, + fill_value=(velocity[0, 1], velocity[-1, 1]), + ) + pnote_array["velocity"] = np.round( + velocity_fun(snote_array["onset_beat"]), + ).astype(int) + + else: + pnote_array["velocity"] = np.round(velocity).astype(int) + + elif callable(velocity): + # The velocity parameter is a callable that returns a + # velocity value for each score onset + pnote_array["velocity"] = np.round( + velocity(snote_array["onset_beat"]), + ).astype(int) + + else: + pnote_array["velocity"] = int(velocity) + + unique_onsets = np.unique(snote_array["onset_beat"]) + # Cast as object to avoid warnings, but seems to work well + # in numpy version 1.20.1 + unique_onset_idxs = np.array( + [np.where(snote_array["onset_beat"] == u)[0] for u in unique_onsets], + dtype=object, + ) + + iois = np.diff(unique_onsets) + + if callable(bpm) or isinstance(bpm, np.ndarray): + if callable(bpm): + # bpm parameter is a callable that returns a bpm value + # for each score onset + bp = 60 / bpm(unique_onsets) + bp_duration = ( + 60 / bpm(snote_array["onset_beat"]) * snote_array["duration_beat"] + ) + + elif isinstance(bpm, np.ndarray): + if bpm.ndim != 2: + raise ValueError("`bpm` should be a 2D array") + + bpm_fun = interp1d( + x=bpm[:, 0], + y=bpm[:, 1], + kind="previous", + bounds_error=False, + fill_value=(bpm[0, 1], bpm[-1, 1]), + ) + bp = 60 / bpm_fun(unique_onsets) + bp_duration = ( + 60 / bpm_fun(snote_array["onset_beat"]) * snote_array["duration_beat"] + ) + + p_onsets = np.r_[0, np.cumsum(iois * bp[:-1])] + pnote_array["duration_sec"] = bp_duration * snote_array["duration_beat"] + + else: + # convert bpm to beat period + bp = 60 / float(bpm) + p_onsets = np.r_[0, np.cumsum(iois * bp)] + pnote_array["duration_sec"] = bp * snote_array["duration_beat"] + + pnote_array["pitch"] = snote_array["pitch"] + pnote_array["id"] = snote_array["id"] + + for ix, on in zip(unique_onset_idxs, p_onsets): + # ix has to be cast as integer depending on the + # numpy version... + pnote_array["onset_sec"][ix.astype(int)] = on + + if return_alignment: + + def alignment_dict(score_id: str, perf_id: str) -> Dict[str, str]: + output = dict( + label="match", + score_id=score_id, + performance_id=perf_id, + ) + return output + + alignment = [ + alignment_dict(sid, pid) + for sid, pid in zip(snote_array["id"], pnote_array["id"]) + ] + return pnote_array, alignment + return pnote_array + + if __name__ == "__main__": fn = "../demo_data/twinkle_twinkle_little_star_score.musicxml" diff --git a/bin/app.py b/bin/app.py index 70f3c88..343c50c 100644 --- a/bin/app.py +++ b/bin/app.py @@ -1,16 +1,20 @@ # !/usr/bin/env python # -*- coding: utf-8 -*- +import argparse +import glob import multiprocessing + # import tkinter as tk # import mido # from copy import deepcopy # import time import os -import argparse -import glob -from accompanion import PLATFORM import sys + import config_gui + +from accompanion import PLATFORM + os.environ["KMP_DUPLICATE_LIB_OK"] = "True" sys.path.append("..") @@ -26,8 +30,6 @@ ] - - if __name__ == "__main__": # This creates a RuntimeError: context has already been set. @@ -37,9 +39,7 @@ parser = argparse.ArgumentParser("Configure and Launch ACCompanion") parser.add_argument( - "--skip_gui", - action="store_true", - help="skip configuration gui at startup" + "--skip_gui", action="store_true", help="skip configuration gui at startup" ) parser.add_argument( @@ -70,20 +70,28 @@ ) parser.add_argument("--input", required=False, help="Input MIDI instrument port.") parser.add_argument("--output", required=False, help="Output MIDI instrument port.") - parser.add_argument("--record-midi", action="store_true", help="Record Midi input and Output.") - parser.add_argument("--midi-fn", help="Midi file to play instead of real time input.") + parser.add_argument( + "--record-midi", action="store_true", help="Record Midi input and Output." + ) + parser.add_argument( + "--midi-fn", help="Midi file to play instead of real time input." + ) args = parser.parse_args() if not args.skip_gui: - configurations, ACCompanion = config_gui.accompanion_configurations_and_version_via_gui() + ( + configurations, + ACCompanion, + ) = config_gui.accompanion_configurations_and_version_via_gui() if configurations is None: import sys + sys.exit() - if 'midi_fn' in configurations.keys() and configurations['midi_fn']=='': - configurations['midi_fn']=None + if "midi_fn" in configurations.keys() and configurations["midi_fn"] == "": + configurations["midi_fn"] = None elif args.config_file: import yaml @@ -105,17 +113,26 @@ "complex_pieces", info_file["piece_dir"], ) - configurations["acc_fn"] = os.path.join(file_dir, os.path.normpath(info_file["acc_fn"])) - configurations["accompaniment_match"] = os.path.join( - file_dir, os.path.normpath(info_file["accompaniment_match"]) - ) if "accompaniment_match" in info_file.keys() else None - configurations["solo_fn"] = glob.glob( - os.path.join(file_dir, "match", "cc_solo", "*.match") - )[-5:] if "solo_fn" not in info_file.keys() else os.path.join( - file_dir, os.path.normpath(info_file["solo_fn"]) + configurations["acc_fn"] = os.path.join( + file_dir, os.path.normpath(info_file["acc_fn"]) + ) + configurations["accompaniment_match"] = ( + os.path.join( + file_dir, os.path.normpath(info_file["accompaniment_match"]) + ) + if "accompaniment_match" in info_file.keys() + else None + ) + configurations["solo_fn"] = ( + glob.glob(os.path.join(file_dir, "match", "cc_solo", "*.match"))[-5:] + if "solo_fn" not in info_file.keys() + else os.path.join(file_dir, os.path.normpath(info_file["solo_fn"])) + ) + configurations["midi_fn"] = ( + os.path.join(file_dir, os.path.normpath(info_file["midi_fn"])) + if "midi_fn" in info_file.keys() + else None ) - configurations["midi_fn"] = os.path.join(file_dir, os.path.normpath( - info_file["midi_fn"])) if "midi_fn" in info_file.keys() else None else: file_dir = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), @@ -124,13 +141,20 @@ info_file["piece_dir"], ) # If only one piece is available load and separate to parts - if os.path.join(file_dir, "primo.musicxml") not in glob.glob(os.path.join(file_dir, "*.musicxml")): + if os.path.join(file_dir, "primo.musicxml") not in glob.glob( + os.path.join(file_dir, "*.musicxml") + ): import partitura score = partitura.load_score( - (glob.glob(os.path.join(file_dir, "*.musicxml")) + glob.glob(os.path.join(file_dir, "*.mxl")))[0]) + ( + glob.glob(os.path.join(file_dir, "*.musicxml")) + + glob.glob(os.path.join(file_dir, "*.mxl")) + )[0] + ) if len(score.parts) == 1: from copy import deepcopy + import numpy as np # find individual staff @@ -140,15 +164,23 @@ secondo_part = deepcopy(score.parts[0]) for st in staff: if st == 1: - primo_part.notes = [note for note in primo_part.notes if note.staff == st] + primo_part.notes = [ + note for note in primo_part.notes if note.staff == st + ] else: - secondo_part.notes = [note for note in secondo_part.notes if note.staff == st] + secondo_part.notes = [ + note for note in secondo_part.notes if note.staff == st + ] elif len(score.parts) == 2: primo_part = score.parts[0] - partitura.save_musicxml(primo_part, os.path.join(file_dir, "primo.musicxml")) + partitura.save_musicxml( + primo_part, os.path.join(file_dir, "primo.musicxml") + ) secondo_part = score.parts[1] - partitura.save_musicxml(secondo_part, os.path.join(file_dir, "secondo.musicxml")) + partitura.save_musicxml( + secondo_part, os.path.join(file_dir, "secondo.musicxml") + ) else: raise ValueError("Score has more than two parts.") @@ -232,15 +264,18 @@ configurations["midi_fn"] = args.midi_fn if args.midi_fn else None if configurations["midi_fn"] is not None: - configurations["midi_router_kwargs"]["solo_input_to_accompaniment_port_name"] = 0 - configurations["midi_router_kwargs"]["MIDIPlayer_to_accompaniment_port_name"] = 0 + configurations["midi_router_kwargs"][ + "solo_input_to_accompaniment_port_name" + ] = 0 + configurations["midi_router_kwargs"][ + "MIDIPlayer_to_accompaniment_port_name" + ] = 0 accompanion = ACCompanion(**configurations) accompanion.run() - # This file will be used to create a GUI for the ACCompanion # The GUI will be used to select the input and output ports available # and to start the accompaniment by running the script launch_acc.py with the @@ -579,4 +614,3 @@ # if __name__ == "__main__": # # launch the app # app = ACCompanionApp() - diff --git a/bin/config_gui.py b/bin/config_gui.py index 1f73543..51fabf8 100644 --- a/bin/config_gui.py +++ b/bin/config_gui.py @@ -1,8 +1,9 @@ -from inspect import signature +import os from ast import literal_eval -import PySimpleGUI +from inspect import signature from typing import Union -import os + +import PySimpleGUI def load_class(module_name, class_name): @@ -22,15 +23,21 @@ def currently_supported_types(t): _currently_supported_versions = { - "HMM based (for solo simple Pieces)": ('accompanion.hmm_accompanion', 'HMMACCompanion'), - "OLTW based (usually for four hands pieces)": ('accompanion.oltw_accompanion', 'OLTWACCompanion') + "HMM based (for solo simple Pieces)": ( + "accompanion.hmm_accompanion", + "HMMACCompanion", + ), + "OLTW based (usually for four hands pieces)": ( + "accompanion.oltw_accompanion", + "OLTWACCompanion", + ), } class ConfigurationNode(object): - __slots__ = ['type', 'child_names_and_children', 'data'] + __slots__ = ["type", "child_names_and_children", "data"] - ''' + """ This class is for structuring the configuration process as * building a tree whose Nodes contain - the type of the parameter that can be configured @@ -57,7 +64,7 @@ class ConfigurationNode(object): data: Python object currently, data and child_names_and_children are supposed to exclude each other from being set to a non-None value meaning, if data is not None, then child_names_and_children is None, and vice versa - ''' + """ def __init__(self, node_type, child_names_and_children=[], data=None): self.type = node_type @@ -66,12 +73,15 @@ def __init__(self, node_type, child_names_and_children=[], data=None): def value(self): if self.type is dict and len(self.child_names_and_children) > 0: - return {child_name: child.value() for child_name, child in self.child_names_and_children} + return { + child_name: child.value() + for child_name, child in self.child_names_and_children + } else: return self.data def search(self, search_name): - dot_loc = search_name.find('.') + dot_loc = search_name.find(".") outer_scope = search_name[:dot_loc] if dot_loc >= 0 else search_name @@ -80,36 +90,40 @@ def search(self, search_name): if dot_loc < 0: return child if child.type is dict: - return child.search(search_name[dot_loc + 1:]) + return child.search(search_name[dot_loc + 1 :]) else: return None return None -def check_for_type_error(config_node, enclosing_scope=''): +def check_for_type_error(config_node, enclosing_scope=""): if not config_node.type is dict: if len(config_node.child_names_and_children) > 0: raise TypeError( - f"Node error at {enclosing_scope[1:]}\nNode is not of type dict, but has children {config_node.child_names_and_children}") + f"Node error at {enclosing_scope[1:]}\nNode is not of type dict, but has children {config_node.child_names_and_children}" + ) elif type(config_node.data) != config_node.type: raise TypeError( - f"Type error at {enclosing_scope[1:]}\nNode is of type {config_node.type},\nbut value {config_node.data}\nis of type {type(config_node.data)}") + f"Type error at {enclosing_scope[1:]}\nNode is of type {config_node.type},\nbut value {config_node.data}\nis of type {type(config_node.data)}" + ) elif len(config_node.child_names_and_children) > 0: if not config_node.data is None: raise TypeError( - f"Type error at {enclosing_scope[1:]}\nNode has children {config_node.child_names_and_children}, but also data {config_node.data}") + f"Type error at {enclosing_scope[1:]}\nNode has children {config_node.child_names_and_children}, but also data {config_node.data}" + ) for child_name, child in config_node.child_names_and_children: - check_for_type_error(child, enclosing_scope + '.' + child_name) + check_for_type_error(child, enclosing_scope + "." + child_name) elif not type(config_node.data) is dict: raise TypeError( - f"Type error at {enclosing_scope[1:]}\nNode is of type dict,\nbut value {config_node.data}\nis of type {type(config_node.data)}") + f"Type error at {enclosing_scope[1:]}\nNode is of type dict,\nbut value {config_node.data}\nis of type {type(config_node.data)}" + ) class Hook(object): - __slots__ = ('trigger', 'configuration', 'layout', 'update', 'evaluation') - ''' + __slots__ = ("trigger", "configuration", "layout", "update", "evaluation") + """ A Hook object is intended to make it possible for users to provide or override functionality in the configuration GUI system Attributes: @@ -153,14 +167,23 @@ class Hook(object): lambda n,t,o: n.split('.')[-1]=='scale' and t is float and the trigger for the specific parameter 'cube.scale' would look like this lambda n,t,o: n=='cube.scale' - ''' + """ - def __init__(self, trigger, configuration=None, layout=None, update=None, evaluation=None): + def __init__( + self, trigger, configuration=None, layout=None, update=None, evaluation=None + ): if trigger is None: raise ValueError("a Hook object needs a trigger") - if configuration is None and layout is None and update is None and evaluation is None: - raise ValueError("a Hook object needs either a configuration, layout, update or evaluation function") + if ( + configuration is None + and layout is None + and update is None + and evaluation is None + ): + raise ValueError( + "a Hook object needs either a configuration, layout, update or evaluation function" + ) self.trigger = trigger self.configuration = configuration @@ -170,13 +193,17 @@ def __init__(self, trigger, configuration=None, layout=None, update=None, evalua def _retrieve(full_name, data_type, data, hooks): - for i, trigger in enumerate(hooks['triggers']): + for i, trigger in enumerate(hooks["triggers"]): if trigger(full_name, data_type, data): - return hooks['functions'][i] + return hooks["functions"][i] return None -def configuration_tree(underlying_dict, configuration_hooks=dict(triggers=[], functions=[]), enclosing_scope=''): +def configuration_tree( + underlying_dict, + configuration_hooks=dict(triggers=[], functions=[]), + enclosing_scope="", +): child_names_and_children = [] for k, v in underlying_dict.items(): @@ -185,13 +212,16 @@ def configuration_tree(underlying_dict, configuration_hooks=dict(triggers=[], fu if configure is None and not currently_supported_types(type(v)): print( - f"the configuration GUI currently doesn't support parameters of type {type(v)} and therefore silently ignores {enclosing_scope + k}") + f"the configuration GUI currently doesn't support parameters of type {type(v)} and therefore silently ignores {enclosing_scope + k}" + ) continue if not configure is None: child = configure(v) elif type(v) is dict and len(v) > 0: - child = configuration_tree(v, configuration_hooks, enclosing_scope + k + '.') + child = configuration_tree( + v, configuration_hooks, enclosing_scope + k + "." + ) else: child = ConfigurationNode(type(v), data=v) @@ -201,39 +231,73 @@ def configuration_tree(underlying_dict, configuration_hooks=dict(triggers=[], fu def Collapsable(layout, key): - return PySimpleGUI.pin(PySimpleGUI.Column(layout, key=key + 'collapsable')) + return PySimpleGUI.pin(PySimpleGUI.Column(layout, key=key + "collapsable")) -def gui_layout(config_node, layout_hooks=dict(triggers=[], functions=[]), enclosing_scope=''): - field_names = [f'{child_name} : {str(child.type)[len(" 0: - sub_layout = gui_layout(child, layout_hooks, enclosing_scope + child_name + '.') + sub_layout = gui_layout( + child, layout_hooks, enclosing_scope + child_name + "." + ) integrate_sub_layout(sub_layout) else: - layout.append([Collapsable([[PySimpleGUI.Text(f, size=(max_length, 1)), - PySimpleGUI.InputText(str(child.data) if not child.type is dict else '{}', - key=enclosing_scope + child_name)]], - enclosing_scope + child_name)]) + layout.append( + [ + Collapsable( + [ + [ + PySimpleGUI.Text(f, size=(max_length, 1)), + PySimpleGUI.InputText( + str(child.data) if not child.type is dict else "{}", + key=enclosing_scope + child_name, + ), + ] + ], + enclosing_scope + child_name, + ) + ] + ) return layout @@ -242,7 +306,7 @@ def integrate_sub_layout(sub_layout): ##################################################################### def midi_router_kwargs_trigger(name, data_type, data): - return name == 'midi_router_kwargs' + return name == "midi_router_kwargs" def _in_out_port_distribution(): @@ -255,21 +319,29 @@ def _in_out_port_distribution(): def midi_router_kwargs_configuration(value): - assert type(value) is dict, "midi_router_kwargs_configuration was expected to be a dict" - - port_names = [p.name for p in - class_init_args(load_class('accompanion.midi_handler.midi_routing', 'MidiRouter').__init__)] + assert ( + type(value) is dict + ), "midi_router_kwargs_configuration was expected to be a dict" + + port_names = [ + p.name + for p in class_init_args( + load_class("accompanion.midi_handler.midi_routing", "MidiRouter").__init__ + ) + ] distribution = _in_out_port_distribution() child_names_and_children = [] for port_name, ports in zip(port_names, distribution): - data = '' + data = "" if port_name in value.keys(): data = value[port_name] - assert len(data) == 0 or data in ports, f"{data} is not a port that is found among viable ports {ports}" + assert ( + len(data) == 0 or data in ports + ), f"{data} is not a port that is found among viable ports {ports}" elif len(ports) > 0: data = ports[0] @@ -287,23 +359,40 @@ def midi_router_kwargs_layout(config_node, enclosing_scope): # layout=[[gui.Text(pn,size=(max_length,1)),gui.Combo(ports,default_value=ports[0] if len(ports)>0 else '',key=enclosing_scope+'.'+pn)] for pn,ports in zip(port_names,distribution)] - max_length = max([len(port_name) for port_name, _ in config_node.child_names_and_children]) + max_length = max( + [len(port_name) for port_name, _ in config_node.child_names_and_children] + ) layout = [] - for (port_name, child), ports in zip(config_node.child_names_and_children, _in_out_port_distribution()): + for (port_name, child), ports in zip( + config_node.child_names_and_children, _in_out_port_distribution() + ): if len(child.data) > 0: try: pos = ports.index(child.data) ports[pos], ports[0] = ports[0], ports[pos] except ValueError: - raise ValueError(f"{child.data} is not a port that is found among viable ports {ports}") + raise ValueError( + f"{child.data} is not a port that is found among viable ports {ports}" + ) combo_list = ports - layout.append([PySimpleGUI.Text(port_name, size=(max_length, 1), key=enclosing_scope + '.' + port_name + 'name'), - PySimpleGUI.Combo(combo_list, default_value=combo_list[0] if len(child.data) > 0 else '', - key=enclosing_scope + '.' + port_name)]) + layout.append( + [ + PySimpleGUI.Text( + port_name, + size=(max_length, 1), + key=enclosing_scope + "." + port_name + "name", + ), + PySimpleGUI.Combo( + combo_list, + default_value=combo_list[0] if len(child.data) > 0 else "", + key=enclosing_scope + "." + port_name, + ), + ] + ) return layout @@ -313,23 +402,26 @@ def midi_router_kwargs_layout(config_node, enclosing_scope): ###################################################################################### def tempo_model_trigger(name, data_type, data): - return name == 'tempo_model_kwargs.tempo_model' + return name == "tempo_model_kwargs.tempo_model" def tempo_model_configuration(value): import accompanion.accompanist.tempo_models as tempo_models - sync_model_names = [a for a in dir(tempo_models) if 'SyncModel' in a] + sync_model_names = [a for a in dir(tempo_models) if "SyncModel" in a] - assert len(sync_model_names) > 0, "can't load SyncModels if there are none in accompanion.accompanist.tempo_models" + assert ( + len(sync_model_names) > 0 + ), "can't load SyncModels if there are none in accompanion.accompanist.tempo_models" if type(value) is type: data = value.__name__ else: data = value - assert data in sync_model_names or len( - data) == 0, f"default value {value} is neither an empty string nor something that can be found among the SyncModels" + assert ( + data in sync_model_names or len(data) == 0 + ), f"default value {value} is neither an empty string nor something that can be found among the SyncModels" return ConfigurationNode(type, data=data) @@ -337,12 +429,15 @@ def tempo_model_configuration(value): def tempo_model_layout(config_node, enclosing_scope): import accompanion.accompanist.tempo_models as tempo_models - sync_model_names = [a for a in dir(tempo_models) if 'SyncModel' in a] + sync_model_names = [a for a in dir(tempo_models) if "SyncModel" in a] - input_name = config_node.data if type(config_node.data) is str else config_node.data.__name__ + input_name = ( + config_node.data if type(config_node.data) is str else config_node.data.__name__ + ) - assert len( - input_name) == 0 or input_name in sync_model_names, f"at config_node {enclosing_scope} default value {config_node.data} is neither an empty string nor something that can be found among the SyncModels" + assert ( + len(input_name) == 0 or input_name in sync_model_names + ), f"at config_node {enclosing_scope} default value {config_node.data} is neither an empty string nor something that can be found among the SyncModels" if len(sync_model_names) == 0: return [] @@ -350,15 +445,21 @@ def tempo_model_layout(config_node, enclosing_scope): if len(input_name) > 0: pos = sync_model_names.index(input_name) - sync_model_names[0], sync_model_names[pos] = sync_model_names[pos], sync_model_names[0] + sync_model_names[0], sync_model_names[pos] = ( + sync_model_names[pos], + sync_model_names[0], + ) - name = enclosing_scope.split('.')[-1] + name = enclosing_scope.split(".")[-1] layout = [ [ - PySimpleGUI.Text(name, size=(len(name), 1), key=enclosing_scope + 'name'), - PySimpleGUI.Combo(sync_model_names, default_value=sync_model_names[0] if len(input_name) > 0 else '', - key=enclosing_scope) + PySimpleGUI.Text(name, size=(len(name), 1), key=enclosing_scope + "name"), + PySimpleGUI.Combo( + sync_model_names, + default_value=sync_model_names[0] if len(input_name) > 0 else "", + key=enclosing_scope, + ), ] ] @@ -366,7 +467,9 @@ def tempo_model_layout(config_node, enclosing_scope): def tempo_model_eval(config_string): - tempo_models = __import__('accompanion.accompanist.tempo_models', fromlist=[config_string]) + tempo_models = __import__( + "accompanion.accompanist.tempo_models", fromlist=[config_string] + ) return getattr(tempo_models, config_string) @@ -379,16 +482,22 @@ def tempo_model_eval(config_string): ####################################################################################### def single_file_name_trigger(name, data_type, data): - return 'fn' in name.split('.')[-1] and data_type is str + return "fn" in name.split(".")[-1] and data_type is str def accompaniment_match_trigger(name, data_type, data): - return name == 'accompaniment_match' + return name == "accompaniment_match" def single_file_name_layout(config_node, enclosing_scope): - return [[PySimpleGUI.InputText(config_node.data, key=enclosing_scope), - PySimpleGUI.FileBrowse(target=enclosing_scope, key=enclosing_scope + '_browse')]] + return [ + [ + PySimpleGUI.InputText(config_node.data, key=enclosing_scope), + PySimpleGUI.FileBrowse( + target=enclosing_scope, key=enclosing_scope + "_browse" + ), + ] + ] ############################################################################################# @@ -396,32 +505,49 @@ def single_file_name_layout(config_node, enclosing_scope): ########################################################################################## def multiple_file_name_trigger(name, data_type, data): - return 'fn' in name.split('.')[-1] and data_type in (list,) + return "fn" in name.split(".")[-1] and data_type in (list,) def multiple_file_name_layout(config_node, enclosing_scope): - width = max([len(x) for x in config_node.data]) if len(config_node.data) > 0 else None + width = ( + max([len(x) for x in config_node.data]) if len(config_node.data) > 0 else None + ) height = len(config_node.data) if len(config_node.data) > 0 else None - return [[PySimpleGUI.Multiline('\n'.join(config_node.data), autoscroll=True, key=enclosing_scope, enable_events=True, - size=(width, height)), - PySimpleGUI.FilesBrowse(enable_events=True, key=enclosing_scope + '_browse', target=enclosing_scope + '_browse', - files_delimiter='\n')]] + return [ + [ + PySimpleGUI.Multiline( + "\n".join(config_node.data), + autoscroll=True, + key=enclosing_scope, + enable_events=True, + size=(width, height), + ), + PySimpleGUI.FilesBrowse( + enable_events=True, + key=enclosing_scope + "_browse", + target=enclosing_scope + "_browse", + files_delimiter="\n", + ), + ] + ] def multiple_file_name_eval(config_string): - return config_string.split('\n') if len(config_string) > 0 else [] + return config_string.split("\n") if len(config_string) > 0 else [] def multiple_file_name_browse_trigger(name, data_type, data): - return 'fn' in name.split('.')[-1] and name[-len('_browse'):] == '_browse' + return "fn" in name.split(".")[-1] and name[-len("_browse") :] == "_browse" def multiple_file_name_browse_update(window, event, values): - file_names = values[event].split('\n') + file_names = values[event].split("\n") if len(file_names) > 0: - window[event[:-len('_browse')]].set_size((max([len(fn) for fn in file_names]), len(file_names))) - window[event[:-len('_browse')]].update('\n'.join(file_names)) + window[event[: -len("_browse")]].set_size( + (max([len(fn) for fn in file_names]), len(file_names)) + ) + window[event[: -len("_browse")]].update("\n".join(file_names)) #################################################################################################### @@ -429,10 +555,10 @@ def multiple_file_name_browse_update(window, event, values): def default_instance(t): def get_origin(t): - return getattr(t, '__origin__', None) + return getattr(t, "__origin__", None) def get_args(t): - return getattr(t, '__args__', ()) + return getattr(t, "__args__", ()) if get_origin(t) is list: return [] @@ -444,7 +570,9 @@ def get_args(t): elif not b is type(None): return b() else: - raise TypeError('why on earth does there exist a parameter of Union[None,None]?!') + raise TypeError( + "why on earth does there exist a parameter of Union[None,None]?!" + ) else: return t() @@ -457,7 +585,9 @@ def _create_config(values, config_tree, evaluation_hooks, type_checked): continue if len(result.child_names_and_children) > 0: - raise ValueError(f"{k} was configured, but has children. That shouldn't be the case") + raise ValueError( + f"{k} was configured, but has children. That shouldn't be the case" + ) evaluate = _retrieve(k, result.type, result.data, evaluation_hooks) @@ -473,7 +603,7 @@ def _create_config(values, config_tree, evaluation_hooks, type_checked): check_for_type_error(config_tree) except TypeError as e: error_layout = [[PySimpleGUI.Text(str(e))]] - error_window = PySimpleGUI.Window('ERROR', error_layout) + error_window = PySimpleGUI.Window("ERROR", error_layout) error_window.read() error_window.close() return None @@ -482,17 +612,25 @@ def _create_config(values, config_tree, evaluation_hooks, type_checked): def class_init_configurations_via_gui( - class_object, - window_title=None, - hooks=[], - type_checked=True, + class_object, + window_title=None, + hooks=[], + type_checked=True, ): parameters = class_init_args(class_object.__init__) - underlying_dict = {p.name: (p.default if not p.default in [p.empty, None] else ( - default_instance(p.annotation) if p.annotation != p.empty else '')) for p in parameters} + underlying_dict = { + p.name: ( + p.default + if not p.default in [p.empty, None] + else (default_instance(p.annotation) if p.annotation != p.empty else "") + ) + for p in parameters + } - hook_init_args = [p.name for p in class_init_args(Hook.__init__) if p.name != 'trigger'] + hook_init_args = [ + p.name for p in class_init_args(Hook.__init__) if p.name != "trigger" + ] hook_system = {name: dict(triggers=[], functions=[]) for name in hook_init_args} @@ -501,29 +639,40 @@ def class_init_configurations_via_gui( function = getattr(hook, name, None) if not function is None: - hook_system[name]['triggers'].append(hook.trigger) - hook_system[name]['functions'].append(function) + hook_system[name]["triggers"].append(hook.trigger) + hook_system[name]["functions"].append(function) if window_title is None: - window_title = class_object.__name__ + ' configuration' + window_title = class_object.__name__ + " configuration" - main_window = PySimpleGUI.Window('') + main_window = PySimpleGUI.Window("") while True: - config_tree = configuration_tree(underlying_dict, hook_system['configuration']) + config_tree = configuration_tree(underlying_dict, hook_system["configuration"]) - main_layout = gui_layout(config_tree, hook_system['layout']) + main_layout = gui_layout(config_tree, hook_system["layout"]) - if 'gui_config_files' in os.listdir(os.getcwd()): + if "gui_config_files" in os.listdir(os.getcwd()): gui_config_file_dir = "./gui_config_files" else: gui_config_file_dir = "../gui_config_files" - header = PySimpleGUI.Frame('Menu', [[PySimpleGUI.Button('Configuration finished', key='config done'), - PySimpleGUI.Button('Save Configuration to File', key='save config'), - PySimpleGUI.FileBrowse('Load Configuration from File', key='load config', - target='load config', enable_events=True, - initial_folder=gui_config_file_dir)]]) + header = PySimpleGUI.Frame( + "Menu", + [ + [ + PySimpleGUI.Button("Configuration finished", key="config done"), + PySimpleGUI.Button("Save Configuration to File", key="save config"), + PySimpleGUI.FileBrowse( + "Load Configuration from File", + key="load config", + target="load config", + enable_events=True, + initial_folder=gui_config_file_dir, + ), + ] + ], + ) main_layout = [[header]] + main_layout @@ -544,42 +693,57 @@ def class_init_configurations_via_gui( main_window.close() return None - update = _retrieve(event, str, values, hook_system['update']) + update = _retrieve(event, str, values, hook_system["update"]) if not update is None: update(main_window, event, values) - elif event[-len('toggle'):] == 'toggle': - target = event[:-len('toggle')] + elif event[-len("toggle") :] == "toggle": + target = event[: -len("toggle")] - main_window[event].metadata = object() if main_window[event].metadata is None else None + main_window[event].metadata = ( + object() if main_window[event].metadata is None else None + ) for element in main_window.key_dict.keys(): - if element[:len(target)] == target and element[-len('collapsable'):] == 'collapsable': + if ( + element[: len(target)] == target + and element[-len("collapsable") :] == "collapsable" + ): # print(element) - main_window[element].update(visible=(main_window[event].metadata is None)) + main_window[element].update( + visible=(main_window[event].metadata is None) + ) # print('-----------------------------------------------------') - elif event == 'load config': - with open(values[event], 'r') as config_file: + elif event == "load config": + with open(values[event], "r") as config_file: import yaml underlying_dict = yaml.unsafe_load(config_file) break - elif event == 'save config': - config = _create_config(values, config_tree, hook_system['evaluation'], type_checked) + elif event == "save config": + config = _create_config( + values, config_tree, hook_system["evaluation"], type_checked + ) if config is None: continue file_id = len(os.listdir(gui_config_file_dir)) - with open(f"{gui_config_file_dir}/{class_object.__name__}_configuration{file_id}.yaml", 'w') as dest: + with open( + f"{gui_config_file_dir}/{class_object.__name__}_configuration{file_id}.yaml", + "w", + ) as dest: import yaml + yaml.dump(config, dest) - elif event == 'config done': - config = _create_config(values, config_tree, hook_system['evaluation'], type_checked) + elif event == "config done": + config = _create_config( + values, config_tree, hook_system["evaluation"], type_checked + ) if config is None: continue @@ -589,12 +753,14 @@ def class_init_configurations_via_gui( def accompanion_configurations_and_version_via_gui(): - version_layout = [[PySimpleGUI.Text('Please choose a version. Currently supported are:')]] + version_layout = [ + [PySimpleGUI.Text("Please choose a version. Currently supported are:")] + ] for csf in _currently_supported_versions.keys(): version_layout.append([PySimpleGUI.Button(csf)]) - init_window = PySimpleGUI.Window('ACCompanion version', version_layout) + init_window = PySimpleGUI.Window("ACCompanion version", version_layout) event, values = init_window.read() @@ -604,20 +770,36 @@ def accompanion_configurations_and_version_via_gui(): print("Configuration aborted") return None, None - acc_version = load_class(_currently_supported_versions[event][0], _currently_supported_versions[event][1]) + acc_version = load_class( + _currently_supported_versions[event][0], _currently_supported_versions[event][1] + ) configs = class_init_configurations_via_gui( acc_version, hooks=( - Hook(midi_router_kwargs_trigger, layout=midi_router_kwargs_layout, - configuration=midi_router_kwargs_configuration), - Hook(tempo_model_trigger, layout=tempo_model_layout, configuration=tempo_model_configuration, - evaluation=tempo_model_eval), + Hook( + midi_router_kwargs_trigger, + layout=midi_router_kwargs_layout, + configuration=midi_router_kwargs_configuration, + ), + Hook( + tempo_model_trigger, + layout=tempo_model_layout, + configuration=tempo_model_configuration, + evaluation=tempo_model_eval, + ), Hook(single_file_name_trigger, layout=single_file_name_layout), - Hook(multiple_file_name_trigger, layout=multiple_file_name_layout, evaluation=multiple_file_name_eval), + Hook( + multiple_file_name_trigger, + layout=multiple_file_name_layout, + evaluation=multiple_file_name_eval, + ), Hook(accompaniment_match_trigger, layout=single_file_name_layout), - Hook(multiple_file_name_browse_trigger, update=multiple_file_name_browse_update) - ) + Hook( + multiple_file_name_browse_trigger, + update=multiple_file_name_browse_update, + ), + ), ) - return configs, acc_version \ No newline at end of file + return configs, acc_version diff --git a/bin/launch_acc.py b/bin/launch_acc.py index 91ba7f9..2eb5052 100644 --- a/bin/launch_acc.py +++ b/bin/launch_acc.py @@ -2,13 +2,19 @@ # -*- coding: utf-8 -*- import multiprocessing import os +import warnings + os.environ["KMP_DUPLICATE_LIB_OK"] = "True" -import os import argparse import glob +import os +import sys + from accompanion import PLATFORM -import sys +warnings.filterwarnings("ignore", module="partitura") + + sys.path.append("..") overridable_args = [ @@ -53,12 +59,19 @@ parser.add_argument("--piece") parser.add_argument("--follower", default="hmm") parser.add_argument( - "-f", "--config_file", default="simple_pieces", help="config file to load." + "-f", + "--config_file", + default="simple_pieces", + help="config file to load.", ) parser.add_argument("--input", required=False, help="Input MIDI instrument port.") parser.add_argument("--output", required=False, help="Output MIDI instrument port.") - parser.add_argument("--record-midi", action="store_true", help="Record Midi input and Output.") - parser.add_argument("--midi-fn", help="Midi file to play instead of real time input.") + parser.add_argument( + "--record-midi", action="store_true", help="Record Midi input and Output." + ) + parser.add_argument( + "--midi-fn", help="Midi file to play instead of real time input." + ) args = parser.parse_args() @@ -76,25 +89,43 @@ info_file = yaml.safe_load(f) configurations = info_file["config"] # TODO : add a configuration for the default loaded file and directories. - if args.config_file in ["brahms", "mozart", "schubert", "fourhands", "FH"]: - args.follower = "oltw" + if args.config_file in [ + "brahms", + "mozart", + "schubert", + "fourhands", + "FH", + "grieg_morning_mood", + "grieg_ases_tod", + "grieg_mountain_king", + ]: + # args.follower = "oltw" file_dir = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "accompanion_pieces", "complex_pieces", info_file["piece_dir"], ) - configurations["acc_fn"] = os.path.join(file_dir, os.path.normpath(info_file["acc_fn"])) - configurations["accompaniment_match"] = os.path.join( - file_dir, os.path.normpath(info_file["accompaniment_match"]) - ) if "accompaniment_match" in info_file.keys() else None - configurations["solo_fn"] = glob.glob( - os.path.join(file_dir, "match", "cc_solo", "*.match") - )[-5:] if "solo_fn" not in info_file.keys() else os.path.join( - file_dir, os.path.normpath(info_file["solo_fn"]) + configurations["acc_fn"] = os.path.join( + file_dir, os.path.normpath(info_file["acc_fn"]) + ) + configurations["accompaniment_match"] = ( + os.path.join( + file_dir, os.path.normpath(info_file["accompaniment_match"]) + ) + if "accompaniment_match" in info_file.keys() + else None + ) + configurations["solo_fn"] = ( + glob.glob(os.path.join(file_dir, "match", "cc_solo", "*.match"))[-5:] + if "solo_fn" not in info_file.keys() + else os.path.join(file_dir, os.path.normpath(info_file["solo_fn"])) + ) + configurations["midi_fn"] = ( + os.path.join(file_dir, os.path.normpath(info_file["midi_fn"])) + if "midi_fn" in info_file.keys() + else None ) - configurations["midi_fn"] = os.path.join(file_dir, os.path.normpath( - info_file["midi_fn"])) if "midi_fn" in info_file.keys() else None else: file_dir = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), @@ -103,33 +134,66 @@ info_file["piece_dir"], ) # If only one piece is available load and separate to parts - if os.path.join(file_dir, "primo.musicxml") not in glob.glob(os.path.join(file_dir, "*.musicxml")): + if os.path.join(file_dir, "primo.musicxml") not in glob.glob( + os.path.join(file_dir, "*.musicxml") + ): import partitura - score = partitura.load_score((glob.glob(os.path.join(file_dir, "*.musicxml")) + glob.glob(os.path.join(file_dir, "*.mxl")))[0]) + + score = partitura.load_score( + ( + glob.glob(os.path.join(file_dir, "*.musicxml")) + + glob.glob(os.path.join(file_dir, "*.mxl")) + )[0] + ) if len(score.parts) == 1: from copy import deepcopy + import numpy as np + # find individual staff na = score.note_array(include_staff=True) staff = np.unique(na["staff"]) - primo_part = deepcopy(score.parts[0]) - secondo_part = deepcopy(score.parts[0]) + primo_part = partitura.load_score( + ( + glob.glob(os.path.join(file_dir, "*.musicxml")) + + glob.glob(os.path.join(file_dir, "*.mxl")) + )[0] + )[0] + secondo_part = partitura.load_score( + ( + glob.glob(os.path.join(file_dir, "*.musicxml")) + + glob.glob(os.path.join(file_dir, "*.mxl")) + )[0] + )[0] + # primo_part = deepcopy(score.parts[0]) + # secondo_part = deepcopy(score.parts[0]) for st in staff: if st == 1: - primo_part.notes = [note for note in primo_part.notes if note.staff == st] + primo_notes = primo_part.notes + + for note in primo_part.notes: + if note.staff != st: + primo_part.remove(note) + else: - secondo_part.notes = [note for note in secondo_part.notes if note.staff == st] + secondo_notes = secondo_part.notes + + for note in secondo_notes: + if note.staff == 1: + secondo_part.remove(note) elif len(score.parts) == 2: primo_part = score.parts[0] - partitura.save_musicxml(primo_part, os.path.join(file_dir, "primo.musicxml")) + partitura.save_musicxml( + primo_part, os.path.join(file_dir, "primo.musicxml") + ) secondo_part = score.parts[1] - partitura.save_musicxml(secondo_part, os.path.join(file_dir, "secondo.musicxml")) + partitura.save_musicxml( + secondo_part, os.path.join(file_dir, "secondo.musicxml") + ) else: raise ValueError("Score has more than two parts.") - - configurations["acc_fn"] = os.path.join(file_dir, "secondo.musicxml") configurations["solo_fn"] = os.path.join(file_dir, "primo.musicxml") @@ -137,7 +201,18 @@ configurations = dict() # import ACCompanion version - if args.follower: + + if "follower" in configurations.keys(): + if configurations["follower"] == "hmm": + from accompanion.hmm_accompanion import HMMACCompanion as ACCompanion + elif configurations["follower"] == "oltw": + from accompanion.oltw_accompanion import OLTWACCompanion as ACCompanion + else: + raise ValueError( + f"configuration parameter 'follower' is of unknown value {configurations['follower']}" + ) + elif args.follower: + # Use the version in follower only if not specified in the if args.follower == "hmm": from accompanion.hmm_accompanion import HMMACCompanion as ACCompanion @@ -164,15 +239,6 @@ raise ValueError( f"console argument 'follower' is of unknown value {args.follower}" ) - elif "follower" in configurations.keys(): - if configurations["follower"] == "hmm": - from accompanion.hmm_accompanion import HMMACCompanion as ACCompanion - elif configurations["follower"] == "oltw": - from accompanion.oltw_accompanion import OLTWACCompanion as ACCompanion - else: - raise ValueError( - f"configuration parameter 'follower' is of unknown value {configurations['follower']}" - ) else: raise ValueError( "Neither through console arguments nor configuration file has a score follower type been specified" @@ -181,6 +247,9 @@ if "follower" in configurations.keys(): del configurations["follower"] + if not "accompanist_decoder_kwargs" in configurations.keys(): + configurations["accompanist_decoder_kwargs"] = None + if args.input: configurations["midi_router_kwargs"][ "solo_input_to_accompaniment_port_name" @@ -209,10 +278,13 @@ configurations["midi_fn"] = args.midi_fn if args.midi_fn else None if configurations["midi_fn"] is not None: - configurations["midi_router_kwargs"]["solo_input_to_accompaniment_port_name"] = 0 - configurations["midi_router_kwargs"]["MIDIPlayer_to_accompaniment_port_name"] = 0 + configurations["midi_router_kwargs"][ + "solo_input_to_accompaniment_port_name" + ] = 0 + configurations["midi_router_kwargs"][ + "MIDIPlayer_to_accompaniment_port_name" + ] = 0 accompanion = ACCompanion(**configurations) accompanion.run() - diff --git a/config_files/bach_invention.yml b/config_files/bach_invention.yml index f7882de..be93f2c 100644 --- a/config_files/bach_invention.yml +++ b/config_files/bach_invention.yml @@ -16,10 +16,10 @@ config: velocity_solo_scale: 0.95 timing_scale: 0.001 log_articulation_scale: 0.1 - mechanical_delay: 0.125 + mechanical_delay: 0.0 midi_router_kwargs : - solo_input_to_accompaniment_port_name: YAMAHA USB Device Port1 - acc_output_to_sound_port_name: YAMAHA USB Device Port1 + solo_input_to_accompaniment_port_name: Logic Pro Virtual Out + acc_output_to_sound_port_name: Logic Pro Virtual In MIDIPlayer_to_sound_port_name: None MIDIPlayer_to_accompaniment_port_name: None simple_button_input_port_name: None @@ -29,8 +29,8 @@ config: tempo_model: LSM use_ceus_mediator: False polling_period: 0.035 - init_bpm : 90 + init_bpm : 60 init_velocity : 60 -piece_dir : bach_invention +piece_dir : bach_invention1 acc_fn : "secondo.musicxml" solo_fn: "primo.musicxml" \ No newline at end of file diff --git a/config_files/bach_menuett.yml b/config_files/bach_menuett.yml index 76ea7b3..bd13c12 100644 --- a/config_files/bach_menuett.yml +++ b/config_files/bach_menuett.yml @@ -16,10 +16,10 @@ config: velocity_solo_scale: 0.95 timing_scale: 0.001 log_articulation_scale: 0.1 - mechanical_delay: 0.125 + mechanical_delay: 0.0 midi_router_kwargs : - solo_input_to_accompaniment_port_name: YAMAHA USB Device Port1 - acc_output_to_sound_port_name: YAMAHA USB Device Port1 + solo_input_to_accompaniment_port_name: Logic Pro Virtual Out + acc_output_to_sound_port_name: Logic Pro Virtual In MIDIPlayer_to_sound_port_name: None MIDIPlayer_to_accompaniment_port_name: None simple_button_input_port_name: None diff --git a/config_files/bach_menuett2.yml b/config_files/bach_menuett2.yml index 258f3c5..6c3925f 100644 --- a/config_files/bach_menuett2.yml +++ b/config_files/bach_menuett2.yml @@ -16,10 +16,10 @@ config: velocity_solo_scale: 0.95 timing_scale: 0.001 log_articulation_scale: 0.1 - mechanical_delay: 0.125 + mechanical_delay: 0.0 midi_router_kwargs : - solo_input_to_accompaniment_port_name: YAMAHA USB Device Port1 - acc_output_to_sound_port_name: YAMAHA USB Device Port1 + solo_input_to_accompaniment_port_name: Logic Pro Virtual Out + acc_output_to_sound_port_name: Logic Pro Virtual In MIDIPlayer_to_sound_port_name: None MIDIPlayer_to_accompaniment_port_name: None simple_button_input_port_name: None diff --git a/config_files/bach_menuett_gminor.yml b/config_files/bach_menuett_gminor.yml index 9c6f754..a30079e 100644 --- a/config_files/bach_menuett_gminor.yml +++ b/config_files/bach_menuett_gminor.yml @@ -16,10 +16,10 @@ config: velocity_solo_scale: 0.95 timing_scale: 0.001 log_articulation_scale: 0.1 - mechanical_delay: 0.1 + mechanical_delay: 0.0 midi_router_kwargs : - solo_input_to_accompaniment_port_name: YAMAHA USB Device Port1 - acc_output_to_sound_port_name: YAMAHA USB Device Port1 + solo_input_to_accompaniment_port_name: Logic Pro Virtual Out + acc_output_to_sound_port_name: Logic Pro Virtual In MIDIPlayer_to_sound_port_name: None MIDIPlayer_to_accompaniment_port_name: None simple_button_input_port_name: None diff --git a/config_files/beethoven_elise.yml b/config_files/beethoven_elise.yml index 4f30f72..5192072 100644 --- a/config_files/beethoven_elise.yml +++ b/config_files/beethoven_elise.yml @@ -16,10 +16,10 @@ config: velocity_solo_scale: 0.95 timing_scale: 0.001 log_articulation_scale: 0.1 - mechanical_delay: 0.125 + mechanical_delay: 0.0 midi_router_kwargs : - solo_input_to_accompaniment_port_name: YAMAHA USB Device Port1 - acc_output_to_sound_port_name: YAMAHA USB Device Port1 + solo_input_to_accompaniment_port_name: Logic Pro Virtual Out + acc_output_to_sound_port_name: Logic Pro Virtual In MIDIPlayer_to_sound_port_name: None MIDIPlayer_to_accompaniment_port_name: None simple_button_input_port_name: None diff --git a/config_files/blue_danube.yml b/config_files/blue_danube.yml index 6aabbec..7ea85f7 100644 --- a/config_files/blue_danube.yml +++ b/config_files/blue_danube.yml @@ -16,10 +16,10 @@ config: velocity_solo_scale: 0.95 timing_scale: 0.001 log_articulation_scale: 0.1 - mechanical_delay: 0.125 + mechanical_delay: 0.0 midi_router_kwargs : - solo_input_to_accompaniment_port_name: YAMAHA USB Device Port1 - acc_output_to_sound_port_name: YAMAHA USB Device Port1 + solo_input_to_accompaniment_port_name: Logic Pro Virtual Out + acc_output_to_sound_port_name: Logic Pro Virtual In MIDIPlayer_to_sound_port_name: None MIDIPlayer_to_accompaniment_port_name: None simple_button_input_port_name: None @@ -32,5 +32,5 @@ config: init_bpm: 140 init_velocity : 60 piece_dir : blue_danube_C -acc_fn : "secondo.musicxml" -solo_fn: "primo.musicxml" \ No newline at end of file +solo_fn : "secondo.musicxml" +acc_fn: "primo.musicxml" \ No newline at end of file diff --git a/config_files/brahms.yml b/config_files/brahms.yml index 35ff992..a0e9483 100644 --- a/config_files/brahms.yml +++ b/config_files/brahms.yml @@ -14,14 +14,14 @@ config: articulation_ma_alpha: 0.4 velocity_dev_scale: 70 velocity_min: 45 - velocity_max: 105 + velocity_max: 90 velocity_solo_scale: 1.00 timing_scale: 0.001 log_articulation_scale: 0.12 - mechanical_delay: 0.13 + mechanical_delay: 0.0 midi_router_kwargs : - solo_input_to_accompaniment_port_name: YAMAHA USB Device Port1 - acc_output_to_sound_port_name: YAMAHA USB Device Port1 + solo_input_to_accompaniment_port_name: Logic Pro Virtual Out + acc_output_to_sound_port_name: Logic Pro Virtual In MIDIPlayer_to_sound_port_name: None MIDIPlayer_to_accompaniment_port_name: None simple_button_input_port_name: None diff --git a/config_files/grieg_ases_tod.yml b/config_files/grieg_ases_tod.yml new file mode 100644 index 0000000..a5ec1a1 --- /dev/null +++ b/config_files/grieg_ases_tod.yml @@ -0,0 +1,51 @@ +# Accompanion config for Åses Tod. +config: + follower : oltw + score_follower_kwargs : + score_follower: OnlineTimeWarping + window_size: 100 + step_size: 3 + start_window_size: 100 + input_processor: + processor: PianoRollProcessor + processor_kwargs: + piano_range: True + # score_follower: PitchIOIKHMM + # input_processor: + # processor: PitchIOIProcessor + # processor_kwargs: + # piano_range: True + performance_codec_kwargs : + velocity_trend_ma_alpha: 0.6 + articulation_ma_alpha: 0.4 + velocity_dev_scale: 70 + velocity_min: 30 + velocity_max: 105 + velocity_solo_scale: 0.95 + timing_scale: 0.001 + log_articulation_scale: 0.5 + mechanical_delay: 0.0 + midi_router_kwargs : + solo_input_to_accompaniment_port_name: Logic Pro Virtual Out + acc_output_to_sound_port_name: Logic Pro Virtual In + MIDIPlayer_to_sound_port_name: None + MIDIPlayer_to_accompaniment_port_name: None + simple_button_input_port_name: None + adjust_following_rate: 0.2 + bypass_audio: False + tempo_model_kwargs: + tempo_model: JADAMSM # LSM + # eta_p: 0.9 + # eta_t: 0.9 + use_ceus_mediator: False + polling_period: 0.01 + init_bpm: 70 + init_velocity : 40 + accompanist_decoder_kwargs: + rit_len: 10 + rit_w: 1.0 + rit_q: 2.0 +piece_dir : grieg_ases_tod +acc_fn : musicxml/åses_tod-Secondo.musicxml +accompaniment_match: match/cc_secondo/åses_tod-Secondo_åses_tod_v0.match +# solo_fn: "primo.musicxml" \ No newline at end of file diff --git a/config_files/grieg_morning_mood.yml b/config_files/grieg_morning_mood.yml new file mode 100644 index 0000000..bf965fd --- /dev/null +++ b/config_files/grieg_morning_mood.yml @@ -0,0 +1,54 @@ +# Accompanion config for Morning Mood. +config: + follower : oltw + score_follower_kwargs : + score_follower: OnlineTimeWarping + window_size: 200 + step_size: 2 + start_window_size: 40 + input_processor: + processor: PianoRollProcessor + processor_kwargs: + piano_range: True + # score_follower: PitchIOIKHMM + # input_processor: + # processor: PitchIOIProcessor + # processor_kwargs: + # piano_range: True + performance_codec_kwargs : + velocity_trend_ma_alpha: 0.6 + articulation_ma_alpha: 0.4 + velocity_dev_scale: 70 + velocity_min: 30 + velocity_max: 105 + velocity_solo_scale: 0.95 + timing_scale: 0.001 + log_articulation_scale: 0.1 + mechanical_delay: 0.0 + midi_router_kwargs : + solo_input_to_accompaniment_port_name: Logic Pro Virtual Out + acc_output_to_sound_port_name: Logic Pro Virtual In + MIDIPlayer_to_sound_port_name: None + MIDIPlayer_to_accompaniment_port_name: None + simple_button_input_port_name: None + adjust_following_rate: 0.2 + expected_position_weight: 0.7 + bypass_audio: False + tempo_model_kwargs: + tempo_model: LSM + # eta_p: 0.0 + # eta_t: 0.0 + use_ceus_mediator: False + polling_period: 0.01 + init_bpm: 150 + init_velocity : 40 + accompanist_decoder_kwargs: + rit_len: 10 + rit_w: 1.0 + rit_q: 2.0 +piece_dir : grieg_morning_mood +# accompaniment_match : "match/cc_acc/secondo_morgenstimmung_secondo_03.match" +acc_fn : "musicxml/secondo.musicxml" +solo_fn : "musicxml/primo.musicxml" +# solo_fn: "primo.musicxml" +# acc_fn: "primo.musicxml" \ No newline at end of file diff --git a/config_files/mozart.yml b/config_files/mozart.yml index c7b3c49..e2e8ee2 100644 --- a/config_files/mozart.yml +++ b/config_files/mozart.yml @@ -4,11 +4,18 @@ config: score_follower_kwargs : score_follower: OnlineTimeWarping window_size: 80 - step_size: 8 + step_size: 2 + start_window_size: 40 + local_cost_fun : Manhattan input_processor: processor: PianoRollProcessor processor_kwargs: piano_range: True + # score_follower: PitchIOIHMM + # input_processor: + # processor: PitchIOIProcessor + # processor_kwargs: + # piano_range: True performance_codec_kwargs : velocity_trend_ma_alpha: 0.6 articulation_ma_alpha: 0.4 @@ -17,23 +24,24 @@ config: velocity_max: 105 velocity_solo_scale: 0.95 timing_scale: 0.001 - log_articulation_scale: 0.1 - mechanical_delay: 0.125 + log_articulation_scale: 0.08 + mechanical_delay: 0.0 midi_router_kwargs : - solo_input_to_accompaniment_port_name: YAMAHA USB Device Port1 - acc_output_to_sound_port_name: YAMAHA USB Device Port1 + solo_input_to_accompaniment_port_name: Logic Pro Virtual Out + acc_output_to_sound_port_name: Logic Pro Virtual In MIDIPlayer_to_sound_port_name: None MIDIPlayer_to_accompaniment_port_name: None simple_button_input_port_name: None - adjust_following_rate: 0.22 + adjust_following_rate: 0.2 bypass_audio: False tempo_model_kwargs: tempo_model: LTESM + # eta_t: 0.3 + # eta_p: 0.8 use_ceus_mediator: False polling_period: 0.01 - init_bpm : 112 + init_bpm : 60 init_velocity : 60 piece_dir : mozart_demo -#solo_fn : musicxml/Sonata-k381-a123_Primo.musicxml acc_fn : musicxml/Sonata-k381-a123_Secondo.musicxml accompaniment_match : basismixer/mozart_sonata_secondo.match \ No newline at end of file diff --git a/config_files/mozart_sonata_facile.yml b/config_files/mozart_sonata_facile.yml index 7dd43a0..0a68ca7 100644 --- a/config_files/mozart_sonata_facile.yml +++ b/config_files/mozart_sonata_facile.yml @@ -16,7 +16,7 @@ config: velocity_solo_scale: 0.95 timing_scale: 0.001 log_articulation_scale: 0.1 - mechanical_delay: 0.125 + mechanical_delay: 0.0 midi_router_kwargs : solo_input_to_accompaniment_port_name: YAMAHA USB Device Port1 acc_output_to_sound_port_name: YAMAHA USB Device Port1 diff --git a/config_files/mozart_sonata_facile_long.yml b/config_files/mozart_sonata_facile_long.yml index 156a892..d218975 100644 --- a/config_files/mozart_sonata_facile_long.yml +++ b/config_files/mozart_sonata_facile_long.yml @@ -16,10 +16,10 @@ config: velocity_solo_scale: 0.95 timing_scale: 0.001 log_articulation_scale: 0.1 - mechanical_delay: 0.125 + mechanical_delay: 0.0 midi_router_kwargs : - solo_input_to_accompaniment_port_name: YAMAHA USB Device Port1 - acc_output_to_sound_port_name: YAMAHA USB Device Port1 + solo_input_to_accompaniment_port_name: Logic Pro Virtual Out + acc_output_to_sound_port_name: Logic Pro Virtual In MIDIPlayer_to_sound_port_name: None MIDIPlayer_to_accompaniment_port_name: None simple_button_input_port_name: None diff --git a/config_files/mozart_variation1.yml b/config_files/mozart_variation1.yml new file mode 100644 index 0000000..06d51f7 --- /dev/null +++ b/config_files/mozart_variation1.yml @@ -0,0 +1,36 @@ +# Accompanion config for Bach Menuet. +config: + follower : hmm + score_follower_kwargs : + score_follower: PitchIOIKHMM + input_processor: + processor: PitchIOIProcessor + processor_kwargs: + piano_range: True + performance_codec_kwargs : + velocity_trend_ma_alpha: 0.6 + articulation_ma_alpha: 0.4 + velocity_dev_scale: 70 + velocity_min: 30 + velocity_max: 105 + velocity_solo_scale: 0.95 + timing_scale: 0.001 + log_articulation_scale: 0.1 + mechanical_delay: 0.0 + midi_router_kwargs : + solo_input_to_accompaniment_port_name: Logic Pro Virtual Out + acc_output_to_sound_port_name: Logic Pro Virtual In + MIDIPlayer_to_sound_port_name: None + MIDIPlayer_to_accompaniment_port_name: None + simple_button_input_port_name: None + adjust_following_rate: 0.2 + bypass_audio: False + tempo_model_kwargs: + tempo_model: LSM + use_ceus_mediator: False + polling_period: 0.01 + init_bpm: 120 + init_velocity : 60 +piece_dir : mozart_variation1 +acc_fn : "secondo.musicxml" +solo_fn: "primo.musicxml" \ No newline at end of file diff --git a/config_files/shubert.yml b/config_files/schubert.yml similarity index 84% rename from config_files/shubert.yml rename to config_files/schubert.yml index c2024bd..b2b10d3 100644 --- a/config_files/shubert.yml +++ b/config_files/schubert.yml @@ -18,16 +18,16 @@ config: velocity_solo_scale: 0.95 timing_scale: 0.001 log_articulation_scale: 0.1 - mechanical_delay: 0.125 + mechanical_delay: 0.0 midi_router_kwargs : - solo_input_to_accompaniment_port_name: YAMAHA USB Device Port1 - MIDIPlayer_to_sound_port_name: YAMAHA USB Device Port1 + solo_input_to_accompaniment_port_name: Logic Pro Virtual Out + acc_output_to_sound_port_name: Logic Pro Virtual In MIDIPlayer_to_accompaniment_port_name: None simple_button_input_port_name: None adjust_following_rate: 0.1 bypass_audio: False tempo_model_kwargs: - tempo_model: LTESM + tempo_model: LSM use_ceus_mediator: False polling_period: 0.01 init_bpm : 60 diff --git a/config_files/simple_pieces.yml b/config_files/simple_pieces.yml index b52a0c1..e233b03 100644 --- a/config_files/simple_pieces.yml +++ b/config_files/simple_pieces.yml @@ -16,7 +16,7 @@ config: velocity_solo_scale: 0.95 timing_scale: 0.001 log_articulation_scale: 0.1 - mechanical_delay: 0.125 + mechanical_delay: 0.0 midi_router_kwargs : solo_input_to_accompaniment_port_name: YAMAHA USB Device Port1 acc_output_to_sound_port_name: YAMAHA USB Device Port1 diff --git a/environment.yml b/environment.yml index 24aec4a..44f59ef 100644 --- a/environment.yml +++ b/environment.yml @@ -7,23 +7,22 @@ channels: dependencies: - python=3.9 - - Cython - - matplotlib - - numpy - - pytorch - - scipy - - tqdm + - Cython==3.0.0 + - matplotlib==3.8.0 + - numpy==1.26.0 + - pytorch==2.1.0 + - scipy==1.11.3 + - tqdm==4.65.0 - pip - pip: - - fastdtw - - librosa - - opencv-python-headless - - mido - - python-rtmidi - - madmom - - pyyaml + - fastdtw==0.3.4 + - librosa==0.10.1 + - mido==1.3.0 + - python-rtmidi==1.5.8 + - git+https://github.com/CPJKU/madmom.git + - pyyaml==6.0.1 - PySimpleGUI==4.18.2 - partitura>=1.3.0 - git+https://github.com/OFAI/basismixer.git - - git+https://github.com/neosatrapahereje/hiddenmarkov.git \ No newline at end of file + - python-hiddenmarkov==0.1.3 \ No newline at end of file diff --git a/setup.py b/setup.py index 2736d5b..c1fe6c4 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,10 @@ +from distutils.extension import Extension +from sys import platform + import numpy import setuptools -from setuptools import setup from Cython.Build import cythonize -from distutils.extension import Extension -from sys import platform +from setuptools import setup # Package meta-data. NAME = "accompanion"