Skip to content

Commit dad1c17

Browse files
committed
Fix the ADPCM-B note playback
ADPCM-B can vary sample playback, and the frequency to be used for sample playback depends on the sample's configured frequency. Expose that frequency in intruments and use it to generate the proper frequency for the requested note in the Furnace module.
1 parent 389c427 commit dad1c17

File tree

2 files changed

+129
-25
lines changed

2 files changed

+129
-25
lines changed

tools/furtool.py

+49-24
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,14 @@ class adpcm_a_sample:
207207
class adpcm_b_sample:
208208
name: str = ""
209209
data: bytearray = field(default=b"", repr=False)
210+
frequency: int = 0
210211

211212

212213
@dataclass
213214
class pcm_sample:
214215
name: str = ""
215216
data: bytearray = field(default=b"", repr=False)
217+
frequency: int = 0
216218
loop: bool = False
217219

218220

@@ -245,6 +247,7 @@ class adpcm_a_instrument:
245247
class adpcm_b_instrument:
246248
name: str = ""
247249
sample: adpcm_b_sample = None
250+
tuned: int = 0
248251
loop: bool = False
249252

250253

@@ -276,54 +279,70 @@ def read_fm_instrument(bs):
276279
return ifm
277280

278281

279-
@dataclass
280-
class ssg_prop:
281-
name: str = ""
282-
offset: int = 0
283-
284-
285-
def read_ssg_macro(length, bs):
286-
# TODO -1 are unsupported in nullsound
287-
code_map = {0: ssg_prop("volume", 3), # volume
288-
3: ssg_prop("waveform", 4), # noise_tune
289-
6: ssg_prop("env", 0), # envelope shape
290-
7: ssg_prop("env_vol_num", 1), # volume envelope numerator
291-
8: ssg_prop("env_vol_den", 2) # volume envelope denominator
292-
}
293-
294-
blocks={}
295-
autoenv=False
296-
init=bs.pos
282+
def read_macro_data(length, bs):
283+
macros={}
297284
max_pos = bs.pos + length
298285
header_len = bs.u2()
299-
# pass: read all macro blocks
300286
while bs.pos < max_pos:
301287
header_start = bs.pos
288+
# macro code (vol, arp, pitch...)
302289
code = bs.u1()
303290
if code == 255:
304291
break
305292
length = bs.u1()
306293
# TODO unsupported. no loop
307294
loop = bs.u1()
308-
# TODO unsupported. last macro stays
295+
# TODO unsupported. last macro value stays
309296
release = bs.u1()
310297
# TODO meaning?
311298
mode = bs.u1()
312299
msize, mtype = ubits(bs.u1(), [7, 6], [2, 1])
313300
assert msize == 0, "macro value should be of type '8-bit unsigned'"
314-
assert mtype == 0, "macro should be of type 'sequence'"
301+
assert mtype == 0, "macro should be of type 'sequence'. ADSR or LFO unsupported"
315302
# TODO unsupported. no delay
316303
delay = bs.u1()
317304
# TODO unsupported. same speed as the module tick
318305
speed = bs.u1()
319306
header_end = bs.pos
320307
assert header_end - header_start == header_len
321308
data = [bs.u1() for i in range(length)]
309+
macros[code]=data
310+
assert bs.pos == max_pos
311+
return macros
312+
313+
314+
def configure_b_macros(ins, macros):
315+
# temporary workaround for instrument manually tuned with arpeggio macro
316+
if 1 in macros and len(macros[1])==1:
317+
ins.tuned = macros[1][0]
318+
else:
319+
error("unsupported use of macros in ADPCM-B instrument %s"%ins.name)
320+
321+
322+
@dataclass
323+
class ssg_prop:
324+
name: str = ""
325+
offset: int = 0
326+
327+
328+
def read_ssg_macro(length, bs):
329+
# TODO -1 are unsupported in nullsound
330+
code_map = {0: ssg_prop("volume", 3), # volume
331+
3: ssg_prop("waveform", 4), # noise_tune
332+
6: ssg_prop("env", 0), # envelope shape
333+
7: ssg_prop("env_vol_num", 1), # volume envelope numerator
334+
8: ssg_prop("env_vol_den", 2) # volume envelope denominator
335+
}
336+
337+
autoenv=False
338+
blocks = {}
339+
macros = read_macro_data(length, bs)
340+
for code in macros:
322341
if code not in code_map:
323342
warning("macro element not supported yet: %02x"%code)
324343
else:
325-
blocks[code_map[code].offset]=data
326-
assert bs.pos == max_pos
344+
blocks[code_map[code].offset] = macros[code]
345+
327346
# pass: create a "empty" waveform property if it's not there
328347
# we need it to tell nullsound to not update the envelope SSG register
329348
if 0 not in blocks:
@@ -416,7 +435,11 @@ def asm_ident(x):
416435
sample = bs.u2()
417436
bs.u2() # unused flags and waveform
418437
elif feat == b"MA" and itype == 6:
438+
# SSG macro is essentially the full SSG instrument
419439
mac = read_ssg_macro(length, bs)
440+
elif feat == b"MA" and itype == 38:
441+
# other macro types are currently not supported
442+
mac = read_macro_data(length, bs)
420443
elif feat == b"NE":
421444
# NES DPCM tag is present when the instrument
422445
# uses a PCM sample instead of ADPCM. Skip it
@@ -447,6 +470,8 @@ def asm_ident(x):
447470
return mac
448471
else:
449472
ins.name = asm_ident("instr_%02x_%s"%(nth, name))
473+
if itype == 38 and mac:
474+
configure_b_macros(ins, mac)
450475
return ins
451476

452477

