]> git.immae.eu Git - perso/Immae/Projets/Python/MusicSampler.git/blame - music_sampler/mapping.py
Don't lock the application when failing while reloading configuration
[perso/Immae/Projets/Python/MusicSampler.git] / music_sampler / mapping.py
CommitLineData
4b2d79ca 1from kivy.uix.relativelayout import RelativeLayout
ab47d2a1 2from kivy.properties import NumericProperty, ListProperty, StringProperty
4b2d79ca 3from kivy.core.window import Window
30d8796f 4from kivy.clock import Clock
4b2d79ca 5
be27763f 6import threading
4b2d79ca 7import yaml
b68b4e8f 8import sys
51f6ce0f 9import copy
05d0d2ed 10from collections import defaultdict
4b2d79ca 11
8ba7d831
IB
12from transitions.extensions import HierarchicalMachine as Machine
13
e55b29bb 14from .music_file import MusicFile
22514f3a 15from .mixer import Mixer
6ebe6247 16from .helpers import Config, gain, error_print, warn_print
3aaddc9d 17from .action import Action
4b2d79ca
IB
18
19class Mapping(RelativeLayout):
8ba7d831
IB
20 STATES = [
21 'initial',
22 'configuring',
23 'configured',
24 'loading',
93a3e51e 25 'loaded'
8ba7d831
IB
26 ]
27
28 TRANSITIONS = [
29 {
30 'trigger': 'configure',
31 'source': 'initial',
32 'dest': 'configuring'
33 },
8ba7d831
IB
34 {
35 'trigger': 'success',
36 'source': 'configuring',
37 'dest': 'configured',
38 'after': 'load'
39 },
40 {
41 'trigger': 'load',
42 'source': 'configured',
43 'dest': 'loading'
44 },
8ba7d831
IB
45 {
46 'trigger': 'success',
47 'source': 'loading',
48 'dest': 'loaded'
ab47d2a1
IB
49 },
50 {
51 'trigger': 'reload',
52 'source': 'loaded',
53 'dest': 'configuring'
8ba7d831
IB
54 }
55 ]
56
1b4b78f5 57 master_volume = NumericProperty(100)
30d8796f 58 ready_color = ListProperty([1, 165/255, 0, 1])
ab47d2a1 59 state = StringProperty("")
4b2d79ca
IB
60
61 def __init__(self, **kwargs):
ab47d2a1
IB
62 self.keys = []
63 self.running = []
64 self.wait_ids = {}
65 self.open_files = {}
93a3e51e 66 self.is_leaving_application = False
ab47d2a1
IB
67
68 Machine(model=self, states=self.STATES,
69 transitions=self.TRANSITIONS, initial='initial',
93a3e51e 70 auto_transitions=False, queued=True)
ab47d2a1
IB
71 super(Mapping, self).__init__(**kwargs)
72 self.keyboard = Window.request_keyboard(self.on_keyboard_closed, self)
73 self.keyboard.bind(on_key_down=self.on_keyboard_down)
74
f9aeecf1 75 self.configure(initial=True)
ab47d2a1 76
f9aeecf1 77 def on_enter_configuring(self, initial=True):
d6290f14 78 if Config.builtin_mixing:
af27d782 79 self.mixer = Mixer()
d6290f14
IB
80 else:
81 self.mixer = None
9c4f705f
IB
82
83 try:
84 self.key_config, self.open_files = self.parse_config()
85 except Exception as e:
05d0d2ed 86 error_print("Error while loading configuration: {}".format(e),
f9aeecf1
IB
87 with_trace=False, exit=initial)
88
89 self.success()
9c4f705f 90
ab47d2a1
IB
91 def on_enter_loading(self):
92 for key in self.keys:
93 key.reload()
94 self.success()
1b4b78f5 95
8ba7d831
IB
96 # Kivy events
97 def add_widget(self, widget, index=0):
98 if type(widget).__name__ == "Key" and widget not in self.keys:
99 self.keys.append(widget)
100 return super(Mapping, self).add_widget(widget, index)
3aaddc9d 101
8ba7d831
IB
102 def remove_widget(self, widget, index=0):
103 if type(widget).__name__ == "Key" and widget in self.keys:
104 self.keys.remove(widget)
105 return super(Mapping, self).remove_widget(widget, index)
3aaddc9d 106
8ba7d831
IB
107 def on_keyboard_closed(self):
108 self.keyboard.unbind(on_key_down=self.on_keyboard_down)
109 self.keyboard = None
4b2d79ca 110
8ba7d831 111 def on_keyboard_down(self, keyboard, keycode, text, modifiers):
4b2d79ca 112 key = self.find_by_key_code(keycode)
4b6d1836
IB
113 if self.allowed_modifiers(modifiers) and key is not None:
114 modifiers.sort()
115 threading.Thread(name="MSKeyAction", target=key.run,
116 args=['-'.join(modifiers)]).start()
b68b4e8f 117 elif 'ctrl' in modifiers and (keycode[0] == 113 or keycode[0] == '99'):
ca3264c8 118 self.leave_application()
b68b4e8f 119 sys.exit()
93a3e51e 120 elif 'ctrl' in modifiers and keycode[0] == 114 and self.is_loaded():
f9aeecf1 121 self.reload(initial=False)
4b2d79ca
IB
122 return True
123
ca3264c8
IB
124 def leave_application(self):
125 self.keyboard.unbind(on_key_down=self.on_keyboard_down)
126 self.stop_all_running()
93a3e51e 127 self.is_leaving_application = True
ca3264c8
IB
128 for music in self.open_files.values():
129 music.stop()
130 for thread in threading.enumerate():
131 if thread.getName()[0:2] == "MS":
132 thread.join()
133 elif thread.__class__ == threading.Timer:
134 thread.cancel()
135 thread.join()
136
4b6d1836
IB
137 # Helpers
138 def allowed_modifiers(self, modifiers):
139 allowed = []
140 return len([a for a in modifiers if a not in allowed]) == 0
141
4b2d79ca
IB
142 def find_by_key_code(self, key_code):
143 if "Key_" + str(key_code[0]) in self.ids:
144 return self.ids["Key_" + str(key_code[0])]
be27763f
IB
145 return None
146
8ba7d831
IB
147 def all_keys_ready(self):
148 partial = False
149 for key in self.keys:
e55b29bb 150 if not key.is_loaded_or_failed():
8ba7d831
IB
151 return "not_ready"
152 partial = partial or key.is_failed()
153
154 if partial:
155 return "partial"
156 else:
157 return "success"
158
159 # Callbacks
160 def key_loaded_callback(self):
93a3e51e
IB
161 if hasattr(self, 'finished_loading'):
162 return
163
164 opacity = int(Config.load_all_musics)
165
8ba7d831
IB
166 result = self.all_keys_ready()
167 if result == "success":
93a3e51e
IB
168 self.ready_color = [0, 1, 0, opacity]
169 self.finished_loading = True
8ba7d831 170 elif result == "partial":
93a3e51e
IB
171 self.ready_color = [1, 0, 0, opacity]
172 self.finished_loading = True
ab47d2a1 173 else:
93a3e51e 174 self.ready_color = [1, 165/255, 0, opacity]
30d8796f 175
8ba7d831 176 ## Some global actions
62a8b07a 177 def stop_all_running(self, except_key=None, key_start_time=0):
0deb82a5 178 running = self.running
62a8b07a
IB
179 self.running = [r for r in running\
180 if r[0] == except_key and r[1] == key_start_time]
0deb82a5 181 for (key, start_time) in running:
62a8b07a
IB
182 if (key, start_time) != (except_key, key_start_time):
183 key.interrupt()
be27763f 184
8ba7d831
IB
185 # Master volume methods
186 @property
187 def master_gain(self):
188 return gain(self.master_volume)
189
190 def set_master_volume(self, value, delta=False, fade=0):
191 [db_gain, self.master_volume] = gain(
192 value + int(delta) * self.master_volume,
193 self.master_volume)
194
195 for music in self.open_files.values():
196 music.set_gain_with_effect(db_gain, fade=fade)
197
198 # Wait handler methods
21ffec31
IB
199 def add_wait(self, action_or_wait, wait_id=None):
200 if wait_id is not None:
201 self.wait_ids[wait_id] = [action_or_wait]
202 else:
203 if None not in self.wait_ids:
204 self.wait_ids[None] = []
205 self.wait_ids[None].append(action_or_wait)
206
d4217fda 207 def matching_wait_ids(self, wait_id=None):
21ffec31 208 if wait_id is None:
d4217fda 209 matching_ids = list(self.wait_ids.keys())
21ffec31 210 elif wait_id in self.wait_ids:
d4217fda 211 matching_ids = [wait_id]
21ffec31 212 else:
d4217fda
IB
213 matching_ids = []
214 return matching_ids
21ffec31 215
d4217fda
IB
216 def interrupt_wait(self, wait_id=None):
217 for _wait_id in self.matching_wait_ids(wait_id=wait_id):
21ffec31
IB
218 action_or_waits = self.wait_ids[_wait_id]
219 del(self.wait_ids[_wait_id])
220 for action_or_wait in action_or_waits:
221 if isinstance(action_or_wait, Action):
222 action_or_wait.interrupt()
223 else:
224 action_or_wait.set()
8ba7d831 225
d4217fda
IB
226 def pause_wait(self, wait_id=None):
227 for _wait_id in self.matching_wait_ids(wait_id=wait_id):
228 action_or_waits = self.wait_ids[_wait_id]
229 for action_or_wait in action_or_waits:
230 if isinstance(action_or_wait, Action):
231 action_or_wait.pause()
232
233 def unpause_wait(self, wait_id=None):
234 for _wait_id in self.matching_wait_ids(wait_id=wait_id):
235 action_or_waits = self.wait_ids[_wait_id]
236 for action_or_wait in action_or_waits:
237 if isinstance(action_or_wait, Action):
238 action_or_wait.unpause()
239
240 def reset_wait(self, wait_id=None):
241 for _wait_id in self.matching_wait_ids(wait_id=wait_id):
242 action_or_waits = self.wait_ids[_wait_id]
243 for action_or_wait in action_or_waits:
244 if isinstance(action_or_wait, Action):
245 action_or_wait.reset()
246
8ba7d831 247 # Methods to control running keys
be27763f
IB
248 def start_running(self, key, start_time):
249 self.running.append((key, start_time))
250
251 def keep_running(self, key, start_time):
252 return (key, start_time) in self.running
253
254 def finished_running(self, key, start_time):
255 if (key, start_time) in self.running:
256 self.running.remove((key, start_time))
257
8ba7d831 258 # YML config parser
4b2d79ca 259 def parse_config(self):
05d0d2ed
IB
260 def update_alias(prop_hash, aliases, key):
261 if isinstance(aliases[key], dict):
dac64df8
IB
262 for alias in aliases[key]:
263 prop_hash.setdefault(alias, aliases[key][alias])
05d0d2ed
IB
264 else:
265 warn_print("Alias {} is not a hash, ignored".format(key))
266
267 def include_aliases(prop_hash, aliases):
268 if 'include' not in prop_hash:
269 return
270
271 included = prop_hash['include']
272 del(prop_hash['include'])
273 if isinstance(included, str):
274 update_alias(prop_hash, aliases, included)
275 elif isinstance(included, list):
276 for included_ in included:
277 if isinstance(included_, str):
278 update_alias(prop_hash, aliases, included_)
279 else:
280 warn_print("Unkown alias include type, ignored: "
281 "{} in {}".format(included_, included))
282 else:
283 warn_print("Unkown alias include type, ignored: {}"
284 .format(included))
285
286 def check_key_property(key_property, key):
287 if 'description' in key_property:
288 desc = key_property['description']
289 if not isinstance(desc, list):
290 warn_print("description in key_property '{}' is not "
291 "a list, ignored".format(key))
292 del(key_property['description'])
293 if 'color' in key_property:
294 color = key_property['color']
295 if not isinstance(color, list)\
296 or len(color) != 3\
297 or not all(isinstance(item, int) for item in color)\
298 or any(item < 0 or item > 255 for item in color):
299 warn_print("color in key_property '{}' is not "
300 "a list of 3 valid integers, ignored".format(key))
301 del(key_property['color'])
302
303 def check_key_properties(config):
304 if 'key_properties' in config:
305 if isinstance(config['key_properties'], dict):
306 return config['key_properties']
307 else:
308 warn_print("key_properties config is not a hash, ignored")
309 return {}
310 else:
311 return {}
312
313 def check_mapped_keys(config):
314 if 'keys' in config:
315 if isinstance(config['keys'], dict):
316 return config['keys']
317 else:
318 warn_print("keys config is not a hash, ignored")
319 return {}
320 else:
321 return {}
322
323 def check_mapped_key(mapped_keys, key):
324 if not isinstance(mapped_keys[key], list):
325 warn_print("key config '{}' is not an array, ignored"
326 .format(key))
327 return []
328 else:
329 return mapped_keys[key]
330
331 def check_music_property(music_property, filename):
332 if not isinstance(music_property, dict):
333 warn_print("music_property config '{}' is not a hash, ignored"
334 .format(filename))
335 return {}
336 if 'name' in music_property:
337 music_property['name'] = str(music_property['name'])
338 if 'gain' in music_property:
339 try:
340 music_property['gain'] = float(music_property['gain'])
341 except ValueError as e:
342 del(music_property['gain'])
343 warn_print("gain for music_property '{}' is not "
344 "a float, ignored".format(filename))
345 return music_property
346
75d6cdba 347 stream = open(Config.yml_file, "r")
6c44b231 348 try:
05d0d2ed 349 config = yaml.safe_load(stream)
aee1334c 350 except Exception as e:
f9aeecf1 351 raise Exception("Error while loading config file: {}".format(e)) from e
4b2d79ca
IB
352 stream.close()
353
05d0d2ed 354 if not isinstance(config, dict):
f9aeecf1 355 raise Exception("Top level config is supposed to be a hash")
05d0d2ed
IB
356
357 if 'aliases' in config and isinstance(config['aliases'], dict):
358 aliases = config['aliases']
359 else:
360 aliases = defaultdict(dict)
361 if 'aliases' in config:
362 warn_print("aliases config is not a hash, ignored")
363
364 music_properties = defaultdict(dict)
365 if 'music_properties' in config and\
366 isinstance(config['music_properties'], dict):
367 music_properties.update(config['music_properties'])
368 elif 'music_properties' in config:
369 warn_print("music_properties config is not a hash, ignored")
370
4b2d79ca
IB
371 seen_files = {}
372
dac64df8
IB
373 common_key_properties = {}
374 if 'common' in config['key_properties'] and\
375 isinstance(config['key_properties'], dict):
376 common_key_properties = config['key_properties']['common']
377 include_aliases(common_key_properties, aliases)
51f6ce0f 378 check_key_property(common_key_properties, 'common')
dac64df8
IB
379 elif 'common' in config['key_properties']:
380 warn_print("'common' key in key_properties is not a hash, ignored")
381
05d0d2ed
IB
382 key_properties = defaultdict(lambda: {
383 "actions": [],
51f6ce0f 384 "properties": copy.deepcopy(common_key_properties),
05d0d2ed
IB
385 "files": []
386 })
4b2d79ca 387
05d0d2ed 388 for key in check_key_properties(config):
dac64df8
IB
389 if key == 'common':
390 continue
391
05d0d2ed
IB
392 key_prop = config['key_properties'][key]
393
394 if not isinstance(key_prop, dict):
395 warn_print("key_property '{}' is not a hash, ignored"
396 .format(key))
397 continue
398
399 include_aliases(key_prop, aliases)
400 check_key_property(key_prop, key)
401
51f6ce0f 402 key_properties[key]["properties"].update(key_prop)
05d0d2ed
IB
403
404 for mapped_key in check_mapped_keys(config):
405 for index, action in enumerate(check_mapped_key(
406 config['keys'], mapped_key)):
407 if not isinstance(action, dict) or\
408 not len(action) == 1 or\
409 not isinstance(list(action.values())[0] or {}, dict):
410 warn_print("action number {} of key '{}' is invalid, "
411 "ignored".format(index + 1, mapped_key))
412 continue
e5edd8b9 413
4b2d79ca
IB
414 action_name = list(action)[0]
415 action_args = {}
416 if action[action_name] is None:
05d0d2ed 417 action[action_name] = {}
4b2d79ca 418
05d0d2ed 419 include_aliases(action[action_name], aliases)
4b2d79ca
IB
420
421 for argument in action[action_name]:
422 if argument == 'file':
05d0d2ed 423 filename = str(action[action_name]['file'])
4b2d79ca 424 if filename not in seen_files:
05d0d2ed
IB
425 music_property = check_music_property(
426 music_properties[filename],
427 filename)
428
ab47d2a1
IB
429 if filename in self.open_files:
430 self.open_files[filename]\
431 .reload_properties(**music_property)
432
6c42e32d
IB
433 seen_files[filename] =\
434 self.open_files[filename]
ab47d2a1
IB
435 else:
436 seen_files[filename] = MusicFile(
437 filename, self, **music_property)
4b2d79ca
IB
438
439 if filename not in key_properties[mapped_key]['files']:
2e404903
IB
440 key_properties[mapped_key]['files'] \
441 .append(seen_files[filename])
4b2d79ca
IB
442
443 action_args['music'] = seen_files[filename]
4b2d79ca
IB
444 else:
445 action_args[argument] = action[action_name][argument]
446
2e404903
IB
447 key_properties[mapped_key]['actions'] \
448 .append([action_name, action_args])
4b2d79ca 449
29597680 450 return (key_properties, seen_files)
4b2d79ca
IB
451
452