diff options
author | Ismaël Bouya <ismael.bouya@normalesup.org> | 2016-07-27 21:33:09 +0200 |
---|---|---|
committer | Ismaël Bouya <ismael.bouya@normalesup.org> | 2016-07-27 21:33:09 +0200 |
commit | 63ba5a8dc2aa4ec3e6f203b0ba4db249ecf0b00e (patch) | |
tree | f043ab215425aca3434bf178029dad9f9e62dbe9 /music_sampler | |
parent | 35bde798b6cda13579337b0ec5a803fdd5eab19a (diff) | |
download | MusicSampler-63ba5a8dc2aa4ec3e6f203b0ba4db249ecf0b00e.tar.gz MusicSampler-63ba5a8dc2aa4ec3e6f203b0ba4db249ecf0b00e.tar.zst MusicSampler-63ba5a8dc2aa4ec3e6f203b0ba4db249ecf0b00e.zip |
Rename helpers to music_sampler
Diffstat (limited to 'music_sampler')
-rw-r--r-- | music_sampler/__init__.py | 192 | ||||
-rw-r--r-- | music_sampler/action.py | 113 | ||||
-rw-r--r-- | music_sampler/actions/__init__.py | 10 | ||||
-rw-r--r-- | music_sampler/actions/interrupt_wait.py | 5 | ||||
-rw-r--r-- | music_sampler/actions/pause.py | 10 | ||||
-rw-r--r-- | music_sampler/actions/play.py | 44 | ||||
-rw-r--r-- | music_sampler/actions/run_command.py | 13 | ||||
-rw-r--r-- | music_sampler/actions/seek.py | 19 | ||||
-rw-r--r-- | music_sampler/actions/stop.py | 42 | ||||
-rw-r--r-- | music_sampler/actions/stop_all_actions.py | 14 | ||||
-rw-r--r-- | music_sampler/actions/unpause.py | 10 | ||||
-rw-r--r-- | music_sampler/actions/volume.py | 28 | ||||
-rw-r--r-- | music_sampler/actions/wait.py | 40 | ||||
-rw-r--r-- | music_sampler/key.py | 280 | ||||
-rw-r--r-- | music_sampler/lock.py | 23 | ||||
-rw-r--r-- | music_sampler/mapping.py | 399 | ||||
-rw-r--r-- | music_sampler/mixer.py | 63 | ||||
-rw-r--r-- | music_sampler/music_effect.py | 62 | ||||
-rw-r--r-- | music_sampler/music_file.py | 378 | ||||
-rw-r--r-- | music_sampler/sysfont.py | 224 |
20 files changed, 1969 insertions, 0 deletions
diff --git a/music_sampler/__init__.py b/music_sampler/__init__.py new file mode 100644 index 0000000..4827e6c --- /dev/null +++ b/music_sampler/__init__.py | |||
@@ -0,0 +1,192 @@ | |||
1 | # -*- coding: utf-8 -*- | ||
2 | import argparse | ||
3 | import sys | ||
4 | import os | ||
5 | import math | ||
6 | import sounddevice as sd | ||
7 | import logging | ||
8 | |||
9 | from . import sysfont | ||
10 | |||
11 | class Config: | ||
12 | pass | ||
13 | |||
14 | def find_font(name, style=sysfont.STYLE_NONE): | ||
15 | if getattr(sys, 'frozen', False): | ||
16 | font = sys._MEIPASS + "/fonts/{}_{}.ttf".format(name, style) | ||
17 | else: | ||
18 | font = sysfont.get_font(name, style=style) | ||
19 | if font is not None: | ||
20 | font = font[4] | ||
21 | return font | ||
22 | |||
23 | def register_fonts(): | ||
24 | from kivy.core.text import LabelBase | ||
25 | |||
26 | ubuntu_regular = find_font("Ubuntu", style=sysfont.STYLE_NORMAL) | ||
27 | ubuntu_bold = find_font("Ubuntu", style=sysfont.STYLE_BOLD) | ||
28 | symbola = find_font("Symbola") | ||
29 | |||
30 | if ubuntu_regular is None: | ||
31 | error_print("Font Ubuntu regular could not be found, please install it.") | ||
32 | sys.exit() | ||
33 | if symbola is None: | ||
34 | error_print("Font Symbola could not be found, please install it.") | ||
35 | sys.exit() | ||
36 | if ubuntu_bold is None: | ||
37 | warn_print("Font Ubuntu Bold could not be found.") | ||
38 | |||
39 | LabelBase.register(name="Ubuntu", | ||
40 | fn_regular=ubuntu_regular, | ||
41 | fn_bold=ubuntu_bold) | ||
42 | LabelBase.register(name="Symbola", | ||
43 | fn_regular=symbola) | ||
44 | |||
45 | |||
46 | def path(): | ||
47 | if getattr(sys, 'frozen', False): | ||
48 | return sys._MEIPASS + "/" | ||
49 | else: | ||
50 | path = os.path.dirname(os.path.realpath(__file__)) | ||
51 | return path + "/../" | ||
52 | |||
53 | def parse_args(): | ||
54 | argv = sys.argv[1 :] | ||
55 | sys.argv = sys.argv[: 1] | ||
56 | if "--" in argv: | ||
57 | index = argv.index("--") | ||
58 | kivy_args = argv[index+1 :] | ||
59 | argv = argv[: index] | ||
60 | |||
61 | sys.argv.extend(kivy_args) | ||
62 | |||
63 | parser = argparse.ArgumentParser( | ||
64 | description="A Music Sampler application.", | ||
65 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) | ||
66 | parser.add_argument("-c", "--config", | ||
67 | default="config.yml", | ||
68 | required=False, | ||
69 | help="Config file to load") | ||
70 | parser.add_argument("-p", "--music-path", | ||
71 | default=".", | ||
72 | required=False, | ||
73 | help="Folder in which to find the music files") | ||
74 | parser.add_argument("-d", "--debug", | ||
75 | nargs=0, | ||
76 | action=DebugModeAction, | ||
77 | help="Print messages in console") | ||
78 | parser.add_argument("-m", "--builtin-mixing", | ||
79 | action="store_true", | ||
80 | help="Make the mixing of sounds manually\ | ||
81 | (do it if the system cannot handle it correctly)") | ||
82 | parser.add_argument("-l", "--latency", | ||
83 | default="high", | ||
84 | required=False, | ||
85 | help="Latency: low, high or number of seconds") | ||
86 | parser.add_argument("-b", "--blocksize", | ||
87 | default=0, | ||
88 | type=int, | ||
89 | required=False, | ||
90 | help="Blocksize: If not 0, the number of frames to take\ | ||
91 | at each step for the mixer") | ||
92 | parser.add_argument("-f", "--frame-rate", | ||
93 | default=44100, | ||
94 | type=int, | ||
95 | required=False, | ||
96 | help="Frame rate to play the musics") | ||
97 | parser.add_argument("-x", "--channels", | ||
98 | default=2, | ||
99 | type=int, | ||
100 | required=False, | ||
101 | help="Number of channels to use") | ||
102 | parser.add_argument("-s", "--sample-width", | ||
103 | default=2, | ||
104 | type=int, | ||
105 | required=False, | ||
106 | help="Sample width (number of bytes for each frame)") | ||
107 | parser.add_argument("-V", "--version", | ||
108 | action="version", | ||
109 | help="Displays the current version and exits. Only use\ | ||
110 | in bundled package", | ||
111 | version=show_version()) | ||
112 | parser.add_argument("--device", | ||
113 | action=SelectDeviceAction, | ||
114 | help="Select this sound device" | ||
115 | ) | ||
116 | parser.add_argument("--list-devices", | ||
117 | nargs=0, | ||
118 | action=ListDevicesAction, | ||
119 | help="List available sound devices" | ||
120 | ) | ||
121 | parser.add_argument('--', | ||
122 | dest="args", | ||
123 | help="Kivy arguments. All arguments after this are interpreted\ | ||
124 | by Kivy. Pass \"-- --help\" to get Kivy's usage.") | ||
125 | |||
126 | from kivy.logger import Logger | ||
127 | Logger.setLevel(logging.WARN) | ||
128 | |||
129 | args = parser.parse_args(argv) | ||
130 | |||
131 | Config.yml_file = args.config | ||
132 | |||
133 | Config.latency = args.latency | ||
134 | Config.blocksize = args.blocksize | ||
135 | Config.frame_rate = args.frame_rate | ||
136 | Config.channels = args.channels | ||
137 | Config.sample_width = args.sample_width | ||
138 | Config.builtin_mixing = args.builtin_mixing | ||
139 | if args.music_path.endswith("/"): | ||
140 | Config.music_path = args.music_path | ||
141 | else: | ||
142 | Config.music_path = args.music_path + "/" | ||
143 | |||
144 | class DebugModeAction(argparse.Action): | ||
145 | def __call__(self, parser, namespace, values, option_string=None): | ||
146 | from kivy.logger import Logger | ||
147 | Logger.setLevel(logging.DEBUG) | ||
148 | |||
149 | class SelectDeviceAction(argparse.Action): | ||
150 | def __call__(self, parser, namespace, values, option_string=None): | ||
151 | sd.default.device = values | ||
152 | |||
153 | class ListDevicesAction(argparse.Action): | ||
154 | nargs = 0 | ||
155 | def __call__(self, parser, namespace, values, option_string=None): | ||
156 | print(sd.query_devices()) | ||
157 | sys.exit() | ||
158 | |||
159 | def show_version(): | ||
160 | if getattr(sys, 'frozen', False): | ||
161 | with open(path() + ".pyinstaller_commit", "r") as f: | ||
162 | return f.read() | ||
163 | else: | ||
164 | return "option '-v' can only be used in bundled package" | ||
165 | |||
166 | def duration_to_min_sec(duration): | ||
167 | minutes = int(duration / 60) | ||
168 | seconds = int(duration) % 60 | ||
169 | if minutes < 100: | ||
170 | return "{:2}:{:0>2}".format(minutes, seconds) | ||
171 | else: | ||
172 | return "{}:{:0>2}".format(minutes, seconds) | ||
173 | |||
174 | def gain(volume, old_volume=None): | ||
175 | if old_volume is None: | ||
176 | return 20 * math.log10(max(volume, 0.1) / 100) | ||
177 | else: | ||
178 | return [ | ||
179 | 20 * math.log10(max(volume, 0.1) / max(old_volume, 0.1)), | ||
180 | max(volume, 0)] | ||
181 | |||
182 | def debug_print(message, with_trace=False): | ||
183 | from kivy.logger import Logger | ||
184 | Logger.debug('MusicSampler: ' + message, exc_info=with_trace) | ||
185 | |||
186 | def error_print(message, with_trace=False): | ||
187 | from kivy.logger import Logger | ||
188 | Logger.error('MusicSampler: ' + message, exc_info=with_trace) | ||
189 | |||
190 | def warn_print(message, with_trace=False): | ||
191 | from kivy.logger import Logger | ||
192 | Logger.warn('MusicSampler: ' + message, exc_info=with_trace) | ||
diff --git a/music_sampler/action.py b/music_sampler/action.py new file mode 100644 index 0000000..4b5a71d --- /dev/null +++ b/music_sampler/action.py | |||
@@ -0,0 +1,113 @@ | |||
1 | from transitions.extensions import HierarchicalMachine as Machine | ||
2 | from . import debug_print, error_print | ||
3 | from . import actions | ||
4 | |||
5 | class Action: | ||
6 | STATES = [ | ||
7 | 'initial', | ||
8 | 'loading', | ||
9 | 'failed', | ||
10 | { | ||
11 | 'name': 'loaded', | ||
12 | 'children': ['running'] | ||
13 | } | ||
14 | ] | ||
15 | |||
16 | TRANSITIONS = [ | ||
17 | { | ||
18 | 'trigger': 'load', | ||
19 | 'source': 'initial', | ||
20 | 'dest': 'loading' | ||
21 | }, | ||
22 | { | ||
23 | 'trigger': 'fail', | ||
24 | 'source': 'loading', | ||
25 | 'dest': 'failed', | ||
26 | 'after': 'poll_loaded' | ||
27 | }, | ||
28 | { | ||
29 | 'trigger': 'success', | ||
30 | 'source': 'loading', | ||
31 | 'dest': 'loaded', | ||
32 | 'after': 'poll_loaded' | ||
33 | }, | ||
34 | { | ||
35 | 'trigger': 'run', | ||
36 | 'source': 'loaded', | ||
37 | 'dest': 'loaded_running', | ||
38 | 'after': 'finish_action', | ||
39 | # if a child has no transitions, then it is bubbled to the parent, | ||
40 | # and we don't want that. Not useful in that machine precisely. | ||
41 | 'conditions': ['is_loaded'] | ||
42 | }, | ||
43 | { | ||
44 | 'trigger': 'finish_action', | ||
45 | 'source': 'loaded_running', | ||
46 | 'dest': 'loaded' | ||
47 | } | ||
48 | ] | ||
49 | |||
50 | def __init__(self, action, key, **kwargs): | ||
51 | Machine(model=self, states=self.STATES, | ||
52 | transitions=self.TRANSITIONS, initial='initial', | ||
53 | ignore_invalid_triggers=True, queued=True) | ||
54 | |||
55 | self.action = action | ||
56 | self.key = key | ||
57 | self.mapping = key.parent | ||
58 | self.arguments = kwargs | ||
59 | self.sleep_event = None | ||
60 | self.waiting_music = None | ||
61 | |||
62 | def is_loaded_or_failed(self): | ||
63 | return self.is_loaded(allow_substates=True) or self.is_failed() | ||
64 | |||
65 | def callback_music_loaded(self, success): | ||
66 | if success: | ||
67 | self.success() | ||
68 | else: | ||
69 | self.fail() | ||
70 | |||
71 | # Machine states / events | ||
72 | def on_enter_loading(self): | ||
73 | if hasattr(actions, self.action): | ||
74 | if 'music' in self.arguments: | ||
75 | self.arguments['music'].subscribe_loaded( | ||
76 | self.callback_music_loaded) | ||
77 | else: | ||
78 | self.success() | ||
79 | else: | ||
80 | error_print("Unknown action {}".format(self.action)) | ||
81 | self.fail() | ||
82 | |||
83 | def on_enter_loaded_running(self, key_start_time): | ||
84 | debug_print(self.description()) | ||
85 | if hasattr(actions, self.action): | ||
86 | getattr(actions, self.action).run(self, | ||
87 | key_start_time=key_start_time, **self.arguments) | ||
88 | |||
89 | def poll_loaded(self): | ||
90 | self.key.callback_action_ready(self, | ||
91 | self.is_loaded(allow_substates=True)) | ||
92 | |||
93 | # This one cannot be in the Machine state since it would be queued to run | ||
94 | # *after* the wait is ended... | ||
95 | def interrupt(self): | ||
96 | if getattr(actions, self.action, None) and\ | ||
97 | hasattr(getattr(actions, self.action), 'interrupt'): | ||
98 | return getattr(getattr(actions, self.action), 'interrupt')( | ||
99 | self, **self.arguments) | ||
100 | |||
101 | # Helpers | ||
102 | def music_list(self, music): | ||
103 | if music is not None: | ||
104 | return [music] | ||
105 | else: | ||
106 | return self.mapping.open_files.values() | ||
107 | |||
108 | def description(self): | ||
109 | if hasattr(actions, self.action): | ||
110 | return getattr(actions, self.action)\ | ||
111 | .description(self, **self.arguments) | ||
112 | else: | ||
113 | return "unknown action {}".format(self.action) | ||
diff --git a/music_sampler/actions/__init__.py b/music_sampler/actions/__init__.py new file mode 100644 index 0000000..658cef0 --- /dev/null +++ b/music_sampler/actions/__init__.py | |||
@@ -0,0 +1,10 @@ | |||
1 | from . import interrupt_wait | ||
2 | from . import pause | ||
3 | from . import play | ||
4 | from . import run_command | ||
5 | from . import seek | ||
6 | from . import stop | ||
7 | from . import stop_all_actions | ||
8 | from . import unpause | ||
9 | from . import volume | ||
10 | from . import wait | ||
diff --git a/music_sampler/actions/interrupt_wait.py b/music_sampler/actions/interrupt_wait.py new file mode 100644 index 0000000..8f465f0 --- /dev/null +++ b/music_sampler/actions/interrupt_wait.py | |||
@@ -0,0 +1,5 @@ | |||
1 | def run(action, wait_id=None, **kwargs): | ||
2 | action.mapping.interrupt_wait(wait_id) | ||
3 | |||
4 | def description(action, wait_id=None, **kwargs): | ||
5 | return "interrupt wait with id {}".format(wait_id) | ||
diff --git a/music_sampler/actions/pause.py b/music_sampler/actions/pause.py new file mode 100644 index 0000000..bb27734 --- /dev/null +++ b/music_sampler/actions/pause.py | |||
@@ -0,0 +1,10 @@ | |||
1 | def run(action, music=None, **kwargs): | ||
2 | for music in action.music_list(music): | ||
3 | if music.is_loaded_playing(): | ||
4 | music.pause() | ||
5 | |||
6 | def description(action, music=None, **kwargs): | ||
7 | if music is not None: | ||
8 | return "pausing « {} »".format(music.name) | ||
9 | else: | ||
10 | return "pausing all musics" | ||
diff --git a/music_sampler/actions/play.py b/music_sampler/actions/play.py new file mode 100644 index 0000000..fdba95b --- /dev/null +++ b/music_sampler/actions/play.py | |||
@@ -0,0 +1,44 @@ | |||
1 | def run(action, music=None, fade_in=0, start_at=0, | ||
2 | restart_if_running=False, volume=100, | ||
3 | loop=0, **kwargs): | ||
4 | for music in action.music_list(music): | ||
5 | if restart_if_running: | ||
6 | if music.is_in_use(): | ||
7 | music.stop() | ||
8 | music.play( | ||
9 | volume=volume, | ||
10 | fade_in=fade_in, | ||
11 | start_at=start_at, | ||
12 | loop=loop) | ||
13 | elif not music.is_in_use(): | ||
14 | music.play( | ||
15 | volume=volume, | ||
16 | fade_in=fade_in, | ||
17 | start_at=start_at, | ||
18 | loop=loop) | ||
19 | |||
20 | def description(action, music=None, fade_in=0, start_at=0, | ||
21 | restart_if_running=False, volume=100, loop=0, **kwargs): | ||
22 | message = "starting " | ||
23 | if music is not None: | ||
24 | message += "« {} »".format(music.name) | ||
25 | else: | ||
26 | message += "all musics" | ||
27 | |||
28 | if start_at != 0: | ||
29 | message += " at {}s".format(start_at) | ||
30 | |||
31 | if fade_in != 0: | ||
32 | message += " with {}s fade_in".format(fade_in) | ||
33 | |||
34 | message += " at volume {}%".format(volume) | ||
35 | |||
36 | if loop > 0: | ||
37 | message += " {} times".format(loop + 1) | ||
38 | elif loop < 0: | ||
39 | message += " in loop" | ||
40 | |||
41 | if restart_if_running: | ||
42 | message += " (restarting if already running)" | ||
43 | |||
44 | return message | ||
diff --git a/music_sampler/actions/run_command.py b/music_sampler/actions/run_command.py new file mode 100644 index 0000000..1e80c1e --- /dev/null +++ b/music_sampler/actions/run_command.py | |||
@@ -0,0 +1,13 @@ | |||
1 | import shlex, subprocess | ||
2 | |||
3 | def run(action, command="", wait=False, **kwargs): | ||
4 | action.process = subprocess.Popen(command, shell=True) | ||
5 | if wait: | ||
6 | action.process.wait() | ||
7 | |||
8 | def description(action, command="", wait=False, **kwargs): | ||
9 | message = "running command {}".format(command) | ||
10 | if wait: | ||
11 | message += " (waiting for its execution to finish)" | ||
12 | |||
13 | return message | ||
diff --git a/music_sampler/actions/seek.py b/music_sampler/actions/seek.py new file mode 100644 index 0000000..467af7d --- /dev/null +++ b/music_sampler/actions/seek.py | |||
@@ -0,0 +1,19 @@ | |||
1 | def run(action, music=None, value=0, delta=False, **kwargs): | ||
2 | for music in action.music_list(music): | ||
3 | music.seek(value=value, delta=delta) | ||
4 | |||
5 | def description(action, music=None, value=0, delta=False, **kwargs): | ||
6 | if delta: | ||
7 | if music is not None: | ||
8 | return "moving music « {} » by {:+d}s" \ | ||
9 | .format(music.name, value) | ||
10 | else: | ||
11 | return "moving all musics by {:+d}s" \ | ||
12 | .format(value) | ||
13 | else: | ||
14 | if music is not None: | ||
15 | return "moving music « {} » to position {}s" \ | ||
16 | .format(music.name, value) | ||
17 | else: | ||
18 | return "moving all musics to position {}s" \ | ||
19 | .format(value) | ||
diff --git a/music_sampler/actions/stop.py b/music_sampler/actions/stop.py new file mode 100644 index 0000000..88cc66d --- /dev/null +++ b/music_sampler/actions/stop.py | |||
@@ -0,0 +1,42 @@ | |||
1 | def run(action, music=None, fade_out=0, wait=False, | ||
2 | set_wait_id=None, **kwargs): | ||
3 | previous = None | ||
4 | for music in action.music_list(music): | ||
5 | if music.is_loaded_paused() or music.is_loaded_playing(): | ||
6 | if previous is not None: | ||
7 | previous.stop(fade_out=fade_out) | ||
8 | previous = music | ||
9 | else: | ||
10 | music.stop(fade_out=fade_out) | ||
11 | |||
12 | if previous is not None: | ||
13 | action.waiting_music = previous | ||
14 | previous.stop( | ||
15 | fade_out=fade_out, | ||
16 | wait=wait, | ||
17 | set_wait_id=set_wait_id) | ||
18 | |||
19 | def description(action, music=None, fade_out=0, wait=False, | ||
20 | set_wait_id=None, **kwargs): | ||
21 | |||
22 | message = "stopping " | ||
23 | if music is not None: | ||
24 | message += "music « {} »".format(music.name) | ||
25 | else: | ||
26 | message += "all musics" | ||
27 | |||
28 | if fade_out > 0: | ||
29 | message += " with {}s fadeout".format(fade_out) | ||
30 | if wait: | ||
31 | if set_wait_id is not None: | ||
32 | message += " (waiting the end of fadeout, with id {})"\ | ||
33 | .format(set_wait_id) | ||
34 | else: | ||
35 | message += " (waiting the end of fadeout)" | ||
36 | |||
37 | return message | ||
38 | |||
39 | def interrupt(action, music=None, fade_out=0, wait=False, | ||
40 | set_wait_id=None, **kwargs): | ||
41 | if action.waiting_music is not None: | ||
42 | action.waiting_music.wait_event.set() | ||
diff --git a/music_sampler/actions/stop_all_actions.py b/music_sampler/actions/stop_all_actions.py new file mode 100644 index 0000000..4ea875a --- /dev/null +++ b/music_sampler/actions/stop_all_actions.py | |||
@@ -0,0 +1,14 @@ | |||
1 | def run(action, key_start_time=0, other_only=False, **kwargs): | ||
2 | if other_only: | ||
3 | action.mapping.stop_all_running( | ||
4 | except_key=action.key, | ||
5 | key_start_time=key_start_time) | ||
6 | else: | ||
7 | action.mapping.stop_all_running() | ||
8 | |||
9 | def description(action, other_only=False, **kwargs): | ||
10 | message = "stopping all actions" | ||
11 | if other_only: | ||
12 | message += " except this key" | ||
13 | |||
14 | return message | ||
diff --git a/music_sampler/actions/unpause.py b/music_sampler/actions/unpause.py new file mode 100644 index 0000000..5fa88c3 --- /dev/null +++ b/music_sampler/actions/unpause.py | |||
@@ -0,0 +1,10 @@ | |||
1 | def run(action, music=None, **kwargs): | ||
2 | for music in action.music_list(music): | ||
3 | if music.is_loaded_paused(): | ||
4 | music.unpause() | ||
5 | |||
6 | def description(action, music=None, **kwargs): | ||
7 | if music is not None: | ||
8 | return "unpausing « {} »".format(music.name) | ||
9 | else: | ||
10 | return "unpausing all musics" | ||
diff --git a/music_sampler/actions/volume.py b/music_sampler/actions/volume.py new file mode 100644 index 0000000..7dda3c1 --- /dev/null +++ b/music_sampler/actions/volume.py | |||
@@ -0,0 +1,28 @@ | |||
1 | def run(action, music=None, value=100, fade=0, delta=False, **kwargs): | ||
2 | if music is not None: | ||
3 | music.set_volume(value, delta=delta, fade=fade) | ||
4 | else: | ||
5 | action.mapping.set_master_volume(value, delta=delta, fade=fade) | ||
6 | |||
7 | def description(action, music=None, | ||
8 | value=100, delta=False, fade=0, **kwargs): | ||
9 | message = "" | ||
10 | if delta: | ||
11 | if music is not None: | ||
12 | message += "{:+d}% to volume of « {} »" \ | ||
13 | .format(value, music.name) | ||
14 | else: | ||
15 | message += "{:+d}% to volume" \ | ||
16 | .format(value) | ||
17 | else: | ||
18 | if music is not None: | ||
19 | message += "setting volume of « {} » to {}%" \ | ||
20 | .format(music.name, value) | ||
21 | else: | ||
22 | message += "setting volume to {}%" \ | ||
23 | .format(value) | ||
24 | |||
25 | if fade > 0: | ||
26 | message += " with {}s fade".format(fade) | ||
27 | |||
28 | return message | ||
diff --git a/music_sampler/actions/wait.py b/music_sampler/actions/wait.py new file mode 100644 index 0000000..ea42408 --- /dev/null +++ b/music_sampler/actions/wait.py | |||
@@ -0,0 +1,40 @@ | |||
1 | import threading | ||
2 | |||
3 | def run(action, duration=0, music=None, set_wait_id=None, **kwargs): | ||
4 | if set_wait_id is not None: | ||
5 | action.mapping.add_wait_id(set_wait_id, action) | ||
6 | |||
7 | action.sleep_event = threading.Event() | ||
8 | action.sleep_event_timer = threading.Timer( | ||
9 | duration, | ||
10 | action.sleep_event.set) | ||
11 | |||
12 | if music is not None: | ||
13 | music.wait_end() | ||
14 | |||
15 | action.sleep_event_timer.start() | ||
16 | action.sleep_event.wait() | ||
17 | |||
18 | def description(action, duration=0, music=None, set_wait_id=None, **kwargs): | ||
19 | message = "" | ||
20 | if music is None: | ||
21 | message += "waiting {}s" \ | ||
22 | .format(duration) | ||
23 | elif duration == 0: | ||
24 | message += "waiting the end of « {} »" \ | ||
25 | .format(music.name) | ||
26 | else: | ||
27 | message += "waiting the end of « {} » + {}s" \ | ||
28 | .format(music.name, duration) | ||
29 | |||
30 | if set_wait_id is not None: | ||
31 | message += " (setting id = {})".format(set_wait_id) | ||
32 | |||
33 | return message | ||
34 | |||
35 | def interrupt(action, duration=0, music=None, **kwargs): | ||
36 | if action.sleep_event is not None: | ||
37 | action.sleep_event.set() | ||
38 | action.sleep_event_timer.cancel() | ||
39 | if music is not None: | ||
40 | music.wait_event.set() | ||
diff --git a/music_sampler/key.py b/music_sampler/key.py new file mode 100644 index 0000000..66e792d --- /dev/null +++ b/music_sampler/key.py | |||
@@ -0,0 +1,280 @@ | |||
1 | from kivy.uix.widget import Widget | ||
2 | from kivy.properties import AliasProperty, BooleanProperty, \ | ||
3 | ListProperty, StringProperty | ||
4 | from kivy.uix.behaviors import ButtonBehavior | ||
5 | |||
6 | from .action import Action | ||
7 | from . import debug_print | ||
8 | import time | ||
9 | import threading | ||
10 | from transitions.extensions import HierarchicalMachine as Machine | ||
11 | |||
12 | class Key(ButtonBehavior, Widget): | ||
13 | STATES = [ | ||
14 | 'initial', | ||
15 | 'configuring', | ||
16 | 'configured', | ||
17 | 'loading', | ||
18 | 'failed', | ||
19 | { | ||
20 | 'name': 'loaded', | ||
21 | 'children': [ | ||
22 | 'no_config', | ||
23 | 'no_actions', | ||
24 | 'running', | ||
25 | 'protecting_repeat' | ||
26 | ] | ||
27 | } | ||
28 | ] | ||
29 | |||
30 | TRANSITIONS = [ | ||
31 | { | ||
32 | 'trigger': 'configure', | ||
33 | 'source': 'initial', | ||
34 | 'dest': 'configuring' | ||
35 | }, | ||
36 | { | ||
37 | 'trigger': 'fail', | ||
38 | 'source': 'configuring', | ||
39 | 'dest': 'failed', | ||
40 | 'after': 'key_loaded_callback' | ||
41 | }, | ||
42 | { | ||
43 | 'trigger': 'success', | ||
44 | 'source': 'configuring', | ||
45 | 'dest': 'configured', | ||
46 | 'after': 'load' | ||
47 | }, | ||
48 | { | ||
49 | 'trigger': 'no_config', | ||
50 | 'source': 'configuring', | ||
51 | 'dest': 'loaded_no_config', | ||
52 | 'after': 'key_loaded_callback' | ||
53 | }, | ||
54 | { | ||
55 | 'trigger': 'load', | ||
56 | 'source': 'configured', | ||
57 | 'dest': 'loading' | ||
58 | }, | ||
59 | { | ||
60 | 'trigger': 'fail', | ||
61 | 'source': 'loading', | ||
62 | 'dest': 'failed', | ||
63 | 'after': 'key_loaded_callback' | ||
64 | }, | ||
65 | { | ||
66 | 'trigger': 'success', | ||
67 | 'source': 'loading', | ||
68 | 'dest': 'loaded', | ||
69 | 'after': 'key_loaded_callback' | ||
70 | }, | ||
71 | { | ||
72 | 'trigger': 'no_actions', | ||
73 | 'source': 'loading', | ||
74 | 'dest': 'loaded_no_actions', | ||
75 | 'after': 'key_loaded_callback' | ||
76 | }, | ||
77 | { | ||
78 | 'trigger': 'reload', | ||
79 | 'source': ['loaded','failed'], | ||
80 | 'dest': 'configuring', | ||
81 | 'after': 'key_loaded_callback' | ||
82 | }, | ||
83 | { | ||
84 | 'trigger': 'run', | ||
85 | 'source': 'loaded', | ||
86 | 'dest': 'loaded_running', | ||
87 | 'after': ['run_actions', 'finish'], | ||
88 | # if a child, like loaded_no_actions, has no transitions, then it | ||
89 | # is bubbled to the parent, and we don't want that. | ||
90 | 'conditions': ['is_loaded'] | ||
91 | }, | ||
92 | { | ||
93 | 'trigger': 'finish', | ||
94 | 'source': 'loaded_running', | ||
95 | 'dest': 'loaded_protecting_repeat' | ||
96 | }, | ||
97 | { | ||
98 | 'trigger': 'repeat_protection_finished', | ||
99 | 'source': 'loaded_protecting_repeat', | ||
100 | 'dest': 'loaded' | ||
101 | }, | ||
102 | ] | ||
103 | |||
104 | key_sym = StringProperty(None) | ||
105 | custom_color = ListProperty([0, 1, 0]) | ||
106 | description_title = StringProperty("") | ||
107 | description = ListProperty([]) | ||
108 | state = StringProperty("") | ||
109 | |||
110 | def get_alias_line_cross_color(self): | ||
111 | if not self.is_failed() and ( | ||
112 | not self.is_loaded(allow_substates=True)\ | ||
113 | or self.is_loaded_running()\ | ||
114 | or self.is_loaded_protecting_repeat()): | ||
115 | return [120/255, 120/255, 120/255, 1] | ||
116 | else: | ||
117 | return [0, 0, 0, 0] | ||
118 | |||
119 | def set_alias_line_cross_color(self): | ||
120 | pass | ||
121 | |||
122 | line_cross_color = AliasProperty( | ||
123 | get_alias_line_cross_color, | ||
124 | set_alias_line_cross_color, | ||
125 | bind=['state']) | ||
126 | |||
127 | def get_alias_line_color(self): | ||
128 | if self.is_loaded_running(): | ||
129 | return [0, 0, 0, 1] | ||
130 | else: | ||
131 | return [120/255, 120/255, 120/255, 1] | ||
132 | |||
133 | def set_alias_line_color(self): | ||
134 | pass | ||
135 | |||
136 | line_color = AliasProperty(get_alias_line_color, set_alias_line_color, | ||
137 | bind=['state']) | ||
138 | |||
139 | def get_alias_color(self): | ||
140 | if self.is_loaded_inactive(): | ||
141 | return [1, 1, 1, 1] | ||
142 | elif self.is_loaded_protecting_repeat(): | ||
143 | return [*self.custom_color, 100/255] | ||
144 | elif self.is_loaded_running(): | ||
145 | return [*self.custom_color, 100/255] | ||
146 | elif self.is_loaded(allow_substates=True): | ||
147 | return [*self.custom_color, 1] | ||
148 | elif self.is_failed(): | ||
149 | return [0, 0, 0, 1] | ||
150 | else: | ||
151 | return [*self.custom_color, 100/255] | ||
152 | def set_alias_color(self): | ||
153 | pass | ||
154 | |||
155 | color = AliasProperty(get_alias_color, set_alias_color, | ||
156 | bind=['state', 'custom_color']) | ||
157 | |||
158 | def __init__(self, **kwargs): | ||
159 | self.actions = [] | ||
160 | self.current_action = None | ||
161 | |||
162 | Machine(model=self, states=self.STATES, | ||
163 | transitions=self.TRANSITIONS, initial='initial', | ||
164 | ignore_invalid_triggers=True, queued=True) | ||
165 | super(Key, self).__init__(**kwargs) | ||
166 | |||
167 | # Kivy events | ||
168 | def on_key_sym(self, key, key_sym): | ||
169 | if key_sym != "": | ||
170 | self.configure() | ||
171 | |||
172 | def on_press(self): | ||
173 | self.list_actions() | ||
174 | |||
175 | # Machine states / events | ||
176 | def is_loaded_or_failed(self): | ||
177 | return self.is_loaded(allow_substates=True) or self.is_failed() | ||
178 | |||
179 | def is_loaded_inactive(self): | ||
180 | return self.is_loaded_no_config() or self.is_loaded_no_actions() | ||
181 | |||
182 | def on_enter_configuring(self): | ||
183 | if self.key_sym in self.parent.key_config: | ||
184 | self.config = self.parent.key_config[self.key_sym] | ||
185 | |||
186 | self.actions = [] | ||
187 | for key_action in self.config['actions']: | ||
188 | self.add_action(key_action[0], **key_action[1]) | ||
189 | |||
190 | if 'description' in self.config['properties']: | ||
191 | self.set_description(self.config['properties']['description']) | ||
192 | if 'color' in self.config['properties']: | ||
193 | self.set_color(self.config['properties']['color']) | ||
194 | self.success() | ||
195 | else: | ||
196 | self.no_config() | ||
197 | |||
198 | def on_enter_loading(self): | ||
199 | if len(self.actions) > 0: | ||
200 | for action in self.actions: | ||
201 | action.load() | ||
202 | else: | ||
203 | self.no_actions() | ||
204 | |||
205 | def run_actions(self, modifiers): | ||
206 | self.parent.parent.ids['KeyList'].append(self.key_sym) | ||
207 | debug_print("running actions for {}".format(self.key_sym)) | ||
208 | start_time = time.time() | ||
209 | self.parent.start_running(self, start_time) | ||
210 | for self.current_action in self.actions: | ||
211 | if self.parent.keep_running(self, start_time): | ||
212 | self.list_actions() | ||
213 | self.current_action.run(start_time) | ||
214 | self.list_actions(last_action_finished=True) | ||
215 | |||
216 | self.parent.finished_running(self, start_time) | ||
217 | |||
218 | def on_enter_loaded_protecting_repeat(self, modifiers): | ||
219 | if 'repeat_delay' in self.config['properties']: | ||
220 | self.protecting_repeat_timer = threading.Timer( | ||
221 | self.config['properties']['repeat_delay'], | ||
222 | self.repeat_protection_finished) | ||
223 | self.protecting_repeat_timer.start() | ||
224 | else: | ||
225 | self.repeat_protection_finished() | ||
226 | |||
227 | # This one cannot be in the Machine state since it would be queued to run | ||
228 | # *after* the loop is ended... | ||
229 | def interrupt(self): | ||
230 | self.current_action.interrupt() | ||
231 | |||
232 | # Callbacks | ||
233 | def key_loaded_callback(self): | ||
234 | self.parent.key_loaded_callback() | ||
235 | |||
236 | def callback_action_ready(self, action, success): | ||
237 | if not success: | ||
238 | self.fail() | ||
239 | elif all(action.is_loaded_or_failed() for action in self.actions): | ||
240 | self.success() | ||
241 | |||
242 | # Setters | ||
243 | def set_description(self, description): | ||
244 | if description[0] is not None: | ||
245 | self.description_title = str(description[0]) | ||
246 | self.description = [] | ||
247 | for desc in description[1 :]: | ||
248 | if desc is None: | ||
249 | self.description.append("") | ||
250 | else: | ||
251 | self.description.append(str(desc).replace(" ", " ")) | ||
252 | |||
253 | def set_color(self, color): | ||
254 | color = [x / 255 for x in color] | ||
255 | self.custom_color = color | ||
256 | |||
257 | # Actions handling | ||
258 | def add_action(self, action_name, **arguments): | ||
259 | self.actions.append(Action(action_name, self, **arguments)) | ||
260 | |||
261 | def list_actions(self, last_action_finished=False): | ||
262 | not_running = (not self.is_loaded_running()) | ||
263 | current_action_seen = False | ||
264 | action_descriptions = [] | ||
265 | for action in self.actions: | ||
266 | if not_running: | ||
267 | state = "inactive" | ||
268 | elif last_action_finished: | ||
269 | state = "done" | ||
270 | elif current_action_seen: | ||
271 | state = "pending" | ||
272 | elif action == self.current_action: | ||
273 | current_action_seen = True | ||
274 | state = "current" | ||
275 | else: | ||
276 | state = "done" | ||
277 | action_descriptions.append([action.description(), state]) | ||
278 | self.parent.parent.ids['ActionList'].update_list( | ||
279 | self, | ||
280 | action_descriptions) | ||
diff --git a/music_sampler/lock.py b/music_sampler/lock.py new file mode 100644 index 0000000..9beafcd --- /dev/null +++ b/music_sampler/lock.py | |||
@@ -0,0 +1,23 @@ | |||
1 | import threading | ||
2 | |||
3 | from . import debug_print | ||
4 | |||
5 | class Lock: | ||
6 | def __init__(self, lock_type): | ||
7 | self.type = lock_type | ||
8 | self.lock = threading.RLock() | ||
9 | |||
10 | def __enter__(self, *args, **kwargs): | ||
11 | self.acquire(*args, **kwargs) | ||
12 | |||
13 | def __exit__(self, type, value, traceback, *args, **kwargs): | ||
14 | self.release(*args, **kwargs) | ||
15 | |||
16 | def acquire(self, *args, **kwargs): | ||
17 | #debug_print("acquiring lock for {}".format(self.type)) | ||
18 | self.lock.acquire(*args, **kwargs) | ||
19 | |||
20 | def release(self, *args, **kwargs): | ||
21 | #debug_print("releasing lock for {}".format(self.type)) | ||
22 | self.lock.release(*args, **kwargs) | ||
23 | |||
diff --git a/music_sampler/mapping.py b/music_sampler/mapping.py new file mode 100644 index 0000000..bb20e67 --- /dev/null +++ b/music_sampler/mapping.py | |||
@@ -0,0 +1,399 @@ | |||
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 . 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) | ||
97 | sys.exit() | ||
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.stop_all_running() | ||
129 | for thread in threading.enumerate(): | ||
130 | if thread.getName()[0:2] != "MS": | ||
131 | continue | ||
132 | thread.join() | ||
133 | |||
134 | sys.exit() | ||
135 | elif 'ctrl' in modifiers and keycode[0] == 114: | ||
136 | threading.Thread(name="MSReload", target=self.reload).start() | ||
137 | return True | ||
138 | |||
139 | # Helpers | ||
140 | def allowed_modifiers(self, modifiers): | ||
141 | allowed = [] | ||
142 | return len([a for a in modifiers if a not in allowed]) == 0 | ||
143 | |||
144 | def find_by_key_code(self, key_code): | ||
145 | if "Key_" + str(key_code[0]) in self.ids: | ||
146 | return self.ids["Key_" + str(key_code[0])] | ||
147 | return None | ||
148 | |||
149 | def all_keys_ready(self): | ||
150 | partial = False | ||
151 | for key in self.keys: | ||
152 | if not key.is_loaded_or_failed(): | ||
153 | return "not_ready" | ||
154 | partial = partial or key.is_failed() | ||
155 | |||
156 | if partial: | ||
157 | return "partial" | ||
158 | else: | ||
159 | return "success" | ||
160 | |||
161 | # Callbacks | ||
162 | def key_loaded_callback(self): | ||
163 | result = self.all_keys_ready() | ||
164 | if result == "success": | ||
165 | self.ready_color = [0, 1, 0, 1] | ||
166 | elif result == "partial": | ||
167 | self.ready_color = [1, 0, 0, 1] | ||
168 | else: | ||
169 | self.ready_color = [1, 165/255, 0, 1] | ||
170 | |||
171 | ## Some global actions | ||
172 | def stop_all_running(self, except_key=None, key_start_time=0): | ||
173 | running = self.running | ||
174 | self.running = [r for r in running\ | ||
175 | if r[0] == except_key and r[1] == key_start_time] | ||
176 | for (key, start_time) in running: | ||
177 | if (key, start_time) != (except_key, key_start_time): | ||
178 | key.interrupt() | ||
179 | |||
180 | # Master volume methods | ||
181 | @property | ||
182 | def master_gain(self): | ||
183 | return gain(self.master_volume) | ||
184 | |||
185 | def set_master_volume(self, value, delta=False, fade=0): | ||
186 | [db_gain, self.master_volume] = gain( | ||
187 | value + int(delta) * self.master_volume, | ||
188 | self.master_volume) | ||
189 | |||
190 | for music in self.open_files.values(): | ||
191 | music.set_gain_with_effect(db_gain, fade=fade) | ||
192 | |||
193 | # Wait handler methods | ||
194 | def add_wait_id(self, wait_id, action_or_wait): | ||
195 | self.wait_ids[wait_id] = action_or_wait | ||
196 | |||
197 | def interrupt_wait(self, wait_id): | ||
198 | if wait_id in self.wait_ids: | ||
199 | action_or_wait = self.wait_ids[wait_id] | ||
200 | del(self.wait_ids[wait_id]) | ||
201 | if isinstance(action_or_wait, Action): | ||
202 | action_or_wait.interrupt() | ||
203 | else: | ||
204 | action_or_wait.set() | ||
205 | |||
206 | # Methods to control running keys | ||
207 | def start_running(self, key, start_time): | ||
208 | self.running.append((key, start_time)) | ||
209 | |||
210 | def keep_running(self, key, start_time): | ||
211 | return (key, start_time) in self.running | ||
212 | |||
213 | def finished_running(self, key, start_time): | ||
214 | if (key, start_time) in self.running: | ||
215 | self.running.remove((key, start_time)) | ||
216 | |||
217 | # YML config parser | ||
218 | def parse_config(self): | ||
219 | def update_alias(prop_hash, aliases, key): | ||
220 | if isinstance(aliases[key], dict): | ||
221 | prop_hash.update(aliases[key], **prop_hash) | ||
222 | else: | ||
223 | warn_print("Alias {} is not a hash, ignored".format(key)) | ||
224 | |||
225 | def include_aliases(prop_hash, aliases): | ||
226 | if 'include' not in prop_hash: | ||
227 | return | ||
228 | |||
229 | included = prop_hash['include'] | ||
230 | del(prop_hash['include']) | ||
231 | if isinstance(included, str): | ||
232 | update_alias(prop_hash, aliases, included) | ||
233 | elif isinstance(included, list): | ||
234 | for included_ in included: | ||
235 | if isinstance(included_, str): | ||
236 | update_alias(prop_hash, aliases, included_) | ||
237 | else: | ||
238 | warn_print("Unkown alias include type, ignored: " | ||
239 | "{} in {}".format(included_, included)) | ||
240 | else: | ||
241 | warn_print("Unkown alias include type, ignored: {}" | ||
242 | .format(included)) | ||
243 | |||
244 | def check_key_property(key_property, key): | ||
245 | if 'description' in key_property: | ||
246 | desc = key_property['description'] | ||
247 | if not isinstance(desc, list): | ||
248 | warn_print("description in key_property '{}' is not " | ||
249 | "a list, ignored".format(key)) | ||
250 | del(key_property['description']) | ||
251 | if 'color' in key_property: | ||
252 | color = key_property['color'] | ||
253 | if not isinstance(color, list)\ | ||
254 | or len(color) != 3\ | ||
255 | or not all(isinstance(item, int) for item in color)\ | ||
256 | or any(item < 0 or item > 255 for item in color): | ||
257 | warn_print("color in key_property '{}' is not " | ||
258 | "a list of 3 valid integers, ignored".format(key)) | ||
259 | del(key_property['color']) | ||
260 | |||
261 | def check_key_properties(config): | ||
262 | if 'key_properties' in config: | ||
263 | if isinstance(config['key_properties'], dict): | ||
264 | return config['key_properties'] | ||
265 | else: | ||
266 | warn_print("key_properties config is not a hash, ignored") | ||
267 | return {} | ||
268 | else: | ||
269 | return {} | ||
270 | |||
271 | def check_mapped_keys(config): | ||
272 | if 'keys' in config: | ||
273 | if isinstance(config['keys'], dict): | ||
274 | return config['keys'] | ||
275 | else: | ||
276 | warn_print("keys config is not a hash, ignored") | ||
277 | return {} | ||
278 | else: | ||
279 | return {} | ||
280 | |||
281 | def check_mapped_key(mapped_keys, key): | ||
282 | if not isinstance(mapped_keys[key], list): | ||
283 | warn_print("key config '{}' is not an array, ignored" | ||
284 | .format(key)) | ||
285 | return [] | ||
286 | else: | ||
287 | return mapped_keys[key] | ||
288 | |||
289 | def check_music_property(music_property, filename): | ||
290 | if not isinstance(music_property, dict): | ||
291 | warn_print("music_property config '{}' is not a hash, ignored" | ||
292 | .format(filename)) | ||
293 | return {} | ||
294 | if 'name' in music_property: | ||
295 | music_property['name'] = str(music_property['name']) | ||
296 | if 'gain' in music_property: | ||
297 | try: | ||
298 | music_property['gain'] = float(music_property['gain']) | ||
299 | except ValueError as e: | ||
300 | del(music_property['gain']) | ||
301 | warn_print("gain for music_property '{}' is not " | ||
302 | "a float, ignored".format(filename)) | ||
303 | return music_property | ||
304 | |||
305 | stream = open(Config.yml_file, "r") | ||
306 | try: | ||
307 | config = yaml.safe_load(stream) | ||
308 | except Exception as e: | ||
309 | error_print("Error while loading config file: {}".format(e)) | ||
310 | sys.exit() | ||
311 | stream.close() | ||
312 | |||
313 | if not isinstance(config, dict): | ||
314 | raise Exception("Top level config is supposed to be a hash") | ||
315 | |||
316 | if 'aliases' in config and isinstance(config['aliases'], dict): | ||
317 | aliases = config['aliases'] | ||
318 | else: | ||
319 | aliases = defaultdict(dict) | ||
320 | if 'aliases' in config: | ||
321 | warn_print("aliases config is not a hash, ignored") | ||
322 | |||
323 | music_properties = defaultdict(dict) | ||
324 | if 'music_properties' in config and\ | ||
325 | isinstance(config['music_properties'], dict): | ||
326 | music_properties.update(config['music_properties']) | ||
327 | elif 'music_properties' in config: | ||
328 | warn_print("music_properties config is not a hash, ignored") | ||
329 | |||
330 | seen_files = {} | ||
331 | |||
332 | key_properties = defaultdict(lambda: { | ||
333 | "actions": [], | ||
334 | "properties": {}, | ||
335 | "files": [] | ||
336 | }) | ||
337 | |||
338 | for key in check_key_properties(config): | ||
339 | key_prop = config['key_properties'][key] | ||
340 | |||
341 | if not isinstance(key_prop, dict): | ||
342 | warn_print("key_property '{}' is not a hash, ignored" | ||
343 | .format(key)) | ||
344 | continue | ||
345 | |||
346 | include_aliases(key_prop, aliases) | ||
347 | check_key_property(key_prop, key) | ||
348 | |||
349 | key_properties[key]["properties"] = key_prop | ||
350 | |||
351 | for mapped_key in check_mapped_keys(config): | ||
352 | for index, action in enumerate(check_mapped_key( | ||
353 | config['keys'], mapped_key)): | ||
354 | if not isinstance(action, dict) or\ | ||
355 | not len(action) == 1 or\ | ||
356 | not isinstance(list(action.values())[0] or {}, dict): | ||
357 | warn_print("action number {} of key '{}' is invalid, " | ||
358 | "ignored".format(index + 1, mapped_key)) | ||
359 | continue | ||
360 | |||
361 | action_name = list(action)[0] | ||
362 | action_args = {} | ||
363 | if action[action_name] is None: | ||
364 | action[action_name] = {} | ||
365 | |||
366 | include_aliases(action[action_name], aliases) | ||
367 | |||
368 | for argument in action[action_name]: | ||
369 | if argument == 'file': | ||
370 | filename = str(action[action_name]['file']) | ||
371 | if filename not in seen_files: | ||
372 | music_property = check_music_property( | ||
373 | music_properties[filename], | ||
374 | filename) | ||
375 | |||
376 | if filename in self.open_files: | ||
377 | self.open_files[filename]\ | ||
378 | .reload_properties(**music_property) | ||
379 | |||
380 | seen_files[filename] =\ | ||
381 | self.open_files[filename] | ||
382 | else: | ||
383 | seen_files[filename] = MusicFile( | ||
384 | filename, self, **music_property) | ||
385 | |||
386 | if filename not in key_properties[mapped_key]['files']: | ||
387 | key_properties[mapped_key]['files'] \ | ||
388 | .append(seen_files[filename]) | ||
389 | |||
390 | action_args['music'] = seen_files[filename] | ||
391 | else: | ||
392 | action_args[argument] = action[action_name][argument] | ||
393 | |||
394 | key_properties[mapped_key]['actions'] \ | ||
395 | .append([action_name, action_args]) | ||
396 | |||
397 | return (key_properties, seen_files) | ||
398 | |||
399 | |||
diff --git a/music_sampler/mixer.py b/music_sampler/mixer.py new file mode 100644 index 0000000..9242b61 --- /dev/null +++ b/music_sampler/mixer.py | |||
@@ -0,0 +1,63 @@ | |||
1 | import sounddevice as sd | ||
2 | import audioop | ||
3 | import time | ||
4 | |||
5 | from . import Config | ||
6 | |||
7 | sample_width = Config.sample_width | ||
8 | |||
9 | def sample_width_to_dtype(sample_width): | ||
10 | if sample_width == 1 or sample_width == 2 or sample_width == 4: | ||
11 | return 'int' + str(8*sample_width) | ||
12 | else: | ||
13 | raise "Unknown sample width" | ||
14 | |||
15 | def _latency(latency): | ||
16 | if latency == "high" or latency == "low": | ||
17 | return latency | ||
18 | else: | ||
19 | return float(latency) | ||
20 | |||
21 | class Mixer: | ||
22 | def __init__(self): | ||
23 | self.stream = sd.RawOutputStream( | ||
24 | samplerate=Config.frame_rate, | ||
25 | channels=Config.channels, | ||
26 | dtype=sample_width_to_dtype(Config.sample_width), | ||
27 | latency=_latency(Config.latency), | ||
28 | blocksize=Config.blocksize, | ||
29 | callback=self.play_callback) | ||
30 | self.open_files = [] | ||
31 | |||
32 | def add_file(self, music_file): | ||
33 | if music_file not in self.open_files: | ||
34 | self.open_files.append(music_file) | ||
35 | self.start() | ||
36 | |||
37 | def remove_file(self, music_file): | ||
38 | if music_file in self.open_files: | ||
39 | self.open_files.remove(music_file) | ||
40 | if len(self.open_files) == 0: | ||
41 | self.stop() | ||
42 | |||
43 | def stop(self): | ||
44 | self.stream.stop() | ||
45 | |||
46 | def start(self): | ||
47 | self.stream.start() | ||
48 | |||
49 | def play_callback(self, out_data, frame_count, time_info, status_flags): | ||
50 | out_data_length = len(out_data) | ||
51 | empty_data = b"\0" * out_data_length | ||
52 | data = b"\0" * out_data_length | ||
53 | |||
54 | for open_file in self.open_files: | ||
55 | file_data = open_file.play_callback(out_data_length, frame_count) | ||
56 | |||
57 | if data == empty_data: | ||
58 | data = file_data | ||
59 | elif file_data != empty_data: | ||
60 | data = audioop.add(data, file_data, sample_width) | ||
61 | |||
62 | out_data[:] = data | ||
63 | |||
diff --git a/music_sampler/music_effect.py b/music_sampler/music_effect.py new file mode 100644 index 0000000..4bdbb26 --- /dev/null +++ b/music_sampler/music_effect.py | |||
@@ -0,0 +1,62 @@ | |||
1 | class GainEffect: | ||
2 | effect_types = [ | ||
3 | 'fade' | ||
4 | ] | ||
5 | |||
6 | def __init__(self, effect, audio_segment, initial_loop, start, end, | ||
7 | **kwargs): | ||
8 | if effect in self.effect_types: | ||
9 | self.effect = effect | ||
10 | else: | ||
11 | raise Exception("Unknown effect {}".format(effect)) | ||
12 | |||
13 | self.start = start | ||
14 | self.end = end | ||
15 | self.audio_segment = audio_segment | ||
16 | self.initial_loop = initial_loop | ||
17 | getattr(self, self.effect + "_init")(**kwargs) | ||
18 | |||
19 | def get_last_gain(self): | ||
20 | return getattr(self, self.effect + "_get_last_gain")() | ||
21 | |||
22 | def get_next_gain(self, current_frame, current_loop, frame_count): | ||
23 | # This returns two values: | ||
24 | # - The first one is the gain to apply on that frame | ||
25 | # - The last one is True or False depending on whether it is the last | ||
26 | # call to the function and the last gain should be saved permanently | ||
27 | return getattr(self, self.effect + "_get_next_gain")( | ||
28 | current_frame, | ||
29 | current_loop, | ||
30 | frame_count) | ||
31 | |||
32 | # Fading | ||
33 | def fade_init(self, gain=0, **kwargs): | ||
34 | self.audio_segment_frame_count = self.audio_segment.frame_count() | ||
35 | self.first_frame = int( | ||
36 | self.audio_segment_frame_count * self.initial_loop +\ | ||
37 | self.audio_segment.frame_rate * self.start) | ||
38 | self.last_frame = int( | ||
39 | self.audio_segment_frame_count * self.initial_loop +\ | ||
40 | self.audio_segment.frame_rate * self.end) | ||
41 | self.gain= gain | ||
42 | |||
43 | def fade_get_last_gain(self): | ||
44 | return self.gain | ||
45 | |||
46 | def fade_get_next_gain(self, current_frame, current_loop, frame_count): | ||
47 | current_frame = current_frame \ | ||
48 | + (current_loop - self.initial_loop) \ | ||
49 | * self.audio_segment_frame_count | ||
50 | |||
51 | if current_frame >= self.last_frame: | ||
52 | return [self.gain, True] | ||
53 | elif current_frame < self.first_frame: | ||
54 | return [0, False] | ||
55 | else: | ||
56 | return [ | ||
57 | (current_frame - self.first_frame) / \ | ||
58 | (self.last_frame - self.first_frame) * self.gain, | ||
59 | False | ||
60 | ] | ||
61 | |||
62 | |||
diff --git a/music_sampler/music_file.py b/music_sampler/music_file.py new file mode 100644 index 0000000..2d3ba72 --- /dev/null +++ b/music_sampler/music_file.py | |||
@@ -0,0 +1,378 @@ | |||
1 | import threading | ||
2 | import pydub | ||
3 | import time | ||
4 | from transitions.extensions import HierarchicalMachine as Machine | ||
5 | |||
6 | import os.path | ||
7 | |||
8 | import audioop | ||
9 | |||
10 | from .lock import Lock | ||
11 | from . import Config, gain, debug_print, error_print | ||
12 | from .mixer import Mixer | ||
13 | from .music_effect import GainEffect | ||
14 | |||
15 | file_lock = Lock("file") | ||
16 | |||
17 | class MusicFile: | ||
18 | STATES = [ | ||
19 | 'initial', | ||
20 | 'loading', | ||
21 | 'failed', | ||
22 | { | ||
23 | 'name': 'loaded', | ||
24 | 'children': [ | ||
25 | 'playing', | ||
26 | 'paused', | ||
27 | 'stopping' | ||
28 | ] | ||
29 | } | ||
30 | ] | ||
31 | TRANSITIONS = [ | ||
32 | { | ||
33 | 'trigger': 'load', | ||
34 | 'source': 'initial', | ||
35 | 'dest': 'loading', | ||
36 | 'after': 'poll_loaded' | ||
37 | }, | ||
38 | { | ||
39 | 'trigger': 'fail', | ||
40 | 'source': 'loading', | ||
41 | 'dest': 'failed' | ||
42 | }, | ||
43 | { | ||
44 | 'trigger': 'success', | ||
45 | 'source': 'loading', | ||
46 | 'dest': 'loaded' | ||
47 | }, | ||
48 | { | ||
49 | 'trigger': 'start_playing', | ||
50 | 'source': 'loaded', | ||
51 | 'dest': 'loaded_playing', | ||
52 | # if a child has no transitions, then it is bubbled to the parent, | ||
53 | # and we don't want that. Not useful in that machine precisely. | ||
54 | 'conditions': ['is_loaded'] | ||
55 | }, | ||
56 | { | ||
57 | 'trigger': 'pause', | ||
58 | 'source': 'loaded_playing', | ||
59 | 'dest': 'loaded_paused' | ||
60 | }, | ||
61 | { | ||
62 | 'trigger': 'unpause', | ||
63 | 'source': 'loaded_paused', | ||
64 | 'dest': 'loaded_playing' | ||
65 | }, | ||
66 | { | ||
67 | 'trigger': 'stop_playing', | ||
68 | 'source': ['loaded_playing','loaded_paused'], | ||
69 | 'dest': 'loaded_stopping' | ||
70 | }, | ||
71 | { | ||
72 | 'trigger': 'stopped', | ||
73 | 'source': '*', | ||
74 | 'dest': 'loaded', | ||
75 | 'before': 'trigger_stopped_events', | ||
76 | 'conditions': ['is_in_use'] | ||
77 | } | ||
78 | ] | ||
79 | |||
80 | def __init__(self, filename, mapping, name=None, gain=1): | ||
81 | Machine(model=self, states=self.STATES, | ||
82 | transitions=self.TRANSITIONS, initial='initial', | ||
83 | ignore_invalid_triggers=True) | ||
84 | |||
85 | self.loaded_callbacks = [] | ||
86 | self.mapping = mapping | ||
87 | self.filename = filename | ||
88 | self.name = name or filename | ||
89 | self.audio_segment = None | ||
90 | self.initial_volume_factor = gain | ||
91 | self.music_lock = Lock("music__" + filename) | ||
92 | |||
93 | threading.Thread(name="MSMusicLoad", target=self.load).start() | ||
94 | |||
95 | def reload_properties(self, name=None, gain=1): | ||
96 | self.name = name or self.filename | ||
97 | if gain != self.initial_volume_factor: | ||
98 | self.initial_volume_factor = gain | ||
99 | self.reload_music_file() | ||
100 | |||
101 | def reload_music_file(self): | ||
102 | with file_lock: | ||
103 | try: | ||
104 | if self.filename.startswith("/"): | ||
105 | filename = self.filename | ||
106 | else: | ||
107 | filename = Config.music_path + self.filename | ||
108 | |||
109 | debug_print("Reloading « {} »".format(self.name)) | ||
110 | initial_db_gain = gain(self.initial_volume_factor * 100) | ||
111 | self.audio_segment = pydub.AudioSegment \ | ||
112 | .from_file(filename) \ | ||
113 | .set_frame_rate(Config.frame_rate) \ | ||
114 | .set_channels(Config.channels) \ | ||
115 | .set_sample_width(Config.sample_width) \ | ||
116 | .apply_gain(initial_db_gain) | ||
117 | except Exception as e: | ||
118 | error_print("failed to reload « {} »: {}"\ | ||
119 | .format(self.name, e)) | ||
120 | self.loading_error = e | ||
121 | self.to_failed() | ||
122 | else: | ||
123 | debug_print("Reloaded « {} »".format(self.name)) | ||
124 | |||
125 | # Machine related events | ||
126 | def on_enter_loading(self): | ||
127 | with file_lock: | ||
128 | try: | ||
129 | if self.filename.startswith("/"): | ||
130 | filename = self.filename | ||
131 | else: | ||
132 | filename = Config.music_path + self.filename | ||
133 | |||
134 | debug_print("Loading « {} »".format(self.name)) | ||
135 | self.mixer = self.mapping.mixer or Mixer() | ||
136 | initial_db_gain = gain(self.initial_volume_factor * 100) | ||
137 | self.audio_segment = pydub.AudioSegment \ | ||
138 | .from_file(filename) \ | ||
139 | .set_frame_rate(Config.frame_rate) \ | ||
140 | .set_channels(Config.channels) \ | ||
141 | .set_sample_width(Config.sample_width) \ | ||
142 | .apply_gain(initial_db_gain) | ||
143 | self.sound_duration = self.audio_segment.duration_seconds | ||
144 | except Exception as e: | ||
145 | error_print("failed to load « {} »: {}".format(self.name, e)) | ||
146 | self.loading_error = e | ||
147 | self.fail() | ||
148 | else: | ||
149 | self.success() | ||
150 | debug_print("Loaded « {} »".format(self.name)) | ||
151 | |||
152 | def on_enter_loaded(self): | ||
153 | self.cleanup() | ||
154 | |||
155 | def cleanup(self): | ||
156 | self.gain_effects = [] | ||
157 | self.set_gain(0, absolute=True) | ||
158 | self.current_audio_segment = None | ||
159 | self.volume = 100 | ||
160 | self.wait_event = threading.Event() | ||
161 | self.current_loop = 0 | ||
162 | |||
163 | def on_enter_loaded_playing(self): | ||
164 | self.mixer.add_file(self) | ||
165 | |||
166 | # Machine related states | ||
167 | def is_in_use(self): | ||
168 | return self.is_loaded(allow_substates=True) and not self.is_loaded() | ||
169 | |||
170 | def is_in_use_not_stopping(self): | ||
171 | return self.is_loaded_playing() or self.is_loaded_paused() | ||
172 | |||
173 | # Machine related triggers | ||
174 | def trigger_stopped_events(self): | ||
175 | self.mixer.remove_file(self) | ||
176 | self.wait_event.set() | ||
177 | self.cleanup() | ||
178 | |||
179 | # Actions and properties called externally | ||
180 | @property | ||
181 | def sound_position(self): | ||
182 | if self.is_in_use(): | ||
183 | return self.current_frame / self.current_audio_segment.frame_rate | ||
184 | else: | ||
185 | return 0 | ||
186 | |||
187 | def play(self, fade_in=0, volume=100, loop=0, start_at=0): | ||
188 | self.set_gain(gain(volume) + self.mapping.master_gain, absolute=True) | ||
189 | self.volume = volume | ||
190 | if loop < 0: | ||
191 | self.last_loop = float('inf') | ||
192 | else: | ||
193 | self.last_loop = loop | ||
194 | |||
195 | with self.music_lock: | ||
196 | self.current_audio_segment = self.audio_segment | ||
197 | self.current_frame = int(start_at * self.audio_segment.frame_rate) | ||
198 | |||
199 | self.start_playing() | ||
200 | |||
201 | if fade_in > 0: | ||
202 | db_gain = gain(self.volume, 0)[0] | ||
203 | self.set_gain(-db_gain) | ||
204 | self.add_fade_effect(db_gain, fade_in) | ||
205 | |||
206 | def seek(self, value=0, delta=False): | ||
207 | if not self.is_in_use_not_stopping(): | ||
208 | return | ||
209 | |||
210 | with self.music_lock: | ||
211 | self.abandon_all_effects() | ||
212 | if delta: | ||
213 | frame_count = int(self.audio_segment.frame_count()) | ||
214 | frame_diff = int(value * self.audio_segment.frame_rate) | ||
215 | self.current_frame += frame_diff | ||
216 | while self.current_frame < 0: | ||
217 | self.current_loop -= 1 | ||
218 | self.current_frame += frame_count | ||
219 | while self.current_frame > frame_count: | ||
220 | self.current_loop += 1 | ||
221 | self.current_frame -= frame_count | ||
222 | if self.current_loop < 0: | ||
223 | self.current_loop = 0 | ||
224 | self.current_frame = 0 | ||
225 | if self.current_loop > self.last_loop: | ||
226 | self.current_loop = self.last_loop | ||
227 | self.current_frame = frame_count | ||
228 | else: | ||
229 | self.current_frame = max( | ||
230 | 0, | ||
231 | int(value * self.audio_segment.frame_rate)) | ||
232 | |||
233 | def stop(self, fade_out=0, wait=False, set_wait_id=None): | ||
234 | if self.is_loaded_playing(): | ||
235 | ms = int(self.sound_position * 1000) | ||
236 | ms_fo = max(1, int(fade_out * 1000)) | ||
237 | |||
238 | new_audio_segment = self.current_audio_segment[: ms+ms_fo] \ | ||
239 | .fade_out(ms_fo) | ||
240 | with self.music_lock: | ||
241 | self.current_audio_segment = new_audio_segment | ||
242 | self.stop_playing() | ||
243 | if wait: | ||
244 | if set_wait_id is not None: | ||
245 | self.mapping.add_wait_id(set_wait_id, self.wait_event) | ||
246 | self.wait_end() | ||
247 | else: | ||
248 | self.stopped() | ||
249 | |||
250 | def abandon_all_effects(self): | ||
251 | db_gain = 0 | ||
252 | for gain_effect in self.gain_effects: | ||
253 | db_gain += gain_effect.get_last_gain() | ||
254 | |||
255 | self.gain_effects = [] | ||
256 | self.set_gain(db_gain) | ||
257 | |||
258 | def set_volume(self, value, delta=False, fade=0): | ||
259 | [db_gain, self.volume] = gain( | ||
260 | value + int(delta) * self.volume, | ||
261 | self.volume) | ||
262 | |||
263 | self.set_gain_with_effect(db_gain, fade=fade) | ||
264 | |||
265 | def set_gain_with_effect(self, db_gain, fade=0): | ||
266 | if not self.is_in_use(): | ||
267 | return | ||
268 | |||
269 | if fade > 0: | ||
270 | self.add_fade_effect(db_gain, fade) | ||
271 | else: | ||
272 | self.set_gain(db_gain) | ||
273 | |||
274 | def wait_end(self): | ||
275 | self.wait_event.clear() | ||
276 | self.wait_event.wait() | ||
277 | |||
278 | # Let other subscribe for an event when they are ready | ||
279 | def subscribe_loaded(self, callback): | ||
280 | # FIXME: should lock to be sure we have no race, but it makes the | ||
281 | # initialization screen not showing until everything is loaded | ||
282 | if self.is_loaded(allow_substates=True): | ||
283 | callback(True) | ||
284 | elif self.is_failed(): | ||
285 | callback(False) | ||
286 | else: | ||
287 | self.loaded_callbacks.append(callback) | ||
288 | |||
289 | def poll_loaded(self): | ||
290 | for callback in self.loaded_callbacks: | ||
291 | callback(self.is_loaded()) | ||
292 | self.loaded_callbacks = [] | ||
293 | |||
294 | # Callbacks | ||
295 | def finished_callback(self): | ||
296 | self.stopped() | ||
297 | |||
298 | def play_callback(self, out_data_length, frame_count): | ||
299 | if self.is_loaded_paused(): | ||
300 | return b'\0' * out_data_length | ||
301 | |||
302 | with self.music_lock: | ||
303 | [data, nb_frames] = self.get_next_sample(frame_count) | ||
304 | if nb_frames < frame_count: | ||
305 | if self.is_loaded_playing() and\ | ||
306 | self.current_loop < self.last_loop: | ||
307 | self.current_loop += 1 | ||
308 | self.current_frame = 0 | ||
309 | [new_data, new_nb_frames] = self.get_next_sample( | ||
310 | frame_count - nb_frames) | ||
311 | data += new_data | ||
312 | nb_frames += new_nb_frames | ||
313 | elif nb_frames == 0: | ||
314 | # FIXME: too slow when mixing multiple streams | ||
315 | threading.Thread( | ||
316 | name="MSFinishedCallback", | ||
317 | target=self.finished_callback).start() | ||
318 | |||
319 | return data.ljust(out_data_length, b'\0') | ||
320 | |||
321 | # Helpers | ||
322 | def set_gain(self, db_gain, absolute=False): | ||
323 | if absolute: | ||
324 | self.db_gain = db_gain | ||
325 | else: | ||
326 | self.db_gain += db_gain | ||
327 | |||
328 | def get_next_sample(self, frame_count): | ||
329 | fw = self.audio_segment.frame_width | ||
330 | |||
331 | data = b"" | ||
332 | nb_frames = 0 | ||
333 | |||
334 | segment = self.current_audio_segment | ||
335 | max_val = int(segment.frame_count()) | ||
336 | |||
337 | start_i = max(self.current_frame, 0) | ||
338 | end_i = min(self.current_frame + frame_count, max_val) | ||
339 | data += segment._data[start_i*fw : end_i*fw] | ||
340 | nb_frames += end_i - start_i | ||
341 | self.current_frame += end_i - start_i | ||
342 | |||
343 | volume_factor = self.volume_factor(self.effects_next_gain(nb_frames)) | ||
344 | |||
345 | data = audioop.mul(data, Config.sample_width, volume_factor) | ||
346 | |||
347 | return [data, nb_frames] | ||
348 | |||
349 | def add_fade_effect(self, db_gain, fade_duration): | ||
350 | if not self.is_in_use(): | ||
351 | return | ||
352 | |||
353 | self.gain_effects.append(GainEffect( | ||
354 | "fade", | ||
355 | self.current_audio_segment, | ||
356 | self.current_loop, | ||
357 | self.sound_position, | ||
358 | self.sound_position + fade_duration, | ||
359 | gain=db_gain)) | ||
360 | |||
361 | def effects_next_gain(self, frame_count): | ||
362 | db_gain = 0 | ||
363 | for gain_effect in self.gain_effects: | ||
364 | [new_gain, last_gain] = gain_effect.get_next_gain( | ||
365 | self.current_frame, | ||
366 | self.current_loop, | ||
367 | frame_count) | ||
368 | if last_gain: | ||
369 | self.set_gain(new_gain) | ||
370 | self.gain_effects.remove(gain_effect) | ||
371 | else: | ||
372 | db_gain += new_gain | ||
373 | return db_gain | ||
374 | |||
375 | |||
376 | def volume_factor(self, additional_gain=0): | ||
377 | return 10 ** ( (self.db_gain + additional_gain) / 20) | ||
378 | |||
diff --git a/music_sampler/sysfont.py b/music_sampler/sysfont.py new file mode 100644 index 0000000..f47693e --- /dev/null +++ b/music_sampler/sysfont.py | |||
@@ -0,0 +1,224 @@ | |||
1 | # This file was imported from | ||
2 | # https://bitbucket.org/marcusva/python-utils/overview | ||
3 | # And slightly adapted | ||
4 | |||
5 | """OS-specific font detection.""" | ||
6 | import os | ||
7 | import sys | ||
8 | from subprocess import Popen, PIPE | ||
9 | |||
10 | if sys.platform in ("win32", "cli"): | ||
11 | import winreg | ||
12 | |||
13 | __all__ = ["STYLE_NORMAL", "STYLE_BOLD", "STYLE_ITALIC", "STYLE_LIGHT", | ||
14 | "init", "list_fonts", "get_fonts", "get_font" | ||
15 | ] | ||
16 | |||
17 | # Font cache entries: | ||
18 | # { family : [..., | ||
19 | # (name, styles, fonttype, filename) | ||
20 | # ... | ||
21 | # ] | ||
22 | # } | ||
23 | __FONTCACHE = None | ||
24 | |||
25 | |||
26 | STYLE_NONE = 0x00 | ||
27 | STYLE_NORMAL = 0x01 | ||
28 | STYLE_BOLD = 0x02 | ||
29 | STYLE_ITALIC = 0x04 | ||
30 | STYLE_LIGHT = 0x08 | ||
31 | STYLE_MEDIUM = 0x10 | ||
32 | |||
33 | def _add_font(family, name, styles, fonttype, filename): | ||
34 | """Adds a font to the internal font cache.""" | ||
35 | global __FONTCACHE | ||
36 | |||
37 | if family not in __FONTCACHE: | ||
38 | __FONTCACHE[family] = [] | ||
39 | __FONTCACHE[family].append((name, styles, fonttype, filename)) | ||
40 | |||
41 | |||
42 | def _cache_fonts_win32(): | ||
43 | """Caches fonts on a Win32 platform.""" | ||
44 | key = "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Fonts" | ||
45 | regfonts = [] | ||
46 | try: | ||
47 | with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key) as fontkey: | ||
48 | idx = 0 | ||
49 | enumval = winreg.EnumValue | ||
50 | rappend = regfonts.append | ||
51 | while True: | ||
52 | rappend(enumval(fontkey, idx)[:2]) | ||
53 | idx += 1 | ||
54 | except WindowsError: | ||
55 | pass | ||
56 | |||
57 | # TODO: integrate alias handling for fonts within the registry. | ||
58 | # TODO: Scan and index fonts from %SystemRoot%\\Fonts that are not in the | ||
59 | # registry | ||
60 | |||
61 | # Received all fonts from the registry. | ||
62 | for name, filename in regfonts: | ||
63 | fonttype = os.path.splitext(filename)[1][1:].lower() | ||
64 | if name.endswith("(TrueType)"): | ||
65 | name = name[:-10].strip() | ||
66 | if name.endswith("(All Res)"): | ||
67 | name = name[:-9].strip() | ||
68 | style = STYLE_NORMAL | ||
69 | if name.find(" Bold") >= 0: | ||
70 | style |= STYLE_BOLD | ||
71 | if name.find(" Italic") >= 0 or name.find(" Oblique") >= 0: | ||
72 | style |= STYLE_ITALIC | ||
73 | |||
74 | family = name | ||
75 | for rm in ("Bold", "Italic", "Oblique"): | ||
76 | family = family.replace(rm, "") | ||
77 | family = family.lower().strip() | ||
78 | |||
79 | fontpath = os.environ.get("SystemRoot", "C:\\Windows") | ||
80 | fontpath = os.path.join(fontpath, "Fonts") | ||
81 | if filename.find("\\") == -1: | ||
82 | # No path delimiter is given; we assume it to be a font in | ||
83 | # %SystemRoot%\Fonts | ||
84 | filename = os.path.join(fontpath, filename) | ||
85 | _add_font(family, name, style, fonttype, filename) | ||
86 | |||
87 | |||
88 | def _cache_fonts_darwin(): | ||
89 | """Caches fonts on Mac OS.""" | ||
90 | raise NotImplementedError("Mac OS X support is not given yet") | ||
91 | |||
92 | |||
93 | def _cache_fonts_fontconfig(): | ||
94 | """Caches font on POSIX-alike platforms.""" | ||
95 | try: | ||
96 | command = "fc-list : file family style fullname fullnamelang" | ||
97 | proc = Popen(command, stdout=PIPE, shell=True, stderr=PIPE) | ||
98 | pout = proc.communicate()[0] | ||
99 | output = pout.decode("utf-8") | ||
100 | except OSError: | ||
101 | return | ||
102 | |||
103 | for entry in output.split(os.linesep): | ||
104 | if entry.strip() == "": | ||
105 | continue | ||
106 | values = entry.split(":") | ||
107 | filename = values[0] | ||
108 | |||
109 | # get the font type | ||
110 | fname, fonttype = os.path.splitext(filename) | ||
111 | if fonttype == ".gz": | ||
112 | fonttype = os.path.splitext(fname)[1][1:].lower() | ||
113 | else: | ||
114 | fonttype = fonttype.lstrip(".").lower() | ||
115 | |||
116 | # get the font name | ||
117 | name = None | ||
118 | if len(values) > 3: | ||
119 | fullnames, fullnamelangs = values[3:] | ||
120 | langs = fullnamelangs.split(",") | ||
121 | try: | ||
122 | offset = langs.index("fullnamelang=en") | ||
123 | except ValueError: | ||
124 | offset = -1 | ||
125 | if offset == -1: | ||
126 | try: | ||
127 | offset = langs.index("en") | ||
128 | except ValueError: | ||
129 | offset = -1 | ||
130 | if offset != -1: | ||
131 | # got an english name, use that one | ||
132 | name = fullnames.split(",")[offset] | ||
133 | if name.startswith("fullname="): | ||
134 | name = name[9:] | ||
135 | if name is None: | ||
136 | if fname.endswith(".pcf") or fname.endswith(".bdf"): | ||
137 | name = os.path.basename(fname[:-4]) | ||
138 | else: | ||
139 | name = os.path.basename(fname) | ||
140 | name = name.lower() | ||
141 | |||
142 | # family and styles | ||
143 | family = values[1].strip().lower() | ||
144 | stylevals = values[2].strip() | ||
145 | style = STYLE_NONE | ||
146 | |||
147 | if stylevals.find("Bold") >= 0: | ||
148 | style |= STYLE_BOLD | ||
149 | if stylevals.find("Light") >= 0: | ||
150 | style |= STYLE_LIGHT | ||
151 | if stylevals.find("Italic") >= 0 or stylevals.find("Oblique") >= 0: | ||
152 | style |= STYLE_ITALIC | ||
153 | if stylevals.find("Medium") >= 0: | ||
154 | style |= STYLE_MEDIUM | ||
155 | if style == STYLE_NONE: | ||
156 | style = STYLE_NORMAL | ||
157 | _add_font(family, name, style, fonttype, filename) | ||
158 | |||
159 | |||
160 | def init(): | ||
161 | """Initialises the internal font cache. | ||
162 | |||
163 | It does not need to be called explicitly. | ||
164 | """ | ||
165 | global __FONTCACHE | ||
166 | if __FONTCACHE is not None: | ||
167 | return | ||
168 | __FONTCACHE = {} | ||
169 | if sys.platform in ("win32", "cli"): | ||
170 | _cache_fonts_win32() | ||
171 | elif sys.platform == "darwin": | ||
172 | _cache_fonts_darwin() | ||
173 | else: | ||
174 | _cache_fonts_fontconfig() | ||
175 | |||
176 | |||
177 | def list_fonts(): | ||
178 | """Returns an iterator over the cached fonts.""" | ||
179 | if __FONTCACHE is None: | ||
180 | init() | ||
181 | if len(__FONTCACHE) == 0: | ||
182 | yield None | ||
183 | for family, entries in __FONTCACHE.items(): | ||
184 | for fname, styles, fonttype, filename in entries: | ||
185 | yield (family, fname, styles, fonttype, filename) | ||
186 | |||
187 | |||
188 | def get_fonts(name, style=STYLE_NONE, ftype=None): | ||
189 | """Retrieves all fonts matching the given family or font name.""" | ||
190 | if __FONTCACHE is None: | ||
191 | init() | ||
192 | if len(__FONTCACHE) == 0: | ||
193 | return None | ||
194 | |||
195 | results = [] | ||
196 | rappend = results.append | ||
197 | |||
198 | name = name.lower() | ||
199 | if ftype: | ||
200 | ftype = ftype.lower() | ||
201 | |||
202 | fonts = __FONTCACHE.get(name, []) | ||
203 | for fname, fstyles, fonttype, filename in fonts: | ||
204 | if ftype and fonttype != ftype: | ||
205 | # ignore font filetype mismatches | ||
206 | continue | ||
207 | if style == STYLE_NONE or fstyles == style: | ||
208 | rappend((name, fname, fstyles, fonttype, filename)) | ||
209 | |||
210 | for family, fonts in __FONTCACHE.items(): | ||
211 | for fname, fstyles, fonttype, filename in fonts: | ||
212 | if fname.lower() == name and filename not in results: | ||
213 | rappend((family, fname, fstyles, fonttype, filename)) | ||
214 | return results | ||
215 | |||
216 | |||
217 | def get_font(name, style=STYLE_NONE, ftype=None): | ||
218 | """Retrieves the best matching font file for the given name and | ||
219 | criteria. | ||
220 | """ | ||
221 | retvals = get_fonts(name, style, ftype) | ||
222 | if len(retvals) > 0: | ||
223 | return retvals[0] | ||
224 | return None | ||