reflecting spec changes

This commit is contained in:
minjaesong
2026-05-01 12:25:47 +09:00
parent 50802186ce
commit 01ff4b1d47
7 changed files with 521 additions and 194 deletions

View File

@@ -10,7 +10,7 @@ const TAUD_MAGIC = [0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64] // \x1F TSV
const TAUD_VERSION = 1 const TAUD_VERSION = 1
const TAUD_HEADER_SIZE = 32 // magic(8) + version(1) + numSongs(1) + compSize(4) + rsvd(2) + sig(16) const TAUD_HEADER_SIZE = 32 // magic(8) + version(1) + numSongs(1) + compSize(4) + rsvd(2) + sig(16)
const TAUD_SONG_ENTRY = 16 // bytes per song-table row (offset(4)+voices(1)+pats(2)+bpm(1)+tick(1)+pad(7)) const TAUD_SONG_ENTRY = 16 // bytes per song-table row (offset(4)+voices(1)+pats(2)+bpm(1)+tick(1)+pad(7))
const SAMPLEINST_SIZE = 786432 // 770047 sample + 16384 instrument const SAMPLEINST_SIZE = 786432 // 737280 sample + 49152 instrument (256 × 192)
const PATTERN_SIZE = 512 // bytes per pattern (64 rows × 8 bytes) const PATTERN_SIZE = 512 // bytes per pattern (64 rows × 8 bytes)
const NUM_PATTERNS_MAX = 256 const NUM_PATTERNS_MAX = 256
const NUM_CUES = 1024 const NUM_CUES = 1024
@@ -95,6 +95,7 @@ function uploadTaudFile(inFile, songIndex, playhead) {
// Write decompressed data to peripheral memory (backwards addressing: // Write decompressed data to peripheral memory (backwards addressing:
// peripheral byte k lives at memBase - k). // peripheral byte k lives at memBase - k).
for (let i = 0; i < SAMPLEINST_SIZE; i++) { for (let i = 0; i < SAMPLEINST_SIZE; i++) {
// TODO use sys.memcpy
sys.poke(memBase - i, sys.peek(decompPtr + i)) sys.poke(memBase - i, sys.peek(decompPtr + i))
} }
sys.free(decompPtr) sys.free(decompPtr)

View File

@@ -47,7 +47,7 @@ from taud_common import (
TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY, TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE,
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES, PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C3, NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4,
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I, TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I,
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_Y, TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_Y,
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE, SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
@@ -976,10 +976,10 @@ def encode_note_it(it_note: int) -> int:
if it_note == IT_NOTE_CUT: if it_note == IT_NOTE_CUT:
return NOTE_CUT return NOTE_CUT
if 0 <= it_note <= 119: if 0 <= it_note <= 119:
# IT middle C is C-5 (note 60); Taud reference is C-3 (TAUD_C3 = 0x4000). # IT middle C is C-5 (note 60); Taud reference is C-4 (TAUD_C4 = 0x5000).
# IT C-5 anchors to Taud C-3, so offset = it_note - 60. # IT C-5 anchors to Taud C-4, so offset = it_note - 60.
semis = it_note - 60 semis = it_note - 60
val = round(TAUD_C3 + semis * 4096 / 12) val = round(TAUD_C4 + semis * 4096 / 12)
return max(1, min(0xFFFD, val)) return max(1, min(0xFFFD, val))
return NOTE_NOP return NOTE_NOP
@@ -1375,14 +1375,18 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
s.length = n; s.loop_end = min(s.loop_end, n) s.length = n; s.loop_end = min(s.loop_end, n)
pos += n pos += n
# New 192-byte instrument layout (terranmon.txt:1997-2070).
# Vol env @ 21..70 (25 pts), Pan env @ 71..120 (25 pts), P/F env @ 121..170 (25 pts).
# Envelope flag bits (16-bit, 0b 0ut sssss pcb eeeee):
# bit 14=u(enable), 13=t(sustain), 12..8=sus_start, 7=p, 6=c(carry),
# 5=b(use envelope), 4..0=sus_end.
USE_ENV_BIT = 0x0020 # b
inst_bin = bytearray(INSTBIN_SIZE) inst_bin = bytearray(INSTBIN_SIZE)
for i, s in enumerate(samples_or_proxy): for i, s in enumerate(samples_or_proxy):
taud_idx = i # samples_or_proxy is 0-based here; slot 0 unused taud_idx = i # samples_or_proxy is 0-based here; slot 0 unused
if i == 0 or i >= 256 or s is None: if i == 0 or i >= 256 or s is None:
continue continue
ptr = offsets.get(i, 0) ptr = offsets.get(i, 0) & 0xFFFFFFFF
ptr_lo = ptr & 0xFFFF
ptr_hi = ptr >> 16
s_len = min(s.length, 65535) s_len = min(s.length, 65535)
c2spd = min(s.c5_speed, 65535) c2spd = min(s.c5_speed, 65535)
ls = min(s.loop_beg, 65535) ls = min(s.loop_beg, 65535)
@@ -1393,44 +1397,67 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
loop_mode = 1 # forward loop loop_mode = 1 # forward loop
else: else:
loop_mode = 0 # no loop loop_mode = 0 # no loop
flags_byte = (ptr_hi << 4) | (loop_mode & 0x3) flags_byte = loop_mode & 0x3
base = taud_idx * 64 base = taud_idx * 192
struct.pack_into('<H', inst_bin, base + 0, ptr_lo) struct.pack_into('<I', inst_bin, base + 0, ptr)
struct.pack_into('<H', inst_bin, base + 2, s_len) struct.pack_into('<H', inst_bin, base + 4, s_len)
struct.pack_into('<H', inst_bin, base + 4, c2spd) struct.pack_into('<H', inst_bin, base + 6, c2spd)
struct.pack_into('<H', inst_bin, base + 6, 0) struct.pack_into('<H', inst_bin, base + 8, 0) # play start
struct.pack_into('<H', inst_bin, base + 8, ls) struct.pack_into('<H', inst_bin, base + 10, ls)
struct.pack_into('<H', inst_bin, base + 10, le) struct.pack_into('<H', inst_bin, base + 12, le)
inst_bin[base + 12] = flags_byte inst_bin[base + 14] = flags_byte
# Write envelope data (12-point format: vol at +16..+39, pan at +40..+63) # Write envelope data
env_data = envelopes_by_slot.get(taud_idx) if envelopes_by_slot else None env_data = envelopes_by_slot.get(taud_idx) if envelopes_by_slot else None
if env_data and env_data[0]: if env_data and env_data[0]:
vol_env, vol_sus, pan_env, pan_sus, inst_gv = env_data vol_env, vol_sus, pan_env, pan_sus, inst_gv = env_data
inst_bin[base + 13] = vol_sus & 0xFF # Old caller passed an 8-bit sustain byte (0b ut eee sss for 12-point indices).
inst_bin[base + 14] = pan_sus & 0xFF # Convert to new 16-bit layout (5-bit sus indices in bits 12..8 / 4..0).
inst_bin[base + 15] = inst_gv & 0xFF def _convert_old_sus(b: int, has_env: bool) -> int:
for k, (val, mf) in enumerate(vol_env[:12]): if not has_env:
inst_bin[base + 16 + k*2] = val & 0xFF return 0
inst_bin[base + 16 + k*2 + 1] = mf & 0xFF sus_start = b & 0x07
sus_end = (b >> 3) & 0x07
t_bit = (b >> 6) & 0x01
u_bit = (b >> 7) & 0x01
out = USE_ENV_BIT
out |= (sus_start & 0x1F) << 8
out |= (sus_end & 0x1F)
out |= (t_bit << 13)
out |= (u_bit << 14)
return out
vol_flags = _convert_old_sus(vol_sus, True)
pan_flags = _convert_old_sus(pan_sus, bool(pan_env))
struct.pack_into('<H', inst_bin, base + 15, vol_flags)
struct.pack_into('<H', inst_bin, base + 17, pan_flags)
struct.pack_into('<H', inst_bin, base + 19, 0) # pitch/filter env unused
inst_bin[base + 171] = inst_gv & 0xFF
for k, (val, mf) in enumerate(vol_env[:25]):
inst_bin[base + 21 + k*2] = val & 0xFF
inst_bin[base + 21 + k*2 + 1] = mf & 0xFF
if pan_env: if pan_env:
for k, (val, mf) in enumerate(pan_env[:12]): for k, (val, mf) in enumerate(pan_env[:25]):
inst_bin[base + 40 + k*2] = val & 0xFF inst_bin[base + 71 + k*2] = val & 0xFF
inst_bin[base + 40 + k*2 + 1] = mf & 0xFF inst_bin[base + 71 + k*2 + 1] = mf & 0xFF
else: else:
for k in range(12): for k in range(25):
inst_bin[base + 40 + k*2] = 0x80 # pan centre inst_bin[base + 71 + k*2] = 0x80 # pan centre
inst_bin[base + 40 + k*2 + 1] = 0x00 # hold inst_bin[base + 71 + k*2 + 1] = 0x00 # hold
else: else:
# No instrument envelope: single-point vol, neutral pan, full gv # No instrument envelope: single-point vol, neutral pan, full gv.
inst_gv = env_data[4] if env_data else 255 inst_gv = env_data[4] if env_data else 255
inst_bin[base + 15] = inst_gv & 0xFF struct.pack_into('<H', inst_bin, base + 15, USE_ENV_BIT)
inst_bin[base + 16] = min(s.vol, 63) # value 0-63 struct.pack_into('<H', inst_bin, base + 17, 0)
inst_bin[base + 17] = 0 # offset 0 = hold struct.pack_into('<H', inst_bin, base + 19, 0)
for k in range(12): inst_bin[base + 171] = inst_gv & 0xFF
inst_bin[base + 40 + k*2] = 0x80 # pan centre inst_bin[base + 21] = min(s.vol, 63) # value 0-63
inst_bin[base + 40 + k*2 + 1] = 0x00 # hold inst_bin[base + 22] = 0 # offset 0 = hold
for k in range(25):
inst_bin[base + 71 + k*2] = 0x80
inst_bin[base + 71 + k*2 + 1] = 0x00
vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}") vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}")
return bytes(sample_bin) + bytes(inst_bin), offsets return bytes(sample_bin) + bytes(inst_bin), offsets
@@ -1553,6 +1580,68 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int,
# ── Main assembly ───────────────────────────────────────────────────────────── # ── Main assembly ─────────────────────────────────────────────────────────────
def relocate_late_note_delays(patterns_rows: list, order_list: list,
num_channels: int, initial_speed: int) -> None:
"""Move SDx-delayed notes to the next row when x ≥ tick speed.
IT triggers a Note Delay during the current row; if x reaches the tick
speed, the trigger never lands. When the next row in the same channel is
empty, relocate the note (with delay = x speed) so it actually plays.
"""
visited = set()
for order in order_list:
if order >= IT_ORD_END:
break
if order >= len(patterns_rows) or order in visited:
continue
visited.add(order)
grid, rows = patterns_rows[order]
speed = initial_speed
for r in range(rows):
for ch in range(min(num_channels, len(grid))):
cell = grid[ch][r]
if cell.effect == EFF_A and cell.effect_arg > 0:
speed = cell.effect_arg
break
if r + 1 >= rows or speed <= 0:
continue
for ch in range(min(num_channels, len(grid))):
cell = grid[ch][r]
if cell.effect != EFF_S or cell.note < 0:
continue
if ((cell.effect_arg >> 4) & 0xF) != 0xD:
continue
x = cell.effect_arg & 0xF
if x < speed:
continue
nxt = grid[ch][r + 1]
if (nxt.note >= 0 or nxt.inst or nxt.effect or nxt.effect_arg
or nxt.vol != -1 or nxt.volcol != -1
or nxt.pan_set is not None or nxt.aux_effect is not None):
continue
new_delay = x - speed
nxt.note = cell.note
nxt.inst = cell.inst
nxt.vol = cell.vol
nxt.volcol = cell.volcol
nxt.pan_set = cell.pan_set
nxt.aux_effect = cell.aux_effect
if new_delay > 0:
nxt.effect = EFF_S
nxt.effect_arg = 0xD0 | (new_delay & 0xF)
cell.note = -1
cell.inst = 0
cell.vol = -1
cell.volcol = -1
cell.pan_set = None
cell.aux_effect = None
cell.effect = 0
cell.effect_arg = 0
vprint(f" fix: pat{order} ch{ch} row{r}: SD{x:X} ≥ speed{speed}, "
f"moved note to row{r+1}"
+ (f" with SD{new_delay:X}" if new_delay > 0 else ""))
def find_initial_bpm_speed(patterns_rows: list, order_list: list, def find_initial_bpm_speed(patterns_rows: list, order_list: list,
default_speed: int, default_tempo: int) -> tuple: default_speed: int, default_tempo: int) -> tuple:
speed = default_speed or 6 speed = default_speed or 6
@@ -1604,6 +1693,10 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
old_effects=h.old_effects, old_effects=h.old_effects,
initial_global_vol=h.global_vol) initial_global_vol=h.global_vol)
init_speed, _ = find_initial_bpm_speed(patterns_rows, h.order_list,
h.initial_speed, h.initial_tempo)
relocate_late_note_delays(patterns_rows, h.order_list, 64, init_speed)
# ── Check SBx chunk crossing (warn only) ───────────────────────────────── # ── Check SBx chunk crossing (warn only) ─────────────────────────────────
for pi, (grid, rows) in enumerate(patterns_rows): for pi, (grid, rows) in enumerate(patterns_rows):
if rows <= PATTERN_ROWS: continue if rows <= PATTERN_ROWS: continue