@@ -482,7 +507,6 @@ def read_sample(bs, sample_idx):
482507
data_padding = 0 # adpcmtool codecs automatically adds padding
483508
else:
484509
error("sample '%s' is of unsupported type: %d"%(str(name), stype))
485-
# assert c4_freq == {5: 18500, 6: 44100}[stype]
486510
bs.u1() # unused loop direction
487511
bs.u2() # unused flags
488512
loop_start, loop_end = bs.s4(), bs.s4()
@@ -494,6 +518,7 @@ def read_sample(bs, sample_idx):
494518
6: adpcm_b_sample,
495519
16: pcm_sample}[stype](insname, data)
496520
ins.loop = loop_start != -1 and loop_end != -1
521+
ins.frequency = c4_freq
497522
return ins
498523

499524

tools/nsstool.py

+80-1
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def to_nss_note(furnace_note):
7575
return nss_note
7676

7777
def to_nss_b_note(furnace_note):
78-
octave = (furnace_note // 12) - 6
78+
octave = (furnace_note // 12) - 5
7979
note = furnace_note % 12
8080
nss_note = (octave << 4) + note
8181
return nss_note
@@ -682,6 +682,82 @@ def compact_instr_pass(op, out):
682682
return out
683683

684684

685+
def tune_adpcm_b_notes(nss, ins):
686+
# idea: all b-notes in the b channels are seen as an offset from C-4
687+
# the playback freq is also a an offset, but from the current instrument's sample freq
688+
# for convenience, each playback freq of the B-channel is given a note notation
689+
# we have to find the note `i_note` corresponding the the current intrument's sample freq
690+
# and from there, each note played will be an offset from `i_note`.
691+
# this is the note that must be effectively stored in the NSS bytecode for correct playback
692+
693+
semitones = [ "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" ]
694+
# lowest frequency playback for 12 semitones (can be seen as octave 0)
695+
base_delta = [3255, 3448, 3653, 3871, 4101, 4345, 4603, 4877, 5167, 5474, 5800, 6144]
696+
# all the frequencies for each supported octaves (up to 55Khz)
697+
freqs = [[int(55555*x/65536)*(2**o) for x in base_delta] for o in range(5)]
698+
# each octave's frequency bounds (with a 2.9% uncertainty/tolerance)
699+
octaves_freqs = [(int(f[0]/1.029), int(f[-1]*1.029)) for f in freqs]
700+
701+
# all notes in the ADPCM-B channel are relative to C-4
702+
c4 = (4*12)+0
703+
# current instrument for ADPCM-B
704+
current_inst = -1
705+
# nullsound note from current sample's frequency
706+
sample_note = c4
707+
708+
def to_direct_note(note):
709+
semitone = note & 0xf
710+
octave = (note>>4) & 0xf
711+
return (octave*12)+semitone
712+
713+
def to_b_note(direct):
714+
octave, semitone = direct // 12, direct % 12
715+
return (octave<<4) | semitone
716+
717+
def note_str(note):
718+
octave, semitone = note // 12, note % 12
719+
return semitones[semitone].ljust(2, '-')+str(octave)
720+
721+
def tune_adpcm_b_pass(op, out):
722+
nonlocal current_inst
723+
nonlocal sample_note
724+
nonlocal c4
725+
726+
if type(op) == nss_label:
727+
current_inst = -1
728+
out.append(op)
729+
elif type(op) == b_instr:
730+
if current_inst != op.inst:
731+
current_inst = op.inst
732+
sample_freq = ins[current_inst].sample.frequency
733+
# determine nullsound note based on sample frequency
734+
i_octave = next((i for i, f in enumerate(octaves_freqs) if f[0] < sample_freq < f[-1]), -1)
735+
assert i_octave != -1
736+
i_semitone = next((i for i, f in enumerate(freqs[i_octave]) if f/1.029 < sample_freq < f *1.029))
737+
sample_note = (i_octave * 12) + i_semitone
738+
# temporary workaround to support arpeggio tweak from macro
739+
sample_note += ins[current_inst].tuned
740+
out.append(op)
741+
elif type(op) == b_note:
742+
# get the semitone offset from c4
743+
dnote = to_direct_note(op.note)
744+
semitone_offset = dnote - c4
745+
# the "tuned" note is the note to use in nullsound to configure the
746+
# right frequency in the YM2610 (i.e. the semitone offset from the
747+
# sample's base frequency)
748+
tuned = to_b_note(sample_note + semitone_offset)
749+
# fmt_off = "%s %2d"%("+" if semitone_offset>=0 else "-", abs(semitone_offset))
750+
# print("B NOTE: %s (= C-4 %s)"%(note_str(dnote), fmt_off),
751+
# "-> TUNED: %s %s = %s"%(note_str(sample_note),
752+
# fmt_off, note_str(to_direct_note(tuned))))
753+
out.append(b_note(tuned))
754+
else:
755+
out.append(op)
756+
757+
out = run_control_flow_pass(tune_adpcm_b_pass, nss)
758+
return out
759+
760+
685761
def remove_ctx(nss):
686762
ctxs = [fm_ctx_1, fm_ctx_2, fm_ctx_3, fm_ctx_4,
687763
s_ctx_1, s_ctx_2, s_ctx_3,
@@ -971,6 +1047,9 @@ def generate_nss_stream(m, p, bs, ins, channels, stream_idx):
9711047
dbg(" - remove successive INSTR opcodes if they keep intrument unchanged")
9721048
nss = compact_instr(nss)
9731049

1050+
dbg(" - tune ADPCM-B notes based on instrument's sample speed")
1051+
nss = tune_adpcm_b_notes(nss, ins)
1052+
9741053
if compact:
9751054
dbg(" - remove CTX opcodes for compact stream")
9761055
nss = remove_ctx(nss)

0 commit comments

Comments
 (0)