]> git.immae.eu Git - perso/Immae/Projets/Python/MusicSampler.git/blob - music_sampler/mapping.py
9e40d401852438e410f662cbf6544f70781e5b99
[perso/Immae/Projets/Python/MusicSampler.git] / music_sampler / mapping.py
1 from kivy.uix.relativelayout import RelativeLayout
2 from kivy.properties import NumericProperty, ListProperty, StringProperty
3 from kivy.core.window import Window
4 from kivy.clock import Clock
5
6 import threading
7 import yaml
8 import sys
9 import copy
10 from collections import defaultdict
11
12 from transitions.extensions import HierarchicalMachine as Machine
13
14 from .music_file import MusicFile
15 from .mixer import Mixer
16 from .helpers import Config, gain, error_print, warn_print
17 from .action import Action
18
19 class Mapping(RelativeLayout):
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'
60 },
61 {
62 'trigger': 'reload',
63 'source': 'loaded',
64 'dest': 'configuring'
65 }
66 ]
67
68 master_volume = NumericProperty(100)
69 ready_color = ListProperty([1, 165/255, 0, 1])
70 state = StringProperty("")
71
72 def __init__(self, **kwargs):
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):
88 if Config.builtin_mixing:
89 self.mixer = Mixer()
90 else:
91 self.mixer = None
92
93 try:
94 self.key_config, self.open_files = self.parse_config()
95 except Exception as e:
96 error_print("Error while loading configuration: {}".format(e),
97 with_trace=True, exit=True)
98 else:
99 self.success()
100
101 def on_enter_loading(self):
102 for key in self.keys:
103 key.reload()
104 self.success()
105
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)
111
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)
116
117 def on_keyboard_closed(self):
118 self.keyboard.unbind(on_key_down=self.on_keyboard_down)
119 self.keyboard = None
120
121 def on_keyboard_down(self, keyboard, keycode, text, modifiers):
122 key = self.find_by_key_code(keycode)
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()
127 elif 'ctrl' in modifiers and (keycode[0] == 113 or keycode[0] == '99'):
128 self.leave_application()
129 sys.exit()
130 elif 'ctrl' in modifiers and keycode[0] == 114:
131 threading.Thread(name="MSReload", target=self.reload).start()
132 return True
133
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
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
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])]
154 return None
155
156 def all_keys_ready(self):
157 partial = False
158 for key in self.keys:
159 if not key.is_loaded_or_failed():
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]
175 else:
176 self.ready_color = [1, 165/255, 0, 1]
177
178 ## Some global actions
179 def stop_all_running(self, except_key=None, key_start_time=0):
180 running = self.running
181 self.running = [r for r in running\
182 if r[0] == except_key and r[1] == key_start_time]
183 for (key, start_time) in running:
184 if (key, start_time) != (except_key, key_start_time):
185 key.interrupt()
186
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
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
209 def matching_wait_ids(self, wait_id=None):
210 if wait_id is None:
211 matching_ids = list(self.wait_ids.keys())
212 elif wait_id in self.wait_ids:
213 matching_ids = [wait_id]
214 else:
215 matching_ids = []
216 return matching_ids
217
218 def interrupt_wait(self, wait_id=None):
219 for _wait_id in self.matching_wait_ids(wait_id=wait_id):
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()
227
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
249 # Methods to control running keys
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
260 # YML config parser
261 def parse_config(self):
262 def update_alias(prop_hash, aliases, key):
263 if isinstance(aliases[key], dict):
264 for alias in aliases[key]:
265 prop_hash.setdefault(alias, aliases[key][alias])
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
349 stream = open(Config.yml_file, "r")
350 try:
351 config = yaml.safe_load(stream)
352 except Exception as e:
353 error_print("Error while loading config file: {}".format(e),
354 exit=True)
355 stream.close()
356
357 if not isinstance(config, dict):
358 error_print("Top level config is supposed to be a hash",
359 exit=True)
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
375 seen_files = {}
376
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)
382 check_key_property(common_key_properties, 'common')
383 elif 'common' in config['key_properties']:
384 warn_print("'common' key in key_properties is not a hash, ignored")
385
386 key_properties = defaultdict(lambda: {
387 "actions": [],
388 "properties": copy.deepcopy(common_key_properties),
389 "files": []
390 })
391
392 for key in check_key_properties(config):
393 if key == 'common':
394 continue
395
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
406 key_properties[key]["properties"].update(key_prop)
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
417
418 action_name = list(action)[0]
419 action_args = {}
420 if action[action_name] is None:
421 action[action_name] = {}
422
423 include_aliases(action[action_name], aliases)
424
425 for argument in action[action_name]:
426 if argument == 'file':
427 filename = str(action[action_name]['file'])
428 if filename not in seen_files:
429 music_property = check_music_property(
430 music_properties[filename],
431 filename)
432
433 if filename in self.open_files:
434 self.open_files[filename]\
435 .reload_properties(**music_property)
436
437 seen_files[filename] =\
438 self.open_files[filename]
439 else:
440 seen_files[filename] = MusicFile(
441 filename, self, **music_property)
442
443 if filename not in key_properties[mapped_key]['files']:
444 key_properties[mapped_key]['files'] \
445 .append(seen_files[filename])
446
447 action_args['music'] = seen_files[filename]
448 else:
449 action_args[argument] = action[action_name][argument]
450
451 key_properties[mapped_key]['actions'] \
452 .append([action_name, action_args])
453
454 return (key_properties, seen_files)
455
456