View File

@@ -7,7 +7,7 @@ Usage:
Limits: Limits:
- Up to 20 MOD channels (excess disabled; hard error if pattern count - Up to 20 MOD channels (excess disabled; hard error if pattern count
× channel count > 4095). × channel count > 4095).
- Sample bin is 770048 bytes; if all samples together exceed this, every - Sample bin is 737280 bytes; if all samples together exceed this, every
sample is globally resampled down (with c2spd adjusted) so pitch is sample is globally resampled down (with c2spd adjusted) so pitch is
preserved. preserved.
@@ -34,7 +34,7 @@ from taud_common import (
TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY, TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE,
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES, PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C3, NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4,
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I, TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I,
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_Y, TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_Y,
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE, SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
@@ -64,7 +64,7 @@ PT_MEM_E_SUB = frozenset({0x1, 0x2, 0xA, 0xB})
SIGNATURE = b"mod2taud/TSVM " # 14 bytes SIGNATURE = b"mod2taud/TSVM " # 14 bytes
# PT period 428 (PT "C-2") corresponds to OpenMPT/IT C-4 which s3m2taud # PT period 428 (PT "C-2") corresponds to OpenMPT/IT C-4 which s3m2taud
# anchors to Taud C3 (0x4000). We use the same anchor so MOD/S3M imports # anchors to Taud C4 (0x5000). We use the same anchor so MOD/S3M imports
# share a pitch reference. # share a pitch reference.
PT_REFERENCE_PERIOD = 428.0 PT_REFERENCE_PERIOD = 428.0
@@ -224,7 +224,7 @@ def _signed4(nibble: int) -> int:
def period_to_taud_note(period: int) -> int: def period_to_taud_note(period: int) -> int:
if period <= 0: if period <= 0:
return NOTE_NOP return NOTE_NOP
val = round(TAUD_C3 + 4096.0 * math.log2(PT_REFERENCE_PERIOD / period)) val = round(TAUD_C4 + 4096.0 * math.log2(PT_REFERENCE_PERIOD / period))
return max(1, min(0xFFFD, val)) return max(1, min(0xFFFD, val))
@@ -350,6 +350,61 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
return (TOP_NONE, 0, None, None) return (TOP_NONE, 0, None, None)
def relocate_late_note_delays(patterns: list, order_list: list,
n_channels: int, initial_speed: int) -> None:
"""Move EDx-delayed notes to the next row when x ≥ tick speed.
PT triggers a Note Delay during the current row; if x reaches the tick
speed, the trigger never lands. When the next row in the same channel is
empty, relocate the note (with delay = x speed) so it actually plays.
"""
visited = set()
for order in order_list:
if order >= 0xFF:
break
if order >= len(patterns) or order in visited:
continue
visited.add(order)
grid = patterns[order]
speed = initial_speed
for r in range(MOD_PATTERN_ROWS):
for ch in range(min(n_channels, len(grid))):
row = grid[ch][r]
if row.effect == 0xF and 0 < row.effect_arg < 0x20:
speed = row.effect_arg
break
if r + 1 >= MOD_PATTERN_ROWS or speed <= 0:
continue
for ch in range(min(n_channels, len(grid))):
row = grid[ch][r]
if row.effect != 0xE or row.period == 0:
continue
if ((row.effect_arg >> 4) & 0xF) != 0xD:
continue
x = row.effect_arg & 0xF
if x < speed:
continue
nxt = grid[ch][r + 1]
if (nxt.period or nxt.inst or nxt.effect or nxt.effect_arg
or nxt.vol_set != -1):
continue
new_delay = x - speed
nxt.period = row.period
nxt.inst = row.inst
nxt.vol_set = row.vol_set
if new_delay > 0:
nxt.effect = 0xE
nxt.effect_arg = 0xD0 | (new_delay & 0xF)
row.period = 0
row.inst = 0
row.effect = 0
row.effect_arg = 0
row.vol_set = -1
vprint(f" fix: pat{order} ch{ch} row{r}: ED{x:X} ≥ speed{speed}, "
f"moved note to row{r+1}"
+ (f" with ED{new_delay:X}" if new_delay > 0 else ""))
def resolve_pt_recalls(patterns: list, order_list: list, n_channels: int) -> None: def resolve_pt_recalls(patterns: list, order_list: list, n_channels: int) -> None:
"""In-place: replace PT zero-arg recalls with each effect's last non-zero arg. """In-place: replace PT zero-arg recalls with each effect's last non-zero arg.
@@ -427,6 +482,7 @@ def build_sample_inst_bin(samples: list) -> tuple:
s.loop_end = min(s.loop_end, n) s.loop_end = min(s.loop_end, n)
pos += n pos += n
# New 192-byte instrument layout (terranmon.txt:1997-2070).
inst_bin = bytearray(INSTBIN_SIZE) inst_bin = bytearray(INSTBIN_SIZE)
for i, s in enumerate(samples): for i, s in enumerate(samples):
taud_idx = i + 1 # 1-based instrument number taud_idx = i + 1 # 1-based instrument number
@@ -434,29 +490,31 @@ def build_sample_inst_bin(samples: list) -> tuple:
break break
if not s.sample_data: if not s.sample_data:
continue continue
ptr = offsets.get(i, 0) ptr = offsets.get(i, 0) & 0xFFFFFFFF
ptr_lo = ptr & 0xFFFF
ptr_hi = (ptr >> 16)
s_len = min(s.length, 65535) s_len = min(s.length, 65535)
c2spd = min(s.c2spd, 65535) c2spd = min(s.c2spd, 65535)
ps = 0 ps = 0
ls = min(s.loop_begin, 65535) ls = min(s.loop_begin, 65535)
le = min(s.loop_end, 65535) le = min(s.loop_end, 65535)
loop_mode = 1 if (s.flags & 1) else 0 loop_mode = 1 if (s.flags & 1) else 0
flags_byte = (ptr_hi << 4) | (loop_mode & 0x3) flags_byte = loop_mode & 0x3
env_vol = min(s.volume, 63)
vol_env_flags = 0x0020 # use-envelope bit
base = taud_idx * 64 base = taud_idx * 192
struct.pack_into('<H', inst_bin, base + 0, ptr_lo) struct.pack_into('<I', inst_bin, base + 0, ptr)
struct.pack_into('<H', inst_bin, base + 2, s_len) struct.pack_into('<H', inst_bin, base + 4, s_len)
struct.pack_into('<H', inst_bin, base + 4, c2spd) struct.pack_into('<H', inst_bin, base + 6, c2spd)
struct.pack_into('<H', inst_bin, base + 6, ps) struct.pack_into('<H', inst_bin, base + 8, ps)
struct.pack_into('<H', inst_bin, base + 8, ls) struct.pack_into('<H', inst_bin, base + 10, ls)
struct.pack_into('<H', inst_bin, base + 10, le) struct.pack_into('<H', inst_bin, base + 12, le)
inst_bin[base + 12] = flags_byte inst_bin[base + 14] = flags_byte
inst_bin[base + 15] = 0xFF # global volume — full struct.pack_into('<H', inst_bin, base + 15, vol_env_flags)
env_vol = min(s.volume, 63) struct.pack_into('<H', inst_bin, base + 17, 0)
inst_bin[base + 16] = env_vol # envelope hold value struct.pack_into('<H', inst_bin, base + 19, 0)
inst_bin[base + 17] = 0 # offset minifloat = 0 → hold inst_bin[base + 21] = env_vol
inst_bin[base + 22] = 0
inst_bin[base + 171] = 0xFF
vprint(f" instrument[{taud_idx}] '{s.name}' ptr={ptr} c2spd={s.c2spd} " vprint(f" instrument[{taud_idx}] '{s.name}' ptr={ptr} c2spd={s.c2spd} "
f"vol={s.volume} loop=({ls},{le},{'on' if loop_mode else 'off'})") f"vol={s.volume} loop=({ls},{le},{'on' if loop_mode else 'off'})")
@@ -626,6 +684,9 @@ def assemble_taud(mod: dict) -> bytes:
vprint(" resolving PT per-effect recalls…") vprint(" resolving PT per-effect recalls…")
resolve_pt_recalls(patterns, order_list, n_channels) resolve_pt_recalls(patterns, order_list, n_channels)
init_speed, _ = find_initial_bpm_speed(patterns, order_list)
relocate_late_note_delays(patterns, order_list, n_channels, init_speed)
vprint(" building sample/instrument bin…") vprint(" building sample/instrument bin…")
sampleinst_raw, _offsets = build_sample_inst_bin(samples) sampleinst_raw, _offsets = build_sample_inst_bin(samples)
assert len(sampleinst_raw) == SAMPLEINST_SIZE assert len(sampleinst_raw) == SAMPLEINST_SIZE

View File

@@ -7,7 +7,7 @@ Usage:
Limits: Limits:
- Up to 20 S3M channels (excess disabled; hard error if pattern count - Up to 20 S3M channels (excess disabled; hard error if pattern count
× channel count > 4095). × channel count > 4095).
- Sample bin is 770048 bytes; if all samples together exceed this, every - Sample bin is 737280 bytes; if all samples together exceed this, every
sample is globally resampled down (with c2spd adjusted) so pitch is sample is globally resampled down (with c2spd adjusted) so pitch is
preserved. preserved.
- AdLib instruments are skipped. - AdLib instruments are skipped.
@@ -34,7 +34,7 @@ from taud_common import (
TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY, TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE,
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES, PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C3, NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4,
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I, TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I,
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_Y, TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_Y,
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE, SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
@@ -231,7 +231,7 @@ def encode_note(s3m_note: int) -> int:
if pitch > 11: if pitch > 11:
return NOTE_NOP return NOTE_NOP
semitones = (octave - 4) * 12 + pitch semitones = (octave - 4) * 12 + pitch
val = round(TAUD_C3 + semitones * 4096 / 12) val = round(TAUD_C4 + semitones * 4096 / 12)
return max(1, min(0xFFFD, val)) return max(1, min(0xFFFD, val))
@@ -455,7 +455,9 @@ def build_sample_inst_bin(instruments: list) -> tuple:
inst.loop_end = min(inst.loop_end, n) inst.loop_end = min(inst.loop_end, n)
pos += n pos += n
# Build instrument bin (256 × 64 bytes) # Build instrument bin (256 × 192 bytes)
# New layout (terranmon.txt:1997-2070): u32 sample ptr, ..., 25-point envelopes,
# plus a host of optional fields. S3M doesn't supply most of those — they default to 0.
inst_bin = bytearray(INSTBIN_SIZE) inst_bin = bytearray(INSTBIN_SIZE)
for i, inst in enumerate(instruments): for i, inst in enumerate(instruments):
taud_idx = i + 1 taud_idx = i + 1
@@ -463,33 +465,37 @@ def build_sample_inst_bin(instruments: list) -> tuple:
break break
if inst is None or inst.itype != S3M_TYPE_PCM: if inst is None or inst.itype != S3M_TYPE_PCM:
continue continue
ptr = offsets.get(i, 0) ptr = offsets.get(i, 0) & 0xFFFFFFFF
ptr_lo = ptr & 0xFFFF
ptr_hi = (ptr >> 16)
s_len = min(inst.length, 65535) s_len = min(inst.length, 65535)
c2spd = min(inst.c2spd, 65535) c2spd = min(inst.c2spd, 65535)
ps = 0 ps = 0
ls = min(inst.loop_begin, 65535) ls = min(inst.loop_begin, 65535)
le = min(inst.loop_end, 65535) le = min(inst.loop_end, 65535)
loop_mode = 1 if (inst.flags & 1) else 0 loop_mode = 1 if (inst.flags & 1) else 0
flags_byte = (ptr_hi << 4) | (loop_mode & 0x3) # hhhh 00pp flags_byte = loop_mode & 0x3 # 0b 0000 00pp
base = taud_idx * 64 # Volume envelope: hold at instrument volume (clamped to 0x3F).
struct.pack_into('<H', inst_bin, base + 0, ptr_lo)
struct.pack_into('<H', inst_bin, base + 2, s_len)
struct.pack_into('<H', inst_bin, base + 4, c2spd)
struct.pack_into('<H', inst_bin, base + 6, ps)
struct.pack_into('<H', inst_bin, base + 8, ls)
struct.pack_into('<H', inst_bin, base + 10, le)
inst_bin[base + 12] = flags_byte
inst_bin[base + 15] = 0xFF # instrument global volume (S3M has none → full)
# Volume envelope: hold at instrument volume (clamped to 0x3F)
env_vol = min(inst.volume, 63) env_vol = min(inst.volume, 63)
inst_bin[base + 16] = env_vol # volume # Vol env-flags: enable use-envelope bit (b=1) so engine reads the single point.
inst_bin[base + 17] = 0 # offset minifloat = 0 → hold vol_env_flags = 0x0020 # b=bit 5
base = taud_idx * 192
struct.pack_into('<I', inst_bin, base + 0, ptr) # u32 sample pointer
struct.pack_into('<H', inst_bin, base + 4, s_len)
struct.pack_into('<H', inst_bin, base + 6, c2spd) # rate at TAUD_C4
struct.pack_into('<H', inst_bin, base + 8, ps)
struct.pack_into('<H', inst_bin, base + 10, ls)
struct.pack_into('<H', inst_bin, base + 12, le)
inst_bin[base + 14] = flags_byte
struct.pack_into('<H', inst_bin, base + 15, vol_env_flags)
struct.pack_into('<H', inst_bin, base + 17, 0) # pan env-flags
struct.pack_into('<H', inst_bin, base + 19, 0) # pitch/filter env-flags
# Volume env point 0: hold at env_vol indefinitely (offset minifloat = 0 → hold).
inst_bin[base + 21] = env_vol
inst_bin[base + 22] = 0
inst_bin[base + 171] = 0xFF # instrument global volume
vprint(f" instrument[{base // 64}] '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'") vprint(f" instrument[{base // 192}] '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'")
if inst.c2spd > 65535: if inst.c2spd > 65535:
vprint(f" warning: sampling rate of '{inst.name}' exceeds 65535 (got '{inst.c2spd}')") vprint(f" warning: sampling rate of '{inst.name}' exceeds 65535 (got '{inst.c2spd}')")
@@ -624,6 +630,61 @@ def build_cue_sheet(order_list: list, num_pats_s3m: int, num_channels: int,
return bytes(sheet) return bytes(sheet)
def relocate_late_note_delays(patterns: list, order_list: list,
num_channels: int, initial_speed: int) -> None:
"""Move SDx-delayed notes to the next row when x ≥ tick speed.
ST3 triggers a Note Delay during the current row; if x reaches the tick
speed, the trigger never lands. When the next row in the same channel is
empty, relocate the note (with delay = x speed) so it actually plays.
"""
visited = set()
for order in order_list:
if order >= S3M_ORDER_END:
break
if order >= len(patterns) or order in visited:
continue
visited.add(order)
grid = patterns[order]
speed = initial_speed
for r in range(PATTERN_ROWS):
for ch in range(min(num_channels, len(grid))):
row = grid[ch][r]
if row.effect == EFF_A and row.effect_arg > 0:
speed = row.effect_arg
break
if r + 1 >= PATTERN_ROWS or speed <= 0:
continue
for ch in range(min(num_channels, len(grid))):
row = grid[ch][r]
if row.effect != EFF_S or row.note == S3M_NOTE_EMPTY:
continue
if ((row.effect_arg >> 4) & 0xF) != 0xD:
continue
x = row.effect_arg & 0xF
if x < speed:
continue
nxt = grid[ch][r + 1]
if (nxt.note != S3M_NOTE_EMPTY or nxt.inst or nxt.effect
or nxt.effect_arg or nxt.vol != -1):
continue
new_delay = x - speed
nxt.note = row.note
nxt.inst = row.inst
nxt.vol = row.vol
if new_delay > 0:
nxt.effect = EFF_S
nxt.effect_arg = 0xD0 | (new_delay & 0xF)
row.note = S3M_NOTE_EMPTY
row.inst = 0
row.vol = -1
row.effect = 0
row.effect_arg = 0
vprint(f" fix: pat{order} ch{ch} row{r}: SD{x:X} ≥ speed{speed}, "
f"moved note to row{r+1}"
+ (f" with SD{new_delay:X}" if new_delay > 0 else ""))
def find_initial_bpm_speed(patterns: list, order_list: list, def find_initial_bpm_speed(patterns: list, order_list: list,
default_speed: int, default_tempo: int) -> tuple: default_speed: int, default_tempo: int) -> tuple:
"""Scan first pattern in order for Axx/Txx in row 0 of any channel.""" """Scan first pattern in order for Axx/Txx in row 0 of any channel."""
@@ -668,6 +729,10 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
resolve_st3_recalls(patterns, h.order_list, 32) resolve_st3_recalls(patterns, h.order_list, 32)
warn_st3_quirks(patterns, h.order_list, 32) warn_st3_quirks(patterns, h.order_list, 32)
init_speed, _ = find_initial_bpm_speed(patterns, h.order_list,
h.initial_speed, h.initial_tempo)
relocate_late_note_delays(patterns, h.order_list, 32, init_speed)
# Build sample+instrument bin # Build sample+instrument bin
vprint(" building sample/instrument bin…") vprint(" building sample/instrument bin…")
sampleinst_raw, _offsets = build_sample_inst_bin(instruments) sampleinst_raw, _offsets = build_sample_inst_bin(instruments)

View File

@@ -30,8 +30,8 @@ TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64])
TAUD_VERSION = 1 TAUD_VERSION = 1
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+rsvd(4)+sig(14) TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+rsvd(4)+sig(14)
TAUD_SONG_ENTRY = 16 # offset(4)+voices(1)+pats(2)+bpm(1)+tick(1)+basenote(2)+basefreq(4)+flags(1) TAUD_SONG_ENTRY = 16 # offset(4)+voices(1)+pats(2)+bpm(1)+tick(1)+basenote(2)+basefreq(4)+flags(1)
SAMPLEBIN_SIZE = 770048 SAMPLEBIN_SIZE = 737280
INSTBIN_SIZE = 16384 # 256 instruments × 64 bytes INSTBIN_SIZE = 49152 # 256 instruments × 192 bytes
SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE
PATTERN_ROWS = 64 PATTERN_ROWS = 64
PATTERN_BYTES = PATTERN_ROWS * 8 # 512 PATTERN_BYTES = PATTERN_ROWS * 8 # 512
@@ -44,7 +44,7 @@ NUM_VOICES = 20
NOTE_NOP = 0xFFFF NOTE_NOP = 0xFFFF
NOTE_KEYOFF = 0x0000 NOTE_KEYOFF = 0x0000
NOTE_CUT = 0xFFFE NOTE_CUT = 0xFFFE
TAUD_C3 = 0x4000 TAUD_C4 = 0x5000 # reference C for instrument sampling rate (was TAUD_C3 = 0x4000)
# Taud effect opcodes (base-36: 0..9 → 0x00..0x09, A..Z → 0x0A..0x23) # Taud effect opcodes (base-36: 0..9 → 0x00..0x09, A..Z → 0x0A..0x23)
TOP_NONE = 0x00 TOP_NONE = 0x00

View File

@@ -110,10 +110,10 @@ class AudioJSR223Delegate(private val vm: VM) {
} }
} }
/** Upload 64 bytes defining instrument `slot` (0-255). */ /** Upload up to 192 bytes defining instrument `slot` (0-255). */
fun uploadInstrument(slot: Int, bytes: IntArray) { fun uploadInstrument(slot: Int, bytes: IntArray) {
getFirstSnd()?.instruments?.get(slot and 0xFF)?.let { inst -> getFirstSnd()?.instruments?.get(slot and 0xFF)?.let { inst ->
for (i in 0 until minOf(64, bytes.size)) inst.setByte(i, bytes[i] and 0xFF) for (i in 0 until minOf(192, bytes.size)) inst.setByte(i, bytes[i] and 0xFF)
} }
} }

