aboutsummaryrefslogtreecommitdiff
path: root/music_sampler
diff options
context:
space:
mode:
Diffstat (limited to 'music_sampler')
-rw-r--r--music_sampler/__init__.py192
-rw-r--r--music_sampler/action.py113
-rw-r--r--music_sampler/actions/__init__.py10
-rw-r--r--music_sampler/actions/interrupt_wait.py5
-rw-r--r--music_sampler/actions/pause.py10
-rw-r--r--music_sampler/actions/play.py44
-rw-r--r--music_sampler/actions/run_command.py13
-rw-r--r--music_sampler/actions/seek.py19
-rw-r--r--music_sampler/actions/stop.py42
-rw-r--r--music_sampler/actions/stop_all_actions.py14
-rw-r--r--music_sampler/actions/unpause.py10
-rw-r--r--music_sampler/actions/volume.py28
-rw-r--r--music_sampler/actions/wait.py40
-rw-r--r--music_sampler/key.py280
-rw-r--r--music_sampler/lock.py23
-rw-r--r--music_sampler/mapping.py399
-rw-r--r--music_sampler/mixer.py63
-rw-r--r--music_sampler/music_effect.py62
-rw-r--r--music_sampler/music_file.py378
-rw-r--r--music_sampler/sysfont.py224
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 -*-
2import argparse
3import sys
4import os
5import math
6import sounddevice as sd
7import logging
8
9from . import sysfont
10
11class Config:
12 pass
13
14def 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
23def 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
46def 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
53def 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
144class 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
149class SelectDeviceAction(argparse.Action):
150 def __call__(self, parser, namespace, values, option_string=None):
151 sd.default.device = values
152
153class 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
159def 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
166def 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
174def 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
182def debug_print(message, with_trace=False):
183 from kivy.logger import Logger
184 Logger.debug('MusicSampler: ' + message, exc_info=with_trace)
185
186def error_print(message, with_trace=False):
187 from kivy.logger import Logger
188 Logger.error('MusicSampler: ' + message, exc_info=with_trace)
189
190def 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 @@
1from transitions.extensions import HierarchicalMachine as Machine
2from . import debug_print, error_print
3from . import actions
4
5class 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 @@
1from . import interrupt_wait
2from . import pause
3from . import play
4from . import run_command
5from . import seek
6from . import stop
7from . import stop_all_actions
8from . import unpause
9from . import volume
10from . 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 @@
1def run(action, wait_id=None, **kwargs):
2 action.mapping.interrupt_wait(wait_id)
3
4def 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 @@
1def run(action, music=None, **kwargs):
2 for music in action.music_list(music):
3 if music.is_loaded_playing():
4 music.pause()
5
6def 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 @@
1def 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
20def 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 @@
1import shlex, subprocess
2
3def run(action, command="", wait=False, **kwargs):
4 action.process = subprocess.Popen(command, shell=True)
5 if wait:
6 action.process.wait()
7
8def 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 @@
1def 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
5def 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 @@
1def 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
19def 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
39def 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 @@
1def 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
9def 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 @@
1def run(action, music=None, **kwargs):
2 for music in action.music_list(music):
3 if music.is_loaded_paused():
4 music.unpause()
5
6def 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 @@
1def 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
7def 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 @@
1import threading
2
3def 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
18def 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
35def 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 @@
1from kivy.uix.widget import Widget
2from kivy.properties import AliasProperty, BooleanProperty, \
3 ListProperty, StringProperty
4from kivy.uix.behaviors import ButtonBehavior
5
6from .action import Action
7from . import debug_print
8import time
9import threading
10from transitions.extensions import HierarchicalMachine as Machine
11
12class 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 @@
1import threading
2
3from . import debug_print
4
5class 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 @@
1from kivy.uix.relativelayout import RelativeLayout
2from kivy.properties import NumericProperty, ListProperty, StringProperty
3from kivy.core.window import Window
4from kivy.clock import Clock
5
6import threading
7import yaml
8import sys
9from collections import defaultdict
10
11from transitions.extensions import HierarchicalMachine as Machine
12
13from .music_file import MusicFile
14from .mixer import Mixer
15from . import Config, gain, error_print, warn_print
16from .action import Action
17
18class 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 @@
1import sounddevice as sd
2import audioop
3import time
4
5from . import Config
6
7sample_width = Config.sample_width
8
9def 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
15def _latency(latency):
16 if latency == "high" or latency == "low":
17 return latency
18 else:
19 return float(latency)
20
21class 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 @@
1class 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 @@
1import threading
2import pydub
3import time
4from transitions.extensions import HierarchicalMachine as Machine
5
6import os.path
7
8import audioop
9
10from .lock import Lock
11from . import Config, gain, debug_print, error_print
12from .mixer import Mixer
13from .music_effect import GainEffect
14
15file_lock = Lock("file")
16
17class 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."""
6import os
7import sys
8from subprocess import Popen, PIPE
9
10if 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
26STYLE_NONE = 0x00
27STYLE_NORMAL = 0x01
28STYLE_BOLD = 0x02
29STYLE_ITALIC = 0x04
30STYLE_LIGHT = 0x08
31STYLE_MEDIUM = 0x10
32
33def _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
42def _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
88def _cache_fonts_darwin():
89 """Caches fonts on Mac OS."""
90 raise NotImplementedError("Mac OS X support is not given yet")
91
92
93def _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
160def 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
177def 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
188def 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
217def 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