-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathworkout.py
298 lines (256 loc) · 8.59 KB
/
workout.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
import os, inspect, re, tempfile, math
from dataclasses import dataclass
# configuration
MAKE_MP3S = True
TARGET_DIR = "target/"
DRILLS_DIR = "02.drills/"
BRACKETS_DIR = "03.brackets/"
NUM_PADDING = 4
DRILL_LENGTH_MINS = 5
METRONOME_INSTRUMENT = 116 - 1 # woodblock
ALARM_INSTRUMENT = 128 - 1 # gunshot
DRONE_INSTRUMENT = 57 - 1 # trumpet (closest to perfect pitch)
CHUNK_FADE_SECS = 2.5
CHUNK_DELAY_SECS = 5
# global state
os.mkdir(TARGET_DIR)
os.chdir(TARGET_DIR)
os.mkdir(DRILLS_DIR)
os.mkdir(BRACKETS_DIR)
drills = set()
brackets = 0
@dataclass
class Note:
start_beat: str
stop_beat: str
degree: str
attack: str
dynamics: str
label: str
@dataclass
class Phrase:
label: str
notes: list[Note]
start_secs: float
stop_secs: float
@dataclass
class Section:
function: str
label: str
phrases: list[Phrase]
@dataclass
class Piece:
name: str
meter: int
tempo: int
tonic: str
sections: list[Section]
mp3: str
def parse_note(text: str):
"""
field order: beat degree attack dynamic " " label
example: "10LM twin- 10LM kl"
"""
return Note(text[0], text[1], text[2], text[3], text[5:], {})
#
# Make a new directory of cards and change into that directory.
# The caller of this function is responsible for changing back
# to the original directory when the cards have been generated.
#
def mcd(dirname):
os.makedirs(dirname)
os.chdir(dirname)
def make_bracket(tempo, notes):
if len(notes) == 0: return
global brackets
with open(BRACKETS_DIR + bracketnum() + "B." + notes[0].label + ".txt", "w") as f:
for note in notes: f.write(note.to_string() + "\n")
brackets += 1
def make_phrase_drill(name, tempo, notes, reps=5):
if len(notes) == 0: return
# check for duplicates
hash = make_hash(name, {"tempo":tempo, "notes":notes })
if hash in drills: return False
drills.add(hash)
with open(DRILLS_DIR + drillnum() + "A.txt", "w") as f:
f.write(f"{name} @{tempo} x{reps}\n")
for note in notes: f.write(note.to_string() + "\n")
#
# make a drill card, ensuring it is unique, and formatting
# it appropriately as a text file
#
def make_drill(params={}, reps=5):
# check for duplicates
name = inspect.stack()[1].function
hash = make_hash(name, params)
if hash in drills: return False
drills.add(hash)
# write card text
with open(DRILLS_DIR + drillnum() + "A.txt", "w") as f:
f.write(name + " x" + str(reps) + "\n")
for key in params:
if params[key]:
f.write(key[0:3].upper() + " ")
if isinstance(params[key], list):
f.write(" ".join(params[key]) + "\n")
else:
f.write(str(params[key]) + "\n")
return True
#
# hash a drill with the given parameters, such that combinations
# resulting in the same physics result in the same value
#
def make_hash(name, params):
keys = params.copy()
if "lyrics" in keys: del keys["lyrics"]
if "index" in keys: del keys["index"]
if "rhythm" in keys: keys["rhythm"] = shift_rhythm(keys["rhythm"])
return name + str(keys)
# shift a rhythm pattern to start on the first beat
def shift_rhythm(rhythm):
onsets = "1bar2dup3cet4mow"
shift = int(onsets.index(rhythm[0]) / 4) * 4
if shift == 0:
return rhythm
else:
return "".join(map(lambda c: onsets[onsets.index(c) - shift], rhythm))
# format the current drill number as a zero-padded string
def drillnum(): return str(len(drills)).zfill(NUM_PADDING)
def bracketnum(): return str(brackets).zfill(NUM_PADDING)
#
# make a metronome with the given tempo which goes for DRILL_LENGTH_MINS
# before sounding an alarm
#
# note: abc2midi miscalculates when tempo < 4, so we multiply the tempo by 4
# and set the midi speed to 25%
#
def make_metronome(tempo):
if MAKE_MP3S:
num_notes = int(DRILL_LENGTH_MINS * tempo)
filename = "=T" + str(int(tempo)).zfill(NUM_PADDING - 1) + ".mp3"
make_mp3(f"""
X:0
M:1/4
L:1/4
Q:{tempo*4}
K:C
%%MIDI program {METRONOME_INSTRUMENT}
{"|c" * num_notes}
%%MIDI program {ALARM_INSTRUMENT}
Q:240
|C|C|C|C|z|z|z|z""", DRILLS_DIR + filename, tempo_percent=25)
#
# make a drone of a single pitch which lasts for DRILL_LENGTH_MINS
# and finishes with an alarm
#
def make_drone(note):
make_mp3(f"""
X:0
M:4/4
L:1/4
Q:4
K:C transpose={note_to_decimal(note)}
%%MIDI program {DRONE_INSTRUMENT}
{"|C,,,,C,,,,C,,,,C,,,," * DRILL_LENGTH_MINS}
%%MIDI program {ALARM_INSTRUMENT}
Q:60
|CCCC|z4""", DRILLS_DIR + drillnum() + "B.mp3")
#
# convert an abc score to an mp3 file
#
def make_mp3(score, filename, transpose=0, tempo_percent=100):
if MAKE_MP3S:
if not os.path.exists(filename):
os.system(f"""echo '{score}' \
| abc2midi /dev/stdin -o /dev/stdout \
| timidity - --quiet --quiet --output-24bit -A800 -K{transpose} -T{tempo_percent} -Ow -o - \
| ffmpeg -loglevel error -i - -ac 1 -ab 64k "{filename}"
""")
def make_whole(mp3, speed=1, silence=0):
if MAKE_MP3S:
outfile = BRACKETS_DIR + bracketnum() + ".mp3"
os.system(f"""
ffmpeg -nostdin -loglevel error -i {mp3} -ac 1 -ar 48000 -q 4 \
-af atempo={speed},adelay={silence}s:all=true "{outfile}"
""")
def make_chunk(mp3, start_secs, stop_secs, speed=1.0):
if MAKE_MP3S and stop_secs > 0:
cut_chunk(mp3, start_secs, stop_secs, speed, BRACKETS_DIR + bracketnum() + "A.mp3");
def make_mixed_chunk(mp3, start_secs, stop_secs):
if MAKE_MP3S and stop_secs > 0:
with tempfile.TemporaryDirectory() as tmpdir:
# generate chunks for repetition
cut_chunk(mp3, start_secs, stop_secs, 0.5, tmpdir + "/050.mp3");
cut_chunk(mp3, start_secs, stop_secs, 1.0, tmpdir + "/100.mp3");
cut_alarm(tmpdir + "/alarm.mp3")
# repeat for the duration of the drill
chunk_length = CHUNK_FADE_SECS + stop_secs - start_secs + CHUNK_FADE_SECS
combo_length = CHUNK_DELAY_SECS + chunk_length + CHUNK_DELAY_SECS + 2 * chunk_length
reps = math.ceil(DRILL_LENGTH_MINS * 60 / combo_length)
with open(tmpdir + "/list", "w") as f:
for i in range(reps):
f.write("file {tmpdir}/050.mp3\nfile {tmpdir}/100.mp3\n".format(tmpdir=tmpdir))
f.write("file {tmpdir}/alarm.mp3\n".format(tmpdir=tmpdir))
# concatenate the chunks
outfile = BRACKETS_DIR + bracketnum() + ".mp3"
os.system(f"""
ffmpeg -nostdin -loglevel error -f concat -safe 0 -i "{tmpdir}/list" \
-codec copy "{outfile}" """)
def make_repeating_chunk(mp3, start_secs, stop_secs, speed=1.0):
if MAKE_MP3S and stop_secs > 0:
with tempfile.TemporaryDirectory() as tmpdir:
# generate chunks for repetition
cut_chunk(mp3, start_secs, stop_secs, speed, tmpdir + "/chunk.mp3");
cut_alarm(tmpdir + "/alarm.mp3")
# repeat for the duration of the drill
length = CHUNK_DELAY_SECS + (CHUNK_FADE_SECS + stop_secs - start_secs + CHUNK_FADE_SECS) / speed
reps = math.ceil(DRILL_LENGTH_MINS * 60 / length)
with open(tmpdir + "/list", "w") as f:
for i in range(reps):
f.write(f"file {tmpdir}/chunk.mp3\n")
f.write(f"file {tmpdir}/alarm.mp3\n")
# concatenate the chunks
outfile = BRACKETS_DIR + bracketnum() + ".mp3"
os.system(f"""ffmpeg -nostdin -loglevel error -f concat -safe 0 -i "{tmpdir}/list" \
-codec copy "{outfile}" """)
def cut_chunk(mp3, start_secs, stop_secs, speed, outfile):
if MAKE_MP3S and stop_secs > 0:
padding = CHUNK_FADE_SECS
delay = CHUNK_DELAY_SECS
ss = start_secs - padding
to = stop_secs + padding
st = stop_secs - start_secs + padding
os.system(f"""
ffmpeg -nostdin -loglevel error -ss {ss} -to {to} -i {mp3} -ac 1 -ar 48000 -q 4 \
-af afade=d={padding},afade=t=out:st={st}:d={padding},atempo={speed},adelay={delay}s:all=true \
"{outfile}"
""")
def cut_alarm(outfile):
make_mp3(f"""
X:0
M:4/4
L:1/4
Q:60
K:C
%%MIDI program {ALARM_INSTRUMENT}
|cccc|z4""", outfile)
# remove bar lines and spaces and replace repeat marks
def normalise_tab(x):
raw = x.replace(" ", "").replace("|", "").replace("\n", "")
result = []
for i, letter in enumerate(raw):
result.append(letter if letter != "=" else result[i-1])
return ''.join(result)
# add base-12 notes and intervals
def add(note, interval):
return decimal_to_note(note_to_decimal(note) + note_to_decimal(interval))
def note_to_decimal(note):
return int(str(note).replace("X", "A").replace("Y", "B"), 12)
def decimal_to_note(note):
if not note:
return "0"
else:
return decimal_to_note(note // 12).lstrip("0") + "0123456789XY"[note % 12]
def half(val): return int(val / 2)
# stubs
def clone(*params): return []