From 93a3e51e749afc0c3ba8488b900124fda6bb8774 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Isma=C3=ABl=20Bouya?= Date: Mon, 19 Sep 2016 15:57:42 +0200 Subject: [PATCH] Cleanup key and action workflows --- music_sampler/action.py | 70 ++++++++++++++------- music_sampler/key.py | 35 ++++++----- music_sampler/mapping.py | 34 +++++------ music_sampler/music_file.py | 119 ++++++++++++++++++------------------ 4 files changed, 142 insertions(+), 116 deletions(-) diff --git a/music_sampler/action.py b/music_sampler/action.py index 22a2bdc..bc62f33 100644 --- a/music_sampler/action.py +++ b/music_sampler/action.py @@ -9,8 +9,9 @@ class Action: 'failed', { 'name': 'loaded', - 'children': ['running'] - } + 'children': ['stopped', 'running'] + }, + 'destroyed' ] TRANSITIONS = [ @@ -21,36 +22,42 @@ class Action: }, { 'trigger': 'fail', - 'source': 'loading', + 'source': ['loading', 'loaded'], 'dest': 'failed', - 'after': 'poll_loaded' }, { 'trigger': 'success', 'source': 'loading', - 'dest': 'loaded', - 'after': 'poll_loaded' + 'dest': 'loaded_stopped', }, { - 'trigger': 'run', + 'trigger': 'reload', 'source': 'loaded', + 'dest': 'loading', + }, + { + 'trigger': 'run', + 'source': 'loaded_stopped', 'dest': 'loaded_running', 'after': 'finish_action', - # if a child has no transitions, then it is bubbled to the parent, - # and we don't want that. Not useful in that machine precisely. - 'conditions': ['is_loaded'] }, { 'trigger': 'finish_action', 'source': 'loaded_running', - 'dest': 'loaded' + 'dest': 'loaded_stopped' + }, + { + 'trigger': 'destroy', + 'source': '*', + 'dest': 'destroyed' } ] def __init__(self, action, key, **kwargs): Machine(model=self, states=self.STATES, transitions=self.TRANSITIONS, initial='initial', - ignore_invalid_triggers=True, queued=True) + ignore_invalid_triggers=True, queued=True, + after_state_change=self.notify_state_change) self.action = action self.key = key @@ -62,18 +69,31 @@ class Action: def is_loaded_or_failed(self): return self.is_loaded(allow_substates=True) or self.is_failed() - def callback_music_loaded(self, success): - if success: - self.success() - else: + def callback_music_state(self, new_state): + # If a music gets unloaded while the action is loaded_running and + # depending on the music, it won't be able to do the finish_action. + # Can that happen? + # a: play 'mp3'; + # z: wait 'mp3'; + # e: pause 'mp3'; + # r: stop 'mp3'; unload_music 'mp3' + if new_state == 'failed': self.fail() + elif self.is_loaded(allow_substates=True) and\ + new_state in ['initial', 'loading']: + self.reload(reloading=True) + elif self.is_loading() and new_state.startswith('loaded_'): + self.success() # Machine states / events - def on_enter_loading(self): + def on_enter_loading(self, reloading=False): + if reloading: + return if hasattr(actions, self.action): - if 'music' in self.arguments: - self.arguments['music'].subscribe_loaded( - self.callback_music_loaded) + if 'music' in self.arguments and\ + self.action not in ['unload_music', 'load_music']: + self.arguments['music'].subscribe_state_change( + self.callback_music_state) else: self.success() else: @@ -86,9 +106,13 @@ class Action: getattr(actions, self.action).run(self, key_start_time=key_start_time, **self.arguments) - def poll_loaded(self): - self.key.callback_action_ready(self, - self.is_loaded(allow_substates=True)) + def on_enter_destroyed(self): + if 'music' in self.arguments: + self.arguments['music'].unsubscribe_state_change( + self.callback_music_state) + + def notify_state_change(self, *args, **kwargs): + self.key.callback_action_state_changed() # This one cannot be in the Machine state since it would be queued to run # *after* the wait is ended... diff --git a/music_sampler/key.py b/music_sampler/key.py index e524c35..e05bb16 100644 --- a/music_sampler/key.py +++ b/music_sampler/key.py @@ -13,7 +13,7 @@ from transitions.extensions import HierarchicalMachine as Machine # https://github.com/kivy/kivy/wiki/Working-with-Python-threads-inside-a-Kivy-application from kivy.clock import mainthread -class KeyMachine(Widget): +class KeyMachine(): STATES = [ 'initial', 'configuring', @@ -101,11 +101,15 @@ class KeyMachine(Widget): { 'trigger': 'repeat_protection_finished', 'source': 'loaded_protecting_repeat', - 'dest': 'loaded' + 'dest': 'loaded', + 'after': 'callback_action_state_changed' }, ] - state = StringProperty("") + def __setattr__(self, name, value): + if hasattr(self, 'initialized') and name == 'state': + self.key.update_state(value) + super().__setattr__(name, value) def __init__(self, key, **kwargs): self.key = key @@ -113,7 +117,8 @@ class KeyMachine(Widget): Machine(model=self, states=self.STATES, transitions=self.TRANSITIONS, initial='initial', ignore_invalid_triggers=True, queued=True) - super(KeyMachine, self).__init__(**kwargs) + + self.initialized = True # Machine states / events def is_loaded_or_failed(self): @@ -181,6 +186,17 @@ class KeyMachine(Widget): def key_loaded_callback(self): self.key.parent.key_loaded_callback() + def callback_action_state_changed(self): + if self.state not in ['failed', 'loading', 'loaded']: + return + + if any(action.is_failed() for action in self.key.actions): + self.to_failed() + elif any(action.is_loading() for action in self.key.actions): + self.to_loading() + else: + self.to_loaded() + self.key_loaded_callback() class Key(ButtonBehavior, Widget): @@ -244,14 +260,10 @@ class Key(ButtonBehavior, Widget): else: raise AttributeError - def machine_state_changed(self, instance, machine_state): - self.machine_state = self.machine.state - def __init__(self, **kwargs): self.actions = [] self.current_action = None self.machine = KeyMachine(self) - self.machine.bind(state=self.machine_state_changed) super(Key, self).__init__(**kwargs) @@ -272,13 +284,6 @@ class Key(ButtonBehavior, Widget): def interrupt(self): self.current_action.interrupt() - # Callbacks - def callback_action_ready(self, action, success): - if not success: - self.fail() - elif all(action.is_loaded_or_failed() for action in self.actions): - self.success() - # Setters def set_description(self, description): if description[0] is not None: diff --git a/music_sampler/mapping.py b/music_sampler/mapping.py index 9e40d40..5c61f8a 100644 --- a/music_sampler/mapping.py +++ b/music_sampler/mapping.py @@ -22,8 +22,7 @@ class Mapping(RelativeLayout): 'configuring', 'configured', 'loading', - 'loaded', - 'failed' + 'loaded' ] TRANSITIONS = [ @@ -32,11 +31,6 @@ class Mapping(RelativeLayout): 'source': 'initial', 'dest': 'configuring' }, - { - 'trigger': 'fail', - 'source': 'configuring', - 'dest': 'failed' - }, { 'trigger': 'success', 'source': 'configuring', @@ -48,11 +42,6 @@ class Mapping(RelativeLayout): 'source': 'configured', 'dest': 'loading' }, - { - 'trigger': 'fail', - 'source': 'loading', - 'dest': 'failed' - }, { 'trigger': 'success', 'source': 'loading', @@ -74,10 +63,11 @@ class Mapping(RelativeLayout): self.running = [] self.wait_ids = {} self.open_files = {} + self.is_leaving_application = False Machine(model=self, states=self.STATES, transitions=self.TRANSITIONS, initial='initial', - ignore_invalid_triggers=True, queued=True) + auto_transitions=False, queued=True) super(Mapping, self).__init__(**kwargs) self.keyboard = Window.request_keyboard(self.on_keyboard_closed, self) self.keyboard.bind(on_key_down=self.on_keyboard_down) @@ -127,13 +117,14 @@ class Mapping(RelativeLayout): elif 'ctrl' in modifiers and (keycode[0] == 113 or keycode[0] == '99'): self.leave_application() sys.exit() - elif 'ctrl' in modifiers and keycode[0] == 114: - threading.Thread(name="MSReload", target=self.reload).start() + elif 'ctrl' in modifiers and keycode[0] == 114 and self.is_loaded(): + self.reload() return True def leave_application(self): self.keyboard.unbind(on_key_down=self.on_keyboard_down) self.stop_all_running() + self.is_leaving_application = True for music in self.open_files.values(): music.stop() for thread in threading.enumerate(): @@ -167,13 +158,20 @@ class Mapping(RelativeLayout): # Callbacks def key_loaded_callback(self): + if hasattr(self, 'finished_loading'): + return + + opacity = int(Config.load_all_musics) + result = self.all_keys_ready() if result == "success": - self.ready_color = [0, 1, 0, 1] + self.ready_color = [0, 1, 0, opacity] + self.finished_loading = True elif result == "partial": - self.ready_color = [1, 0, 0, 1] + self.ready_color = [1, 0, 0, opacity] + self.finished_loading = True else: - self.ready_color = [1, 165/255, 0, 1] + self.ready_color = [1, 165/255, 0, opacity] ## Some global actions def stop_all_running(self, except_key=None, key_start_time=0): diff --git a/music_sampler/music_file.py b/music_sampler/music_file.py index 4ba65e3..ec50951 100644 --- a/music_sampler/music_file.py +++ b/music_sampler/music_file.py @@ -22,6 +22,7 @@ class MusicFile: { 'name': 'loaded', 'children': [ + 'stopped', 'playing', 'paused', 'stopping' @@ -31,27 +32,28 @@ class MusicFile: TRANSITIONS = [ { 'trigger': 'load', - 'source': 'initial', - 'dest': 'loading', - 'after': 'poll_loaded' + 'source': ['initial', 'failed'], + 'dest': 'loading' }, { 'trigger': 'fail', 'source': 'loading', 'dest': 'failed' }, + { + 'trigger': 'unload', + 'source': ['failed', 'loaded_stopped'], + 'dest': 'initial', + }, { 'trigger': 'success', 'source': 'loading', - 'dest': 'loaded' + 'dest': 'loaded_stopped' }, { 'trigger': 'start_playing', - 'source': 'loaded', - 'dest': 'loaded_playing', - # if a child has no transitions, then it is bubbled to the parent, - # and we don't want that. Not useful in that machine precisely. - 'conditions': ['is_loaded'] + 'source': 'loaded_stopped', + 'dest': 'loaded_playing' }, { 'trigger': 'pause', @@ -70,19 +72,20 @@ class MusicFile: }, { 'trigger': 'stopped', - 'source': '*', - 'dest': 'loaded', + 'source': 'loaded', + 'dest': 'loaded_stopped', 'before': 'trigger_stopped_events', - 'conditions': ['is_in_use'] + 'unless': 'is_loaded_stopped', } ] def __init__(self, filename, mapping, name=None, gain=1): - Machine(model=self, states=self.STATES, + machine = Machine(model=self, states=self.STATES, transitions=self.TRANSITIONS, initial='initial', - ignore_invalid_triggers=True) + auto_transitions=False, + after_state_change=self.notify_state_change) - self.loaded_callbacks = [] + self.state_change_callbacks = [] self.mapping = mapping self.filename = filename self.name = name or filename @@ -90,48 +93,41 @@ class MusicFile: self.initial_volume_factor = gain self.music_lock = Lock("music__" + filename) - threading.Thread(name="MSMusicLoad", target=self.load).start() + if Config.load_all_musics: + threading.Thread(name="MSMusicLoad", target=self.load).start() def reload_properties(self, name=None, gain=1): self.name = name or self.filename if gain != self.initial_volume_factor: self.initial_volume_factor = gain - self.reload_music_file() + self.stopped() + self.unload() + self.load(reloading=True) - def reload_music_file(self): - with file_lock: - try: - if self.filename.startswith("/"): - filename = self.filename - else: - filename = Config.music_path + self.filename + # Machine related events + def on_enter_initial(self): + self.audio_segment = None - debug_print("Reloading « {} »".format(self.name)) - initial_db_gain = gain(self.initial_volume_factor * 100) - self.audio_segment = pydub.AudioSegment \ - .from_file(filename) \ - .set_frame_rate(Config.frame_rate) \ - .set_channels(Config.channels) \ - .set_sample_width(Config.sample_width) \ - .apply_gain(initial_db_gain) - except Exception as e: - error_print("failed to reload « {} »: {}"\ - .format(self.name, e)) - self.loading_error = e - self.to_failed() - else: - debug_print("Reloaded « {} »".format(self.name)) + def on_enter_loading(self, reloading=False): + if reloading: + prefix = 'Rel' + prefix_s = 'rel' + else: + prefix = 'L' + prefix_s = 'l' - # Machine related events - def on_enter_loading(self): with file_lock: + if self.mapping.is_leaving_application: + self.fail() + return + try: if self.filename.startswith("/"): filename = self.filename else: filename = Config.music_path + self.filename - debug_print("Loading « {} »".format(self.name)) + debug_print("{}oading « {} »".format(prefix, self.name)) self.mixer = self.mapping.mixer or Mixer() initial_db_gain = gain(self.initial_volume_factor * 100) self.audio_segment = pydub.AudioSegment \ @@ -142,12 +138,13 @@ class MusicFile: .apply_gain(initial_db_gain) self.sound_duration = self.audio_segment.duration_seconds except Exception as e: - error_print("failed to load « {} »: {}".format(self.name, e)) + error_print("failed to {}oad « {} »: {}".format( + prefix_s, self.name, e)) self.loading_error = e self.fail() else: self.success() - debug_print("Loaded « {} »".format(self.name)) + debug_print("{}oaded « {} »".format(prefix, self.name)) def on_enter_loaded(self): self.cleanup() @@ -165,11 +162,15 @@ class MusicFile: # Machine related states def is_in_use(self): - return self.is_loaded(allow_substates=True) and not self.is_loaded() + return self.is_loaded(allow_substates=True) and\ + not self.is_loaded_stopped() def is_in_use_not_stopping(self): return self.is_loaded_playing() or self.is_loaded_paused() + def is_unloadable(self): + return self.is_loaded_stopped() or self.is_failed() + # Machine related triggers def trigger_stopped_events(self): self.mixer.remove_file(self) @@ -243,7 +244,7 @@ class MusicFile: if wait: self.mapping.add_wait(self.wait_event, wait_id=set_wait_id) self.wait_end() - else: + elif self.is_loaded(allow_substates=True): self.stopped() def abandon_all_effects(self): @@ -274,21 +275,19 @@ class MusicFile: self.wait_event.clear() self.wait_event.wait() - # Let other subscribe for an event when they are ready - def subscribe_loaded(self, callback): - # FIXME: should lock to be sure we have no race, but it makes the - # initialization screen not showing until everything is loaded - if self.is_loaded(allow_substates=True): - callback(True) - elif self.is_failed(): - callback(False) - else: - self.loaded_callbacks.append(callback) + # Let other subscribe for state change + def notify_state_change(self, **kwargs): + for callback in self.state_change_callbacks: + callback(self.state) + + def subscribe_state_change(self, callback): + if callback not in self.state_change_callbacks: + self.state_change_callbacks.append(callback) + callback(self.state) - def poll_loaded(self): - for callback in self.loaded_callbacks: - callback(self.is_loaded()) - self.loaded_callbacks = [] + def unsubscribe_state_change(self, callback): + if callback in self.state_change_callbacks: + self.state_change_callbacks.remove(callback) # Callbacks def finished_callback(self): -- 2.41.0