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