-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmod.py
More file actions
611 lines (532 loc) · 28.2 KB
/
Copy pathmod.py
File metadata and controls
611 lines (532 loc) · 28.2 KB
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
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
#!/usr/bin/env python3
"""--write-mod: emit a real ProTracker .MOD file. Samples and patterns, not audio.
WHY THIS INSTEAD OF A MODEL
There are no generative models for tracker formats — searched, and the only hits
are audio diffusion experiments that *sound* chiptune rather than emitting module
files. There are structural reasons: a module is a score AND its instruments (two
unrelated generative problems), its effect columns are effectively a small program,
and nobody has even packaged a corpus.
But a model was never needed. A MOD file *is* samples plus pattern data, and
soundmon already produces both — it was just mixing them down to audio instead of
writing them out separately. Same insight as --write-midi: emit the SOURCE, not
only the render.
The payoff is that the output is EDITABLE. Open it in Schism Tracker or OpenMPT,
hear the crispness natively, rewrite a pattern by hand. No rendered file can offer
that.
FORMAT (ProTracker, 31-sample "M.K.")
0 20 bytes song title
20 31 x 30 sample headers: 22-byte name, length in WORDS (BE),
finetune, volume 0-64, repeat point + length in words
950 1 byte song length in patterns (1-128)
951 1 byte 127, for historical reasons
952 128 bytes pattern order table
1080 4 bytes "M.K."
1084 ... patterns: 64 rows x 4 channels x 4 bytes
... ... sample data, signed 8-bit, concatenated in order
Each 4-byte cell packs sample number across two nibbles either side of a 12-bit
Amiga period — a layout that only makes sense once you know the sample number grew
from 4 bits to 5 after the format shipped.
"""
import math
import os
import struct
# Amiga period table, finetune 0. ProTracker itself only offered these three
# octaves, because that is all the Amiga's DMA could tune a sample across.
_PT3 = [
856, 808, 762, 720, 678, 640, 604, 570, 538, 508, 480, 453, # MIDI 36-47
428, 404, 381, 360, 339, 320, 302, 285, 269, 254, 240, 226, # MIDI 48-59
214, 202, 190, 180, 170, 160, 151, 143, 135, 127, 120, 113, # MIDI 60-71
]
# ...but the period field is 12 bits, and every modern replayer (libxmp, OpenMPT,
# Schism) honours the full range. Doubling a period drops an octave, halving it
# raises one, so extending the table is just arithmetic.
#
# THIS MATTERS: the three-octave table forced _period() to FOLD out-of-range
# notes by octave, and a folded note is not a slightly-wrong note — it is a
# different one. 12 of 328 notes in the Zelda title theme sit above MIDI 71, and
# they are the melody's peaks, so the phrase that should rise instead drops an
# octave at its high point.
# The extremes are the ones every replayer agrees on: 1712 is the standard
# extended low note, and 113 // 2 == 56 is exactly the lowest period ProTracker's
# own Amiga limit allows. Going one octave further up would need period 53, which
# replayers in ProTracker-compatible mode clamp — so five octaves is the honest
# range, not an arbitrary stop.
PERIODS = ([p * 2 for p in _PT3[:12]] # MIDI 24-35
+ _PT3 # MIDI 36-71
+ [p // 2 for p in _PT3[24:]]) # MIDI 72-83
MIDI_BASE = 24
ROWS = 64 # rows per pattern, fixed by the format
CHANNELS = 4 # ProTracker: exactly four
# Channel assignment. Four voices is the format's limit and also the 2A03's, so
# the mapping is the same one chip.py already uses.
CH_LEAD, CH_ARP, CH_BASS, CH_DRUM = 0, 1, 2, 3
def _period(pitch):
"""MIDI note -> Amiga period, folded by octaves into the table's range."""
p = int(pitch)
while p < MIDI_BASE:
p += 12
while p > MIDI_BASE + len(PERIODS) - 1:
p -= 12
return PERIODS[p - MIDI_BASE]
# A looping tone sample must be tuned to the period table, and the traditional
# 64-byte single cycle is NOT.
#
# period 428 plays at 3546894.6 / 428 = 8287.1 Hz
# 64 bytes of it loops at 8287.1 / 64 = 129.49 Hz
# but period 428 is the table's C-3, and C3 is 130.81 Hz
#
# That is 17.7 cents flat on every tone in the module — a fixed detune against the
# source it was transcribed from, and it is period-independent, so it does not
# average out across the range.
#
# The fix I reached for first was a better RATIO: three cycles in 190 bytes lands
# within 0.5 cents. Measuring the rendered output killed it. The cycle length is
# 63.33 samples, so the three cycles are NOT byte-identical, which makes the
# sample's true period the whole 190 bytes — a component at a THIRD of the
# fundamental. Autocorrelation on the libxmp render locked onto 43.6 Hz for a
# 130.8 Hz note, exactly 1/3, which is that subharmonic being the strongest
# periodicity in the signal.
#
# So: one cycle, and correct the tuning with FINETUNE, which is the field the
# format provides for precisely this. Searching cycle length against the 16
# finetune values (steps of 1/8 semitone) gives 62 bytes at finetune -3, off by
# 0.18 cents — better than the ratio trick AND with a single unambiguous period.
CYCLE_LEN = 62
FINETUNE = -3
def _cycle(kind, np, length=CYCLE_LEN, duty=0.5):
"""One cycle of a waveform in `length` bytes, as signed 8-bit.
A single cycle with the loop covering the whole sample is how trackers sustain
a tone: the replayer just repeats it, exactly as a pulse channel would.
"""
t = np.arange(length) / float(length)
if kind == "pulse":
w = np.where(t < duty, 1.0, -1.0)
elif kind == "triangle":
w = 2.0 * np.abs(2.0 * t - 1.0) - 1.0
w = np.round(w * 7.5) / 7.5 # 15 steps, like the 2A03's
else:
w = np.sin(2 * np.pi * t)
return np.clip(np.round(w * 127.0), -128, 127).astype(np.int8)
def _noise(np, n, period, seed=1, decay=6.0):
"""A one-shot LFSR noise burst for percussion. No loop — drums are one-shots."""
out = np.empty(n)
reg = (int(seed) & 0x7FFF) or 1
step = max(1, int(n / max(period, 1.0)))
val = 1.0
for i in range(n):
if i % step == 0:
fb = (reg ^ (reg >> 1)) & 1
reg = (reg >> 1) | (fb << 14)
val = 1.0 if (reg & 1) else -1.0
out[i] = val
env = np.exp(-np.arange(n) / float(n) * decay)
return np.clip(np.round(out * env * 127.0), -128, 127).astype(np.int8)
def build_samples(np):
"""The instrument set.
Returns [(name, int8 data, loop_start, loop_len, vol, finetune)]. Only the
looping tones carry finetune — the drums are one-shot noise with no pitch to
correct, and detuning them would just change their character.
"""
L, F = CYCLE_LEN, FINETUNE
return [
("lead pulse 25%", _cycle("pulse", np, L, 0.25), 0, L, 64, F),
("arp pulse 50%", _cycle("pulse", np, L, 0.50), 0, L, 48, F),
("bass triangle", _cycle("triangle", np, L), 0, L, 64, F),
("kick", _noise(np, 1024, 40, 7, 9.0), 0, 0, 64, 0),
("snare", _noise(np, 1024, 220, 3, 7.0), 0, 0, 52, 0),
("hat", _noise(np, 512, 400, 11, 16.0), 0, 0, 36, 0),
]
def _cell(sample, period, effect=0, param=0):
"""Pack one 4-byte pattern cell.
The sample number is split across two nibbles either side of the 12-bit
period — an artifact of the field growing from 4 bits to 5 after the format
shipped, and the single easiest thing to get wrong here.
"""
s = int(sample) & 0x1F
p = int(period) & 0xFFF
b0 = ((s >> 4) << 4) | ((p >> 8) & 0x0F)
b1 = p & 0xFF
b2 = ((s & 0x0F) << 4) | (int(effect) & 0x0F)
b3 = int(param) & 0xFF
return bytes((b0, b1, b2, b3))
EMPTY = _cell(0, 0)
def timing_for(spb, rows_per_bar):
"""Pick (ticks_per_row, bpm) so one row lasts exactly spb / rows_per_bar.
A MOD's row duration is `ticks_per_row * 2.5 / bpm` seconds. Effect F sets
ticks-per-row when its parameter is below 32 and BPM when it is 32 or above,
so BOTH are writable — and both must be, because a single BPM cannot express
a fine row grid: 96 rows per bar at 75 bpm needs BPM 450 at the default 6
ticks, well past the 255 the byte holds.
Prefer 6 ticks per row (ProTracker's default, and enough ticks for arpeggio
and vibrato to have somewhere to move) and drop only as far as needed to keep
BPM in range.
"""
row_s = float(spb) / max(1, int(rows_per_bar))
for ticks in (6, 5, 4, 3, 2, 1):
bpm = ticks * 2.5 / row_s
if 32 <= bpm <= 255:
return ticks, int(round(bpm))
# Outside anything expressible: clamp and accept the tempo error.
ticks = 6
return ticks, max(32, min(255, int(round(ticks * 2.5 / row_s))))
def write_mod(path, ev, bars, spb, steps, np, title="soundmon", bpm=None,
chippy="off", rows_per_bar=None):
"""Write composed events as a ProTracker MOD. Returns the path.
`ev` is chip.compose()'s grid-based event dict — which is exactly the shape a
tracker wants, since patterns ARE a grid. This is the one output where
soundmon's quantized composition is not a compromise but the native form.
Tempo is DERIVED from `spb` (seconds per bar), which is the composition's own
tempo. It used to be passed in from --bpm, which for --from-midi is the
unrelated CLI default: a 75 bpm source was written as 120 and played 1.6x too
fast. The tempo of the notes and the tempo in the header cannot be two
different numbers.
"""
samples = build_samples(np)
# A bar is allowed to straddle a pattern boundary. Patterns are a storage
# unit, not a musical one — the order list plays them back to back — so
# clamping rows-per-bar to 64 was capping the time resolution for no reason.
rows_per_bar = max(1, int(rows_per_bar or min(steps, ROWS)))
ticks, mod_bpm = timing_for(spb, rows_per_bar)
n_patterns = min(128, max(1, -(-(bars * rows_per_bar) // ROWS)))
# grid[pattern][row][channel] -> cell
grid = [[[EMPTY] * CHANNELS for _ in range(ROWS)] for _ in range(n_patterns)]
def place(bar, step, ch, sample, pitch, effect=0, param=0):
if step >= rows_per_bar:
return
abs_row = bar * rows_per_bar + step
pat, row = divmod(abs_row, ROWS)
if 0 <= pat < n_patterns:
grid[pat][row][ch] = _cell(sample, _period(pitch), effect, param)
def place_off(bar, step, ch):
"""MOD has NO note-off event. A note ends when you set its channel volume
to zero (effect C, parameter 0) or retrigger it. The tone samples here loop
forever by design — that is how a tracker sustains — so without this every
note rings until the next one on that channel, which is what "no noteoffs,
something is missing" sounds like."""
if step >= rows_per_bar:
return
abs_row = bar * rows_per_bar + step
pat, row = divmod(abs_row, ROWS)
if 0 <= pat < n_patterns and grid[pat][row][ch] == EMPTY:
grid[pat][row][ch] = _cell(0, 0, 0xC, 0)
# Every pitch sounding at each grid position, so an arpeggio can use the real
# chord instead of assuming a major triad.
def place_fx(bar, step, ch, effect, param):
"""Write a bare effect — no note, no sample — into an empty cell.
This is how a MOD sustains an effect across rows: the effect column is
re-stated on every row it should act on. Only empty cells are written, so
a continuation can never displace a note or a release.
"""
abs_row = bar * rows_per_bar + step
pat, row = divmod(abs_row, ROWS)
if 0 <= pat < n_patterns and grid[pat][row][ch] == EMPTY:
grid[pat][row][ch] = _cell(0, 0, effect, param)
chords = {}
for vname in ("lead", "arp", "bass"):
for it in ev.get(vname, []):
chords.setdefault((it[0], it[1]), set()).add(it[3])
def place_voice(items, ch, sample):
"""Lay one voice down a channel, notes and releases together.
This has to see the WHOLE voice at once, not one note at a time. A MOD
channel is monophonic, so a note's release row is bounded by the next
note's onset — and the source's note lengths often overlap, so placing a
release blindly at onset+duration dropped a C00 into the middle of the
FOLLOWING note and cut it off. Per-note placement cannot know that; it has
no view of what comes next.
"""
seq = sorted(items, key=lambda it: it[0] * rows_per_bar + it[1])
prev_pitch = None
prev_end = None # row where the previous note stopped sounding
row_s = float(spb) / rows_per_bar
for i, it in enumerate(seq):
bar, step, dur, pitch = it[0], it[1], it[2], it[3]
start = bar * rows_per_bar + step
nxt = (seq[i + 1][0] * rows_per_bar + seq[i + 1][1]
if i + 1 < len(seq) else None)
# Effects in the era's idiom, matching what --chippy does in
# synthesis. Deterministic on the row so a regenerated file is
# byte-identical.
frac = ((bar * 131 + step * 17 + ch * 7) % 100) / 100.0
e, p, span, cont = chip_effects(
frac, pitch, prev_pitch, int(dur),
is_chord_top=(ch == CH_ARP), chippy=chippy,
ticks_per_row=ticks,
chord=chords.get((bar, step), ()),
avail=(nxt - start) if nxt is not None else None,
gap_s=((start - prev_end) * row_s
if prev_end is not None else 1e9),
row_s=row_s)
prev_pitch = pitch
place(bar, step, ch, sample, pitch, e, p)
# Carry the effect across the rows it needs. Bounded by the next onset
# so a continuation never bleeds into the following note, and only
# written into cells that are otherwise empty.
stop = start + span
if nxt is not None:
stop = min(stop, nxt)
for row in range(start + 1, stop):
place_fx(*divmod(row, rows_per_bar), ch, e, cont)
# Release one row past the last sounding row, clamped to the next
# onset. Landing exactly ON the next onset needs no release at all:
# retriggering a sample ends the previous note by itself.
end = start + max(1, int(dur))
prev_end = min(end, nxt) if nxt is not None else end
if nxt is not None and end >= nxt:
continue
eb, es = divmod(end, rows_per_bar)
place_off(eb, es, ch)
place_voice(ev.get("lead", []), CH_LEAD, 1)
place_voice(ev.get("arp", []), CH_ARP, 2)
place_voice(ev.get("bass", []), CH_BASS, 3)
for bar, step, kind in ev.get("drum", []):
smp = {"k": 4, "s": 5, "h": 6}.get(kind)
if smp:
# Drums are one-shots at a fixed pitch; C-2 is the tuned reference.
place(bar, step, CH_DRUM, smp, 48)
# Tempo, on row 0. Effect F is overloaded: parameter < 32 sets ticks per row,
# >= 32 sets BPM. Both are needed, so they go on two different channels of the
# same row — one cell holds one effect. A cell can carry a note AND an effect,
# so merge rather than overwrite: clobbering row 0 would drop the downbeat.
row0 = list(grid[0][0])
# Two DISTINCT channels. dict.fromkeys dedupes while keeping order: the free
# channels are already in the fallback list, so a plain concatenation aimed
# both commands at the same cell and the BPM silently ate the speed.
pref = ([c for c in range(CHANNELS) if row0[c] == EMPTY]
+ [CH_DRUM, CH_BASS, CH_ARP, CH_LEAD])
slots = list(dict.fromkeys(pref))[:2]
for c, param in zip(slots, (ticks & 0x1F, mod_bpm)):
b0, b1, b2, _b3 = row0[c]
row0[c] = bytes((b0, b1, (b2 & 0xF0) | 0xF, param))
grid[0][0] = row0
# Trim the tail. Patterns are 64 rows whether or not the music fills them, so
# a piece that ends mid-pattern leaves silence before the order list wraps —
# dead air in the middle of what should be a seamless game loop. D00 breaks
# out of the pattern at the end of its row; on the last pattern that wraps to
# the song start, which is exactly the loop point.
last = None
for pi in range(n_patterns - 1, -1, -1):
for r in range(ROWS - 1, -1, -1):
if any(c != EMPTY for c in grid[pi][r]):
last = (pi, r)
break
if last:
break
if last and last != (n_patterns - 1, ROWS - 1):
pi, r = last
row = list(grid[pi][r])
# Prefer a cell with no effect of its own, so a break never displaces an
# arpeggio or a release.
cand = [c for c in range(CHANNELS) if (row[c][2] & 0x0F) == 0 and row[c][3] == 0]
c = cand[0] if cand else CH_DRUM
b0, b1, b2, _b3 = row[c]
row[c] = bytes((b0, b1, (b2 & 0xF0) | 0xD, 0))
grid[pi][r] = row
n_patterns = pi + 1
grid = grid[:n_patterns]
out = bytearray()
out += title.encode("ascii", "replace")[:20].ljust(20, b"\0")
for i in range(31):
if i < len(samples):
name, data, lstart, llen, vol, ft = samples[i]
words = len(data) // 2
out += name.encode("ascii", "replace")[:22].ljust(22, b"\0")
out += struct.pack(">H", words)
# Finetune is a 4-bit SIGNED value in 1/8-semitone steps, stored as
# two's complement in the low nibble. Writing 0 here left every tone
# 17.7 cents flat; the field is not decoration.
out += bytes((int(ft) & 0x0F, int(vol) & 0x7F))
# A repeat length of 0 or 1 word means "no loop" to a replayer.
out += struct.pack(">HH", lstart // 2, (llen // 2) if llen >= 2 else 0)
else:
out += b"\0" * 22 + struct.pack(">H", 0) + bytes((0, 0)) + struct.pack(">HH", 0, 0)
out += bytes((n_patterns, 127))
order = bytes(range(n_patterns)) + b"\0" * (128 - n_patterns)
out += order
out += b"M.K."
for pat in grid:
for row in pat:
for cell in row:
out += cell
for name, data, *_ in samples:
out += data.tobytes()
with open(path, "wb") as fh:
fh.write(bytes(out))
return path
# =============================================================================
# THE FULL PROTRACKER EFFECT SET
#
# All sixteen commands. The effect column is where a MOD stops being a note list
# and becomes a small program — arpeggio, slides and vibrato are how four channels
# were made to sound like more, and they are the same techniques --chippy applies
# in the synthesis path. Emitting them means the tracker file carries the idiom
# itself rather than a rendering of it.
#
# 0xy arpeggio: cycle note, note+x, note+y semitones every tick
# 1xx portamento up 2xx portamento down
# 3xx tone portamento (slide toward the new note)
# 4xy vibrato: x = speed, y = depth
# 5xy tone portamento + volume slide 6xy vibrato + volume slide
# 7xy tremolo
# 8xx set panning (not in ProTracker; widely honoured)
# 9xx sample offset, in 256-byte units
# Axy volume slide: x up, y down
# Bxx position jump Cxx set volume (00 = the note-off this format lacks)
# Dxx pattern break
# Exy extended: E1 fine slide up, E2 fine down, E9 retrigger, EA/EB fine
# volume slide, EC note cut, ED note delay, EE pattern delay
# Fxx set speed (<32 = ticks per row) or BPM (>=32)
# =============================================================================
FX = {
"arpeggio": 0x0, "porta_up": 0x1, "porta_down": 0x2, "tone_porta": 0x3,
"vibrato": 0x4, "porta_vol": 0x5, "vib_vol": 0x6, "tremolo": 0x7,
"pan": 0x8, "offset": 0x9, "vol_slide": 0xA, "jump": 0xB,
"volume": 0xC, "break": 0xD, "extended": 0xE, "speed": 0xF,
}
# Extended (Exy) sub-commands, in the high nibble of the parameter.
EXT = {
"filter": 0x0, "fine_up": 0x1, "fine_down": 0x2, "glissando": 0x3,
"vib_wave": 0x4, "finetune": 0x5, "loop_start": 0x6, "loop_end": 0x7,
"trem_wave": 0x8, "retrigger": 0x9, "fine_vol_up": 0xA,
"fine_vol_down": 0xB, "note_cut": 0xC, "note_delay": 0xD,
"pattern_delay": 0xE, "invert_loop": 0xF,
}
def fx(name, x=0, y=None):
"""Build (effect, param) for an effect by name.
Two-nibble effects take x and y; single-byte ones take x as the whole value.
`fx("vibrato", 4, 3)` -> vibrato speed 4 depth 3; `fx("volume", 0)` -> C00.
"""
e = FX.get(name)
if e is None:
raise ValueError(f"unknown MOD effect {name!r}")
param = ((x & 0x0F) << 4) | (y & 0x0F) if y is not None else (x & 0xFF)
return e, param
def ext(name, value=0):
"""Build (effect, param) for an Exy extended command."""
sub = EXT.get(name)
if sub is None:
raise ValueError(f"unknown extended command {name!r}")
return FX["extended"], ((sub & 0x0F) << 4) | (value & 0x0F)
def porta_rate(pitch, prev_pitch, ticks_per_row, rows=1):
"""Tone-portamento parameter to glide from prev_pitch to pitch in `rows` rows.
THE UNITS ARE THE WHOLE PROBLEM. 3xx slides the channel's current period
toward the target by xx PERIOD UNITS PER TICK — it is not a semitone rate and
not a duration. Passing something semitone-shaped (I used 2 + interval) makes
an octave leap, which is 214 period units, slide at 14 units per tick: at 3
ticks per row that needs five rows to arrive, and the next note lands long
before then. The pitch never reaches its target, so every slide just smears
off somewhere wrong.
Period distance also depends on register: the same interval is far more period
units low than high, because period is inversely proportional to frequency. So
the rate has to be computed from the actual endpoints, every time.
TICK 0 OF EACH ROW DOES NOT SLIDE. It is the tick that reads the cell and sets
the target; only ticks 1..speed-1 advance the pitch. So a row contributes
speed-1 sliding ticks, not speed — and dividing by the wrong budget makes every
slide stop short and HOLD there, permanently out of tune.
This is the one I could not catch by reasoning, because my verifier simulated
the format using the same wrong assumption as the writer and agreed with it:
"83 slides, 0 land short" while the rendered audio settled 2.7 semitones below
its target and stayed. Tracking the pitch in libxmp's actual output found it in
one measurement. When a check and the code share an assumption, the check is
only testing arithmetic.
"""
d = abs(_period(pitch) - _period(prev_pitch))
per_row = max(1, int(ticks_per_row) - 1)
ticks = max(1, per_row * max(1, int(rows)))
return max(1, min(0xFF, -(-d // ticks))) # ceil, so it does arrive
def vib_depth(pitch, cents=20.0, max_cents=40.0):
"""Vibrato depth for a roughly constant `cents` swing. 0 means "do not".
SAME TRAP AS PORTAMENTO, and I walked into it twice. 4xy's depth is in PERIOD
units, and period is inversely proportional to frequency, so one fixed depth is
not one musical interval — it is a swing that widens without limit as the notes
go up. A hard-coded depth of 3 measured +/-16 cents at MIDI 41 and +/-162 cents
at MIDI 80: over a semitone and a half of wobble on exactly the notes the
melody lives on. That is not vibrato, it is the tune going out of tune.
ProTracker's vibrato adds `depth * sine / 128` period units with the sine
peaking at 255, so the swing is about +/- 2 * depth.
Above roughly MIDI 78 the periods get so short that even depth 1 exceeds
max_cents — the format simply cannot express a subtle vibrato up there, so
return 0 rather than emit a bad one.
"""
p = _period(pitch)
span = 1.0 - 2.0 ** (-abs(cents) / 1200.0)
d = int(round(p * span / 2.0))
if d < 1:
# Would round to nothing; check whether the minimum is tolerable.
return 1 if 1200 * math.log2(p / max(1.0, p - 2.0)) <= max_cents else 0
return min(0x0F, d)
def chip_effects(ident_frac, pitch, prev_pitch, dur, is_chord_top=False,
chippy="off", legato=False, ticks_per_row=6, chord=(),
avail=None, gap_s=0.0, row_s=0.02):
"""Choose an effect for a note, in the era's idiom.
Mirrors what --chippy does in the synthesis path, so a MOD carries the same
character:
a slurred step gets tone portamento — how the era connected intervals
a sustained note gets vibrato — cheap, and it hides a static wave
a chord top gets arpeggio — a chord on one channel
Returns (effect, param, span, cont_param):
span how many rows the effect must OCCUPY, not just start on
cont_param the parameter to write on rows 2..span
THE SPAN IS NOT OPTIONAL. A MOD effect acts only on the rows it is actually
written to — there is no "note-scoped" effect. Writing an effect on the onset
row alone gives it one row of life, and at 96 rows per bar a row is 33 ms:
arpeggio measured -56 dBFS against no effects, vibrato -30 dBFS, i.e.
both were inaudible, because 33 ms is not long enough to hear a cycle of
either
portamento was worse than inaudible. Sizing the rate to arrive over three
rows and then writing one row stopped every one of 83 slides a third of
the way, landing up to 500 cents from the written note
Portamento and vibrato both continue with a zero parameter, meaning "same rate
as before". Arpeggio has no continue form — parameter 0 IS "no effect" — so its
parameter must be repeated in full.
"""
if chippy == "off":
return 0, 0, 1, 0
strength = {"some": 0.35, "lots": 0.6, "max": 0.9}.get(chippy, 0.0)
# Rows genuinely available before the next note on this channel. The glide rate
# has to be computed against THIS, not against the span we would prefer: ask
# for three rows, get truncated to one by an early next note, and the slide
# stops two thirds short — which is the same failure as before, just rarer.
avail = 10 ** 6 if avail is None else max(1, int(avail))
if is_chord_top and ident_frac < strength:
# 0xy cycles note, note+x, note+y every tick. The intervals come from the
# ACTUAL chord when we know it — a hard-coded 4,7 major triad over the
# minor harmony this piece is in is not an ornament, it is a wrong note.
iv = [i for i in sorted({(int(p) - int(pitch)) % 12 for p in chord})
if 1 <= i <= 15]
x, y = (iv + [4, 7])[:2] if iv else (4, 7)
e, p = fx("arpeggio", x, y)
return e, p, max(1, min(int(dur), 12, avail)), p
if prev_pitch is not None:
# SAME RULES AS THE SYNTH PATH, which is the version grymmjack signed off
# on ("i adore the pitch slides"). mod.py had invented its own and got
# them close to backwards:
#
# leap synth 3..19 semitones, mod.py 1..7. The ranges barely overlap,
# and the difference is not cosmetic: 39 of 83 slides were under
# 3 semitones and 8 were a SINGLE semitone. A one-semitone glide
# is not heard as a slide, it is heard as a note out of tune,
# which is exactly what "portamento is the issue" sounds like.
#
# spacing synth wants the notes merely CLOSE (gap < 120 ms), not
# overlapping. mod.py demanded true legato, which is both rarer
# and, on a register-assigned channel, arbitrary.
#
# length synth slides for a fixed 45/60/80 ms by level. mod.py used
# "up to 3 rows", which is a different duration at every grid.
leap = abs(int(pitch) - int(prev_pitch))
# At speed 1 there are no sliding ticks at all (tick 0 never slides), so a
# portamento would be written and then do nothing. Do not emit one.
if (3 <= leap <= 19 and gap_s < 0.12 and ident_frac < strength
and ticks_per_row >= 2):
ms = {"some": 45.0, "lots": 60.0, "max": 80.0}.get(chippy, 60.0)
rows = max(1, int(round(ms / 1000.0 / max(row_s, 1e-6))))
rows = max(1, min(rows, max(1, int(dur)), avail))
e, p = fx("tone_porta", porta_rate(pitch, prev_pitch,
ticks_per_row, rows))
return e, p, rows, 0
if dur >= 4 and ident_frac < strength * 0.7:
d = vib_depth(pitch)
if d:
e, p = fx("vibrato", 4, d)
return e, p, max(1, min(int(dur), 16, avail)), 0
return 0, 0, 1, 0