]> git.immae.eu Git - perso/Immae/Projets/Python/MusicSampler.git/blob - music_sampler/mapping.py
Fix common key properties not applying when property is absent
[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 interrupt_wait(self, wait_id=None):
210 if wait_id is None:
211 ids_to_interrupt = list(self.wait_ids.keys())
212 elif wait_id in self.wait_ids:
213 ids_to_interrupt = [wait_id]
214 else:
215 ids_to_interrupt = []
216
217 for _wait_id in ids_to_interrupt:
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()
225
226 # Methods to control running keys
227 def start_running(self, key, start_time):
228 self.running.append((key, start_time))
229
230 def keep_running(self, key, start_time):
231 return (key, start_time) in self.running
232
233 def finished_running(self, key, start_time):
234 if (key, start_time) in self.running:
235 self.running.remove((key, start_time))
236
237 # YML config parser
238 def parse_config(self):
239 def update_alias(prop_hash, aliases, key):
240 if isinstance(aliases[key], dict):
241 for alias in aliases[key]:
242 prop_hash.setdefault(alias, aliases[key][alias])
243 else:
244 warn_print("Alias {} is not a hash, ignored".format(key))
245
246 def include_aliases(prop_hash, aliases):
247 if 'include' not in prop_hash:
248 return
249
250 included = prop_hash['include']
251 del(prop_hash['include'])
252 if isinstance(included, str):
253 update_alias(prop_hash, aliases, included)
254 elif isinstance(included, list):
255 for included_ in included:
256 if isinstance(included_, str):
257 update_alias(prop_hash, aliases, included_)
258 else:
259 warn_print("Unkown alias include type, ignored: "
260 "{} in {}".format(included_, included))
261 else:
262 warn_print("Unkown alias include type, ignored: {}"
263 .format(included))
264
265 def check_key_property(key_property, key):
266 if 'description' in key_property:
267 desc = key_property['description']
268 if not isinstance(desc, list):
269 warn_print("description in key_property '{}' is not "
270 "a list, ignored".format(key))
271 del(key_property['description'])
272 if 'color' in key_property:
273 color = key_property['color']
274 if not isinstance(color, list)\
275 or len(color) != 3\
276 or not all(isinstance(item, int) for item in color)\
277 or any(item < 0 or item > 255 for item in color):
278 warn_print("color in key_property '{}' is not "
279 "a list of 3 valid integers, ignored".format(key))
280 del(key_property['color'])
281
282 def check_key_properties(config):
283 if 'key_properties' in config:
284 if isinstance(config['key_properties'], dict):
285 return config['key_properties']
286 else:
287 warn_print("key_properties config is not a hash, ignored")
288 return {}
289 else:
290 return {}
291
292 def check_mapped_keys(config):
293 if 'keys' in config:
294 if isinstance(config['keys'], dict):
295 return config['keys']
296 else:
297 warn_print("keys config is not a hash, ignored")
298 return {}
299 else:
300 return {}
301
302 def check_mapped_key(mapped_keys, key):
303 if not isinstance(mapped_keys[key], list):
304 warn_print("key config '{}' is not an array, ignored"
305 .format(key))
306 return []
307 else:
308 return mapped_keys[key]
309
310 def check_music_property(music_property, filename):
311 if not isinstance(music_property, dict):
312 warn_print("music_property config '{}' is not a hash, ignored"
313 .format(filename))
314 return {}
315 if 'name' in music_property:
316 music_property['name'] = str(music_property['name'])
317 if 'gain' in music_property:
318 try:
319 music_property['gain'] = float(music_property['gain'])
320 except ValueError as e:
321 del(music_property['gain'])
322 warn_print("gain for music_property '{}' is not "
323 "a float, ignored".format(filename))
324 return music_property
325
326 stream = open(Config.yml_file, "r")
327 try:
328 config = yaml.safe_load(stream)
329 except Exception as e:
330 error_print("Error while loading config file: {}".format(e),
331 exit=True)
332 stream.close()
333
334 if not isinstance(config, dict):
335 error_print("Top level config is supposed to be a hash",
336 exit=True)
337
338 if 'aliases' in config and isinstance(config['aliases'], dict):
339 aliases = config['aliases']
340 else:
341 aliases = defaultdict(dict)
342 if 'aliases' in config:
343 warn_print("aliases config is not a hash, ignored")
344
345 music_properties = defaultdict(dict)
346 if 'music_properties' in config and\
347 isinstance(config['music_properties'], dict):
348 music_properties.update(config['music_properties'])
349 elif 'music_properties' in config:
350 warn_print("music_properties config is not a hash, ignored")
351
352 seen_files = {}
353
354 common_key_properties = {}
355 if 'common' in config['key_properties'] and\
356 isinstance(config['key_properties'], dict):
357 common_key_properties = config['key_properties']['common']
358 include_aliases(common_key_properties, aliases)
359 check_key_property(common_key_properties, 'common')
360 elif 'common' in config['key_properties']:
361 warn_print("'common' key in key_properties is not a hash, ignored")
362
363 key_properties = defaultdict(lambda: {
364 "actions": [],
365 "properties": copy.deepcopy(common_key_properties),
366 "files": []
367 })
368
369 for key in check_key_properties(config):
370 if key == 'common':
371 continue
372
373 key_prop = config['key_properties'][key]
374
375 if not isinstance(key_prop, dict):
376 warn_print("key_property '{}' is not a hash, ignored"
377 .format(key))
378 continue
379
380 include_aliases(key_prop, aliases)
381 check_key_property(key_prop, key)
382
383 key_properties[key]["properties"].update(key_prop)
384
385 for mapped_key in check_mapped_keys(config):
386 for index, action in enumerate(check_mapped_key(
387 config['keys'], mapped_key)):
388 if not isinstance(action, dict) or\
389 not len(action) == 1 or\
390 not isinstance(list(action.values())[0] or {}, dict):
391 warn_print("action number {} of key '{}' is invalid, "
392 "ignored".format(index + 1, mapped_key))
393 continue
394
395 action_name = list(action)[0]
396 action_args = {}
397 if action[action_name] is None:
398 action[action_name] = {}
399
400 include_aliases(action[action_name], aliases)
401
402 for argument in action[action_name]:
403 if argument == 'file':
404 filename = str(action[action_name]['file'])
405 if filename not in seen_files:
406 music_property = check_music_property(
407 music_properties[filename],
408 filename)
409
410 if filename in self.open_files:
411 self.open_files[filename]\
412 .reload_properties(**music_property)
413
414 seen_files[filename] =\
415 self.open_files[filename]
416 else:
417 seen_files[filename] = MusicFile(
418 filename, self, **music_property)
419
420 if filename not in key_properties[mapped_key]['files']:
421 key_properties[mapped_key]['files'] \
422 .append(seen_files[filename])
423
424 action_args['music'] = seen_files[filename]
425 else:
426 action_args[argument] = action[action_name][argument]
427
428 key_properties[mapped_key]['actions'] \
429 .append([action_name, action_args])
430
431 return (key_properties, seen_files)
432
433