aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIsmaël Bouya <ismael.bouya@normalesup.org>2016-09-19 15:57:42 +0200
committerIsmaël Bouya <ismael.bouya@normalesup.org>2016-09-19 18:36:08 +0200
commit93a3e51e749afc0c3ba8488b900124fda6bb8774 (patch)
tree70cb772450af2b989c51a937e36c74275daedc02
parent6dc040edf2f31497d4492c159397c4634037be66 (diff)
downloadMusicSampler-93a3e51e749afc0c3ba8488b900124fda6bb8774.tar.gz
MusicSampler-93a3e51e749afc0c3ba8488b900124fda6bb8774.tar.zst
MusicSampler-93a3e51e749afc0c3ba8488b900124fda6bb8774.zip
Cleanup key and action workflows
-rw-r--r--music_sampler/action.py70
-rw-r--r--music_sampler/key.py35
-rw-r--r--music_sampler/mapping.py34
-rw-r--r--music_sampler/music_file.py119
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:
9 'failed', 9 'failed',
10 { 10 {
11 'name': 'loaded', 11 'name': 'loaded',
12 'children': ['running'] 12 'children': ['stopped', 'running']
13 } 13 },
14 'destroyed'
14 ] 15 ]
15 16
16 TRANSITIONS = [ 17 TRANSITIONS = [
@@ -21,36 +22,42 @@ class Action:
21 }, 22 },
22 { 23 {
23 'trigger': 'fail', 24 'trigger': 'fail',
24 'source': 'loading', 25 'source': ['loading', 'loaded'],
25 'dest': 'failed', 26 'dest': 'failed',
26 'after': 'poll_loaded'
27 }, 27 },
28 { 28 {
29 'trigger': 'success', 29 'trigger': 'success',
30 'source': 'loading', 30 'source': 'loading',
31 'dest': 'loaded', 31 'dest': 'loaded_stopped',
32 'after': 'poll_loaded'
33 }, 32 },
34 { 33 {
35 'trigger': 'run', 34 'trigger': 'reload',
36 'source': 'loaded', 35 'source': 'loaded',
36 'dest': 'loading',
37 },
38 {
39 'trigger': 'run',
40 'source': 'loaded_stopped',
37 'dest': 'loaded_running', 41 'dest': 'loaded_running',
38 'after': 'finish_action', 42 'after': 'finish_action',
39 # if a child has no transitions, then it is bubbled to the parent,
40 # and we don't want that. Not useful in that machine precisely.
41 'conditions': ['is_loaded']
42 }, 43 },
43 { 44 {
44 'trigger': 'finish_action', 45 'trigger': 'finish_action',
45 'source': 'loaded_running', 46 'source': 'loaded_running',
46 'dest': 'loaded' 47 'dest': 'loaded_stopped'
48 },
49 {
50 'trigger': 'destroy',
51 'source': '*',
52 'dest': 'destroyed'
47 } 53 }
48 ] 54 ]
49 55
50 def __init__(self, action, key, **kwargs): 56 def __init__(self, action, key, **kwargs):
51 Machine(model=self, states=self.STATES, 57 Machine(model=self, states=self.STATES,
52 transitions=self.TRANSITIONS, initial='initial', 58 transitions=self.TRANSITIONS, initial='initial',
53 ignore_invalid_triggers=True, queued=True) 59 ignore_invalid_triggers=True, queued=True,
60 after_state_change=self.notify_state_change)
54 61
55 self.action = action 62 self.action = action
56 self.key = key 63 self.key = key
@@ -62,18 +69,31 @@ class Action:
62 def is_loaded_or_failed(self): 69 def is_loaded_or_failed(self):
63 return self.is_loaded(allow_substates=True) or self.is_failed() 70 return self.is_loaded(allow_substates=True) or self.is_failed()
64 71
65 def callback_music_loaded(self, success): 72 def callback_music_state(self, new_state):
66 if success: 73 # If a music gets unloaded while the action is loaded_running and
67 self.success() 74 # depending on the music, it won't be able to do the finish_action.
68 else: 75 # Can that happen?
76 # a: play 'mp3';
77 # z: wait 'mp3';
78 # e: pause 'mp3';
79 # r: stop 'mp3'; unload_music 'mp3'
80 if new_state == 'failed':
69 self.fail() 81 self.fail()
82 elif self.is_loaded(allow_substates=True) and\
83 new_state in ['initial', 'loading']:
84 self.reload(reloading=True)
85 elif self.is_loading() and new_state.startswith('loaded_'):
86 self.success()
70 87
71 # Machine states / events 88 # Machine states / events
72 def on_enter_loading(self): 89 def on_enter_loading(self, reloading=False):
90 if reloading:
91 return
73 if hasattr(actions, self.action): 92 if hasattr(actions, self.action):
74 if 'music' in self.arguments: 93 if 'music' in self.arguments and\
75 self.arguments['music'].subscribe_loaded( 94 self.action not in ['unload_music', 'load_music']:
76 self.callback_music_loaded) 95 self.arguments['music'].subscribe_state_change(
96 self.callback_music_state)
77 else: 97 else:
78 self.success() 98 self.success()
79 else: 99 else:
@@ -86,9 +106,13 @@ class Action:
86 getattr(actions, self.action).run(self, 106 getattr(actions, self.action).run(self,
87 key_start_time=key_start_time, **self.arguments) 107 key_start_time=key_start_time, **self.arguments)
88 108
89 def poll_loaded(self): 109 def on_enter_destroyed(self):
90 self.key.callback_action_ready(self, 110 if 'music' in self.arguments:
91 self.is_loaded(allow_substates=True)) 111 self.arguments['music'].unsubscribe_state_change(
112 self.callback_music_state)
113
114 def notify_state_change(self, *args, **kwargs):
115 self.key.callback_action_state_changed()
92 116
93 # This one cannot be in the Machine state since it would be queued to run 117 # This one cannot be in the Machine state since it would be queued to run
94 # *after* the wait is ended... 118 # *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
13# https://github.com/kivy/kivy/wiki/Working-with-Python-threads-inside-a-Kivy-application 13# https://github.com/kivy/kivy/wiki/Working-with-Python-threads-inside-a-Kivy-application
14from kivy.clock import mainthread 14from kivy.clock import mainthread
15 15
16class KeyMachine(Widget): 16class KeyMachine():
17 STATES = [ 17 STATES = [
18 'initial', 18 'initial',
19 'configuring', 19 'configuring',
@@ -101,11 +101,15 @@ class KeyMachine(Widget):
101 { 101 {
102 'trigger': 'repeat_protection_finished', 102 'trigger': 'repeat_protection_finished',
103 'source': 'loaded_protecting_repeat', 103 'source': 'loaded_protecting_repeat',
104 'dest': 'loaded' 104 'dest': 'loaded',
105 'after': 'callback_action_state_changed'
105 }, 106 },
106 ] 107 ]
107 108
108 state = StringProperty("") 109 def __setattr__(self, name, value):
110 if hasattr(self, 'initialized') and name == 'state':
111 self.key.update_state(value)
112 super().__setattr__(name, value)
109 113
110 def __init__(self, key, **kwargs): 114 def __init__(self, key, **kwargs):
111 self.key = key 115 self.key = key
@@ -113,7 +117,8 @@ class KeyMachine(Widget):
113 Machine(model=self, states=self.STATES, 117 Machine(model=self, states=self.STATES,
114 transitions=self.TRANSITIONS, initial='initial', 118 transitions=self.TRANSITIONS, initial='initial',
115 ignore_invalid_triggers=True, queued=True) 119 ignore_invalid_triggers=True, queued=True)
116 super(KeyMachine, self).__init__(**kwargs) 120
121 self.initialized = True
117 122
118 # Machine states / events 123 # Machine states / events
119 def is_loaded_or_failed(self): 124 def is_loaded_or_failed(self):
@@ -181,6 +186,17 @@ class KeyMachine(Widget):
181 def key_loaded_callback(self): 186 def key_loaded_callback(self):
182 self.key.parent.key_loaded_callback() 187 self.key.parent.key_loaded_callback()
183 188
189 def callback_action_state_changed(self):
190 if self.state not in ['failed', 'loading', 'loaded']:
191 return
192
193 if any(action.is_failed() for action in self.key.actions):
194 self.to_failed()
195 elif any(action.is_loading() for action in self.key.actions):
196 self.to_loading()
197 else:
198 self.to_loaded()
199 self.key_loaded_callback()
184 200
185class Key(ButtonBehavior, Widget): 201class Key(ButtonBehavior, Widget):
186 202
@@ -244,14 +260,10 @@ class Key(ButtonBehavior, Widget):
244 else: 260 else:
245 raise AttributeError 261 raise AttributeError
246 262
247 def machine_state_changed(self, instance, machine_state):
248 self.machine_state = self.machine.state
249
250 def __init__(self, **kwargs): 263 def __init__(self, **kwargs):
251 self.actions = [] 264 self.actions = []
252 self.current_action = None 265 self.current_action = None
253 self.machine = KeyMachine(self) 266 self.machine = KeyMachine(self)
254 self.machine.bind(state=self.machine_state_changed)
255 267
256 super(Key, self).__init__(**kwargs) 268 super(Key, self).__init__(**kwargs)
257 269
@@ -272,13 +284,6 @@ class Key(ButtonBehavior, Widget):
272 def interrupt(self): 284 def interrupt(self):
273 self.current_action.interrupt() 285 self.current_action.interrupt()
274 286
275 # Callbacks
276 def callback_action_ready(self, action, success):
277 if not success:
278 self.fail()
279 elif all(action.is_loaded_or_failed() for action in self.actions):
280 self.success()
281
282 # Setters 287 # Setters
283 def set_description(self, description): 288 def set_description(self, description):
284 if description[0] is not None: 289 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):
22 'configuring', 22 'configuring',
23 'configured', 23 'configured',
24 'loading', 24 'loading',
25 'loaded', 25 'loaded'
26 'failed'
27 ] 26 ]
28 27
29 TRANSITIONS = [ 28 TRANSITIONS = [
@@ -33,11 +32,6 @@ class Mapping(RelativeLayout):
33 'dest': 'configuring' 32 'dest': 'configuring'
34 }, 33 },
35 { 34 {
36 'trigger': 'fail',
37 'source': 'configuring',
38 'dest': 'failed'
39 },
40 {
41 'trigger': 'success', 35 'trigger': 'success',
42 'source': 'configuring', 36 'source': 'configuring',
43 'dest': 'configured', 37 'dest': 'configured',
@@ -49,11 +43,6 @@ class Mapping(RelativeLayout):
49 'dest': 'loading' 43 'dest': 'loading'
50 }, 44 },
51 { 45 {
52 'trigger': 'fail',
53 'source': 'loading',
54 'dest': 'failed'
55 },
56 {
57 'trigger': 'success', 46 'trigger': 'success',
58 'source': 'loading', 47 'source': 'loading',
59 'dest': 'loaded' 48 'dest': 'loaded'
@@ -74,10 +63,11 @@ class Mapping(RelativeLayout):
74 self.running = [] 63 self.running = []
75 self.wait_ids = {} 64 self.wait_ids = {}
76 self.open_files = {} 65 self.open_files = {}
66 self.is_leaving_application = False
77 67
78 Machine(model=self, states=self.STATES, 68 Machine(model=self, states=self.STATES,
79 transitions=self.TRANSITIONS, initial='initial', 69 transitions=self.TRANSITIONS, initial='initial',
80 ignore_invalid_triggers=True, queued=True) 70 auto_transitions=False, queued=True)
81 super(Mapping, self).__init__(**kwargs) 71 super(Mapping, self).__init__(**kwargs)
82 self.keyboard = Window.request_keyboard(self.on_keyboard_closed, self) 72 self.keyboard = Window.request_keyboard(self.on_keyboard_closed, self)
83 self.keyboard.bind(on_key_down=self.on_keyboard_down) 73 self.keyboard.bind(on_key_down=self.on_keyboard_down)
@@ -127,13 +117,14 @@ class Mapping(RelativeLayout):
127 elif 'ctrl' in modifiers and (keycode[0] == 113 or keycode[0] == '99'): 117 elif 'ctrl' in modifiers and (keycode[0] == 113 or keycode[0] == '99'):
128 self.leave_application() 118 self.leave_application()
129 sys.exit() 119 sys.exit()
130 elif 'ctrl' in modifiers and keycode[0] == 114: 120 elif 'ctrl' in modifiers and keycode[0] == 114 and self.is_loaded():
131 threading.Thread(name="MSReload", target=self.reload).start() 121 self.reload()
132 return True 122 return True
133 123
134 def leave_application(self): 124 def leave_application(self):
135 self.keyboard.unbind(on_key_down=self.on_keyboard_down) 125 self.keyboard.unbind(on_key_down=self.on_keyboard_down)
136 self.stop_all_running() 126 self.stop_all_running()
127 self.is_leaving_application = True
137 for music in self.open_files.values(): 128 for music in self.open_files.values():
138 music.stop() 129 music.stop()
139 for thread in threading.enumerate(): 130 for thread in threading.enumerate():
@@ -167,13 +158,20 @@ class Mapping(RelativeLayout):
167 158
168 # Callbacks 159 # Callbacks
169 def key_loaded_callback(self): 160 def key_loaded_callback(self):
161 if hasattr(self, 'finished_loading'):
162 return
163
164 opacity = int(Config.load_all_musics)
165
170 result = self.all_keys_ready() 166 result = self.all_keys_ready()
171 if result == "success": 167 if result == "success":
172 self.ready_color = [0, 1, 0, 1] 168 self.ready_color = [0, 1, 0, opacity]
169 self.finished_loading = True
173 elif result == "partial": 170 elif result == "partial":
174 self.ready_color = [1, 0, 0, 1] 171 self.ready_color = [1, 0, 0, opacity]
172 self.finished_loading = True
175 else: 173 else:
176 self.ready_color = [1, 165/255, 0, 1] 174 self.ready_color = [1, 165/255, 0, opacity]
177 175
178 ## Some global actions 176 ## Some global actions
179 def stop_all_running(self, except_key=None, key_start_time=0): 177 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:
22 { 22 {
23 'name': 'loaded', 23 'name': 'loaded',
24 'children': [ 24 'children': [
25 'stopped',
25 'playing', 26 'playing',
26 'paused', 27 'paused',
27 'stopping' 28 'stopping'
@@ -31,9 +32,8 @@ class MusicFile:
31 TRANSITIONS = [ 32 TRANSITIONS = [
32 { 33 {
33 'trigger': 'load', 34 'trigger': 'load',
34 'source': 'initial', 35 'source': ['initial', 'failed'],
35 'dest': 'loading', 36 'dest': 'loading'
36 'after': 'poll_loaded'
37 }, 37 },
38 { 38 {
39 'trigger': 'fail', 39 'trigger': 'fail',
@@ -41,17 +41,19 @@ class MusicFile:
41 'dest': 'failed' 41 'dest': 'failed'
42 }, 42 },
43 { 43 {
44 'trigger': 'unload',
45 'source': ['failed', 'loaded_stopped'],
46 'dest': 'initial',
47 },
48 {
44 'trigger': 'success', 49 'trigger': 'success',
45 'source': 'loading', 50 'source': 'loading',
46 'dest': 'loaded' 51 'dest': 'loaded_stopped'
47 }, 52 },
48 { 53 {
49 'trigger': 'start_playing', 54 'trigger': 'start_playing',
50 'source': 'loaded', 55 'source': 'loaded_stopped',
51 'dest': 'loaded_playing', 56 'dest': 'loaded_playing'
52 # if a child has no transitions, then it is bubbled to the parent,
53 # and we don't want that. Not useful in that machine precisely.
54 'conditions': ['is_loaded']
55 }, 57 },
56 { 58 {
57 'trigger': 'pause', 59 'trigger': 'pause',
@@ -70,19 +72,20 @@ class MusicFile:
70 }, 72 },
71 { 73 {
72 'trigger': 'stopped', 74 'trigger': 'stopped',
73 'source': '*', 75 'source': 'loaded',
74 'dest': 'loaded', 76 'dest': 'loaded_stopped',
75 'before': 'trigger_stopped_events', 77 'before': 'trigger_stopped_events',
76 'conditions': ['is_in_use'] 78 'unless': 'is_loaded_stopped',
77 } 79 }
78 ] 80 ]
79 81
80 def __init__(self, filename, mapping, name=None, gain=1): 82 def __init__(self, filename, mapping, name=None, gain=1):
81 Machine(model=self, states=self.STATES, 83 machine = Machine(model=self, states=self.STATES,
82 transitions=self.TRANSITIONS, initial='initial', 84 transitions=self.TRANSITIONS, initial='initial',
83 ignore_invalid_triggers=True) 85 auto_transitions=False,
86 after_state_change=self.notify_state_change)
84 87
85 self.loaded_callbacks = [] 88 self.state_change_callbacks = []
86 self.mapping = mapping 89 self.mapping = mapping
87 self.filename = filename 90 self.filename = filename
88 self.name = name or filename 91 self.name = name or filename
@@ -90,48 +93,41 @@ class MusicFile:
90 self.initial_volume_factor = gain 93 self.initial_volume_factor = gain
91 self.music_lock = Lock("music__" + filename) 94 self.music_lock = Lock("music__" + filename)
92 95
93 threading.Thread(name="MSMusicLoad", target=self.load).start() 96 if Config.load_all_musics:
97 threading.Thread(name="MSMusicLoad", target=self.load).start()
94 98
95 def reload_properties(self, name=None, gain=1): 99 def reload_properties(self, name=None, gain=1):
96 self.name = name or self.filename 100 self.name = name or self.filename
97 if gain != self.initial_volume_factor: 101 if gain != self.initial_volume_factor:
98 self.initial_volume_factor = gain 102 self.initial_volume_factor = gain
99 self.reload_music_file() 103 self.stopped()
104 self.unload()
105 self.load(reloading=True)
100 106
101 def reload_music_file(self): 107 # Machine related events
102 with file_lock: 108 def on_enter_initial(self):
103 try: 109 self.audio_segment = None
104 if self.filename.startswith("/"):
105 filename = self.filename
106 else:
107 filename = Config.music_path + self.filename
108 110
109 debug_print("Reloading « {} »".format(self.name)) 111 def on_enter_loading(self, reloading=False):
110 initial_db_gain = gain(self.initial_volume_factor * 100) 112 if reloading:
111 self.audio_segment = pydub.AudioSegment \ 113 prefix = 'Rel'
112 .from_file(filename) \ 114 prefix_s = 'rel'
113 .set_frame_rate(Config.frame_rate) \ 115 else:
114 .set_channels(Config.channels) \ 116 prefix = 'L'
115 .set_sample_width(Config.sample_width) \ 117 prefix_s = 'l'
116 .apply_gain(initial_db_gain)
117 except Exception as e:
118 error_print("failed to reload « {} »: {}"\
119 .format(self.name, e))
120 self.loading_error = e
121 self.to_failed()
122 else:
123 debug_print("Reloaded « {} »".format(self.name))
124 118
125 # Machine related events
126 def on_enter_loading(self):
127 with file_lock: 119 with file_lock:
120 if self.mapping.is_leaving_application:
121 self.fail()
122 return
123
128 try: 124 try:
129 if self.filename.startswith("/"): 125 if self.filename.startswith("/"):
130 filename = self.filename 126 filename = self.filename
131 else: 127 else:
132 filename = Config.music_path + self.filename 128 filename = Config.music_path + self.filename
133 129
134 debug_print("Loading « {} »".format(self.name)) 130 debug_print("{}oading « {} »".format(prefix, self.name))
135 self.mixer = self.mapping.mixer or Mixer() 131 self.mixer = self.mapping.mixer or Mixer()
136 initial_db_gain = gain(self.initial_volume_factor * 100) 132 initial_db_gain = gain(self.initial_volume_factor * 100)
137 self.audio_segment = pydub.AudioSegment \ 133 self.audio_segment = pydub.AudioSegment \
@@ -142,12 +138,13 @@ class MusicFile:
142 .apply_gain(initial_db_gain) 138 .apply_gain(initial_db_gain)
143 self.sound_duration = self.audio_segment.duration_seconds 139 self.sound_duration = self.audio_segment.duration_seconds
144 except Exception as e: 140 except Exception as e:
145 error_print("failed to load « {} »: {}".format(self.name, e)) 141 error_print("failed to {}oad « {} »: {}".format(
142 prefix_s, self.name, e))
146 self.loading_error = e 143 self.loading_error = e
147 self.fail() 144 self.fail()
148 else: 145 else:
149 self.success() 146 self.success()
150 debug_print("Loaded « {} »".format(self.name)) 147 debug_print("{}oaded « {} »".format(prefix, self.name))
151 148
152 def on_enter_loaded(self): 149 def on_enter_loaded(self):
153 self.cleanup() 150 self.cleanup()
@@ -165,11 +162,15 @@ class MusicFile:
165 162
166 # Machine related states 163 # Machine related states
167 def is_in_use(self): 164 def is_in_use(self):
168 return self.is_loaded(allow_substates=True) and not self.is_loaded() 165 return self.is_loaded(allow_substates=True) and\
166 not self.is_loaded_stopped()
169 167
170 def is_in_use_not_stopping(self): 168 def is_in_use_not_stopping(self):
171 return self.is_loaded_playing() or self.is_loaded_paused() 169 return self.is_loaded_playing() or self.is_loaded_paused()
172 170
171 def is_unloadable(self):
172 return self.is_loaded_stopped() or self.is_failed()
173
173 # Machine related triggers 174 # Machine related triggers
174 def trigger_stopped_events(self): 175 def trigger_stopped_events(self):
175 self.mixer.remove_file(self) 176 self.mixer.remove_file(self)
@@ -243,7 +244,7 @@ class MusicFile:
243 if wait: 244 if wait:
244 self.mapping.add_wait(self.wait_event, wait_id=set_wait_id) 245 self.mapping.add_wait(self.wait_event, wait_id=set_wait_id)
245 self.wait_end() 246 self.wait_end()
246 else: 247 elif self.is_loaded(allow_substates=True):
247 self.stopped() 248 self.stopped()
248 249
249 def abandon_all_effects(self): 250 def abandon_all_effects(self):
@@ -274,21 +275,19 @@ class MusicFile:
274 self.wait_event.clear() 275 self.wait_event.clear()
275 self.wait_event.wait() 276 self.wait_event.wait()
276 277
277 # Let other subscribe for an event when they are ready 278 # Let other subscribe for state change
278 def subscribe_loaded(self, callback): 279 def notify_state_change(self, **kwargs):
279 # FIXME: should lock to be sure we have no race, but it makes the 280 for callback in self.state_change_callbacks:
280 # initialization screen not showing until everything is loaded 281 callback(self.state)
281 if self.is_loaded(allow_substates=True): 282
282 callback(True) 283 def subscribe_state_change(self, callback):
283 elif self.is_failed(): 284 if callback not in self.state_change_callbacks:
284 callback(False) 285 self.state_change_callbacks.append(callback)
285 else: 286 callback(self.state)
286 self.loaded_callbacks.append(callback)
287 287
288 def poll_loaded(self): 288 def unsubscribe_state_change(self, callback):
289 for callback in self.loaded_callbacks: 289 if callback in self.state_change_callbacks:
290 callback(self.is_loaded()) 290 self.state_change_callbacks.remove(callback)
291 self.loaded_callbacks = []
292 291
293 # Callbacks 292 # Callbacks
294 def finished_callback(self): 293 def finished_callback(self):