From 9c4f705fc6e8ef19973c1977ae8a5b9982cf6ecc Mon Sep 17 00:00:00 2001 From: =?utf8?q?Isma=C3=ABl=20Bouya?= Date: Mon, 25 Jul 2016 11:51:54 +0200 Subject: [PATCH 01/16] Add error message if the config file doesn't load --- helpers/mapping.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/helpers/mapping.py b/helpers/mapping.py index 0c81af4..d60e709 100644 --- a/helpers/mapping.py +++ b/helpers/mapping.py @@ -23,7 +23,13 @@ class Mapping(RelativeLayout): self.mixer = Mixer() else: self.mixer = None - self.key_config, self.open_files = self.parse_config() + + try: + self.key_config, self.open_files = self.parse_config() + except Exception as e: + error_print("Error while loading configuration: {}".format(e)) + sys.exit() + super(Mapping, self).__init__(**kwargs) self._keyboard = Window.request_keyboard(self._keyboard_closed, self) self._keyboard.bind(on_key_down=self._on_keyboard_down) -- 2.41.0 From 205861936ca55357beea6a8af7c0c9ed5a61f484 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Isma=C3=ABl=20Bouya?= Date: Mon, 25 Jul 2016 16:53:46 +0200 Subject: [PATCH 02/16] Reorder MusicFile methods --- helpers/action.py | 6 +- helpers/mapping.py | 15 +- helpers/mixer.py | 3 +- helpers/music_file.py | 352 +++++++++++++++++++++--------------------- music_sampler.py | 4 +- 5 files changed, 184 insertions(+), 196 deletions(-) diff --git a/helpers/action.py b/helpers/action.py index 99cd399..ec8fcb6 100644 --- a/helpers/action.py +++ b/helpers/action.py @@ -30,7 +30,7 @@ class Action: def ready(self): if 'music' in self.arguments: - return self.arguments['music'].check_is_loaded() + return self.arguments['music'].is_loaded(allow_substates=True) else: return True @@ -71,14 +71,14 @@ class Action: loop=0, **kwargs): for music in self.music_list(music): if restart_if_running: - if music.is_not_stopped(): + if music.is_in_use(): music.stop() music.play( volume=volume, fade_in=fade_in, start_at=start_at, loop=loop) - elif not music.is_not_stopped(): + elif not music.is_in_use(): music.play( volume=volume, fade_in=fade_in, diff --git a/helpers/mapping.py b/helpers/mapping.py index d60e709..c2a94e6 100644 --- a/helpers/mapping.py +++ b/helpers/mapping.py @@ -10,7 +10,6 @@ import sys from .music_file import * from .mixer import Mixer from . import Config, gain, error_print -from .music_effect import GainEffect from .action import Action class Mapping(RelativeLayout): @@ -47,19 +46,7 @@ class Mapping(RelativeLayout): self.master_volume) for music in self.open_files.values(): - if not (music.is_loaded_playing() or music.is_loaded_paused()): - continue - - if fade > 0: - music.gain_effects.append(GainEffect( - "fade", - music.current_audio_segment, - music.current_loop, - music.sound_position, - music.sound_position + fade, - gain=db_gain)) - else: - music.set_gain(db_gain) + music.set_gain_with_effect(db_gain, fade=fade) def add_wait_id(self, wait_id, action_or_wait): self.wait_ids[wait_id] = action_or_wait diff --git a/helpers/mixer.py b/helpers/mixer.py index 1d3f28f..9242b61 100644 --- a/helpers/mixer.py +++ b/helpers/mixer.py @@ -35,7 +35,8 @@ class Mixer: self.start() def remove_file(self, music_file): - self.open_files.remove(music_file) + if music_file in self.open_files: + self.open_files.remove(music_file) if len(self.open_files) == 0: self.stop() diff --git a/helpers/music_file.py b/helpers/music_file.py index 017fc59..ccf60ce 100644 --- a/helpers/music_file.py +++ b/helpers/music_file.py @@ -15,84 +15,78 @@ from .music_effect import GainEffect file_lock = Lock("file") class MusicFile: + STATES = [ + 'initial', + 'loading', + 'failed', + { + 'name': 'loaded', + 'children': [ + 'playing', + 'paused', + 'stopping' + ] + } + ] + TRANSITIONS = [ + { + 'trigger': 'load', + 'source': 'initial', + 'dest': 'loading' + }, + { + 'trigger': 'fail', + 'source': 'loading', + 'dest': 'failed' + }, + { + 'trigger': 'success', + 'source': 'loading', + 'dest': 'loaded' + }, + { + 'trigger': 'start_playing', + 'source': 'loaded', + 'dest': 'loaded_playing' + }, + { + 'trigger': 'pause', + 'source': 'loaded_playing', + 'dest': 'loaded_paused' + }, + { + 'trigger': 'unpause', + 'source': 'loaded_paused', + 'dest': 'loaded_playing' + }, + { + 'trigger': 'stop_playing', + 'source': ['loaded_playing','loaded_paused'], + 'dest': 'loaded_stopping' + }, + { + 'trigger': 'stopped', + 'source': '*', + 'dest': 'loaded', + 'before': 'trigger_stopped_events' + } + ] + def __init__(self, filename, mapping, name=None, gain=1): - states = [ - 'initial', - 'loading', - 'failed', - { - 'name': 'loaded', - 'children': [ - 'stopped', - 'playing', - 'paused', - 'stopping', - 'stopped' - ] - } - ] - transitions = [ - { - 'trigger': 'load', - 'source': 'initial', - 'dest': 'loading' - }, - { - 'trigger': 'fail', - 'source': 'loading', - 'dest': 'failed' - }, - { - 'trigger': 'success', - 'source': 'loading', - 'dest': 'loaded_stopped' - }, - { - 'trigger': 'start_playing', - 'source': 'loaded_stopped', - 'dest': 'loaded_playing' - }, - { - 'trigger': 'pause', - 'source': 'loaded_playing', - 'dest': 'loaded_paused' - }, - { - 'trigger': 'unpause', - 'source': 'loaded_paused', - 'dest': 'loaded_playing' - }, - { - 'trigger': 'stop_playing', - 'source': ['loaded_playing','loaded_paused'], - 'dest': 'loaded_stopping' - }, - { - 'trigger': 'stopped', - 'source': 'loaded_stopping', - 'dest': 'loaded_stopped', - 'after': 'trigger_stopped_events' - } - ] - - Machine(model=self, states=states, - transitions=transitions, initial='initial', + Machine(model=self, states=self.STATES, + transitions=self.TRANSITIONS, initial='initial', ignore_invalid_triggers=True) - self.volume = 100 self.mapping = mapping self.filename = filename self.name = name or filename self.audio_segment = None - self.audio_segment_frame_width = 0 self.initial_volume_factor = gain self.music_lock = Lock("music__" + filename) - self.wait_event = threading.Event() - self.db_gain = 0 - self.gain_effects = [] threading.Thread(name="MSMusicLoad", target=self.load).start() + # Machine related events def on_enter_loading(self): with file_lock: try: @@ -105,7 +99,6 @@ class MusicFile: .set_channels(Config.channels) \ .set_sample_width(Config.sample_width) \ .apply_gain(initial_db_gain) - self.audio_segment_frame_width = self.audio_segment.frame_width self.sound_duration = self.audio_segment.duration_seconds except Exception as e: error_print("failed to load « {} »: {}".format(self.name, e)) @@ -115,28 +108,40 @@ class MusicFile: self.success() debug_print("Loaded « {} »".format(self.name)) - def check_is_loaded(self): - return self.state.startswith('loaded_') + def on_enter_loaded(self): + self.gain_effects = [] + self.set_gain(0, absolute=True) + self.current_audio_segment = None + self.volume = 100 + self.wait_event = threading.Event() + self.current_loop = 0 + + def on_enter_loaded_playing(self): + self.mixer.add_file(self) - def is_not_stopped(self): - return self.check_is_loaded() and not self.is_loaded_stopped() + # Machine related states + def is_in_use(self): + return self.is_loaded(allow_substates=True) and not self.is_loaded() - def is_paused(self): - return self.is_loaded_paused() + def is_in_use_not_stopping(self): + return self.is_loaded_playing() or self.is_loaded_paused() + # Machine related triggers + def trigger_stopped_events(self): + self.mixer.remove_file(self) + self.wait_event.set() + + # Actions and properties called externally @property def sound_position(self): - if self.is_not_stopped(): + if self.is_in_use(): return self.current_frame / self.current_audio_segment.frame_rate else: return 0 def play(self, fade_in=0, volume=100, loop=0, start_at=0): - # FIXME: create a "reinitialize" method - self.gain_effects = [] self.set_gain(gain(volume) + self.mapping.master_gain, absolute=True) self.volume = volume - self.current_loop = 0 if loop < 0: self.last_loop = float('inf') else: @@ -145,31 +150,89 @@ class MusicFile: with self.music_lock: self.current_audio_segment = self.audio_segment self.current_frame = int(start_at * self.audio_segment.frame_rate) - if fade_in > 0: - db_gain = gain(self.volume, 0)[0] - self.set_gain(-db_gain) - self.gain_effects.append(GainEffect( - "fade", - self.current_audio_segment, - self.current_loop, - self.sound_position, - self.sound_position + fade_in, - gain=db_gain)) self.start_playing() - def on_enter_loaded_playing(self): - self.mixer.add_file(self) + if fade_in > 0: + db_gain = gain(self.volume, 0)[0] + self.set_gain(-db_gain) + self.add_fade_effect(db_gain, fade_in) - def finished_callback(self): + def seek(self, value=0, delta=False): + if not self.is_in_use_not_stopping(): + return + + with self.music_lock: + self.abandon_all_effects() + if delta: + frame_count = int(self.audio_segment.frame_count()) + frame_diff = int(value * self.audio_segment.frame_rate) + self.current_frame += frame_diff + while self.current_frame < 0: + self.current_loop -= 1 + self.current_frame += frame_count + while self.current_frame > frame_count: + self.current_loop += 1 + self.current_frame -= frame_count + if self.current_loop < 0: + self.current_loop = 0 + self.current_frame = 0 + if self.current_loop > self.last_loop: + self.current_loop = self.last_loop + self.current_frame = frame_count + else: + self.current_frame = max( + 0, + int(value * self.audio_segment.frame_rate)) + + def stop(self, fade_out=0, wait=False, set_wait_id=None): if self.is_loaded_playing(): + ms = int(self.sound_position * 1000) + ms_fo = max(1, int(fade_out * 1000)) + + new_audio_segment = self.current_audio_segment[: ms+ms_fo] \ + .fade_out(ms_fo) + with self.music_lock: + self.current_audio_segment = new_audio_segment self.stop_playing() - if self.is_loaded_stopping(): + if wait: + if set_wait_id is not None: + self.mapping.add_wait_id(set_wait_id, self.wait_event) + self.wait_end() + else: self.stopped() - def trigger_stopped_events(self): - self.mixer.remove_file(self) - self.wait_event.set() + def abandon_all_effects(self): + db_gain = 0 + for gain_effect in self.gain_effects: + db_gain += gain_effect.get_last_gain() + + self.gain_effects = [] + self.set_gain(db_gain) + + def set_volume(self, value, delta=False, fade=0): + [db_gain, self.volume] = gain( + value + int(delta) * self.volume, + self.volume) + + self.set_gain_with_effect(db_gain, fade=fade) + + def set_gain_with_effect(self, db_gain, fade=0): + if not self.is_in_use(): + return + + if fade > 0: + self.add_fade_effect(db_gain, fade) + else: + self.set_gain(db_gain) + + def wait_end(self): + self.wait_event.clear() + self.wait_event.wait() + + # Callbacks + def finished_callback(self): + self.stopped() def play_callback(self, out_data_length, frame_count): if self.is_loaded_paused(): @@ -194,8 +257,15 @@ class MusicFile: return data.ljust(out_data_length, b'\0') + # Helpers + def set_gain(self, db_gain, absolute=False): + if absolute: + self.db_gain = db_gain + else: + self.db_gain += db_gain + def get_next_sample(self, frame_count): - fw = self.audio_segment_frame_width + fw = self.audio_segment.frame_width data = b"" nb_frames = 0 @@ -215,32 +285,17 @@ class MusicFile: return [data, nb_frames] - def seek(self, value=0, delta=False): - # We don't want to do that while stopping - if not (self.is_loaded_playing() or self.is_loaded_paused()): + def add_fade_effect(self, db_gain, fade_duration): + if not self.is_in_use(): return - with self.music_lock: - self.abandon_all_effects() - if delta: - frame_count = int(self.audio_segment.frame_count()) - frame_diff = int(value * self.audio_segment.frame_rate) - self.current_frame += frame_diff - while self.current_frame < 0: - self.current_loop -= 1 - self.current_frame += frame_count - while self.current_frame > frame_count: - self.current_loop += 1 - self.current_frame -= frame_count - if self.current_loop < 0: - self.current_loop = 0 - self.current_frame = 0 - if self.current_loop > self.last_loop: - self.current_loop = self.last_loop - self.current_frame = frame_count - else: - self.current_frame = max( - 0, - int(value * self.audio_segment.frame_rate)) + + self.gain_effects.append(GainEffect( + "fade", + self.current_audio_segment, + self.current_loop, + self.sound_position, + self.sound_position + fade_duration, + gain=db_gain)) def effects_next_gain(self, frame_count): db_gain = 0 @@ -257,61 +312,6 @@ class MusicFile: return db_gain - def abandon_all_effects(self): - db_gain = 0 - for gain_effect in self.gain_effects: - db_gain += gain_effect.get_last_gain() - - self.gain_effects = [] - self.set_gain(db_gain) - - def stop(self, fade_out=0, wait=False, set_wait_id=None): - if self.is_loaded_playing(): - ms = int(self.sound_position * 1000) - ms_fo = max(1, int(fade_out * 1000)) - - new_audio_segment = self.current_audio_segment[: ms+ms_fo] \ - .fade_out(ms_fo) - with self.music_lock: - self.current_audio_segment = new_audio_segment - self.stop_playing() - if wait: - if set_wait_id is not None: - self.mapping.add_wait_id(set_wait_id, self.wait_event) - self.wait_end() - else: - self.stop_playing() - self.stopped() - - def volume_factor(self, additional_gain): + def volume_factor(self, additional_gain=0): return 10 ** ( (self.db_gain + additional_gain) / 20) - def set_gain(self, db_gain, absolute=False): - if absolute: - self.db_gain = db_gain - else: - self.db_gain += db_gain - - def set_volume(self, value, delta=False, fade=0): - [db_gain, self.volume] = gain( - value + int(delta) * self.volume, - self.volume) - - if not (self.is_loaded_playing() or self.is_loaded_paused()): - return - - if fade > 0: - self.gain_effects.append(GainEffect( - "fade", - self.current_audio_segment, - self.current_loop, - self.sound_position, - self.sound_position + fade, - gain=db_gain)) - else: - self.set_gain(db_gain) - - def wait_end(self): - self.wait_event.clear() - self.wait_event.wait() - diff --git a/music_sampler.py b/music_sampler.py index d91e150..41b71be 100644 --- a/music_sampler.py +++ b/music_sampler.py @@ -45,14 +45,14 @@ class PlayList(RelativeLayout): open_files = self.parent.ids['Mapping'].open_files self.playlist = [] for music_file in open_files.values(): - if not music_file.is_not_stopped(): + if not music_file.is_in_use(): continue text = "{}/{}".format( helpers.duration_to_min_sec(music_file.sound_position), helpers.duration_to_min_sec(music_file.sound_duration)) - if music_file.is_paused(): + if music_file.is_loaded_paused(): self.playlist.append(["⏸", music_file.name, text, False]) else: self.playlist.append(["⏵", music_file.name, text, True]) -- 2.41.0 From db905e0706ab9a1f92102e86f677c66371be4621 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Isma=C3=ABl=20Bouya?= Date: Mon, 25 Jul 2016 16:54:29 +0200 Subject: [PATCH 03/16] Reduce line size --- build_readme.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/build_readme.py b/build_readme.py index eabed58..55a8cec 100644 --- a/build_readme.py +++ b/build_readme.py @@ -2,7 +2,10 @@ import markdown html = markdown.markdownFromFile( input="documentation_fr.md", - extensions=['markdown.extensions.codehilite', 'markdown.extensions.toc'], + extensions=[ + 'markdown.extensions.codehilite', + 'markdown.extensions.toc' + ], extension_configs={ 'markdown.extensions.codehilite': { 'noclasses': True, -- 2.41.0 From b7ca3fc2b6b05d3aafd44dd0b8e40a4707213ff5 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Isma=C3=ABl=20Bouya?= Date: Mon, 25 Jul 2016 17:43:47 +0200 Subject: [PATCH 04/16] Cleanup actions and subscribe to music events for loading --- helpers/action.py | 102 +++++++++++++++++++++++++++++++++++------- helpers/mapping.py | 4 +- helpers/music_file.py | 22 ++++++++- 3 files changed, 109 insertions(+), 19 deletions(-) diff --git a/helpers/action.py b/helpers/action.py index ec8fcb6..a6c48e9 100644 --- a/helpers/action.py +++ b/helpers/action.py @@ -1,10 +1,11 @@ import threading import time -from . import debug_print +from transitions.extensions import HierarchicalMachine as Machine +from . import debug_print, error_print class Action: - action_types = [ + ACTION_TYPES = [ 'command', 'interrupt_wait', 'pause', @@ -17,40 +18,106 @@ class Action: 'wait', ] + STATES = [ + 'initial', + 'loading', + 'failed', + { + 'name': 'loaded', + 'children': ['running'] + } + ] + + TRANSITIONS = [ + { + 'trigger': 'load', + 'source': 'initial', + 'dest': 'loading' + }, + { + 'trigger': 'fail', + 'source': 'loading', + 'dest': 'failed' + }, + { + 'trigger': 'success', + 'source': 'loading', + 'dest': 'loaded' + }, + { + 'trigger': 'run', + 'source': 'loaded', + 'dest': 'loaded_running', + 'after': 'finish_action' + }, + { + 'trigger': 'interrupt', + 'source': 'loaded_running', + 'dest': 'loaded', + 'before': 'trigger_interrupt' + }, + { + 'trigger': 'finish_action', + 'source': 'loaded_running', + 'dest': 'loaded' + } + ] + def __init__(self, action, key, **kwargs): - if action in self.action_types: - self.action = action - else: - raise Exception("Unknown action {}".format(action)) + Machine(model=self, states=self.STATES, + transitions=self.TRANSITIONS, initial='initial', + ignore_invalid_triggers=True, queued=True) + self.action = action self.key = key self.mapping = key.parent self.arguments = kwargs self.sleep_event = None + self.waiting_music = None + self.load() def ready(self): - if 'music' in self.arguments: - return self.arguments['music'].is_loaded(allow_substates=True) + return self.is_loaded(allow_substates=True) + + def callback_loaded(self, success): + if success: + self.success() + else: + self.fail() + + # Machine states / events + def on_enter_loading(self): + if self.action in self.ACTION_TYPES: + if 'music' in self.arguments: + self.arguments['music'].subscribe_loaded(self.callback_loaded) + else: + self.success() else: - return True + error_print("Unknown action {}".format(self.action)) + self.fail() + - def run(self): + def on_enter_loaded_running(self): debug_print(self.description()) getattr(self, self.action)(**self.arguments) - def description(self): - return getattr(self, self.action + "_print")(**self.arguments) - - def interrupt(self): + def trigger_interrupt(self): if getattr(self, self.action + "_interrupt", None): return getattr(self, self.action + "_interrupt")(**self.arguments) + # Helpers def music_list(self, music): if music is not None: return [music] else: return self.mapping.open_files.values() + def description(self): + if getattr(self, self.action + "_print", None): + return getattr(self, self.action + "_print")(**self.arguments) + else: + return "unknown action {}".format(self.action) + # Actions def command(self, command="", **kwargs): # FIXME: todo @@ -104,6 +171,7 @@ class Action: music.stop(fade_out=fade_out) if previous is not None: + self.waiting_music = previous previous.stop( fade_out=fade_out, wait=wait, @@ -254,10 +322,14 @@ class Action: return message - # Interruptions + # Interruptions (only for non-"atomic" actions) def wait_interrupt(self, duration=0, music=None, **kwargs): if self.sleep_event is not None: self.sleep_event.set() if music is not None: music.wait_event.set() + def stop_interrupt(self, music=None, fade_out=0, wait=False, + set_wait_id=None, **kwargs): + if self.waiting_music is not None: + self.waiting_music.wait_event.set() diff --git a/helpers/mapping.py b/helpers/mapping.py index c2a94e6..b71f3fe 100644 --- a/helpers/mapping.py +++ b/helpers/mapping.py @@ -178,8 +178,8 @@ class Mapping(RelativeLayout): **config['music_properties'][filename]) else: seen_files[filename] = MusicFile( - self, - filename) + filename, + self) if filename not in key_properties[mapped_key]['files']: key_properties[mapped_key]['files'] \ diff --git a/helpers/music_file.py b/helpers/music_file.py index ccf60ce..aeba1b9 100644 --- a/helpers/music_file.py +++ b/helpers/music_file.py @@ -32,7 +32,8 @@ class MusicFile: { 'trigger': 'load', 'source': 'initial', - 'dest': 'loading' + 'dest': 'loading', + 'after': 'poll_loaded' }, { 'trigger': 'fail', @@ -68,7 +69,8 @@ class MusicFile: 'trigger': 'stopped', 'source': '*', 'dest': 'loaded', - 'before': 'trigger_stopped_events' + 'before': 'trigger_stopped_events', + 'conditions': ['is_in_use'] } ] @@ -77,6 +79,7 @@ class MusicFile: transitions=self.TRANSITIONS, initial='initial', ignore_invalid_triggers=True) + self.loaded_callbacks = [] self.mapping = mapping self.filename = filename self.name = name or filename @@ -230,6 +233,21 @@ 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): + with file_lock: + if self.is_loaded(allow_substates=True): + callback(True) + elif self.is_failed(): + callback(False) + else: + self.loaded_callbacks.append(callback) + + def poll_loaded(self): + for callback in self.loaded_callbacks: + callback(self.is_loaded()) + self.loaded_callbacks = [] + # Callbacks def finished_callback(self): self.stopped() -- 2.41.0 From e55b29bb38b845c7b9e65a1fbca0198882658e14 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Isma=C3=ABl=20Bouya?= Date: Mon, 25 Jul 2016 23:50:51 +0200 Subject: [PATCH 05/16] Use machine for key handling --- helpers/action.py | 39 ++++---- helpers/key.py | 205 ++++++++++++++++++++++++++++++------------ helpers/mapping.py | 8 +- helpers/music_file.py | 20 +++-- 4 files changed, 186 insertions(+), 86 deletions(-) diff --git a/helpers/action.py b/helpers/action.py index a6c48e9..010a6ca 100644 --- a/helpers/action.py +++ b/helpers/action.py @@ -37,24 +37,23 @@ class Action: { 'trigger': 'fail', 'source': 'loading', - 'dest': 'failed' + 'dest': 'failed', + 'after': 'poll_loaded' }, { 'trigger': 'success', 'source': 'loading', - 'dest': 'loaded' + 'dest': 'loaded', + 'after': 'poll_loaded' }, { 'trigger': 'run', 'source': 'loaded', 'dest': 'loaded_running', - 'after': 'finish_action' - }, - { - 'trigger': 'interrupt', - 'source': 'loaded_running', - 'dest': 'loaded', - 'before': 'trigger_interrupt' + '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', @@ -74,12 +73,11 @@ class Action: self.arguments = kwargs self.sleep_event = None self.waiting_music = None - self.load() - def ready(self): - return self.is_loaded(allow_substates=True) + def is_loaded_or_failed(self): + return self.is_loaded(allow_substates=True) or self.is_failed() - def callback_loaded(self, success): + def callback_music_loaded(self, success): if success: self.success() else: @@ -89,19 +87,24 @@ class Action: def on_enter_loading(self): if self.action in self.ACTION_TYPES: if 'music' in self.arguments: - self.arguments['music'].subscribe_loaded(self.callback_loaded) + self.arguments['music'].subscribe_loaded(self.callback_music_loaded) else: self.success() else: error_print("Unknown action {}".format(self.action)) self.fail() - def on_enter_loaded_running(self): debug_print(self.description()) getattr(self, self.action)(**self.arguments) - def trigger_interrupt(self): + def poll_loaded(self): + self.key.callback_action_ready(self, + self.is_loaded(allow_substates=True)) + + # This one cannot be in the Machine state since it would be queued to run + # *after* the wait is ended... + def interrupt(self): if getattr(self, self.action + "_interrupt", None): return getattr(self, self.action + "_interrupt")(**self.arguments) @@ -191,11 +194,12 @@ class Action: self.mapping.add_wait_id(set_wait_id, self) self.sleep_event = threading.Event() + self.sleep_event_timer = threading.Timer(duration, self.sleep_event.set) if music is not None: music.wait_end() - threading.Timer(duration, self.sleep_event.set).start() + self.sleep_event_timer.start() self.sleep_event.wait() # Action messages @@ -326,6 +330,7 @@ class Action: def wait_interrupt(self, duration=0, music=None, **kwargs): if self.sleep_event is not None: self.sleep_event.set() + self.sleep_event_timer.cancel() if music is not None: music.wait_event.set() diff --git a/helpers/key.py b/helpers/key.py index 34c5140..bf46eeb 100644 --- a/helpers/key.py +++ b/helpers/key.py @@ -1,59 +1,183 @@ from kivy.uix.widget import Widget from kivy.properties import AliasProperty, BooleanProperty, \ ListProperty, StringProperty -from kivy.clock import Clock from kivy.uix.behaviors import ButtonBehavior -from .action import * +from .action import Action from . import debug_print import time +from transitions.extensions import HierarchicalMachine as Machine class Key(ButtonBehavior, Widget): + STATES = [ + 'initial', + 'configuring', + 'configured', + 'loading', + 'failed', + { + 'name': 'loaded', + 'children': ['no_config', 'no_actions', 'running'] + } + ] + + TRANSITIONS = [ + { + 'trigger': 'configure', + 'source': 'initial', + 'dest': 'configuring' + }, + { + 'trigger': 'fail', + 'source': 'configuring', + 'dest': 'failed' + }, + { + 'trigger': 'success', + 'source': 'configuring', + 'dest': 'configured', + 'after': 'load' + }, + { + 'trigger': 'no_config', + 'source': 'configuring', + 'dest': 'loaded_no_config', + }, + { + 'trigger': 'load', + 'source': 'configured', + 'dest': 'loading' + }, + { + 'trigger': 'fail', + 'source': 'loading', + 'dest': 'failed' + }, + { + 'trigger': 'success', + 'source': 'loading', + 'dest': 'loaded' + }, + { + 'trigger': 'no_actions', + 'source': 'loading', + 'dest': 'loaded_no_actions', + }, + { + 'trigger': 'reload', + 'source': 'loaded', + 'dest': 'configuring' + }, + { + 'trigger': 'run', + 'source': 'loaded', + 'dest': 'loaded_running', + 'after': 'finish', + # if a child, like loaded_no_actions, has no transitions, then it is + # bubbled to the parent, and we don't want that. + 'conditions': ['is_loaded'] + }, + { + 'trigger': 'finish', + 'source': 'loaded_running', + 'dest': 'loaded' + } + ] + key_sym = StringProperty(None) - custom_color = ListProperty([0, 1, 0, 1]) - custom_unready_color = ListProperty([0, 1, 0, 100/255]) + custom_color = ListProperty([0, 1, 0]) description_title = StringProperty("") description = ListProperty([]) - is_key_ready = BooleanProperty(True) + state = StringProperty("") - def get_color(self): - if not self.has_actions: + def get_alias_color(self): + if self.is_loaded_inactive(): return [1, 1, 1, 1] - elif self.all_actions_ready: - return self.custom_color + elif self.is_loaded(allow_substates=True): + return [*self.custom_color, 1] + elif self.is_failed(): + return [0, 0, 0, 1] else: - return self.custom_unready_color - def set_color(self): + return [*self.custom_color, 100/255] + def set_alias_color(self): pass - color = AliasProperty(get_color, set_color, bind=['is_key_ready']) + color = AliasProperty(get_alias_color, set_alias_color, + bind=['state', 'custom_color']) def __init__(self, **kwargs): - super(Key, self).__init__(**kwargs) self.actions = [] + Machine(model=self, states=self.STATES, + transitions=self.TRANSITIONS, initial='initial', + ignore_invalid_triggers=True, queued=True) + super(Key, self).__init__(**kwargs) + # Kivy events def on_key_sym(self, key, key_sym): - if key_sym in self.parent.key_config: - self.is_key_ready = False + if key_sym != "": + self.configure() + + def on_press(self): + self.list_actions() - self.config = self.parent.key_config[key_sym] + # Machine states / events + def is_loaded_or_failed(self): + return self.is_loaded(allow_substates=True) or self.is_failed() + + def is_loaded_inactive(self): + return self.is_loaded_no_config() or self.is_loaded_no_actions() + + def on_enter_configuring(self): + if self.key_sym in self.parent.key_config: + self.config = self.parent.key_config[self.key_sym] self.actions = [] for key_action in self.config['actions']: self.add_action(key_action[0], **key_action[1]) if 'description' in self.config['properties']: - key.set_description(self.config['properties']['description']) + self.set_description(self.config['properties']['description']) if 'color' in self.config['properties']: - key.set_color(self.config['properties']['color']) + self.set_color(self.config['properties']['color']) + self.success() + else: + self.no_config() - Clock.schedule_interval(self.check_all_active, 1) + def on_enter_loading(self): + if len(self.actions) > 0: + for action in self.actions: + action.load() + else: + self.no_actions() + + def on_enter_loaded_running(self): + self.parent.parent.ids['KeyList'].append(self.key_sym) + debug_print("running actions for {}".format(self.key_sym)) + start_time = time.time() + self.parent.start_running(self, start_time) + action_number = 0 + for self.current_action in self.actions: + if self.parent.keep_running(self, start_time): + self.list_actions(action_number=action_number + 0.5) + self.current_action.run() + action_number += 1 + self.list_actions(action_number=action_number) - def check_all_active(self, dt): - if self.all_actions_ready: - self.is_key_ready = True - return False + self.parent.finished_running(self, start_time) + # This one cannot be in the Machine state since it would be queued to run + # *after* the loop is ended... + 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: self.description_title = str(description[0]) @@ -65,45 +189,12 @@ class Key(ButtonBehavior, Widget): def set_color(self, color): color = [x / 255 for x in color] - color.append(1) self.custom_color = color - color[3] = 100 / 255 - self.custom_unready_color = tuple(color) - - @property - def has_actions(self): - return len(self.actions) > 0 - - @property - def all_actions_ready(self): - return all(action.ready() for action in self.actions) + # Actions handling def add_action(self, action_name, **arguments): self.actions.append(Action(action_name, self, **arguments)) - def interrupt_action(self): - self.current_action.interrupt() - - def do_actions(self): - if not self.enabled: - return None - - self.parent.parent.ids['KeyList'].append(self.key_sym) - debug_print("running actions for {}".format(self.key_sym)) - start_time = time.time() - self.parent.start_running(self, start_time) - action_number = 0 - for self.current_action in self.actions: - if self.parent.keep_running(self, start_time): - self.list_actions(action_number=action_number + 0.5) - self.current_action.run() - action_number += 1 - self.list_actions(action_number=action_number) - - self.parent.finished_running(self, start_time) - def list_actions(self, action_number=0): self.parent.parent.ids['ActionList'].update_list(self, action_number) - def on_press(self): - self.list_actions() diff --git a/helpers/mapping.py b/helpers/mapping.py index b71f3fe..ba2c340 100644 --- a/helpers/mapping.py +++ b/helpers/mapping.py @@ -7,7 +7,7 @@ import threading import yaml import sys -from .music_file import * +from .music_file import MusicFile from .mixer import Mixer from . import Config, gain, error_print from .action import Action @@ -67,7 +67,7 @@ class Mapping(RelativeLayout): def _on_keyboard_down(self, keyboard, keycode, text, modifiers): key = self.find_by_key_code(keycode) if len(modifiers) == 0 and key is not None: - threading.Thread(name="MSKeyAction", target=key.do_actions).start() + threading.Thread(name="MSKeyAction", target=key.run).start() elif 'ctrl' in modifiers and (keycode[0] == 113 or keycode[0] == '99'): for thread in threading.enumerate(): if thread.getName()[0:2] != "MS": @@ -86,7 +86,7 @@ class Mapping(RelativeLayout): for key in self.children: if not type(key).__name__ == "Key": continue - if not key.is_key_ready: + if not key.is_loaded_or_failed(): return True self.ready_color = [0, 1, 0, 1] return False @@ -95,7 +95,7 @@ class Mapping(RelativeLayout): running = self.running self.running = [] for (key, start_time) in running: - key.interrupt_action() + key.interrupt() def start_running(self, key, start_time): self.running.append((key, start_time)) diff --git a/helpers/music_file.py b/helpers/music_file.py index aeba1b9..a972bc5 100644 --- a/helpers/music_file.py +++ b/helpers/music_file.py @@ -48,7 +48,10 @@ class MusicFile: { 'trigger': 'start_playing', 'source': 'loaded', - 'dest': 'loaded_playing' + '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'] }, { 'trigger': 'pause', @@ -235,13 +238,14 @@ class MusicFile: # Let other subscribe for an event when they are ready def subscribe_loaded(self, callback): - with file_lock: - if self.is_loaded(allow_substates=True): - callback(True) - elif self.is_failed(): - callback(False) - else: - self.loaded_callbacks.append(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) def poll_loaded(self): for callback in self.loaded_callbacks: -- 2.41.0 From 05d0d2ed0672aeb2e056c8af79bebde9c8b27199 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Isma=C3=ABl=20Bouya?= Date: Tue, 26 Jul 2016 15:30:02 +0200 Subject: [PATCH 06/16] Give usable errors when parsing configuration --- helpers/__init__.py | 14 ++-- helpers/mapping.py | 200 +++++++++++++++++++++++++++++++------------- 2 files changed, 153 insertions(+), 61 deletions(-) diff --git a/helpers/__init__.py b/helpers/__init__.py index f5ad848..534e168 100644 --- a/helpers/__init__.py +++ b/helpers/__init__.py @@ -86,7 +86,7 @@ def parse_args(): by Kivy. Pass \"-- --help\" to get Kivy's usage.") from kivy.logger import Logger - Logger.setLevel(logging.ERROR) + Logger.setLevel(logging.WARN) args = parser.parse_args(argv) @@ -137,10 +137,14 @@ def gain(volume, old_volume=None): 20 * math.log10(max(volume, 0.1) / max(old_volume, 0.1)), max(volume, 0)] -def debug_print(message): +def debug_print(message, with_trace=False): from kivy.logger import Logger - Logger.debug('MusicSampler: ' + message) + Logger.debug('MusicSampler: ' + message, exc_info=with_trace) -def error_print(message): +def error_print(message, with_trace=False): from kivy.logger import Logger - Logger.error('MusicSampler: ' + message) + Logger.error('MusicSampler: ' + message, exc_info=with_trace) + +def warn_print(message, with_trace=False): + from kivy.logger import Logger + Logger.warn('MusicSampler: ' + message, exc_info=with_trace) diff --git a/helpers/mapping.py b/helpers/mapping.py index ba2c340..1f63459 100644 --- a/helpers/mapping.py +++ b/helpers/mapping.py @@ -6,10 +6,11 @@ from kivy.clock import Clock import threading import yaml import sys +from collections import defaultdict from .music_file import MusicFile from .mixer import Mixer -from . import Config, gain, error_print +from . import Config, gain, error_print, warn_print from .action import Action class Mapping(RelativeLayout): @@ -26,7 +27,8 @@ class Mapping(RelativeLayout): try: self.key_config, self.open_files = self.parse_config() except Exception as e: - error_print("Error while loading configuration: {}".format(e)) + error_print("Error while loading configuration: {}".format(e), + with_trace=True) sys.exit() super(Mapping, self).__init__(**kwargs) @@ -108,85 +110,171 @@ class Mapping(RelativeLayout): self.running.remove((key, start_time)) def parse_config(self): + def update_alias(prop_hash, aliases, key): + if isinstance(aliases[key], dict): + prop_hash.update(aliases[key], **prop_hash) + else: + warn_print("Alias {} is not a hash, ignored".format(key)) + + def include_aliases(prop_hash, aliases): + if 'include' not in prop_hash: + return + + included = prop_hash['include'] + del(prop_hash['include']) + if isinstance(included, str): + update_alias(prop_hash, aliases, included) + elif isinstance(included, list): + for included_ in included: + if isinstance(included_, str): + update_alias(prop_hash, aliases, included_) + else: + warn_print("Unkown alias include type, ignored: " + "{} in {}".format(included_, included)) + else: + warn_print("Unkown alias include type, ignored: {}" + .format(included)) + + def check_key_property(key_property, key): + if 'description' in key_property: + desc = key_property['description'] + if not isinstance(desc, list): + warn_print("description in key_property '{}' is not " + "a list, ignored".format(key)) + del(key_property['description']) + if 'color' in key_property: + color = key_property['color'] + if not isinstance(color, list)\ + or len(color) != 3\ + or not all(isinstance(item, int) for item in color)\ + or any(item < 0 or item > 255 for item in color): + warn_print("color in key_property '{}' is not " + "a list of 3 valid integers, ignored".format(key)) + del(key_property['color']) + + def check_key_properties(config): + if 'key_properties' in config: + if isinstance(config['key_properties'], dict): + return config['key_properties'] + else: + warn_print("key_properties config is not a hash, ignored") + return {} + else: + return {} + + def check_mapped_keys(config): + if 'keys' in config: + if isinstance(config['keys'], dict): + return config['keys'] + else: + warn_print("keys config is not a hash, ignored") + return {} + else: + return {} + + def check_mapped_key(mapped_keys, key): + if not isinstance(mapped_keys[key], list): + warn_print("key config '{}' is not an array, ignored" + .format(key)) + return [] + else: + return mapped_keys[key] + + def check_music_property(music_property, filename): + if not isinstance(music_property, dict): + warn_print("music_property config '{}' is not a hash, ignored" + .format(filename)) + return {} + if 'name' in music_property: + music_property['name'] = str(music_property['name']) + if 'gain' in music_property: + try: + music_property['gain'] = float(music_property['gain']) + except ValueError as e: + del(music_property['gain']) + warn_print("gain for music_property '{}' is not " + "a float, ignored".format(filename)) + return music_property + stream = open(Config.yml_file, "r") try: - config = yaml.load(stream) + config = yaml.safe_load(stream) except Exception as e: error_print("Error while loading config file: {}".format(e)) sys.exit() stream.close() - aliases = config['aliases'] + if not isinstance(config, dict): + raise Exception("Top level config is supposed to be a hash") + + if 'aliases' in config and isinstance(config['aliases'], dict): + aliases = config['aliases'] + else: + aliases = defaultdict(dict) + if 'aliases' in config: + warn_print("aliases config is not a hash, ignored") + + music_properties = defaultdict(dict) + if 'music_properties' in config and\ + isinstance(config['music_properties'], dict): + music_properties.update(config['music_properties']) + elif 'music_properties' in config: + warn_print("music_properties config is not a hash, ignored") + seen_files = {} - key_properties = {} + key_properties = defaultdict(lambda: { + "actions": [], + "properties": {}, + "files": [] + }) - for key in config['key_properties']: - if key not in key_properties: - key_prop = config['key_properties'][key] - if 'include' in key_prop: - included = key_prop['include'] - del(key_prop['include']) + for key in check_key_properties(config): + key_prop = config['key_properties'][key] + + if not isinstance(key_prop, dict): + warn_print("key_property '{}' is not a hash, ignored" + .format(key)) + continue + + include_aliases(key_prop, aliases) + check_key_property(key_prop, key) + + key_properties[key]["properties"] = key_prop + + for mapped_key in check_mapped_keys(config): + for index, action in enumerate(check_mapped_key( + config['keys'], mapped_key)): + if not isinstance(action, dict) or\ + not len(action) == 1 or\ + not isinstance(list(action.values())[0] or {}, dict): + warn_print("action number {} of key '{}' is invalid, " + "ignored".format(index + 1, mapped_key)) + continue - if isinstance(included, str): - key_prop.update(aliases[included], **key_prop) - else: - for included_ in included: - key_prop.update(aliases[included_], **key_prop) - - key_properties[key] = { - "actions": [], - "properties": key_prop, - "files": [] - } - - for mapped_key in config['keys']: - if mapped_key not in key_properties: - key_properties[mapped_key] = { - "actions": [], - "properties": {}, - "files": [] - } - for action in config['keys'][mapped_key]: action_name = list(action)[0] action_args = {} if action[action_name] is None: - action[action_name] = [] - - if 'include' in action[action_name]: - included = action[action_name]['include'] - del(action[action_name]['include']) + action[action_name] = {} - if isinstance(included, str): - action[action_name].update( - aliases[included], - **action[action_name]) - else: - for included_ in included: - action[action_name].update( - aliases[included_], - **action[action_name]) + include_aliases(action[action_name], aliases) for argument in action[action_name]: if argument == 'file': - filename = action[action_name]['file'] + filename = str(action[action_name]['file']) if filename not in seen_files: - if filename in config['music_properties']: - seen_files[filename] = MusicFile( - filename, - self, - **config['music_properties'][filename]) - else: - seen_files[filename] = MusicFile( - filename, - self) + music_property = check_music_property( + music_properties[filename], + filename) + + seen_files[filename] = MusicFile( + filename, self, **music_property) if filename not in key_properties[mapped_key]['files']: key_properties[mapped_key]['files'] \ .append(seen_files[filename]) action_args['music'] = seen_files[filename] - else: action_args[argument] = action[action_name][argument] -- 2.41.0 From a1d7f30a1cafbfcf3a0a561fcab71ce6437a3d45 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Isma=C3=ABl=20Bouya?= Date: Tue, 26 Jul 2016 16:25:35 +0200 Subject: [PATCH 07/16] Stop all actions before leaving --- helpers/mapping.py | 1 + 1 file changed, 1 insertion(+) diff --git a/helpers/mapping.py b/helpers/mapping.py index 1f63459..6e3b291 100644 --- a/helpers/mapping.py +++ b/helpers/mapping.py @@ -71,6 +71,7 @@ class Mapping(RelativeLayout): if len(modifiers) == 0 and key is not None: threading.Thread(name="MSKeyAction", target=key.run).start() elif 'ctrl' in modifiers and (keycode[0] == 113 or keycode[0] == '99'): + self.stop_all_running() for thread in threading.enumerate(): if thread.getName()[0:2] != "MS": continue -- 2.41.0 From c4f4f2a1d330d8e09021619bbb8dcaac4df0a602 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Isma=C3=ABl=20Bouya?= Date: Tue, 26 Jul 2016 16:27:51 +0200 Subject: [PATCH 08/16] Move actions to separate files --- helpers/action.py | 251 ++-------------------------- helpers/actions/__init__.py | 10 ++ helpers/actions/command.py | 6 + helpers/actions/interrupt_wait.py | 5 + helpers/actions/pause.py | 10 ++ helpers/actions/play.py | 44 +++++ helpers/actions/seek.py | 19 +++ helpers/actions/stop.py | 42 +++++ helpers/actions/stop_all_actions.py | 5 + helpers/actions/unpause.py | 10 ++ helpers/actions/volume.py | 28 ++++ helpers/actions/wait.py | 38 +++++ 12 files changed, 228 insertions(+), 240 deletions(-) create mode 100644 helpers/actions/__init__.py create mode 100644 helpers/actions/command.py create mode 100644 helpers/actions/interrupt_wait.py create mode 100644 helpers/actions/pause.py create mode 100644 helpers/actions/play.py create mode 100644 helpers/actions/seek.py create mode 100644 helpers/actions/stop.py create mode 100644 helpers/actions/stop_all_actions.py create mode 100644 helpers/actions/unpause.py create mode 100644 helpers/actions/volume.py create mode 100644 helpers/actions/wait.py diff --git a/helpers/action.py b/helpers/action.py index 010a6ca..1f374ec 100644 --- a/helpers/action.py +++ b/helpers/action.py @@ -1,23 +1,8 @@ -import threading -import time - from transitions.extensions import HierarchicalMachine as Machine from . import debug_print, error_print +from . import actions class Action: - ACTION_TYPES = [ - 'command', - 'interrupt_wait', - 'pause', - 'play', - 'seek', - 'stop', - 'stop_all_actions', - 'unpause', - 'volume', - 'wait', - ] - STATES = [ 'initial', 'loading', @@ -85,7 +70,7 @@ class Action: # Machine states / events def on_enter_loading(self): - if self.action in self.ACTION_TYPES: + if hasattr(actions, self.action): if 'music' in self.arguments: self.arguments['music'].subscribe_loaded(self.callback_music_loaded) else: @@ -96,7 +81,8 @@ class Action: def on_enter_loaded_running(self): debug_print(self.description()) - getattr(self, self.action)(**self.arguments) + if hasattr(actions, self.action): + getattr(actions, self.action).run(self, **self.arguments) def poll_loaded(self): self.key.callback_action_ready(self, @@ -105,8 +91,10 @@ class Action: # This one cannot be in the Machine state since it would be queued to run # *after* the wait is ended... def interrupt(self): - if getattr(self, self.action + "_interrupt", None): - return getattr(self, self.action + "_interrupt")(**self.arguments) + if getattr(actions, self.action, None) and\ + hasattr(getattr(actions, self.action), 'interrupt'): + return getattr(getattr(actions, self.action), 'interrupt')( + self, **self.arguments) # Helpers def music_list(self, music): @@ -116,225 +104,8 @@ class Action: return self.mapping.open_files.values() def description(self): - if getattr(self, self.action + "_print", None): - return getattr(self, self.action + "_print")(**self.arguments) + if hasattr(actions, self.action): + return getattr(actions, self.action)\ + .description(self, **self.arguments) else: return "unknown action {}".format(self.action) - - # Actions - def command(self, command="", **kwargs): - # FIXME: todo - pass - - def pause(self, music=None, **kwargs): - for music in self.music_list(music): - if music.is_loaded_playing(): - music.pause() - - def unpause(self, music=None, **kwargs): - for music in self.music_list(music): - if music.is_loaded_paused(): - music.unpause() - - def play(self, music=None, fade_in=0, start_at=0, - restart_if_running=False, volume=100, - loop=0, **kwargs): - for music in self.music_list(music): - if restart_if_running: - if music.is_in_use(): - music.stop() - music.play( - volume=volume, - fade_in=fade_in, - start_at=start_at, - loop=loop) - elif not music.is_in_use(): - music.play( - volume=volume, - fade_in=fade_in, - start_at=start_at, - loop=loop) - - def seek(self, music=None, value=0, delta=False, **kwargs): - for music in self.music_list(music): - music.seek(value=value, delta=delta) - - def interrupt_wait(self, wait_id=None): - self.mapping.interrupt_wait(wait_id) - - def stop(self, music=None, fade_out=0, wait=False, - set_wait_id=None, **kwargs): - previous = None - for music in self.music_list(music): - if music.is_loaded_paused() or music.is_loaded_playing(): - if previous is not None: - previous.stop(fade_out=fade_out) - previous = music - else: - music.stop(fade_out=fade_out) - - if previous is not None: - self.waiting_music = previous - previous.stop( - fade_out=fade_out, - wait=wait, - set_wait_id=set_wait_id) - - def stop_all_actions(self, **kwargs): - self.mapping.stop_all_running() - - def volume(self, music=None, value=100, fade=0, delta=False, **kwargs): - if music is not None: - music.set_volume(value, delta=delta, fade=fade) - else: - self.mapping.set_master_volume(value, delta=delta, fade=fade) - - def wait(self, duration=0, music=None, set_wait_id=None, **kwargs): - if set_wait_id is not None: - self.mapping.add_wait_id(set_wait_id, self) - - self.sleep_event = threading.Event() - self.sleep_event_timer = threading.Timer(duration, self.sleep_event.set) - - if music is not None: - music.wait_end() - - self.sleep_event_timer.start() - self.sleep_event.wait() - - # Action messages - def command_print(self, command="", **kwargs): - return "running command {}".format(command) - - def interrupt_wait_print(self, wait_id=None, **kwargs): - return "interrupt wait with id {}".format(wait_id) - - def pause_print(self, music=None, **kwargs): - if music is not None: - return "pausing « {} »".format(music.name) - else: - return "pausing all musics" - - def unpause_print(self, music=None, **kwargs): - if music is not None: - return "unpausing « {} »".format(music.name) - else: - return "unpausing all musics" - - def play_print(self, music=None, fade_in=0, start_at=0, - restart_if_running=False, volume=100, loop=0, **kwargs): - message = "starting " - if music is not None: - message += "« {} »".format(music.name) - else: - message += "all musics" - - if start_at != 0: - message += " at {}s".format(start_at) - - if fade_in != 0: - message += " with {}s fade_in".format(fade_in) - - message += " at volume {}%".format(volume) - - if loop > 0: - message += " {} times".format(loop + 1) - elif loop < 0: - message += " in loop" - - if restart_if_running: - message += " (restarting if already running)" - - return message - - def stop_print(self, music=None, fade_out=0, wait=False, - set_wait_id=None, **kwargs): - - message = "stopping " - if music is not None: - message += "music « {} »".format(music.name) - else: - message += "all musics" - - if fade_out > 0: - message += " with {}s fadeout".format(fade_out) - if wait: - if set_wait_id is not None: - message += " (waiting the end of fadeout, with id {})"\ - .format(set_wait_id) - else: - message += " (waiting the end of fadeout)" - - return message - - def stop_all_actions_print(self, **kwargs): - return "stopping all actions" - - def seek_print(self, music=None, value=0, delta=False, **kwargs): - if delta: - if music is not None: - return "moving music « {} » by {:+d}s" \ - .format(music.name, value) - else: - return "moving all musics by {:+d}s" \ - .format(value) - else: - if music is not None: - return "moving music « {} » to position {}s" \ - .format(music.name, value) - else: - return "moving all musics to position {}s" \ - .format(value) - - def volume_print(self, music=None, - value=100, delta=False, fade=0, **kwargs): - message = "" - if delta: - if music is not None: - message += "{:+d}% to volume of « {} »" \ - .format(value, music.name) - else: - message += "{:+d}% to volume" \ - .format(value) - else: - if music is not None: - message += "setting volume of « {} » to {}%" \ - .format(music.name, value) - else: - message += "setting volume to {}%" \ - .format(value) - - if fade > 0: - message += " with {}s fade".format(fade) - - return message - - def wait_print(self, duration=0, music=None, set_wait_id=None, **kwargs): - message = "" - if music is None: - message += "waiting {}s" \ - .format(duration) - elif duration == 0: - message += "waiting the end of « {} »" \ - .format(music.name) - else: - message += "waiting the end of « {} » + {}s" \ - .format(music.name, duration) - - if set_wait_id is not None: - message += " (setting id = {})".format(set_wait_id) - - return message - - # Interruptions (only for non-"atomic" actions) - def wait_interrupt(self, duration=0, music=None, **kwargs): - if self.sleep_event is not None: - self.sleep_event.set() - self.sleep_event_timer.cancel() - if music is not None: - music.wait_event.set() - - def stop_interrupt(self, music=None, fade_out=0, wait=False, - set_wait_id=None, **kwargs): - if self.waiting_music is not None: - self.waiting_music.wait_event.set() diff --git a/helpers/actions/__init__.py b/helpers/actions/__init__.py new file mode 100644 index 0000000..ea1e800 --- /dev/null +++ b/helpers/actions/__init__.py @@ -0,0 +1,10 @@ +from . import command +from . import interrupt_wait +from . import pause +from . import play +from . import seek +from . import stop +from . import stop_all_actions +from . import unpause +from . import volume +from . import wait diff --git a/helpers/actions/command.py b/helpers/actions/command.py new file mode 100644 index 0000000..96f72fe --- /dev/null +++ b/helpers/actions/command.py @@ -0,0 +1,6 @@ +def run(action, command="", **kwargs): + # FIXME: todo + pass + +def description(action, command="", **kwargs): + return "running command {}".format(command) diff --git a/helpers/actions/interrupt_wait.py b/helpers/actions/interrupt_wait.py new file mode 100644 index 0000000..36766a2 --- /dev/null +++ b/helpers/actions/interrupt_wait.py @@ -0,0 +1,5 @@ +def run(action, wait_id=None): + action.mapping.interrupt_wait(wait_id) + +def description(action, wait_id=None, **kwargs): + return "interrupt wait with id {}".format(wait_id) diff --git a/helpers/actions/pause.py b/helpers/actions/pause.py new file mode 100644 index 0000000..bb27734 --- /dev/null +++ b/helpers/actions/pause.py @@ -0,0 +1,10 @@ +def run(action, music=None, **kwargs): + for music in action.music_list(music): + if music.is_loaded_playing(): + music.pause() + +def description(action, music=None, **kwargs): + if music is not None: + return "pausing « {} »".format(music.name) + else: + return "pausing all musics" diff --git a/helpers/actions/play.py b/helpers/actions/play.py new file mode 100644 index 0000000..fdba95b --- /dev/null +++ b/helpers/actions/play.py @@ -0,0 +1,44 @@ +def run(action, music=None, fade_in=0, start_at=0, + restart_if_running=False, volume=100, + loop=0, **kwargs): + for music in action.music_list(music): + if restart_if_running: + if music.is_in_use(): + music.stop() + music.play( + volume=volume, + fade_in=fade_in, + start_at=start_at, + loop=loop) + elif not music.is_in_use(): + music.play( + volume=volume, + fade_in=fade_in, + start_at=start_at, + loop=loop) + +def description(action, music=None, fade_in=0, start_at=0, + restart_if_running=False, volume=100, loop=0, **kwargs): + message = "starting " + if music is not None: + message += "« {} »".format(music.name) + else: + message += "all musics" + + if start_at != 0: + message += " at {}s".format(start_at) + + if fade_in != 0: + message += " with {}s fade_in".format(fade_in) + + message += " at volume {}%".format(volume) + + if loop > 0: + message += " {} times".format(loop + 1) + elif loop < 0: + message += " in loop" + + if restart_if_running: + message += " (restarting if already running)" + + return message diff --git a/helpers/actions/seek.py b/helpers/actions/seek.py new file mode 100644 index 0000000..467af7d --- /dev/null +++ b/helpers/actions/seek.py @@ -0,0 +1,19 @@ +def run(action, music=None, value=0, delta=False, **kwargs): + for music in action.music_list(music): + music.seek(value=value, delta=delta) + +def description(action, music=None, value=0, delta=False, **kwargs): + if delta: + if music is not None: + return "moving music « {} » by {:+d}s" \ + .format(music.name, value) + else: + return "moving all musics by {:+d}s" \ + .format(value) + else: + if music is not None: + return "moving music « {} » to position {}s" \ + .format(music.name, value) + else: + return "moving all musics to position {}s" \ + .format(value) diff --git a/helpers/actions/stop.py b/helpers/actions/stop.py new file mode 100644 index 0000000..88cc66d --- /dev/null +++ b/helpers/actions/stop.py @@ -0,0 +1,42 @@ +def run(action, music=None, fade_out=0, wait=False, + set_wait_id=None, **kwargs): + previous = None + for music in action.music_list(music): + if music.is_loaded_paused() or music.is_loaded_playing(): + if previous is not None: + previous.stop(fade_out=fade_out) + previous = music + else: + music.stop(fade_out=fade_out) + + if previous is not None: + action.waiting_music = previous + previous.stop( + fade_out=fade_out, + wait=wait, + set_wait_id=set_wait_id) + +def description(action, music=None, fade_out=0, wait=False, + set_wait_id=None, **kwargs): + + message = "stopping " + if music is not None: + message += "music « {} »".format(music.name) + else: + message += "all musics" + + if fade_out > 0: + message += " with {}s fadeout".format(fade_out) + if wait: + if set_wait_id is not None: + message += " (waiting the end of fadeout, with id {})"\ + .format(set_wait_id) + else: + message += " (waiting the end of fadeout)" + + return message + +def interrupt(action, music=None, fade_out=0, wait=False, + set_wait_id=None, **kwargs): + if action.waiting_music is not None: + action.waiting_music.wait_event.set() diff --git a/helpers/actions/stop_all_actions.py b/helpers/actions/stop_all_actions.py new file mode 100644 index 0000000..f3fc5fb --- /dev/null +++ b/helpers/actions/stop_all_actions.py @@ -0,0 +1,5 @@ +def run(action, **kwargs): + action.mapping.stop_all_running() + +def description(action, **kwargs): + return "stopping all actions" diff --git a/helpers/actions/unpause.py b/helpers/actions/unpause.py new file mode 100644 index 0000000..5fa88c3 --- /dev/null +++ b/helpers/actions/unpause.py @@ -0,0 +1,10 @@ +def run(action, music=None, **kwargs): + for music in action.music_list(music): + if music.is_loaded_paused(): + music.unpause() + +def description(action, music=None, **kwargs): + if music is not None: + return "unpausing « {} »".format(music.name) + else: + return "unpausing all musics" diff --git a/helpers/actions/volume.py b/helpers/actions/volume.py new file mode 100644 index 0000000..7dda3c1 --- /dev/null +++ b/helpers/actions/volume.py @@ -0,0 +1,28 @@ +def run(action, music=None, value=100, fade=0, delta=False, **kwargs): + if music is not None: + music.set_volume(value, delta=delta, fade=fade) + else: + action.mapping.set_master_volume(value, delta=delta, fade=fade) + +def description(action, music=None, + value=100, delta=False, fade=0, **kwargs): + message = "" + if delta: + if music is not None: + message += "{:+d}% to volume of « {} »" \ + .format(value, music.name) + else: + message += "{:+d}% to volume" \ + .format(value) + else: + if music is not None: + message += "setting volume of « {} » to {}%" \ + .format(music.name, value) + else: + message += "setting volume to {}%" \ + .format(value) + + if fade > 0: + message += " with {}s fade".format(fade) + + return message diff --git a/helpers/actions/wait.py b/helpers/actions/wait.py new file mode 100644 index 0000000..f7d2a78 --- /dev/null +++ b/helpers/actions/wait.py @@ -0,0 +1,38 @@ +import threading + +def run(action, duration=0, music=None, set_wait_id=None, **kwargs): + if set_wait_id is not None: + action.mapping.add_wait_id(set_wait_id, action) + + action.sleep_event = threading.Event() + action.sleep_event_timer = threading.Timer(duration, action.sleep_event.set) + + if music is not None: + music.wait_end() + + action.sleep_event_timer.start() + action.sleep_event.wait() + +def description(action, duration=0, music=None, set_wait_id=None, **kwargs): + message = "" + if music is None: + message += "waiting {}s" \ + .format(duration) + elif duration == 0: + message += "waiting the end of « {} »" \ + .format(music.name) + else: + message += "waiting the end of « {} » + {}s" \ + .format(music.name, duration) + + if set_wait_id is not None: + message += " (setting id = {})".format(set_wait_id) + + return message + +def interrupt(action, duration=0, music=None, **kwargs): + if action.sleep_event is not None: + action.sleep_event.set() + action.sleep_event_timer.cancel() + if music is not None: + music.wait_event.set() -- 2.41.0 From 62bce32f6174f6a38f09b7203c0b72a6a174c51e Mon Sep 17 00:00:00 2001 From: =?utf8?q?Isma=C3=ABl=20Bouya?= Date: Tue, 26 Jul 2016 21:29:40 +0200 Subject: [PATCH 09/16] Add cleanup when stopping music --- helpers/music_file.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/helpers/music_file.py b/helpers/music_file.py index a972bc5..9976306 100644 --- a/helpers/music_file.py +++ b/helpers/music_file.py @@ -115,6 +115,9 @@ class MusicFile: debug_print("Loaded « {} »".format(self.name)) def on_enter_loaded(self): + self.cleanup() + + def cleanup(self): self.gain_effects = [] self.set_gain(0, absolute=True) self.current_audio_segment = None @@ -136,6 +139,7 @@ class MusicFile: def trigger_stopped_events(self): self.mixer.remove_file(self) self.wait_event.set() + self.cleanup() # Actions and properties called externally @property -- 2.41.0 From d8c3ae04cd6af07d5cdc68e09441bb4df2bbde02 Mon Sep 17 00:00:00 2001 From: Denise sur Lya Date: Tue, 26 Jul 2016 18:02:03 +0200 Subject: [PATCH 10/16] doc: added new features --- documentation_fr.md | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/documentation_fr.md b/documentation_fr.md index bd42e55..1544cc5 100644 --- a/documentation_fr.md +++ b/documentation_fr.md @@ -25,6 +25,8 @@ Appuyer sur une touche déclenche les actions associées à cette touche (affich Un exemple de fichier de configuration est fourni, avec un certain nombre de touches et de transitions programmées (pour les trois musiques fournies), la syntaxe du fichier (expliquée plus bas) se comprend aisément en le regardant. De plus, certaines touches (par exemple 'ÉCHAP' pour tout arrêter) peuvent être gardées d'une fois sur l'autre. +En cas d'appui successif sur une touche, music_sampler ne relance pas les actions associées à cette touche si ces actions ne sont pas terminées ; cela pour éviter les "accidents". + ### Options disponibles au lancement Toutes les options au lancement sont facultatives ; la plupart du temps lancer le programme dans le bon dossier suffit. @@ -49,7 +51,9 @@ Les options suivantes sont plutôt réservées à un usage avancé de music_samp ## Configurer les touches Le fichier config.yml utilise la syntaxe yaml. Les catégories et sous-catégories sont gérées par l'indentation par des espaces (mais PAS par des tabulations !). -le `#` est un symbole de commentaire : tout ce qui suit ce symbole sur une ligne est ignoré. +le `#` est un symbole de commentaire : tout ce qui suit ce symbole sur une ligne est ignoré. + +En cas d'erreur dans le fichier de configuration, un message d'erreur s'affiche dans le terminal. Selon la "gravité" de l'erreur, music_sampler se lance en ignorant les actions erronnées (en colorant éventuellement la touche en noir), ou ne se lance pas du tout. Le fichier contient plusieurs sections : @@ -111,7 +115,7 @@ La touche échap est de couleur rouge, et le texte "STOP !" est affiché sur la ### `keys` : actions sur les touches -Cette section sert à décrire, pour chaque touche, la liste des actions successives. Notez que la plupart des commandes (hors `wait` et quelques cas particuliers, voir plus bas), les actions sont exécutées les unes à la suite des autres, sans attendre que la précédente soit terminée. +Cette section sert à décrire, pour chaque touche, la liste des actions successives. Notez que la plupart des commandes (hors `wait` et quelques cas particuliers, voir plus bas), les actions sont exécutées les unes à la suite des autres, sans attendre que la précédente soit terminée (donc quasi-simultanément). #### Exemples @@ -121,13 +125,14 @@ Cette section sert à décrire, pour chaque touche, la liste des actions success - play: file: "music1.mp3" volume: 70 + start_at: 10 - wait: duration: 5 - stop: file: "music1.mp3" fade_out: 2 -Lance la musique "music1.mp3" à 70% de son volume max, puis au bout de 5 secondes coupe la musique avec un fondu de 2 secondes. +Lance la musique "music1.mp3" à 70% de son volume max, à 10 secondes du début, puis au bout de 5 secondes coupe la musique avec un fondu de 2 secondes. :::yaml 'b': @@ -171,7 +176,7 @@ Coupe la musique "music1.mp3" avec un fondu de 5 secondes, attend la fin du fond file: "music1.mp3" value: 100 -Baisse le volume de "music1.mp3" pendant que le son "noise.mp3" est joué par dessus (deux fois). Le volume revient à la normale une fois que le son "noise" est terminé. +Baisse le volume de "music1.mp3" pendant que le son "noise.mp3" est joué par dessus (deux fois). Le volume revient à la normale une fois que les deux écoutes du son "noise" sont terminées. :::yaml 'e': @@ -189,11 +194,12 @@ Baisse le volume de "music1.mp3" pendant que le son "noise.mp3" est joué par de Met en pause la musique "music1.mp3" pour 10 secondes et la relance après, en avançant de 5 secondes dans la musique. #### Liste des actions possibles: -- `play` : joue une musique. Paramètres : +- `play` : joue une musique. music_sampler ne joue qu'une musique à la fois : si la musique demandée est déjà en train d'être jouée, elle n'est pas relancée ou jouée "par dessus". Paramètres : * `file: "music.mp3"` précise la musique jouée (chemin relatif). * `fade_in: x` (facultatif) lance la musique avec un fondu au départ de x secondes. * `volume: x` (facultatif, défaut : 100) la musique doit être jouée à x% de son volume max (x doit être entre 0 et 100). Pour jouer une musique à plus de 100%, voir la section "file: properties". * `loop: x` (facultatif, défaut : 0) la musique doit être répétée x fois. Indiquer -1 pour la répéter indéfiniment. Attention, x est le nombre de répétitions, donc pour lire trois fois la musique, mettre `loop: 2`. + * `start_at: x` (facultatif, défaut : 0) la musique démarre à x secondes du début. - `stop` : arrête une musique donnée. Paramètres : * `file: "music.mp3"` (facultatif) précise la musique à stopper. Si aucune musique n'est précisée, le `stop` s'applique à toutes les musiques. * `fade_out: x` (facultatif) stoppe la musique avec un fondu de x secondes. @@ -220,13 +226,13 @@ Notez une fois enore que `wait` est quasiment la seule action qui attend d'avoir * `file: "music.mp3"` (facultatif) précise la musique. Si aucune musique n'est précisée, l'action s'applique à toutes les musiques. * `delta: true/false` (facultatif, défaut : false) Si delta est true, le temps est relatif. Si delta est false, le temps est absolu, voir plus bas. * `value: x` Si delta est true, alors fait avancer de x secondes dans la musique (reculer si x est négatif). Si delta est false, alors la lecture se place à x secondes à partir du début. Si la musique est en train de faire un fondu (au départ, ou changement de volume), le fondu se "termine automatiquement" : et la musique est immédiatement au volume final voulu. Si la musique est en train de se terminer en fondu, le "seek" est ignoré (un fondu de fin considère la musique comme déjà terminée). -- `stop_all_actions:` Interrompt toutes les actions en cours et à faire. +- `stop_all_actions:` Interrompt toutes les actions en cours et à faire. Notez qu'une musique lancée (y compris avec une option `loop`) est considérée comme une action "déjà terminée", et ne sera donc pas interrompue (utiliser `stop` sans arguments pour stopper toutes les musiques en écoute). La commande interrompt également les options à faire de cette même touche, il est donc inutile de programmer des actions à la suite de celle-ci. - `interrupt_wait`: Interrompt l'attente (de `wait` ou fin d'un fondu avec attente) et passe directement à l'action suivante. Paramètre : - * `wait_id: name` : précise l'identifiant du `wait` à stopper (défini par `set_wait_id`, voir les actions `wait` et `stop`). + * `wait_id: name` : précise l'identifiant du `wait` à stopper (défini par `set_wait_id`, voir les actions `wait` et `stop`). Pour interrompre plusieurs `wait` d'un seul coup, il faut mettre plusieurs `interrupt_wait`. ### `aliases` : définir des alias -Il est possible de définir des alias pour les différents paramètres. Ces alias sont internes au fichier de configuration, pour afficher un "joli" nom d'une musique, voir plutôt "file: properties". +Il est possible de définir des alias pour les différents paramètres. Ces alias sont internes au fichier de configuration, pour afficher un "joli" nom d'une musique, voir plutôt "music_properties". La syntaxe est la suivante: :::yaml @@ -250,7 +256,7 @@ On utilise ensuite, dans le fichier de configuration, `include: alias1` à la pl play: include: music1 -`music1` est désormais un alias pour `"path/to/my/favourite/music.mp3"`. À chaque fois qu'on veut écrire `file: "path/to/my/favourite/music.mp3"`, on peut écrire à la place `include: music1`. Attention, dans la section "file_properties", les alias ne fonctionnent pas, et il faut écrire le nom du fichier complet. +`music1` est désormais un alias pour `"path/to/my/favourite/music.mp3"`. À chaque fois qu'on veut écrire `file: "path/to/my/favourite/music.mp3"`, on peut écrire à la place `include: music1`. Attention, dans la section "music_properties", les alias ne fonctionnent pas, et il faut écrire le nom du fichier complet. :::yaml aliases: @@ -286,7 +292,7 @@ Sont listés ci-dessous une liste de problèmes rencontrés, avec des solutions * Le programme se lance et s'arrête tout de suite. -Il s'agit généralement d'une erreur de syntaxe dans le fichier de config. Dans ce cas, le terminal doit afficher quelques détails sur l'erreur en question (au moins la ligne correspondante). Si besoin, relancer avec -d (débug) pour avoir plus de détails. +Il s'agit généralement d'une erreur de syntaxe dans le fichier de config. Dans ce cas, le terminal doit afficher quelques détails sur l'erreur en question (au moins la ligne correspondante). * La musique "grésille" affreusement. @@ -307,7 +313,3 @@ Les extraits de musiques proposés en exemples proviennent de [Jamendo](https:// Le bruit de crocodile provient de [Universal-Soundbank](http://www.universal-soundbank.com/). Cet outil a été développé à l'origine pour faciliter la gestion du son pour les spectacles de la compagnie circassienne [Les pieds jaloux](http://piedsjaloux.fr/). N'ayant pas d'ingénieur son, les artistes eux-mêmes peuvent alors gérer leur musique lorsqu'ils ne sont pas sur scène : d'où la nécessité de préparer les transitions à l'avance et, au moment de la représentation, de réduire l'interaction avec la machine au minimum (une touche). - -## Contact - -Blabla -- 2.41.0 From 8ba7d831a1b8da01ba9e86491d7740f674910a53 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Isma=C3=ABl=20Bouya?= Date: Tue, 26 Jul 2016 22:59:41 +0200 Subject: [PATCH 11/16] Make callbacks when key is ready --- helpers/key.py | 17 ++++-- helpers/mapping.py | 146 +++++++++++++++++++++++++++++++++------------ 2 files changed, 122 insertions(+), 41 deletions(-) diff --git a/helpers/key.py b/helpers/key.py index bf46eeb..7ad0565 100644 --- a/helpers/key.py +++ b/helpers/key.py @@ -30,7 +30,8 @@ class Key(ButtonBehavior, Widget): { 'trigger': 'fail', 'source': 'configuring', - 'dest': 'failed' + 'dest': 'failed', + 'after': 'key_loaded_callback' }, { 'trigger': 'success', @@ -42,6 +43,7 @@ class Key(ButtonBehavior, Widget): 'trigger': 'no_config', 'source': 'configuring', 'dest': 'loaded_no_config', + 'after': 'key_loaded_callback' }, { 'trigger': 'load', @@ -51,22 +53,26 @@ class Key(ButtonBehavior, Widget): { 'trigger': 'fail', 'source': 'loading', - 'dest': 'failed' + 'dest': 'failed', + 'after': 'key_loaded_callback' }, { 'trigger': 'success', 'source': 'loading', - 'dest': 'loaded' + 'dest': 'loaded', + 'after': 'key_loaded_callback' }, { 'trigger': 'no_actions', 'source': 'loading', 'dest': 'loaded_no_actions', + 'after': 'key_loaded_callback' }, { 'trigger': 'reload', 'source': 'loaded', - 'dest': 'configuring' + 'dest': 'configuring', + 'after': 'key_loaded_callback' }, { 'trigger': 'run', @@ -171,6 +177,9 @@ class Key(ButtonBehavior, Widget): self.current_action.interrupt() # Callbacks + def key_loaded_callback(self): + self.parent.key_loaded_callback() + def callback_action_ready(self, action, success): if not success: self.fail() diff --git a/helpers/mapping.py b/helpers/mapping.py index 6e3b291..9c05972 100644 --- a/helpers/mapping.py +++ b/helpers/mapping.py @@ -8,13 +8,57 @@ import yaml import sys from collections import defaultdict +from transitions.extensions import HierarchicalMachine as Machine + from .music_file import MusicFile from .mixer import Mixer from . import Config, gain, error_print, warn_print from .action import Action class Mapping(RelativeLayout): - expected_keys = NumericProperty(0) + STATES = [ + 'initial', + 'configuring', + 'configured', + 'loading', + 'loaded', + 'failed' + ] + + TRANSITIONS = [ + { + 'trigger': 'configure', + 'source': 'initial', + 'dest': 'configuring' + }, + { + 'trigger': 'fail', + 'source': 'configuring', + 'dest': 'failed' + }, + { + 'trigger': 'success', + 'source': 'configuring', + 'dest': 'configured', + 'after': 'load' + }, + { + 'trigger': 'load', + 'source': 'configured', + 'dest': 'loading' + }, + { + 'trigger': 'fail', + 'source': 'loading', + 'dest': 'failed' + }, + { + 'trigger': 'success', + 'source': 'loading', + 'dest': 'loaded' + } + ] + master_volume = NumericProperty(100) ready_color = ListProperty([1, 165/255, 0, 1]) @@ -31,42 +75,30 @@ class Mapping(RelativeLayout): with_trace=True) sys.exit() - super(Mapping, self).__init__(**kwargs) - self._keyboard = Window.request_keyboard(self._keyboard_closed, self) - self._keyboard.bind(on_key_down=self._on_keyboard_down) + self.keys = [] self.running = [] self.wait_ids = {} - Clock.schedule_interval(self.not_all_keys_ready, 1) - - @property - def master_gain(self): - return gain(self.master_volume) - def set_master_volume(self, value, delta=False, fade=0): - [db_gain, self.master_volume] = gain( - value + int(delta) * self.master_volume, - self.master_volume) - - for music in self.open_files.values(): - music.set_gain_with_effect(db_gain, fade=fade) + 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) - def add_wait_id(self, wait_id, action_or_wait): - self.wait_ids[wait_id] = action_or_wait + # Kivy events + def add_widget(self, widget, index=0): + if type(widget).__name__ == "Key" and widget not in self.keys: + self.keys.append(widget) + return super(Mapping, self).add_widget(widget, index) - def interrupt_wait(self, wait_id): - if wait_id in self.wait_ids: - action_or_wait = self.wait_ids[wait_id] - del(self.wait_ids[wait_id]) - if isinstance(action_or_wait, Action): - action_or_wait.interrupt() - else: - action_or_wait.set() + def remove_widget(self, widget, index=0): + if type(widget).__name__ == "Key" and widget in self.keys: + self.keys.remove(widget) + return super(Mapping, self).remove_widget(widget, index) - def _keyboard_closed(self): - self._keyboard.unbind(on_key_down=self._on_keyboard_down) - self._keyboard = None + def on_keyboard_closed(self): + self.keyboard.unbind(on_key_down=self.on_keyboard_down) + self.keyboard = None - def _on_keyboard_down(self, keyboard, keycode, text, modifiers): + def on_keyboard_down(self, keyboard, keycode, text, modifiers): key = self.find_by_key_code(keycode) if len(modifiers) == 0 and key is not None: threading.Thread(name="MSKeyAction", target=key.run).start() @@ -85,21 +117,60 @@ class Mapping(RelativeLayout): return self.ids["Key_" + str(key_code[0])] return None - def not_all_keys_ready(self, dt): - for key in self.children: - if not type(key).__name__ == "Key": - continue + def all_keys_ready(self): + partial = False + for key in self.keys: if not key.is_loaded_or_failed(): - return True - self.ready_color = [0, 1, 0, 1] - return False + return "not_ready" + partial = partial or key.is_failed() + if partial: + return "partial" + else: + return "success" + + # Callbacks + def key_loaded_callback(self): + result = self.all_keys_ready() + if result == "success": + self.ready_color = [0, 1, 0, 1] + elif result == "partial": + self.ready_color = [1, 0, 0, 1] + + ## Some global actions def stop_all_running(self): running = self.running self.running = [] for (key, start_time) in running: key.interrupt() + # Master volume methods + @property + def master_gain(self): + return gain(self.master_volume) + + def set_master_volume(self, value, delta=False, fade=0): + [db_gain, self.master_volume] = gain( + value + int(delta) * self.master_volume, + self.master_volume) + + for music in self.open_files.values(): + music.set_gain_with_effect(db_gain, fade=fade) + + # Wait handler methods + def add_wait_id(self, wait_id, action_or_wait): + self.wait_ids[wait_id] = action_or_wait + + def interrupt_wait(self, wait_id): + if wait_id in self.wait_ids: + action_or_wait = self.wait_ids[wait_id] + del(self.wait_ids[wait_id]) + if isinstance(action_or_wait, Action): + action_or_wait.interrupt() + else: + action_or_wait.set() + + # Methods to control running keys def start_running(self, key, start_time): self.running.append((key, start_time)) @@ -110,6 +181,7 @@ class Mapping(RelativeLayout): if (key, start_time) in self.running: self.running.remove((key, start_time)) + # YML config parser def parse_config(self): def update_alias(prop_hash, aliases, key): if isinstance(aliases[key], dict): -- 2.41.0 From 4b6d1836f3cc6e063bca3f4011ce5d17f733baa6 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Isma=C3=ABl=20Bouya?= Date: Tue, 26 Jul 2016 23:30:47 +0200 Subject: [PATCH 12/16] Prepare modifiers --- helpers/key.py | 2 +- helpers/mapping.py | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/helpers/key.py b/helpers/key.py index 7ad0565..363e9ce 100644 --- a/helpers/key.py +++ b/helpers/key.py @@ -156,7 +156,7 @@ class Key(ButtonBehavior, Widget): else: self.no_actions() - def on_enter_loaded_running(self): + def on_enter_loaded_running(self, modifiers): self.parent.parent.ids['KeyList'].append(self.key_sym) debug_print("running actions for {}".format(self.key_sym)) start_time = time.time() diff --git a/helpers/mapping.py b/helpers/mapping.py index 9c05972..864afbe 100644 --- a/helpers/mapping.py +++ b/helpers/mapping.py @@ -100,8 +100,10 @@ class Mapping(RelativeLayout): def on_keyboard_down(self, keyboard, keycode, text, modifiers): key = self.find_by_key_code(keycode) - if len(modifiers) == 0 and key is not None: - threading.Thread(name="MSKeyAction", target=key.run).start() + if self.allowed_modifiers(modifiers) and key is not None: + modifiers.sort() + threading.Thread(name="MSKeyAction", target=key.run, + args=['-'.join(modifiers)]).start() elif 'ctrl' in modifiers and (keycode[0] == 113 or keycode[0] == '99'): self.stop_all_running() for thread in threading.enumerate(): @@ -112,6 +114,11 @@ class Mapping(RelativeLayout): sys.exit() return True + # Helpers + def allowed_modifiers(self, modifiers): + allowed = [] + return len([a for a in modifiers if a not in allowed]) == 0 + def find_by_key_code(self, key_code): if "Key_" + str(key_code[0]) in self.ids: return self.ids["Key_" + str(key_code[0])] -- 2.41.0 From ab47d2a1269c20d70f42942c4295c056544491f4 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Isma=C3=ABl=20Bouya?= Date: Wed, 27 Jul 2016 00:14:08 +0200 Subject: [PATCH 13/16] Add possibility to reload YML config file --- helpers/key.py | 3 ++- helpers/mapping.py | 50 ++++++++++++++++++++++++++++++++++--------- helpers/music_file.py | 25 ++++++++++++++++++++++ 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/helpers/key.py b/helpers/key.py index 363e9ce..4ec08d1 100644 --- a/helpers/key.py +++ b/helpers/key.py @@ -70,7 +70,7 @@ class Key(ButtonBehavior, Widget): }, { 'trigger': 'reload', - 'source': 'loaded', + 'source': ['loaded','failed'], 'dest': 'configuring', 'after': 'key_loaded_callback' }, @@ -190,6 +190,7 @@ class Key(ButtonBehavior, Widget): def set_description(self, description): if description[0] is not None: self.description_title = str(description[0]) + self.description = [] for desc in description[1 :]: if desc is None: self.description.append("") diff --git a/helpers/mapping.py b/helpers/mapping.py index 864afbe..1256696 100644 --- a/helpers/mapping.py +++ b/helpers/mapping.py @@ -1,5 +1,5 @@ from kivy.uix.relativelayout import RelativeLayout -from kivy.properties import NumericProperty, ListProperty +from kivy.properties import NumericProperty, ListProperty, StringProperty from kivy.core.window import Window from kivy.clock import Clock @@ -56,13 +56,34 @@ class Mapping(RelativeLayout): 'trigger': 'success', 'source': 'loading', 'dest': 'loaded' + }, + { + 'trigger': 'reload', + 'source': 'loaded', + 'dest': 'configuring' } ] master_volume = NumericProperty(100) ready_color = ListProperty([1, 165/255, 0, 1]) + state = StringProperty("") def __init__(self, **kwargs): + self.keys = [] + self.running = [] + self.wait_ids = {} + self.open_files = {} + + Machine(model=self, states=self.STATES, + transitions=self.TRANSITIONS, initial='initial', + ignore_invalid_triggers=True, 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) + + self.configure() + + def on_enter_configuring(self): if Config.builtin_mixing: self.mixer = Mixer() else: @@ -74,14 +95,13 @@ class Mapping(RelativeLayout): error_print("Error while loading configuration: {}".format(e), with_trace=True) sys.exit() + else: + self.success() - self.keys = [] - self.running = [] - self.wait_ids = {} - - 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) + def on_enter_loading(self): + for key in self.keys: + key.reload() + self.success() # Kivy events def add_widget(self, widget, index=0): @@ -112,6 +132,8 @@ class Mapping(RelativeLayout): thread.join() sys.exit() + elif 'ctrl' in modifiers and keycode[0] == 114: + threading.Thread(name="MSReload", target=self.reload).start() return True # Helpers @@ -143,6 +165,8 @@ class Mapping(RelativeLayout): self.ready_color = [0, 1, 0, 1] elif result == "partial": self.ready_color = [1, 0, 0, 1] + else: + self.ready_color = [1, 165/255, 0, 1] ## Some global actions def stop_all_running(self): @@ -347,8 +371,14 @@ class Mapping(RelativeLayout): music_properties[filename], filename) - seen_files[filename] = MusicFile( - filename, self, **music_property) + if filename in self.open_files: + self.open_files[filename]\ + .reload_properties(**music_property) + + seen_files[filename] = self.open_files[filename] + else: + seen_files[filename] = MusicFile( + filename, self, **music_property) if filename not in key_properties[mapped_key]['files']: key_properties[mapped_key]['files'] \ diff --git a/helpers/music_file.py b/helpers/music_file.py index 9976306..ba86142 100644 --- a/helpers/music_file.py +++ b/helpers/music_file.py @@ -92,6 +92,31 @@ class MusicFile: 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() + + def reload_music_file(self): + with file_lock: + try: + debug_print("Reloading « {} »".format(self.name)) + initial_db_gain = gain(self.initial_volume_factor * 100) + self.audio_segment = pydub.AudioSegment \ + .from_file(self.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)) + # Machine related events def on_enter_loading(self): with file_lock: -- 2.41.0 From 1094ab1ac48313b0c1caec15bc4fb6c584efa047 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Isma=C3=ABl=20Bouya?= Date: Wed, 27 Jul 2016 00:21:31 +0200 Subject: [PATCH 14/16] Add border around running keys --- helpers/key.py | 12 ++++++++++++ music_sampler.kv | 1 - 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/helpers/key.py b/helpers/key.py index 4ec08d1..9099f00 100644 --- a/helpers/key.py +++ b/helpers/key.py @@ -96,6 +96,18 @@ class Key(ButtonBehavior, Widget): description = ListProperty([]) state = StringProperty("") + def get_alias_line_color(self): + if self.is_loaded_running(): + return [0, 0, 0, 1] + else: + return [120/255, 120/255, 120/255, 1] + + def set_alias_line_color(self): + pass + + line_color = AliasProperty(get_alias_line_color, set_alias_line_color, + bind=['state']) + def get_alias_color(self): if self.is_loaded_inactive(): return [1, 1, 1, 1] diff --git a/music_sampler.kv b/music_sampler.kv index 3232956..882112d 100644 --- a/music_sampler.kv +++ b/music_sampler.kv @@ -8,7 +8,6 @@ y: (self.parent.top-self.parent.y) - (self.row) * self.parent.key_size - (self.row - 1) * self.parent.key_sep x: (self.col - 1) * self.parent.key_size + int(self.col - 1) * self.parent.key_sep + self.pad_col_sep size_hint: None, None - line_color: 120/255, 120/255, 120/255, 1 enabled: True line_width: 2 row: 1 -- 2.41.0 From b17aed6aba689f484a4932d99b30aff1bdee7176 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Isma=C3=ABl=20Bouya?= Date: Wed, 27 Jul 2016 00:51:48 +0200 Subject: [PATCH 15/16] Improve actions listing --- helpers/key.py | 35 ++++++++++++++++++++++++++--------- music_sampler.py | 13 +++++-------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/helpers/key.py b/helpers/key.py index 9099f00..c21f9ae 100644 --- a/helpers/key.py +++ b/helpers/key.py @@ -78,7 +78,7 @@ class Key(ButtonBehavior, Widget): 'trigger': 'run', 'source': 'loaded', 'dest': 'loaded_running', - 'after': 'finish', + 'after': ['run_actions', 'finish'], # if a child, like loaded_no_actions, has no transitions, then it is # bubbled to the parent, and we don't want that. 'conditions': ['is_loaded'] @@ -125,6 +125,8 @@ class Key(ButtonBehavior, Widget): def __init__(self, **kwargs): self.actions = [] + self.current_action = None + Machine(model=self, states=self.STATES, transitions=self.TRANSITIONS, initial='initial', ignore_invalid_triggers=True, queued=True) @@ -168,18 +170,16 @@ class Key(ButtonBehavior, Widget): else: self.no_actions() - def on_enter_loaded_running(self, modifiers): + def run_actions(self, modifiers): self.parent.parent.ids['KeyList'].append(self.key_sym) debug_print("running actions for {}".format(self.key_sym)) start_time = time.time() self.parent.start_running(self, start_time) - action_number = 0 for self.current_action in self.actions: if self.parent.keep_running(self, start_time): - self.list_actions(action_number=action_number + 0.5) + self.list_actions() self.current_action.run() - action_number += 1 - self.list_actions(action_number=action_number) + self.list_actions(last_action_finished=True) self.parent.finished_running(self, start_time) @@ -217,6 +217,23 @@ class Key(ButtonBehavior, Widget): def add_action(self, action_name, **arguments): self.actions.append(Action(action_name, self, **arguments)) - def list_actions(self, action_number=0): - self.parent.parent.ids['ActionList'].update_list(self, action_number) - + def list_actions(self, last_action_finished=False): + not_running = (not self.is_loaded_running()) + current_action_seen = False + action_descriptions = [] + for action in self.actions: + if not_running: + state = "inactive" + elif last_action_finished: + state = "done" + elif current_action_seen: + state = "pending" + elif action == self.current_action: + current_action_seen = True + state = "current" + else: + state = "done" + action_descriptions.append([action.description(), state]) + self.parent.parent.ids['ActionList'].update_list( + self, + action_descriptions) diff --git a/music_sampler.py b/music_sampler.py index 41b71be..c10b634 100644 --- a/music_sampler.py +++ b/music_sampler.py @@ -62,21 +62,18 @@ class ActionList(RelativeLayout): action_title = StringProperty("") action_list = ListProperty([]) - def update_list(self, key, action_number = 0): + def update_list(self, key, action_descriptions): self.action_title = "actions linked to key {}:".format(key.key_sym) self.action_list = [] - action_descriptions = [action.description() for action in key.actions] - - for index, description in enumerate(action_descriptions): - if index < int(action_number): + for [action, status] in action_descriptions: + if status == "done": icon = "✓" - elif index + 0.5 == action_number: + elif status == "current": icon = "✅" else: icon = " " - - self.action_list.append([icon, description]) + self.action_list.append([icon, action]) class Screen(FloatLayout): pass -- 2.41.0 From 343822904ee26955e622e325539c64aee1c2112e Mon Sep 17 00:00:00 2001 From: =?utf8?q?Isma=C3=ABl=20Bouya?= Date: Wed, 27 Jul 2016 01:17:42 +0200 Subject: [PATCH 16/16] Add repeat_delay to key properties --- helpers/key.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/helpers/key.py b/helpers/key.py index c21f9ae..3c98ce7 100644 --- a/helpers/key.py +++ b/helpers/key.py @@ -6,6 +6,7 @@ from kivy.uix.behaviors import ButtonBehavior from .action import Action from . import debug_print import time +import threading from transitions.extensions import HierarchicalMachine as Machine class Key(ButtonBehavior, Widget): @@ -17,7 +18,12 @@ class Key(ButtonBehavior, Widget): 'failed', { 'name': 'loaded', - 'children': ['no_config', 'no_actions', 'running'] + 'children': [ + 'no_config', + 'no_actions', + 'running', + 'protecting_repeat' + ] } ] @@ -86,8 +92,13 @@ class Key(ButtonBehavior, Widget): { 'trigger': 'finish', 'source': 'loaded_running', + 'dest': 'loaded_protecting_repeat' + }, + { + 'trigger': 'repeat_protection_finished', + 'source': 'loaded_protecting_repeat', 'dest': 'loaded' - } + }, ] key_sym = StringProperty(None) @@ -111,6 +122,8 @@ class Key(ButtonBehavior, Widget): def get_alias_color(self): if self.is_loaded_inactive(): return [1, 1, 1, 1] + elif self.is_loaded_protecting_repeat(): + return [*self.custom_color, 100/255] elif self.is_loaded(allow_substates=True): return [*self.custom_color, 1] elif self.is_failed(): @@ -183,6 +196,15 @@ class Key(ButtonBehavior, Widget): self.parent.finished_running(self, start_time) + def on_enter_loaded_protecting_repeat(self, modifiers): + if 'repeat_delay' in self.config['properties']: + self.protecting_repeat_timer = threading.Timer( + self.config['properties']['repeat_delay'], + self.repeat_protection_finished) + self.protecting_repeat_timer.start() + else: + self.repeat_protection_finished() + # This one cannot be in the Machine state since it would be queued to run # *after* the loop is ended... def interrupt(self): -- 2.41.0