]> git.immae.eu Git - perso/Immae/Projets/Python/MusicSampler.git/blame - music_sampler/mapping.py
Fix common key properties not applying when property is absent
[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
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()
8ba7d831
IB
225
226 # Methods to control running keys
be27763f
IB
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
8ba7d831 237 # YML config parser
4b2d79ca 238 def parse_config(self):
05d0d2ed
IB
239 def update_alias(prop_hash, aliases, key):
240 if isinstance(aliases[key], dict):
dac64df8
IB
241 for alias in aliases[key]:
242 prop_hash.setdefault(alias, aliases[key][alias])
05d0d2ed
IB
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
75d6cdba 326 stream = open(Config.yml_file, "r")
6c44b231 327 try:
05d0d2ed 328 config = yaml.safe_load(stream)
aee1334c 329 except Exception as e:
2010311b
IB
330 error_print("Error while loading config file: {}".format(e),
331 exit=True)
4b2d79ca
IB
332 stream.close()
333
05d0d2ed 334 if not isinstance(config, dict):
2010311b
IB
335 error_print("Top level config is supposed to be a hash",
336 exit=True)
05d0d2ed
IB
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
4b2d79ca
IB
352 seen_files = {}
353
dac64df8
IB
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)
51f6ce0f 359 check_key_property(common_key_properties, 'common')
dac64df8
IB
360 elif 'common' in config['key_properties']:
361 warn_print("'common' key in key_properties is not a hash, ignored")
362
05d0d2ed
IB
363 key_properties = defaultdict(lambda: {
364 "actions": [],
51f6ce0f 365 "properties": copy.deepcopy(common_key_properties),
05d0d2ed
IB
366 "files": []
367 })
4b2d79ca 368
05d0d2ed 369 for key in check_key_properties(config):
dac64df8
IB
370 if key == 'common':
371 continue
372
05d0d2ed
IB
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
51f6ce0f 383 key_properties[key]["properties"].update(key_prop)
05d0d2ed
IB
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
e5edd8b9 394
4b2d79ca
IB
395 action_name = list(action)[0]
396 action_args = {}
397 if action[action_name] is None:
05d0d2ed 398 action[action_name] = {}
4b2d79ca 399
05d0d2ed 400 include_aliases(action[action_name], aliases)
4b2d79ca
IB
401
402 for argument in action[action_name]:
403 if argument == 'file':
05d0d2ed 404 filename = str(action[action_name]['file'])
4b2d79ca 405 if filename not in seen_files:
05d0d2ed
IB
406 music_property = check_music_property(
407 music_properties[filename],
408 filename)
409
ab47d2a1
IB
410 if filename in self.open_files:
411 self.open_files[filename]\
412 .reload_properties(**music_property)
413
6c42e32d
IB
414 seen_files[filename] =\
415 self.open_files[filename]
ab47d2a1
IB
416 else:
417 seen_files[filename] = MusicFile(
418 filename, self, **music_property)
4b2d79ca
IB
419
420 if filename not in key_properties[mapped_key]['files']:
2e404903
IB
421 key_properties[mapped_key]['files'] \
422 .append(seen_files[filename])
4b2d79ca
IB
423
424 action_args['music'] = seen_files[filename]
4b2d79ca
IB
425 else:
426 action_args[argument] = action[action_name][argument]
427
2e404903
IB
428 key_properties[mapped_key]['actions'] \
429 .append([action_name, action_args])
4b2d79ca 430
29597680 431 return (key_properties, seen_files)
4b2d79ca
IB
432
433