View File

@@ -124,16 +124,17 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
internal val DBGPRN = false internal val DBGPRN = false
const val SAMPLING_RATE = 32000 const val SAMPLING_RATE = 32000
const val TRACKER_CHUNK = 512 const val TRACKER_CHUNK = 512
const val TRACKER_C3 = 0x4000 const val TRACKER_C3 = 0x4000 // legacy alias (one octave below the new reference)
// Amiga period at TRACKER_C3 for a standard 8363 Hz instrument (NTSC clock 3579545 Hz). const val TRACKER_C4 = 0x5000 // reference C for instrument samplingRate (terranmon.txt:2000)
// Used to implement Amiga-mode pitch slides (effect '1' f-bit or song-table flag). // Amiga period at TRACKER_C4 for a standard 8363 Hz instrument (NTSC clock 3579545 Hz).
const val AMIGA_BASE_PERIOD = 214.0 // Reference shifted from C3→C4 (one octave up), so the period halves: 214 → 107.
const val AMIGA_BASE_PERIOD = 107.0
// Scale factor that converts a Taud coarse-slide unit back to one Amiga period unit. // Scale factor that converts a Taud coarse-slide unit back to one Amiga period unit.
// Taud coarse unit = round(ST3_unit × 64/3), so the inverse is × 3/64. // Taud coarse unit = round(ST3_unit × 64/3), so the inverse is × 3/64.
const val AMIGA_PERIOD_SCALE = 3.0 / 64.0 const val AMIGA_PERIOD_SCALE = 3.0 / 64.0
} }
internal val sampleBin = UnsafeHelper.allocate(770048L, this) internal val sampleBin = UnsafeHelper.allocate(737280L, this)
internal val instruments = Array(256) { TaudInst(it) } internal val instruments = Array(256) { TaudInst(it) }
internal val playdata = Array(4096) { Array(64) { TaudPlayData(0xFFFF, 0, 0, 0, 32, 0, 0, 0) } } internal val playdata = Array(4096) { Array(64) { TaudPlayData(0xFFFF, 0, 0, 0, 32, 0, 0, 0) } }
internal val playheads: Array<Playhead> internal val playheads: Array<Playhead>
@@ -305,8 +306,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
override fun peek(addr: Long): Byte { override fun peek(addr: Long): Byte {
return when (val adi = addr.toInt()) { return when (val adi = addr.toInt()) {
in 0..770047 -> sampleBin[addr] in 0..737279 -> sampleBin[addr]
in 770048..786431 -> (adi - 770048).let { instruments[it / 64].getByte(it % 64) } in 737280..786431 -> (adi - 737280).let { instruments[it / 192].getByte(it % 192) }
in 786432..851967 -> { val off = adi - 786432; playdata[playheads[0].patBank1 * 128 + off / 512][(off % 512) / 8].getByte(off % 8) } in 786432..851967 -> { val off = adi - 786432; playdata[playheads[0].patBank1 * 128 + off / 512][(off % 512) / 8].getByte(off % 8) }
in 851968..917503 -> { val off = adi - 851968; playdata[playheads[0].patBank2 * 128 + off / 512][(off % 512) / 8].getByte(off % 8) } in 851968..917503 -> { val off = adi - 851968; playdata[playheads[0].patBank2 * 128 + off / 512][(off % 512) / 8].getByte(off % 8) }
in 917504..983039 -> tadInputBin[addr - 917504] // TAD input buffer (65536 bytes) in 917504..983039 -> tadInputBin[addr - 917504] // TAD input buffer (65536 bytes)
@@ -319,8 +320,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val adi = addr.toInt() val adi = addr.toInt()
val bi = byte.toUint() val bi = byte.toUint()
when (adi) { when (adi) {
in 0..770047 -> { sampleBin[addr] = byte } in 0..737279 -> { sampleBin[addr] = byte }
in 770048..786431 -> (adi - 770048).let { instruments[it / 64].setByte(it % 64, bi) } in 737280..786431 -> (adi - 737280).let { instruments[it / 192].setByte(it % 192, bi) }
in 786432..851967 -> { val off = adi - 786432; playdata[playheads[0].patBank1 * 128 + off / 512][(off % 512) / 8].setByte(off % 8, bi) } in 786432..851967 -> { val off = adi - 786432; playdata[playheads[0].patBank1 * 128 + off / 512][(off % 512) / 8].setByte(off % 8, bi) }
in 851968..917503 -> { val off = adi - 851968; playdata[playheads[0].patBank2 * 128 + off / 512][(off % 512) / 8].setByte(off % 8, bi) } in 851968..917503 -> { val off = adi - 851968; playdata[playheads[0].patBank2 * 128 + off / 512][(off % 512) / 8].setByte(off % 8, bi) }
in 917504..983039 -> tadInputBin[addr - 917504] = byte // TAD input buffer in 917504..983039 -> tadInputBin[addr - 917504] = byte // TAD input buffer
@@ -1170,56 +1171,65 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
private fun computePlaybackRate(inst: TaudInst, noteVal: Int): Double = private fun computePlaybackRate(inst: TaudInst, noteVal: Int): Double =
inst.samplingRate.toDouble() / SAMPLING_RATE * 2.0.pow((noteVal - TRACKER_C3) / 4096.0) inst.samplingRate.toDouble() / SAMPLING_RATE * 2.0.pow((noteVal - TRACKER_C4) / 4096.0)
// Applies one tick of Amiga-mode pitch slide. slideArg uses the same sign convention as // Applies one tick of Amiga-mode pitch slide. slideArg uses the same sign convention as
// linear mode: negative = pitch down (E effect), positive = pitch up (F effect). // linear mode: negative = pitch down (E effect), positive = pitch up (F effect).
// The Taud coarse-slide value is converted back to Amiga period units via AMIGA_PERIOD_SCALE. // The Taud coarse-slide value is converted back to Amiga period units via AMIGA_PERIOD_SCALE.
private fun amigaSlide(noteVal: Int, slideArg: Int): Int { private fun amigaSlide(noteVal: Int, slideArg: Int): Int {
val period = AMIGA_BASE_PERIOD * 2.0.pow(-(noteVal - TRACKER_C3).toDouble() / 4096.0) val period = AMIGA_BASE_PERIOD * 2.0.pow(-(noteVal - TRACKER_C4).toDouble() / 4096.0)
// Negate slideArg: pitch down (slideArg < 0) → period up, pitch up (slideArg > 0) → period down. // Negate slideArg: pitch down (slideArg < 0) → period up, pitch up (slideArg > 0) → period down.
val newPeriod = (period - slideArg * AMIGA_PERIOD_SCALE).coerceAtLeast(1.0) val newPeriod = (period - slideArg * AMIGA_PERIOD_SCALE).coerceAtLeast(1.0)
return (TRACKER_C3 + 4096.0 * log2(AMIGA_BASE_PERIOD / newPeriod)).roundToInt() return (TRACKER_C4 + 4096.0 * log2(AMIGA_BASE_PERIOD / newPeriod)).roundToInt()
} }
private fun advanceEnvelope(voice: Voice, inst: TaudInst, tickSec: Double) { private fun advanceEnvelope(voice: Voice, inst: TaudInst, tickSec: Double) {
// Volume envelope // 16-bit envelope-flag layout (terranmon.txt:2007-2030):
// sustain byte: bit7=enable (u), bit6=sustain (t: 1=breaks on key-off, // 0b 0ut sssss pcb eeeee
// 0=loops forever), bits[5:3]=end_idx, bits[2:0]=start_idx // bit 14 = u (enable sustain/loop)
val vSus = inst.volEnvSustain // bit 13 = t (sustain — 1=breaks on key-off, 0=loops forever)
val vEnabled = (vSus and 0x80) != 0 // bits 12..8 = sustain/loop start index (0..24)
val vIsSustain = (vSus and 0x40) != 0 // bit 7 = p (channel-specific flag — fadeout zero / use default pan)
// Loop is "active" when enabled AND (it's a forever-loop OR key not yet released) // bit 6 = c (envelope carry)
val vSusOn = vEnabled && (!vIsSustain || !voice.keyOff) // bit 5 = b (use envelope at all)
val vSusStart = vSus and 7 // bits 4..0 = sustain/loop end index (0..24)
val vSusEnd = (vSus ushr 3) and 7 val maxIdx = 24
if (vSusOn && voice.envIndex == vSusEnd && vSusStart == vSusEnd) { // Volume envelope
// slb == sle: hold at this node until key-off (no cycling) val vSus = inst.volEnvSustain
voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0) val vUseEnv = (vSus ushr 5) and 1 != 0
} else if (vSusOn && voice.envIndex == vSusEnd) { if (vUseEnv) {
// At sustain-loop end: snap back to start regardless of stored offset. val vEnabled = (vSus ushr 14) and 1 != 0
voice.envTimeSec = 0.0 val vIsSustain = (vSus ushr 13) and 1 != 0
voice.envIndex = vSusStart val vSusOn = vEnabled && (!vIsSustain || !voice.keyOff)
voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0) val vSusStart = (vSus ushr 8) and 0x1F
} else if (voice.envIndex >= 11) { val vSusEnd = vSus and 0x1F
voice.envVolume = (inst.volEnvelopes[11].value / 63.0).coerceIn(0.0, 1.0)
} else { if (vSusOn && voice.envIndex == vSusEnd && vSusStart == vSusEnd) {
val vOffset = inst.volEnvelopes[voice.envIndex].offset.toDouble()
if (vOffset == 0.0) {
voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0) voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
} else if (vSusOn && voice.envIndex == vSusEnd) {
voice.envTimeSec = 0.0
voice.envIndex = vSusStart
voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
} else if (voice.envIndex >= maxIdx) {
voice.envVolume = (inst.volEnvelopes[maxIdx].value / 63.0).coerceIn(0.0, 1.0)
} else { } else {
voice.envTimeSec += tickSec val vOffset = inst.volEnvelopes[voice.envIndex].offset.toDouble()
if (voice.envTimeSec >= vOffset) { if (vOffset == 0.0) {
voice.envTimeSec -= vOffset
val nextIdx = if (vSusOn && voice.envIndex == vSusEnd) vSusStart
else (voice.envIndex + 1).coerceAtMost(11)
voice.envIndex = nextIdx
voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0) voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
} else { } else {
val cur = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0) voice.envTimeSec += tickSec
val nxt = (inst.volEnvelopes[(voice.envIndex + 1).coerceAtMost(11)].value / 63.0).coerceIn(0.0, 1.0) if (voice.envTimeSec >= vOffset) {
voice.envVolume = cur + (nxt - cur) * (voice.envTimeSec / vOffset) voice.envTimeSec -= vOffset
val nextIdx = if (vSusOn && voice.envIndex == vSusEnd) vSusStart
else (voice.envIndex + 1).coerceAtMost(maxIdx)
voice.envIndex = nextIdx
voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
} else {
val cur = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
val nxt = (inst.volEnvelopes[(voice.envIndex + 1).coerceAtMost(maxIdx)].value / 63.0).coerceIn(0.0, 1.0)
voice.envVolume = cur + (nxt - cur) * (voice.envTimeSec / vOffset)
}
} }
} }
} }
@@ -1227,23 +1237,22 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Pan envelope (only when active for this instrument) // Pan envelope (only when active for this instrument)
if (!voice.hasPanEnv) return if (!voice.hasPanEnv) return
val pSus = inst.panEnvSustain val pSus = inst.panEnvSustain
val pEnabled = (pSus and 0x80) != 0 val pUseEnv = (pSus ushr 5) and 1 != 0
val pIsSustain = (pSus and 0x40) != 0 if (!pUseEnv) return
val pEnabled = (pSus ushr 14) and 1 != 0
val pIsSustain = (pSus ushr 13) and 1 != 0
val pSusOn = pEnabled && (!pIsSustain || !voice.keyOff) val pSusOn = pEnabled && (!pIsSustain || !voice.keyOff)
val pSusStart = pSus and 7 val pSusStart = (pSus ushr 8) and 0x1F
val pSusEnd = (pSus ushr 3) and 7 val pSusEnd = pSus and 0x1F
if (pSusOn && voice.envPanIndex == pSusEnd && pSusStart == pSusEnd) { if (pSusOn && voice.envPanIndex == pSusEnd && pSusStart == pSusEnd) {
// slb == sle: hold at this pan node until key-off
voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0 voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0
} else if (pSusOn && voice.envPanIndex == pSusEnd) { } else if (pSusOn && voice.envPanIndex == pSusEnd) {
// At sustain-loop end: snap back to start regardless of stored offset
// (encoder writes mf=0 on the last node by convention).
voice.envPanTimeSec = 0.0 voice.envPanTimeSec = 0.0
voice.envPanIndex = pSusStart voice.envPanIndex = pSusStart
voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0 voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0
} else if (voice.envPanIndex >= 11) { } else if (voice.envPanIndex >= maxIdx) {
voice.envPan = inst.panEnvelopes[11].value / 255.0 voice.envPan = inst.panEnvelopes[maxIdx].value / 255.0
} else { } else {
val pOffset = inst.panEnvelopes[voice.envPanIndex].offset.toDouble() val pOffset = inst.panEnvelopes[voice.envPanIndex].offset.toDouble()
if (pOffset == 0.0) { if (pOffset == 0.0) {
@@ -1253,12 +1262,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (voice.envPanTimeSec >= pOffset) { if (voice.envPanTimeSec >= pOffset) {
voice.envPanTimeSec -= pOffset voice.envPanTimeSec -= pOffset
val nextIdx = if (pSusOn && voice.envPanIndex == pSusEnd) pSusStart val nextIdx = if (pSusOn && voice.envPanIndex == pSusEnd) pSusStart
else (voice.envPanIndex + 1).coerceAtMost(11) else (voice.envPanIndex + 1).coerceAtMost(maxIdx)
voice.envPanIndex = nextIdx voice.envPanIndex = nextIdx
voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0 voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0
} else { } else {
val cur = inst.panEnvelopes[voice.envPanIndex].value / 255.0 val cur = inst.panEnvelopes[voice.envPanIndex].value / 255.0
val nxt = inst.panEnvelopes[(voice.envPanIndex + 1).coerceAtMost(11)].value / 255.0 val nxt = inst.panEnvelopes[(voice.envPanIndex + 1).coerceAtMost(maxIdx)].value / 255.0
voice.envPan = cur + (nxt - cur) * (voice.envPanTimeSec / pOffset) voice.envPan = cur + (nxt - cur) * (voice.envPanTimeSec / pOffset)
} }
} }
@@ -1272,7 +1281,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val sampleLen = inst.sampleLength.coerceAtLeast(1) val sampleLen = inst.sampleLength.coerceAtLeast(1)
val loopStart = inst.sampleLoopStart.toDouble() val loopStart = inst.sampleLoopStart.toDouble()
val loopEnd = inst.sampleLoopEnd.toDouble().coerceAtLeast(1.0) val loopEnd = inst.sampleLoopEnd.toDouble().coerceAtLeast(1.0)
val binMax = 770047 // sampleBin is 770048 bytes (0..770047) val binMax = 737279 // sampleBin is 737280 bytes (0..737279)
val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1) val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1)
val i1 = (i0 + 1).coerceAtMost(sampleLen - 1) val i1 = (i0 + 1).coerceAtMost(sampleLen - 1)
@@ -1323,7 +1332,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.envPanIndex = 0 voice.envPanIndex = 0
voice.envPanTimeSec = 0.0 voice.envPanTimeSec = 0.0
voice.envPan = inst.panEnvelopes[0].value / 255.0 voice.envPan = inst.panEnvelopes[0].value / 255.0
voice.hasPanEnv = inst.panEnvelopes.any { it.offset.toFloat() > 0.0f } // Pan envelope is active when the `b` (use envelope) flag is set in panEnvSustain.
voice.hasPanEnv = (inst.panEnvSustain ushr 5) and 1 != 0
voice.noteVal = noteVal voice.noteVal = noteVal
voice.basePitch = noteVal voice.basePitch = noteVal
voice.playbackRate = computePlaybackRate(inst, noteVal) voice.playbackRate = computePlaybackRate(inst, noteVal)
@@ -2322,26 +2332,75 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
data class TaudInstEnvPoint(var value: Int, var offset: ThreeFiveMiniUfloat) data class TaudInstEnvPoint(var value: Int, var offset: ThreeFiveMiniUfloat)
/**
* 192-byte instrument record (terranmon.txt:1997-2070).
* Layout:
* 0..3 u32 sample pointer
* 4..5 u16 sample length
* 6..7 u16 sampling rate at TRACKER_C4 (0x5000)
* 8..9 u16 play start
* 10..11 u16 loop start
* 12..13 u16 loop end
* 14 u8 sample flags (low 2 bits = loop mode 0..3)
* 15..16 u16 volume envelope flags (0b 0ut sssss pcb eeeee)
* 17..18 u16 panning envelope flags
* 19..20 u16 pitch/filter envelope flags
* 21..70 Bit16×25 volume envelope points (value 0x00-0x3F + minifloat dt)
* 71..120 Bit16×25 panning envelope points (value 0x00-0xFF, 0x80=centre)
* 121..170 Bit16×25 pitch/filter envelope points
* 171 u8 instrument global volume
* 172 u8 volume fadeout low bits
* 173 u8 fadeout high (low nibble) + vibrato depth (high nibble)
* 174 u8 volume swing
* 175 u8 vibrato speed
* 176 u8 vibrato sweep
* 177 u8 default pan
* 178..179 u16 pitch-pan centre (4096-TET)
* 180 s8 pitch-pan separation
* 181 u8 pan swing
* 182 u8 default cutoff
* 183 u8 default resonance
* 184..191 byte[8] reserved
*/
data class TaudInst( data class TaudInst(
var index: Int, var index: Int,
var samplePtr: Int, // 20-bit number var samplePtr: Int, // 32-bit sample bin offset
var sampleLength: Int, var sampleLength: Int,
var samplingRate: Int, var samplingRate: Int, // rate at TRACKER_C4
var samplePlayStart: Int, var samplePlayStart: Int,
var sampleLoopStart: Int, var sampleLoopStart: Int,
var sampleLoopEnd: Int, var sampleLoopEnd: Int,
// flags var loopMode: Int, // byte 14, low 2 bits
var loopMode: Int, var volEnvSustain: Int, // bytes 15-16 (16-bit, see flag layout)
var volEnvSustain: Int, // byte 13: ut eee sss (u=enable, t=sustain (1=breaks on key-off, 0=loops forever)) var panEnvSustain: Int, // bytes 17-18
var panEnvSustain: Int, // byte 14: ut eee sss (u=enable, t=sustain (1=breaks on key-off, 0=loops forever)) var pfEnvSustain: Int, // bytes 19-20 (pitch/filter)
var instGlobalVolume: Int, // byte 15: instrument global volume (0..255, 255 = unity) var instGlobalVolume: Int, // byte 171
var volEnvelopes: Array<TaudInstEnvPoint>, // 12 points, value 0x00-0x3F var volEnvelopes: Array<TaudInstEnvPoint>, // 25 points
var panEnvelopes: Array<TaudInstEnvPoint> // 12 points, value 0x00-0xFF (0x80 = centre) var panEnvelopes: Array<TaudInstEnvPoint>, // 25 points
var pfEnvelopes: Array<TaudInstEnvPoint>, // 25 points (pitch/filter)
var volumeFadeoutLow: Int, // byte 172
var fadeoutHighVibDepth: Int, // byte 173
var volumeSwing: Int, // byte 174
var vibratoSpeed: Int, // byte 175
var vibratoSweep: Int, // byte 176
var defaultPan: Int, // byte 177
var pitchPanCentre: Int, // bytes 178-179
var pitchPanSeparation: Int, // byte 180 (signed)
var panSwing: Int, // byte 181
var defaultCutoff: Int, // byte 182
var defaultResonance: Int // byte 183
) { ) {
constructor(index: Int) : this(index, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF, constructor(index: Int) : this(
Array(12) { TaudInstEnvPoint(0x3F, ThreeFiveMiniUfloat(0)) }, index, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF,
Array(12) { TaudInstEnvPoint(0x80, ThreeFiveMiniUfloat(0)) }) Array(25) { TaudInstEnvPoint(0x3F, ThreeFiveMiniUfloat(0)) },
Array(25) { TaudInstEnvPoint(0x80, ThreeFiveMiniUfloat(0)) },
Array(25) { TaudInstEnvPoint(0x80, ThreeFiveMiniUfloat(0)) },
0, 0, 0, 0, 0, 0x80, 0x5000, 0, 0, 0xFF, 0
)
// Reserved padding at offsets 184..191 (8 bytes per instrument).
private val reserved = ByteArray(8)
// Funk repeat (S$Fx00) bit-mask — non-destructive XOR overlay across the loop region. // Funk repeat (S$Fx00) bit-mask — non-destructive XOR overlay across the loop region.
// Lazily allocated; a 1-bit flips the byte, a 0-bit leaves it intact. // Lazily allocated; a 1-bit flips the byte, a 0-bit leaves it intact.
@@ -2359,68 +2418,116 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
return (mask[idx / 8].toInt() ushr (idx and 7)) and 1 != 0 return (mask[idx / 8].toInt() ushr (idx and 7)) and 1 != 0
} }
private fun envPointGet(env: Array<TaudInstEnvPoint>, base: Int, offset: Int): Byte {
val rel = offset - base
val pt = env[rel / 2]
return if (rel and 1 == 0) pt.value.toByte() else pt.offset.index.toByte()
}
private fun envPointSet(env: Array<TaudInstEnvPoint>, base: Int, offset: Int, byte: Int) {
val rel = offset - base
val pt = env[rel / 2]
if (rel and 1 == 0) pt.value = byte
else pt.offset = ThreeFiveMiniUfloat(byte)
}
fun getByte(offset: Int): Byte = when (offset) { fun getByte(offset: Int): Byte = when (offset) {
0 -> samplePtr.toByte() 0 -> samplePtr.toByte()
1 -> samplePtr.ushr(8).toByte() 1 -> samplePtr.ushr(8).toByte()
2 -> samplePtr.ushr(16).toByte()
3 -> samplePtr.ushr(24).toByte()
2 -> sampleLength.toByte() 4 -> sampleLength.toByte()
3 -> sampleLength.ushr(8).toByte() 5 -> sampleLength.ushr(8).toByte()
4 -> samplingRate.toByte() 6 -> samplingRate.toByte()
5 -> samplingRate.ushr(8).toByte() 7 -> samplingRate.ushr(8).toByte()
6 -> samplePlayStart.toByte() 8 -> samplePlayStart.toByte()
7 -> samplePlayStart.ushr(8).toByte() 9 -> samplePlayStart.ushr(8).toByte()
8 -> sampleLoopStart.toByte() 10 -> sampleLoopStart.toByte()
9 -> sampleLoopStart.ushr(8).toByte() 11 -> sampleLoopStart.ushr(8).toByte()
10 -> sampleLoopEnd.toByte() 12 -> sampleLoopEnd.toByte()
11 -> sampleLoopEnd.ushr(8).toByte() 13 -> sampleLoopEnd.ushr(8).toByte()
12 -> (samplePtr.ushr(16).and(15).shl(4) or loopMode.and(3)).toByte() 14 -> (loopMode and 3).toByte()
13 -> volEnvSustain.toByte() 15 -> volEnvSustain.toByte()
14 -> panEnvSustain.toByte() 16 -> volEnvSustain.ushr(8).toByte()
15 -> instGlobalVolume.toByte() 17 -> panEnvSustain.toByte()
in 16..38 step 2 -> volEnvelopes[(offset - 16) / 2].value.toByte() 18 -> panEnvSustain.ushr(8).toByte()
in 17..39 step 2 -> volEnvelopes[(offset - 17) / 2].offset.index.toByte() 19 -> pfEnvSustain.toByte()
in 40..62 step 2 -> panEnvelopes[(offset - 40) / 2].value.toByte() 20 -> pfEnvSustain.ushr(8).toByte()
in 41..63 step 2 -> panEnvelopes[(offset - 41) / 2].offset.index.toByte()
in 21..70 -> envPointGet(volEnvelopes, 21, offset)
in 71..120 -> envPointGet(panEnvelopes, 71, offset)
in 121..170 -> envPointGet(pfEnvelopes, 121, offset)
171 -> instGlobalVolume.toByte()
172 -> volumeFadeoutLow.toByte()
173 -> fadeoutHighVibDepth.toByte()
174 -> volumeSwing.toByte()
175 -> vibratoSpeed.toByte()
176 -> vibratoSweep.toByte()
177 -> defaultPan.toByte()
178 -> pitchPanCentre.toByte()
179 -> pitchPanCentre.ushr(8).toByte()
180 -> pitchPanSeparation.toByte()
181 -> panSwing.toByte()
182 -> defaultCutoff.toByte()
183 -> defaultResonance.toByte()
in 184..191 -> reserved[offset - 184]
else -> throw InternalError("Bad offset $offset") else -> throw InternalError("Bad offset $offset")
} }
fun setByte(offset: Int, byte: Int) = when (offset) { fun setByte(offset: Int, byte: Int) = when (offset) {
0 -> { samplePtr = (samplePtr and 0xfff00) or byte } 0 -> { samplePtr = (samplePtr and 0xFFFFFF00.toInt()) or byte }
1 -> { samplePtr = (samplePtr and 0x000ff) or (byte shl 8) } 1 -> { samplePtr = (samplePtr and 0xFFFF00FF.toInt()) or (byte shl 8) }
2 -> { samplePtr = (samplePtr and 0xFF00FFFF.toInt()) or (byte shl 16) }
3 -> { samplePtr = (samplePtr and 0x00FFFFFF) or (byte shl 24) }
2 -> { sampleLength = (sampleLength and 0xff00) or byte } 4 -> { sampleLength = (sampleLength and 0xff00) or byte }
3 -> { sampleLength = (sampleLength and 0x00ff) or (byte shl 8) } 5 -> { sampleLength = (sampleLength and 0x00ff) or (byte shl 8) }
4 -> { samplingRate = (samplingRate and 0xff00) or byte } 6 -> { samplingRate = (samplingRate and 0xff00) or byte }
5 -> { samplingRate = (samplingRate and 0x00ff) or (byte shl 8) } 7 -> { samplingRate = (samplingRate and 0x00ff) or (byte shl 8) }
6 -> { samplePlayStart = (samplePlayStart and 0xff00) or byte } 8 -> { samplePlayStart = (samplePlayStart and 0xff00) or byte }
7 -> { samplePlayStart = (samplePlayStart and 0x00ff) or (byte shl 8) } 9 -> { samplePlayStart = (samplePlayStart and 0x00ff) or (byte shl 8) }
8 -> { sampleLoopStart = (sampleLoopStart and 0xff00) or byte } 10 -> { sampleLoopStart = (sampleLoopStart and 0xff00) or byte }
9 -> { sampleLoopStart = (sampleLoopStart and 0x00ff) or (byte shl 8) } 11 -> { sampleLoopStart = (sampleLoopStart and 0x00ff) or (byte shl 8) }
10 -> { sampleLoopEnd = (sampleLoopEnd and 0xff00) or byte } 12 -> { sampleLoopEnd = (sampleLoopEnd and 0xff00) or byte }
11 -> { sampleLoopEnd = (sampleLoopEnd and 0x00ff) or (byte shl 8) } 13 -> { sampleLoopEnd = (sampleLoopEnd and 0x00ff) or (byte shl 8) }
12 -> { 14 -> { loopMode = byte and 3 }
samplePtr = if (byte and 0b1111_0000 != 0) samplePtr or ((byte ushr 4) shl 16) 15 -> { volEnvSustain = (volEnvSustain and 0xff00) or byte }
else samplePtr and 0x0ffff 16 -> { volEnvSustain = (volEnvSustain and 0x00ff) or (byte shl 8) }
loopMode = byte and 3 17 -> { panEnvSustain = (panEnvSustain and 0xff00) or byte }
} 18 -> { panEnvSustain = (panEnvSustain and 0x00ff) or (byte shl 8) }
13 -> { volEnvSustain = byte } 19 -> { pfEnvSustain = (pfEnvSustain and 0xff00) or byte }
14 -> { panEnvSustain = byte } 20 -> { pfEnvSustain = (pfEnvSustain and 0x00ff) or (byte shl 8) }
15 -> { instGlobalVolume = byte and 0xFF }
in 16..38 step 2 -> volEnvelopes[(offset - 16) / 2].value = byte in 21..70 -> envPointSet(volEnvelopes, 21, offset, byte)
in 17..39 step 2 -> volEnvelopes[(offset - 17) / 2].offset = ThreeFiveMiniUfloat(byte) in 71..120 -> envPointSet(panEnvelopes, 71, offset, byte)
in 40..62 step 2 -> panEnvelopes[(offset - 40) / 2].value = byte in 121..170 -> envPointSet(pfEnvelopes, 121, offset, byte)
in 41..63 step 2 -> panEnvelopes[(offset - 41) / 2].offset = ThreeFiveMiniUfloat(byte)
171 -> { instGlobalVolume = byte and 0xFF }
172 -> { volumeFadeoutLow = byte and 0xFF }
173 -> { fadeoutHighVibDepth = byte and 0xFF }
174 -> { volumeSwing = byte and 0xFF }
175 -> { vibratoSpeed = byte and 0xFF }
176 -> { vibratoSweep = byte and 0xFF }
177 -> { defaultPan = byte and 0xFF }
178 -> { pitchPanCentre = (pitchPanCentre and 0xff00) or byte }
179 -> { pitchPanCentre = (pitchPanCentre and 0x00ff) or (byte shl 8) }
180 -> { pitchPanSeparation = byte.toByte().toInt() } // signed
181 -> { panSwing = byte and 0xFF }
182 -> { defaultCutoff = byte and 0xFF }
183 -> { defaultResonance = byte and 0xFF }
in 184..191 -> { reserved[offset - 184] = byte.toByte() }
else -> throw InternalError("Bad offset $offset") else -> throw InternalError("Bad offset $offset")
} }
} }