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