tracker effects definition

This commit is contained in:
minjaesong
2026-04-20 01:35:23 +09:00
parent 5374ca43c3
commit f84ea5e68a
8 changed files with 1247 additions and 134 deletions

View File

@@ -676,24 +676,31 @@ on sample byte read during loop playback:
# Volume column effects # Volume column effects
The volume column of each cell can carry a secondary effect, encoded as a one-digit selector followed by a two-hex-digit value (`N.$xx`). The two defined selectors are: Each cell carries a 6-bit value field plus a 2-bit selector field for the volume column. The four selectors are:
- **`0.$xx` — Set volume** to `$xx` (clipped to $3F). Equivalent to a note's default volume. - **`0.$xx` — Set volume** to `$xx` (6-bit, $00..$3F). Equivalent to a note's default volume.
- **`1.$xy` — Volume slide** by `$xy`, with `$x` as up-slide amount and `$y` as down-slide amount, using the same encoding as D. Fine slides (`1.$Fx` and `1.$xF`) fire on tick 0 only; other values fire on ticks > 0. - **`1.$xx` — Volume slide up** by `$xx` per non-first tick (6-bit). Volume clamps at $3F.
- **`2.$xx` — Volume slide down** by `$xx` per non-first tick (6-bit). Volume clamps at $00.
- **`3.$Sx` — Fine volume slide** on tick 0 only. The high bit `$S` of the value selects direction (0 = down, 1 = up); the low 5 bits `$x` ($00..$1F) are the magnitude. Equivalent in scale to `D $xF00` / `D $Fy00` but with a 5-bit cap. Fires once per row regardless of speed.
Volume-column effects do not consume the main effect slot; a cell can carry both (for instance, a tone portamento in the effect slot and a volume slide in the volume column). Volume-column effects do not consume the main effect slot; a cell can carry both (for instance, a tone portamento in the effect slot and a volume slide in the volume column).
When the converter folds an ST3 K, L, M, or N effect into the volume column, the slide-up / slide-down nibbles map to selectors 1 / 2 (clamped to 6 bits — values above $3F clip).
NOTE: **`3.00` — is No-op**
--- ---
# Panning column effects # Panning column effects
The optional panning column carries its own one-digit selectors: The panning column uses the same 6-bit value + 2-bit selector layout:
- **`0.$xx` — Set pan** to `$xx` ($00 left, $FF right, $80 centre). Equivalent to S $80xx but without consuming the effect slot. - **`0.$xx` — Set pan** (6-bit, $00..$3F mapped onto the channel's 8-bit pan space; $01 = full left, $1F = centre-left, $20 = centre-right, $3F = full right). For 8-bit precision use `S $80xx` instead.
- **`1.$xy` — Pan slide** by `$xy`, with `$x` as left-slide amount and `$y` as right-slide amount, using the same encoding as D. There is no fine slide, as ST3 does not have panning slides. - **`1.$xx` — Pan slide right** by `$xx` per non-first tick.
- **`2.$xx` — Pan slide left** by `$xx` per non-first tick.
- **`3.$Sx` — Fine pan slide** on tick 0 only, same direction-bit encoding as the volume column's selector 3.
NOTE: **`3.00` — is No-op**
Additional selectors are reserved for future expansion.
--- ---

View File

@@ -10,6 +10,10 @@ const PLAYHEAD = 0
println("Playing "+fullFilePath.full) println("Playing "+fullFilePath.full)
audio.resetParams(PLAYHEAD)
audio.purgeQueue(PLAYHEAD)
audio.stop(PLAYHEAD)
taud.uploadTaudFile(fullFilePath.full, 0, PLAYHEAD) taud.uploadTaudFile(fullFilePath.full, 0, PLAYHEAD)
audio.setMasterVolume(PLAYHEAD, 255) audio.setMasterVolume(PLAYHEAD, 255)
audio.setMasterPan(PLAYHEAD, 128) audio.setMasterPan(PLAYHEAD, 128)

View File

@@ -5,18 +5,20 @@ Usage:
python3 s3m2taud.py input.s3m output.taud [-v] python3 s3m2taud.py input.s3m output.taud [-v]
Limits: Limits:
- Up to 15 S3M channels (excess disabled; hard error if pattern count - Up to 20 S3M channels (excess disabled; hard error if pattern count
× channel count > 256). × channel count > 4095).
- Sample bin is 770048 bytes; if all samples together exceed this, every - Sample bin is 770048 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.
- Effects mapped: D (vol-slide), E/F (pitch slide, rough approx),
SC (note-cut), A (initial speed), T (initial BPM). Others dropped.
Pitch-slide approximation: Effect support:
Amiga-period mode: taud_arg ≈ s3m_arg * 2 (mid-register heuristic) Full A..Z dispatch per TAUD_NOTE_EFFECTS.md "ProTracker to Taud conversion
Linear-slide mode: taud_arg = s3m_arg * 4 (exact) table" and "ScreamTracker 3 conversion notes". ST3 shared-memory recalls
(D/E/F/I/J/K/L/Q/R/S with $00 arg) are eagerly resolved per channel.
Cxx is BCD-decoded. K/L are split into H $0000 / G $0000 + volume-column
slide. M/N/X/P fold into volume / pan columns. W (global vol slide) and
Y (panbrello) are dropped with a -v warning.
""" """
import argparse import argparse
@@ -93,6 +95,44 @@ NOTE_KEYOFF = 0x0000
NOTE_CUT = 0xFFFE NOTE_CUT = 0xFFFE
TAUD_C3 = 0x4000 TAUD_C3 = 0x4000
# Taud effect opcode bytes (base-36: 0..9 → 0x00..0x09, A..Z → 0x0A..0x23)
TOP_NONE = 0x00
TOP_A = 0x0A # set tick speed
TOP_B = 0x0B # jump to order
TOP_C = 0x0C # break to row
TOP_D = 0x0D # volume slide
TOP_E = 0x0E # pitch slide down
TOP_F = 0x0F # pitch slide up
TOP_G = 0x10 # tone porta
TOP_H = 0x11 # vibrato
TOP_I = 0x12 # tremor
TOP_J = 0x13 # microtonal arpeggio
TOP_K = 0x14 # vibrato + vol slide (engine no-op; converter splits)
TOP_L = 0x15 # tone porta + vol slide (engine no-op; converter splits)
TOP_O = 0x18 # sample offset
TOP_Q = 0x1A # retrigger
TOP_R = 0x1B # tremolo
TOP_S = 0x1C # sub-effects
TOP_T = 0x1D # tempo set/slide
TOP_U = 0x1E # fine vibrato
TOP_V = 0x1F # global volume
# Volume / pan column selectors (2-bit field, packed into top of vol/pan byte).
SEL_SET = 0 # 6-bit value: set vol / pan
SEL_UP = 1 # 6-bit per-tick slide up / right
SEL_DOWN = 2 # 6-bit per-tick slide down / left
SEL_FINE = 3 # 1-bit dir + 5-bit magnitude, fired on tick 0
# 12-TET semitone → Taud J-arpeggio byte (high byte of pitch delta).
# byte = round(semitone * 4096 / 12 / 256) = round(semitone * 4 / 3).
J_SEMI_TABLE = [0x00, 0x01, 0x03, 0x04, 0x05, 0x07, 0x08, 0x09,
0x0B, 0x0C, 0x0D, 0x0F, 0x10, 0x11, 0x13, 0x14]
# ST3's single shared memory slot backs these effects.
ST3_SHARED_EFFECTS = frozenset({
EFF_D, EFF_E, EFF_F, EFF_I, EFF_J, EFF_K, EFF_L, EFF_Q, EFF_R, EFF_S
})
# ── S3M parser ─────────────────────────────────────────────────────────────── # ── S3M parser ───────────────────────────────────────────────────────────────
@@ -296,31 +336,202 @@ def encode_note(s3m_note: int) -> int:
return max(1, min(0xFFFD, val)) return max(1, min(0xFFFD, val))
def encode_effect(cmd: int, arg: int, linear: bool) -> tuple: def _d_arg_to_col(arg: int):
"""Return (taud_op, taud_arg16) or (0, 0) for no-op.""" """Convert an ST3 D-style two-nibble vol/pan slide arg into a column override.
Returns (selector, value) or None for no-op. Volume column treats
selector 1 as up / 2 as down; pan column reuses 1 = right, 2 = left.
"""
if arg == 0:
return None
hi = (arg >> 4) & 0xF
lo = arg & 0xF
if hi == 0xF and lo > 0:
return (SEL_FINE, lo & 0x1F) # fine slide down (dir bit 0)
if lo == 0xF and hi > 0:
return (SEL_FINE, (hi & 0x1F) | 0x20) # fine slide up (dir bit 1)
if hi > 0 and lo == 0:
return (SEL_UP, hi)
if lo > 0 and hi == 0:
return (SEL_DOWN, lo)
# Both nibbles non-zero, neither $F → ambiguous; ST3 prefers up.
return (SEL_UP, hi)
def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
"""Return (taud_op, taud_arg16, vol_override, pan_override).
vol/pan_override is None or (selector, value). The caller is responsible
for resolving ST3 zero-arg recalls before this point — see
resolve_st3_recalls().
"""
if cmd == 0:
return (TOP_NONE, 0, None, None)
if cmd == EFF_A:
if arg == 0:
return (TOP_NONE, 0, None, None)
return (TOP_A, (arg & 0xFF) << 8, None, None)
if cmd == EFF_B:
return (TOP_B, arg & 0xFF, None, None)
if cmd == EFF_C:
# ST3 stores break-row as BCD: $10 means decimal 10.
bcd_row = ((arg >> 4) & 0xF) * 10 + (arg & 0xF)
if bcd_row >= PATTERN_ROWS:
bcd_row = 0
return (TOP_C, bcd_row & 0xFF, None, None)
if cmd == EFF_D: if cmd == EFF_D:
# Volume slide: same nibble layout # D-style four-form arg passed through verbatim in the high byte.
return (0x0A, arg & 0xFF) return (TOP_D, (arg & 0xFF) << 8, None, None)
if cmd == EFF_E:
# Porta down if cmd in (EFF_E, EFF_F):
if linear: # ST3 slide unit = 1/16 semitone = $0015 Taud units (per spec PT table).
targ = min(arg * 4, 0xFFFF) op = TOP_E if cmd == EFF_E else TOP_F
else: hi = (arg >> 4) & 0xF
targ = min(arg * 2, 0xFFFF) lo = arg & 0xF
return (0x02, targ) if hi == 0xF and lo > 0:
if cmd == EFF_F: return (op, 0xF000 | ((lo * 0x15) & 0xFFF), None, None)
# Porta up if hi == 0xE and lo > 0:
if linear: return (op, 0xF000 | ((lo * 0x05) & 0xFFF), None, None)
targ = min(arg * 4, 0xFFFF) return (op, (arg * 0x15) & 0xFFFF, None, None)
else:
targ = min(arg * 2, 0xFFFF) if cmd == EFF_G:
return (0x01, targ) return (TOP_G, (arg * 0x15) & 0xFFFF, None, None)
if cmd in (EFF_H, EFF_I, EFF_R, EFF_U):
op = {EFF_H: TOP_H, EFF_I: TOP_I, EFF_R: TOP_R, EFF_U: TOP_U}[cmd]
hi = (arg >> 4) & 0xF
lo = arg & 0xF
return (op, ((hi * 0x11) << 8) | (lo * 0x11), None, None)
if cmd == EFF_J:
hi_semi = (arg >> 4) & 0xF
lo_semi = arg & 0xF
return (TOP_J, (J_SEMI_TABLE[hi_semi] << 8) | J_SEMI_TABLE[lo_semi],
None, None)
if cmd == EFF_K:
# K = vibrato continuation + vol slide; engine treats K as no-op.
# Split into: H $0000 (recall vibrato from HU memory) + vol-col slide.
return (TOP_H, 0x0000, _d_arg_to_col(arg), None)
if cmd == EFF_L:
# L = tone-porta continuation + vol slide; split similarly.
return (TOP_G, 0x0000, _d_arg_to_col(arg), None)
if cmd == EFF_M:
return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None)
if cmd == EFF_N:
return (TOP_NONE, 0, _d_arg_to_col(arg), None)
if cmd == EFF_O:
return (TOP_O, (arg & 0xFF) << 8, None, None)
if cmd == EFF_P:
return (TOP_NONE, 0, None, _d_arg_to_col(arg))
if cmd == EFF_Q:
return (TOP_Q, (arg & 0xFF) << 8, None, None)
if cmd == EFF_S: if cmd == EFF_S:
sub = (arg >> 4) & 0xF sub = (arg >> 4) & 0xF
val = arg & 0xF val = arg & 0xF
if sub == 0xC: # SC - note cut if sub in (0x1, 0x2, 0x3, 0x4, 0xB, 0xC, 0xD, 0xE, 0xF):
return (0xEC, val) return (TOP_S, (sub << 12) | (val << 8), None, None)
return (0x00, 0x0000) if sub == 0x8:
# Coarse pan: nibble-repeat into Taud's S $80xx full-8-bit pan.
return (TOP_S, 0x8000 | (val * 0x11), None, None)
# S0/S5/S6/S7/S9/SA: filter, NNA, sound-control, stereo — drop silently.
return (TOP_NONE, 0, None, None)
if cmd == EFF_T:
if arg >= 0x20:
return (TOP_T, ((arg - 0x18) & 0xFF) << 8, None, None)
# OpenMPT slide forms: $0y down per tick, $1y up per tick.
return (TOP_T, arg & 0xFF, None, None)
if cmd == EFF_V:
return (TOP_V, (min(arg * 4, 0xFF) & 0xFF) << 8, None, None)
if cmd == EFF_W:
vprint(f" dropped W{arg:02X} (global vol slide) at ch{ch} row{row}")
return (TOP_NONE, 0, None, None)
if cmd == EFF_X:
return (TOP_NONE, 0, None, (SEL_SET, min(arg >> 2, 0x3F)))
if cmd == EFF_Y:
vprint(f" dropped Y{arg:02X} (panbrello) at ch{ch} row{row}")
return (TOP_NONE, 0, None, None)
if cmd == EFF_Z:
return (TOP_NONE, 0, None, None)
return (TOP_NONE, 0, None, None)
def resolve_st3_recalls(patterns: list, order_list: list, num_channels: int) -> None:
"""In-place: replace ST3 zero-arg recalls with the last non-zero arg.
ST3 backs D/E/F/I/J/K/L/Q/R/S with a single per-channel memory slot.
Taud's narrower cohort model can't recover this, so we eagerly resolve
by walking patterns in order-list order and rewriting recall args.
Limitation: patterns reused across multiple order entries are mutated
once (with the memory state from their first visit); subsequent visits
may differ from ST3 if cross-pattern memory state changed in between.
"""
last_arg = [0] * num_channels
for order in order_list:
if order >= S3M_ORDER_END:
break
if order >= len(patterns):
continue
grid = patterns[order]
for r in range(PATTERN_ROWS):
for ch in range(num_channels):
if ch >= len(grid):
continue
row = grid[ch][r]
if row.effect in ST3_SHARED_EFFECTS:
if row.effect_arg == 0:
row.effect_arg = last_arg[ch]
else:
last_arg[ch] = row.effect_arg
def warn_st3_quirks(patterns: list, order_list: list, num_channels: int) -> None:
"""Emit -v warnings for ST3 quirks Taud handles differently."""
seen_pats = set()
for order in order_list:
if order >= S3M_ORDER_END:
break
if order >= len(patterns) or order in seen_pats:
continue
seen_pats.add(order)
grid = patterns[order]
for ch in range(min(num_channels, len(grid))):
saw_sbx = saw_sex = False
for r in range(PATTERN_ROWS):
row = grid[ch][r]
if row.effect == EFF_S:
sub = (row.effect_arg >> 4) & 0xF
if sub == 0xB: saw_sbx = True
elif sub == 0xE: saw_sex = True
if saw_sbx and saw_sex:
vprint(f" warning: pattern {order} ch{ch} mixes SBx and SEx "
f"(Taud fixes the ST3 loop-counter bug; loop count may differ)")
for r in range(PATTERN_ROWS):
sex_channels = [ch for ch in range(min(num_channels, len(grid)))
if grid[ch][r].effect == EFF_S
and ((grid[ch][r].effect_arg >> 4) & 0xF) == 0xE]
if len(sex_channels) > 1:
vprint(f" warning: pattern {order} row {r} SEx on multiple "
f"channels {sex_channels} (Taud uses ascending channel order)")
# ── Taud builders ──────────────────────────────────────────────────────────── # ── Taud builders ────────────────────────────────────────────────────────────
@@ -384,6 +595,7 @@ def build_sample_inst_bin(instruments: list) -> tuple:
# Build instrument bin (256 × 64 bytes) # Build instrument bin (256 × 64 bytes)
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
if i >= 256: if i >= 256:
break break
if inst is None or inst.itype != S3M_TYPE_PCM: if inst is None or inst.itype != S3M_TYPE_PCM:
@@ -399,7 +611,7 @@ def build_sample_inst_bin(instruments: list) -> tuple:
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 = (ptr_hi << 4) | (loop_mode & 0x3) # hhhh 00pp
base = i * 64 base = taud_idx * 64
struct.pack_into('<H', inst_bin, base + 0, ptr_lo) 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 + 2, s_len)
struct.pack_into('<H', inst_bin, base + 4, c2spd) struct.pack_into('<H', inst_bin, base + 4, c2spd)
@@ -413,7 +625,7 @@ def build_sample_inst_bin(instruments: list) -> tuple:
inst_bin[base + 17] = 0 # offset minifloat = 0 → hold inst_bin[base + 17] = 0 # offset minifloat = 0 → hold
vprint(f" instrument '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'") vprint(f" instrument[{base // 64}] '{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}')")
@@ -433,24 +645,66 @@ def _default_channel_pan(ch_setting: int) -> int:
def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int, def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int,
linear_slides: bool) -> bytes: linear_slides: bool, inst_vols: dict = None) -> bytes:
"""Build a 512-byte Taud pattern for one S3M channel.""" """Build a 512-byte Taud pattern for one S3M channel.
Volume column: explicit S3M cell vol → SEL_SET; when a note triggers
with no explicit vol, emit SEL_SET using the instrument's default volume
(looked up from inst_vols, a 1-based inst index → 0..63 volume dict).
M/N/K/L overrides apply only when the cell has no explicit vol and no
note trigger. Otherwise SEL_FINE/0 (no-op).
Pan column: row 0 emits SEL_SET = default_pan to position the channel;
other rows default to SEL_FINE/0 unless an X/P/etc effect overrides.
"""
if inst_vols is None:
inst_vols = {}
out = bytearray(PATTERN_BYTES) out = bytearray(PATTERN_BYTES)
rows = s3m_grid[ch_idx] if ch_idx < len(s3m_grid) else [S3MRow()] * PATTERN_ROWS rows = s3m_grid[ch_idx] if ch_idx < len(s3m_grid) else [S3MRow()] * PATTERN_ROWS
last_inst = 0 # 1-based; tracks which instrument is loaded on this channel
for r, row in enumerate(rows[:PATTERN_ROWS]): for r, row in enumerate(rows[:PATTERN_ROWS]):
note = encode_note(row.note) note = encode_note(row.note)
inst = max(0, row.inst - 1) # S3M 1-based → Taud 0-based inst = row.inst # S3M 1-based → Taud 1-based
vol = min(row.vol, 63) if row.vol >= 0 else 63
pan = default_pan if row.inst > 0:
op, arg = encode_effect(row.effect, row.effect_arg, linear_slides) last_inst = row.inst
if row.effect != 0 and op == 0:
eff_name = chr(ord('A') + row.effect - 1) if 1 <= row.effect <= 26 else '?' op, arg, vol_override, pan_override = encode_effect(
vprint(f" dropped effect {eff_name}{row.effect_arg:02X} at ch{ch_idx} row{r}") row.effect, row.effect_arg, ch_idx, r)
# ── Volume column ──
note_triggers = (row.note not in (S3M_NOTE_EMPTY, S3M_NOTE_OFF))
if row.vol >= 0:
vol_sel, vol_value = SEL_SET, min(row.vol, 0x3F)
if vol_override is not None and vol_override[0] != SEL_SET:
vprint(f" ch{ch_idx} row{r}: dropped vol slide "
f"(cell already carries explicit volume)")
elif note_triggers and last_inst > 0:
# Note trigger with no explicit vol: use instrument default volume
# so prior channel-vol state doesn't bleed through.
vol_sel = SEL_SET
vol_value = inst_vols.get(last_inst, 0x3F)
elif vol_override is not None:
vol_sel, vol_value = vol_override
else:
vol_sel, vol_value = SEL_FINE, 0 # no-op fine slide
# ── Pan column ──
if pan_override is not None:
pan_sel, pan_value = pan_override
elif r == 0:
# Position channel to its default pan once per pattern (row 0).
pan_sel, pan_value = SEL_SET, default_pan & 0x3F
else:
pan_sel, pan_value = SEL_FINE, 0
vol_byte = (vol_value & 0x3F) | ((vol_sel & 0x3) << 6)
pan_byte = (pan_value & 0x3F) | ((pan_sel & 0x3) << 6)
base = r * 8 base = r * 8
struct.pack_into('<H', out, base + 0, note) struct.pack_into('<H', out, base + 0, note)
out[base + 2] = inst & 0xFF out[base + 2] = inst & 0xFF
out[base + 3] = vol & 0x3F out[base + 3] = vol_byte
out[base + 4] = pan & 0x3F out[base + 4] = pan_byte
out[base + 5] = op & 0xFF out[base + 5] = op & 0xFF
struct.pack_into('<H', out, base + 6, arg & 0xFFFF) struct.pack_into('<H', out, base + 6, arg & 0xFFFF)
return bytes(out) return bytes(out)
@@ -500,6 +754,7 @@ def build_cue_sheet(order_list: list, num_pats_s3m: int, num_channels: int,
sheet[c*CUE_SIZE : c*CUE_SIZE+CUE_SIZE] = _encode_cue([], 0) sheet[c*CUE_SIZE : c*CUE_SIZE+CUE_SIZE] = _encode_cue([], 0)
cue_idx = 0 cue_idx = 0
last_active = -1
for order in order_list: for order in order_list:
if order == S3M_ORDER_END or cue_idx >= NUM_CUES: if order == S3M_ORDER_END or cue_idx >= NUM_CUES:
break break
@@ -509,11 +764,16 @@ def build_cue_sheet(order_list: list, num_pats_s3m: int, num_channels: int,
orig = [order * num_channels + v for v in range(num_channels)] orig = [order * num_channels + v for v in range(num_channels)]
pats = [pat_remap[p] if pat_remap else p for p in orig] pats = [pat_remap[p] if pat_remap else p for p in orig]
sheet[cue_idx*CUE_SIZE : cue_idx*CUE_SIZE+CUE_SIZE] = _encode_cue(pats, 0) sheet[cue_idx*CUE_SIZE : cue_idx*CUE_SIZE+CUE_SIZE] = _encode_cue(pats, 0)
last_active = cue_idx
cue_idx += 1 cue_idx += 1
# Halt at end # Halt on the last active cue (instruction byte at offset 30), so the
if cue_idx < NUM_CUES: # engine stops immediately after that pattern completes with no silent gap.
sheet[cue_idx*CUE_SIZE : cue_idx*CUE_SIZE+CUE_SIZE] = _encode_cue([], 0x01) if last_active >= 0:
sheet[last_active * CUE_SIZE + 30] = 0x01
elif cue_idx < NUM_CUES:
# Edge case: no active cues at all — halt at cue 0.
sheet[30] = 0x01
return bytes(sheet) return bytes(sheet)
@@ -555,6 +815,13 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
vprint(f" channels: {C}, s3m patterns: {P}, taud patterns: {P*C}") vprint(f" channels: {C}, s3m patterns: {P}, taud patterns: {P*C}")
# Resolve ST3 shared-memory recalls (D/E/F/I/J/K/L/Q/R/S with $00 arg)
# before any per-row encoding, so cohort-aware Taud effects see explicit
# arguments. Mutates patterns in place.
vprint(" resolving ST3 shared-memory recalls…")
resolve_st3_recalls(patterns, h.order_list, 32)
warn_st3_quirks(patterns, h.order_list, 32)
# 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)
@@ -590,11 +857,17 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
# Pattern bin: for each s3m pattern, for each active channel, 512 bytes # Pattern bin: for each s3m pattern, for each active channel, 512 bytes
vprint(" building pattern bin…") vprint(" building pattern bin…")
default_pans = [_default_channel_pan(h.channel_settings[ch]) for ch in active_channels] default_pans = [_default_channel_pan(h.channel_settings[ch]) for ch in active_channels]
# 1-based inst index → default volume (0..63) for note-trigger vol injection.
inst_vols = {
i + 1: min(inst.volume, 0x3F)
for i, inst in enumerate(instruments)
if inst is not None and inst.itype == S3M_TYPE_PCM
}
pat_bin = bytearray() pat_bin = bytearray()
for pi in range(P): for pi in range(P):
grid = patterns[pi] grid = patterns[pi]
for vi, ch in enumerate(active_channels): for vi, ch in enumerate(active_channels):
pat_bin += build_pattern(grid, ch, default_pans[vi], h.linear_slides) pat_bin += build_pattern(grid, ch, default_pans[vi], h.linear_slides, inst_vols)
assert len(pat_bin) == num_taud_pats * PATTERN_BYTES assert len(pat_bin) == num_taud_pats * PATTERN_BYTES
# Deduplicate identical patterns # Deduplicate identical patterns

View File

@@ -1986,7 +1986,7 @@ Synchronisation between playheads are not guaranteed. Do not play music in multi
Memory Space Memory Space
0..770047 RW: Sample bin (752k) 0..770047 RW: Sample bin (752k)
770048..786431 RW: Instrument bin (256 instruments, 64 bytes each; 16k) 770048..786431 RW: Instrument bin (256 instruments, 64 bytes each; instrument 0 does nothing; 16k)
786432..851967 RW: Play data 1 (currently exposed bank; 64k) 786432..851967 RW: Play data 1 (currently exposed bank; 64k)
851968..917503 RW: Play data 2 (currently exposed bank; 64k) 851968..917503 RW: Play data 2 (currently exposed bank; 64k)
917504..983039 RW: TAD Input Buffer (64k) 917504..983039 RW: TAD Input Buffer (64k)
@@ -2022,6 +2022,8 @@ note 0xFFFF: no-op
note 0xFFFE: note cut note 0xFFFE: note cut
note 0x0000: key-off note 0x0000: key-off
inst 0: no instrument change
Sound Adapter MMIO Sound Adapter MMIO
@@ -2211,6 +2213,72 @@ Taud device can queue up to 2 "playdata" in its buffer, which can be interpreted
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
S3M (ScreamTracker 3) to Taud conversion notes
(Implemented in s3m2taud.py)
## Instrument indexing
S3M instrument numbers are 1-based on disk and in pattern cells. Taud's cell instrument byte preserves this: 0 means "no instrument change, reuse whatever was last loaded on this channel"; 1..255 select an instrument slot. The converter passes the raw S3M instrument byte through unchanged (no subtract-1). The instrument bin is written at base = instrument_index * 64, with slot 0 left as an empty/silent entry.
## Effect encoding
Taud opcodes are base-36 digit values: digits 0..9 map to bytes 0x00..0x09; letters A..Z map to bytes 0x0A..0x23. Effects are encoded into a 1-byte opcode plus a 2-byte argument.
## ST3 shared-memory recall (pre-pass)
ST3 backs effects D, E, F, I, J, K, L, Q, R, and S with a single per-channel memory slot. A $00 argument on any of these recalls the last non-zero argument. Taud uses narrower per-cohort memory, so the converter walks patterns in order-list order (per channel) and replaces every $00-arg recall with the current slot value before encoding. Patterns reused by multiple order entries are mutated once on their first visit; later visits may diverge from the ST3 original if cross-pattern memory state changed, but this is acceptable for typical usage.
## Cxx BCD decode
ST3 stores pattern-break row numbers as BCD on disk ($10 means decimal row 10, not hex row 16). The converter decodes: row = (byte >> 4) * 10 + (byte & 0xF). Values that decode to 64 or above clamp to row 0.
## Pitch slide unit
ST3's coarse slide unit is 1/16 of a semitone. One semitone in Taud's 4096-TET grid is 4096/12 ≈ 341.33 units. One 1/16 semitone ≈ 21.33 units ≈ $0015. All E/F/G coarse arguments are therefore multiplied by $0015. Fine slide forms ($Fx, $Ex) are packed into Taud's $F0xx fine form after the same per-step scale.
## J arpeggio (12-TET to 4096-TET)
ST3 Jxy nibbles are 12-TET semitone offsets (0..15). Taud's J argument uses the high byte of a 16-bit pitch delta; one byte = 256 units ≈ 0.75 semitones.
Conversion: byte = round(semitones * 4 / 3).
The full lookup table:
Semitones 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Taud byte $00 $01 $03 $04 $05 $07 $08 $09 $0B $0C $0D $0F $10 $11 $13 $14
## K and L effects
The engine treats K and L as no-ops. The converter splits each into two parts:
K → effect column H $0000 (recall vibrato from HU memory) plus a volume-column slide derived from K's argument
L → effect column G $0000 plus the same volume-column slide. If the S3M cell already carries an explicit volume-column byte, the slide half is dropped with a -v warning.
## M, N (channel volume), X, P (pan) folding
M (set channel volume) and N (channel-vol slide) fold into the volume column. X (set pan) and P (pan slide) fold into the pan column. These effects consume no space in the effect slot. W (global vol slide) and Y (panbrello) are dropped with a -v warning.
## Volume column defaults
When a note trigger is present in a cell with no explicit S3M volume byte, the converter emits SEL_SET (selector 0) with the instrument's default volume. This prevents the channel's prior volume state from persisting into a fresh note. Cells with no note trigger and no explicit volume emit SEL_FINE value 0 (fine slide of 0 = no-op), which leaves channel volume unchanged.
## Pan column defaults
Row 0 of every pattern emits SEL_SET with the channel's default pan (derived from the S3M channel-setting byte: channels 0-7 → left ($10), channels 8-15 → right ($2F), otherwise centre ($1F)). All other rows emit SEL_FINE value 0 (no-op) unless an X, P, or S$8x effect overrides.
## Cue sheet halt placement
The halt instruction (byte value 0x01 at cue offset 30) is placed on the last active cue entry, not in a separate empty cue appended after it. This ensures playback stops immediately after the last pattern row completes, with no silent 64-row gap.
## Tempo mapping
S3M BPM is stored as a raw decimal value. Taud's initial BPM byte uses a bias of -24 (byte 0x00 = 24 BPM, 0xFF = 279 BPM). Conversion: taud_byte = bpm - 24. The converter also scans row 0 of the first pattern in the order list for A (set speed) and T (set tempo) effects and uses those values in preference to the S3M header defaults.
## Global volume
ST3 global volume is 0..$40; Taud's is 0..$FF. Import scale: Taud_vol = ST3_vol × 4 (clamped to $FF).
--------------------------------------------------------------------------------
RomBank / RamBank RomBank / RamBank
Endianness: Little Endianness: Little

View File

@@ -125,7 +125,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
internal val sampleBin = UnsafeHelper.allocate(770048L, this) internal val sampleBin = UnsafeHelper.allocate(770048L, this)
internal val instruments = Array(256) { TaudInst() } 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>
internal val cueSheet = Array(1024) { PlayCue() } internal val cueSheet = Array(1024) { PlayCue() }
@@ -1080,14 +1080,80 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
//========================================================================= //=========================================================================
// Tracker Engine // Tracker Engine
// //
// Effect codes (non-canonical MVP; spec is silent on values): // Effect opcodes follow base-36 digit values (see TAUD_NOTE_EFFECTS.md):
// 0x00: no effect // 0x00 : no effect
// 0x01 arg: pitch slide up, arg = 4096-TET units per tick // 0x0A..0x23 : letters A..Z (A=0x0A speed, B=0x0B order jump,
// 0x02 arg: pitch slide down, arg = 4096-TET units per tick // C=0x0C pattern break, D=0x0D vol slide, E=0x0E pitch
// 0x0A arg: volume slide, high nibble = up/tick, low nibble = down/tick // down, F=0x0F pitch up, G=0x10 tone porta,
// 0xEC arg: note cut at tick (arg & 0xFF) // H=0x11 vibrato, I=0x12 tremor, J=0x13 arpeggio,
// K=0x14 K, L=0x15 L, O=0x18 sample offset,
// Q=0x1A retrig, R=0x1B tremolo, S=0x1C subcommands,
// T=0x1D tempo, U=0x1E fine vibrato, V=0x1F global vol).
// K (0x14) and L (0x15) are intentionally no-op in the engine — the
// converter is required to split them into a recall-only H/G plus a
// volume-column slide cell.
//========================================================================= //=========================================================================
// 64-entry signed sine table (OpenMPT-style). See TAUD_NOTE_EFFECTS.md §H.
private val MOD_SIN_TABLE = intArrayOf(
0x00, 0x0C, 0x19, 0x25, 0x31, 0x3C, 0x47, 0x51,
0x5A, 0x62, 0x6A, 0x70, 0x75, 0x7A, 0x7D, 0x7E,
0x7F, 0x7E, 0x7D, 0x7A, 0x75, 0x70, 0x6A, 0x62,
0x5A, 0x51, 0x47, 0x3C, 0x31, 0x25, 0x19, 0x0C,
0x00, -0x0C, -0x19, -0x25, -0x31, -0x3C, -0x47, -0x51,
-0x5A, -0x62, -0x6A, -0x70, -0x75, -0x7A, -0x7D, -0x7E,
-0x7F, -0x7E, -0x7D, -0x7A, -0x75, -0x70, -0x6A, -0x62,
-0x5A, -0x51, -0x47, -0x3C, -0x31, -0x25, -0x19, -0x0C
)
// Funk repeat advance table (S $Fx00). See TAUD_NOTE_EFFECTS.md §S$Fx.
private val FUNK_TABLE = intArrayOf(
0, 5, 6, 7, 8, 0xA, 0xB, 0xD, 0x10, 0x13, 0x16, 0x1A, 0x20, 0x2B, 0x40, 0x80
)
// ST3-style fine-tune Hz reference offsets in 4096-TET units (S $2x00).
private val FINETUNE_OFFSET = intArrayOf(
-0x0154, -0x0132, -0x0111, -0x00E4, -0x00B8, -0x008B, -0x005D, -0x003B,
0x0000, 0x0023, 0x0046, 0x0074, 0x0098, 0x00C8, 0x00F9, 0x0110
)
// LFO sample for vibrato/tremolo waveforms; pos is the 8-bit phase accumulator.
// See TAUD_NOTE_EFFECTS.md §S$3x for shape semantics.
private fun lfoSample(pos: Int, wave: Int): Int {
val idx = (pos ushr 2) and 0x3F
return when (wave and 3) {
0 -> MOD_SIN_TABLE[idx] // sine
1 -> 0x7F - (idx shl 2) // ramp down
2 -> if (idx < 32) 0x7F else -0x7F // square
else -> ((Math.random() * 256).toInt() and 0xFF) - 0x80 // random
}
}
// Effect opcode constants (base-36 digit values).
// Letters A..Z map to 0x0A..0x23 (digit value 10..35).
private object EffectOp {
const val OP_NONE = 0x00
const val OP_A = 0x0A
const val OP_B = 0x0B
const val OP_C = 0x0C
const val OP_D = 0x0D
const val OP_E = 0x0E
const val OP_F = 0x0F
const val OP_G = 0x10
const val OP_H = 0x11
const val OP_I = 0x12
const val OP_J = 0x13
const val OP_K = 0x14
const val OP_L = 0x15
const val OP_O = 0x18
const val OP_Q = 0x1A
const val OP_R = 0x1B
const val OP_S = 0x1C
const val OP_T = 0x1D
const val OP_U = 0x1E
const val OP_V = 0x1F
}
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_C3) / 4096.0)
@@ -1114,6 +1180,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
private fun fetchTrackerSample(voice: Voice, inst: TaudInst): Double { private fun fetchTrackerSample(voice: Voice, inst: TaudInst): Double {
if (inst.index == 0) return 0.0
val basePtr = inst.samplePtr val basePtr = inst.samplePtr
val sampleLen = inst.sampleLength.coerceAtLeast(1) val sampleLen = inst.sampleLength.coerceAtLeast(1)
val loopStart = inst.sampleLoopStart.toDouble() val loopStart = inst.sampleLoopStart.toDouble()
@@ -1123,8 +1191,17 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
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)
val frac = voice.samplePos - i0.toDouble() val frac = voice.samplePos - i0.toDouble()
val s0 = (sampleBin[(basePtr + i0).coerceAtMost(binMax).toLong()].toUint() - 128) / 128.0 var b0 = sampleBin[(basePtr + i0).coerceAtMost(binMax).toLong()].toUint()
val s1 = (sampleBin[(basePtr + i1).coerceAtMost(binMax).toLong()].toUint() - 128) / 128.0 var b1 = sampleBin[(basePtr + i1).coerceAtMost(binMax).toLong()].toUint()
// S$Fx funk repeat: XOR the high bit of bytes whose loop-relative index
// is set in funkMask. Only meaningful when the sample has a loop region.
if (inst.funkMask != null && inst.sampleLoopEnd > inst.sampleLoopStart) {
val ls = inst.sampleLoopStart
if (i0 in ls until inst.sampleLoopEnd && inst.funkBit(i0 - ls)) b0 = b0 xor 0x80
if (i1 in ls until inst.sampleLoopEnd && inst.funkBit(i1 - ls)) b1 = b1 xor 0x80
}
val s0 = (b0 - 128) / 128.0
val s1 = (b1 - 128) / 128.0
val sample = s0 + (s1 - s0) * frac val sample = s0 + (s1 - s0) * frac
if (voice.forward) { if (voice.forward) {
@@ -1142,8 +1219,71 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
return sample return sample
} }
/**
* Trigger a fresh note on [voice]: load the instrument, reset sample position, kick off the envelope.
* Pulled out so S$Dx (note delay) can defer the same logic to a later tick.
*/
private fun triggerNote(voice: Voice, noteVal: Int, instId: Int, volOverride: Int) {
if (instId != 0) voice.instrumentId = instId
val inst = instruments[voice.instrumentId]
voice.samplePos = inst.samplePlayStart.toDouble()
voice.forward = true
voice.active = true
voice.envIndex = 0
voice.envTimeSec = 0.0
voice.envVolume = inst.envelopes[0].volume / 255.0
voice.noteVal = noteVal
voice.basePitch = noteVal
voice.playbackRate = computePlaybackRate(inst, noteVal)
if (volOverride >= 0) {
voice.channelVolume = volOverride.coerceIn(0, 0x3F)
}
voice.rowVolume = voice.channelVolume
voice.noteWasCut = false
// Vibrato/tremolo retrigger: reset LFO position when waveform requests it.
if (voice.vibratoRetrig) voice.vibratoLfoPos = 0
if (voice.tremoloRetrig) voice.tremoloLfoPos = 0
}
private fun applyVolColumn(voice: Voice, value: Int, sel: Int) {
// value is the 6-bit cell field; sel is the 2-bit selector. See TAUD_NOTE_EFFECTS.md
// §"Volume column effects" for the multi-selector encoding.
when (sel) {
0 -> { voice.channelVolume = value.coerceIn(0, 0x3F); voice.rowVolume = voice.channelVolume }
1 -> voice.volColSlideUp = value
2 -> voice.volColSlideDown = value
3 -> {
if (value == 0) return
val mag = value and 0x1F
voice.rowVolume = if ((value and 0x20) != 0) (voice.rowVolume + mag).coerceAtMost(0x3F)
else (voice.rowVolume - mag).coerceAtLeast(0)
voice.channelVolume = voice.rowVolume
}
}
}
private fun applyPanColumn(voice: Voice, value: Int, sel: Int) {
when (sel) {
0 -> { voice.channelPan = (value shl 2) or (value ushr 4); voice.rowPan = (voice.channelPan shr 2).coerceIn(0, 63) }
1 -> voice.panColSlideRight = value
2 -> voice.panColSlideLeft = value
3 -> {
if (value == 0) return
val mag = value and 0x1F
voice.channelPan = if ((value and 0x20) != 0) (voice.channelPan + mag).coerceAtMost(0xFF)
else (voice.channelPan - mag).coerceAtLeast(0)
voice.rowPan = (voice.channelPan shr 2).coerceIn(0, 63)
}
}
}
private fun applyTrackerRow(ts: TrackerState, playhead: Playhead) { private fun applyTrackerRow(ts: TrackerState, playhead: Playhead) {
val cue = cueSheet[ts.cuePos] val cue = cueSheet[ts.cuePos]
// Reset row-scope state before scanning channels.
if (!ts.patternDelayActive) ts.sexWinningChannel = -1
for (vi in 0..19) { for (vi in 0..19) {
val patNum = cue.patterns[vi] val patNum = cue.patterns[vi]
if (patNum == 0xFFF) continue if (patNum == 0xFFF) continue
@@ -1151,56 +1291,384 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val row = playdata[patIdx][ts.rowIndex] val row = playdata[patIdx][ts.rowIndex]
val voice = ts.voices[vi] val voice = ts.voices[vi]
voice.rowVolume = row.volume // Reset per-row transient state.
voice.rowPan = row.pan
voice.cutAtTick = -1 voice.cutAtTick = -1
voice.pitchSlideAmount = 0.0 voice.noteDelayTick = -1
voice.volSlidePerTick = 0 voice.slideMode = 0
voice.slideArg = 0
when (row.effect) { voice.arpActive = false
0x01 -> voice.pitchSlideAmount = row.effectArg.toDouble() voice.tremorOn = 0
0x02 -> voice.pitchSlideAmount = -row.effectArg.toDouble() voice.vibratoActive = false
0x0A -> { val a = row.effectArg and 0xFF; voice.volSlidePerTick = ((a ushr 4) and 0xF) - (a and 0xF) } voice.tremoloActive = false
0xEC -> voice.cutAtTick = row.effectArg and 0xFF voice.retrigActive = false
} voice.tonePortaTarget = -1
voice.tempoSlideDir = 0
voice.volColSlideUp = 0; voice.volColSlideDown = 0
voice.panColSlideRight = 0; voice.panColSlideLeft = 0
voice.rowEffect = row.effect
voice.rowEffectArg = row.effectArg
// ── Note ──
val toneG = (row.effect == EffectOp.OP_G)
when (row.note) { when (row.note) {
0xFFFF -> {} // no-op: continue current note unchanged 0xFFFF -> {} // no-op
0x0000 -> voice.active = false // key-off (TODO: trigger envelope release phase) 0x0000 -> voice.active = false // key-off (TODO release envelope)
0xFFFE -> voice.active = false // note cut: immediate silence 0xFFFE -> voice.active = false // note cut
else -> { else -> {
val inst = instruments[row.instrment] if (toneG && voice.active) {
voice.instrumentId = row.instrment // Tone porta: target the note, do not retrigger sample.
voice.samplePos = inst.samplePlayStart.toDouble() voice.tonePortaTarget = row.note
voice.forward = true } else if ((row.effect == EffectOp.OP_S) && ((row.effectArg ushr 12) and 0xF) == 0xD) {
voice.active = true // Note delay: defer trigger to the requested tick.
voice.envIndex = 0 voice.noteDelayTick = (row.effectArg ushr 8) and 0xF
voice.envTimeSec = 0.0 voice.delayedNote = row.note
voice.envVolume = inst.envelopes[0].volume / 255.0 voice.delayedInst = row.instrment
voice.noteVal = row.note voice.delayedVol = if (row.volume >= 0) row.volume else -1
voice.playbackRate = computePlaybackRate(inst, row.note) } else {
triggerNote(voice, row.note, row.instrment, -1)
}
} }
} }
// ── Volume column (selectors per TAUD_NOTE_EFFECTS.md) ──
// The cell already separates value (volume) and selector (volumeEff).
applyVolColumn(voice, row.volume, row.volumeEff)
applyPanColumn(voice, row.pan, row.panEff)
// ── Effect column ──
applyEffectRow(ts, playhead, voice, vi, row.effect, row.effectArg)
}
}
/** Resolve a non-zero argument or recall from the cohort memory and return the effective arg. */
private fun resolveArg(arg: Int, mem: Int): Int = if (arg != 0) arg else mem
private fun applyEffectRow(ts: TrackerState, playhead: Playhead, voice: Voice, vi: Int, op: Int, rawArg: Int) {
when (op) {
EffectOp.OP_NONE -> {}
EffectOp.OP_A -> {
val tr = (rawArg ushr 8) and 0xFF
if (tr != 0) playhead.tickRate = tr
}
EffectOp.OP_B -> {
// Highest-priority B wins for the row (lowest channel index in spec); first-set wins by ascending channel scan.
if (ts.pendingOrderJump < 0) ts.pendingOrderJump = rawArg.coerceIn(0, 1023)
}
EffectOp.OP_C -> {
if (ts.pendingRowJump < 0) ts.pendingRowJump = rawArg.coerceIn(0, 63)
}
EffectOp.OP_D -> {
val arg = resolveArg(rawArg, voice.mem.d).also { if (rawArg != 0) voice.mem.d = it }
val hi = (arg ushr 8) and 0xFF
val lo = hi and 0x0F
val hin = (hi ushr 4) and 0x0F
when {
hi == 0xFF -> { voice.rowVolume = (voice.rowVolume + 0xF).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume } // DFF quirk: fine up by F
hin == 0xF && lo != 0 -> { voice.rowVolume = (voice.rowVolume - lo).coerceAtLeast(0); voice.channelVolume = voice.rowVolume }
lo == 0xF && hin != 0 -> { voice.rowVolume = (voice.rowVolume + hin).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume }
hin == 0 && lo != 0 -> { voice.slideMode = 5; voice.slideArg = -lo } // slide down per non-first tick
lo == 0 && hin != 0 -> { voice.slideMode = 5; voice.slideArg = hin } // slide up per non-first tick
}
}
EffectOp.OP_E -> {
val arg = resolveArg(rawArg, voice.mem.ef).also { if (rawArg != 0) voice.mem.ef = it }
if ((arg and 0xF000) == 0xF000) {
val mag = arg and 0x0FFF
voice.noteVal = (voice.noteVal - mag).coerceIn(0, 0xFFFE); voice.basePitch = voice.noteVal
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
} else {
voice.slideMode = 1; voice.slideArg = -arg
}
}
EffectOp.OP_F -> {
val arg = resolveArg(rawArg, voice.mem.ef).also { if (rawArg != 0) voice.mem.ef = it }
if ((arg and 0xF000) == 0xF000) {
val mag = arg and 0x0FFF
voice.noteVal = (voice.noteVal + mag).coerceIn(0, 0xFFFE); voice.basePitch = voice.noteVal
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
} else {
voice.slideMode = 2; voice.slideArg = arg
}
}
EffectOp.OP_G -> {
val arg = resolveArg(rawArg, voice.mem.g).also { if (rawArg != 0) voice.mem.g = it }
voice.tonePortaSpeed = arg
// tonePortaTarget was set in note-handling block above (or remains -1).
}
EffectOp.OP_H -> {
val sp = (rawArg ushr 8) and 0xFF
val dp = rawArg and 0xFF
if (sp != 0) voice.mem.huSpeed = sp
if (dp != 0) voice.mem.huDepth = dp
voice.vibratoActive = true
voice.vibratoFineShift = 6
}
EffectOp.OP_I -> {
val arg = resolveArg(rawArg, voice.mem.i).also { if (rawArg != 0) voice.mem.i = it }
voice.tremorOn = 1
voice.tremorOnTime = ((arg ushr 8) and 0xFF) + 1
voice.tremorOffTime = (arg and 0xFF) + 1
}
EffectOp.OP_J -> {
val arg = resolveArg(rawArg, voice.mem.j).also { if (rawArg != 0) voice.mem.j = it }
voice.arpActive = true
voice.arpOff1 = (arg ushr 8) and 0xFF
voice.arpOff2 = arg and 0xFF
}
EffectOp.OP_K, EffectOp.OP_L -> {} // engine no-op by design (converter splits them)
EffectOp.OP_O -> {
val arg = resolveArg(rawArg, voice.mem.o).also { if (rawArg != 0) voice.mem.o = it }
val inst = instruments[voice.instrumentId]
var off = arg
if (inst.loopMode != 0 && inst.sampleLoopEnd > inst.sampleLoopStart && off > inst.sampleLoopEnd) {
val loopLen = (inst.sampleLoopEnd - inst.sampleLoopStart).coerceAtLeast(1)
off = inst.sampleLoopStart + ((off - inst.sampleLoopStart) % loopLen)
}
voice.samplePos = off.toDouble()
}
EffectOp.OP_Q -> {
val arg = resolveArg(rawArg, voice.mem.q)
val y = arg and 0xFF
if (y != 0) {
voice.mem.q = arg
voice.retrigInterval = y
voice.retrigVolMod = (arg ushr 8) and 0xF
voice.retrigActive = true
// Counter persists across rows per spec.
}
// y == 0 → entire effect ignored, even memory (spec).
}
EffectOp.OP_R -> {
val sp = (rawArg ushr 8) and 0xFF
val dp = rawArg and 0xFF
if (sp != 0) voice.mem.rSpeed = sp
if (dp != 0) voice.mem.rDepth = dp
voice.tremoloActive = true
}
EffectOp.OP_S -> applySEffect(ts, voice, vi, rawArg)
EffectOp.OP_T -> {
val hi = (rawArg ushr 8) and 0xFF
if (hi != 0) {
val tempoByte = hi
playhead.bpm = (tempoByte + 0x18).coerceIn(24, 280)
} else {
val low = rawArg and 0xFF
when (low and 0xF0) {
0x00 -> { voice.tempoSlideDir = -1; voice.tempoSlideAmount = low and 0x0F; voice.mem.tslide = low }
0x10 -> { voice.tempoSlideDir = +1; voice.tempoSlideAmount = low and 0x0F; voice.mem.tslide = low }
}
}
}
EffectOp.OP_U -> {
val sp = (rawArg ushr 8) and 0xFF
val dp = rawArg and 0xFF
if (sp != 0) voice.mem.huSpeed = sp
if (dp != 0) voice.mem.huDepth = dp
voice.vibratoActive = true
voice.vibratoFineShift = 8
}
EffectOp.OP_V -> {
val hi = (rawArg ushr 8) and 0xFF
playhead.globalVolume = hi
}
}
}
private fun applySEffect(ts: TrackerState, voice: Voice, vi: Int, arg: Int) {
val sub = (arg ushr 12) and 0xF
val x = (arg ushr 8) and 0xF
when (sub) {
0x1 -> voice.glissandoOn = (x != 0)
0x2 -> {
voice.noteVal = (voice.noteVal + FINETUNE_OFFSET[x]).coerceIn(0, 0xFFFE)
voice.basePitch = voice.noteVal
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
}
0x3 -> { voice.vibratoWave = x and 3; voice.vibratoRetrig = (x and 4) == 0 }
0x4 -> { voice.tremoloWave = x and 3; voice.tremoloRetrig = (x and 4) == 0 }
0x8 -> {
// S$80xx — full 8-bit pan; arg low byte is the value.
voice.channelPan = arg and 0xFF
voice.rowPan = (voice.channelPan shr 2).coerceIn(0, 63)
}
0xB -> {
if (x == 0) voice.loopStartRow = ts.rowIndex
else {
if (voice.loopCount == 0) {
voice.loopCount = x
ts.pendingRowJump = voice.loopStartRow
} else if (!ts.patternDelayActive) {
voice.loopCount--
if (voice.loopCount > 0) ts.pendingRowJump = voice.loopStartRow
}
}
}
0xC -> if (x != 0) voice.cutAtTick = x
0xD -> {} // handled in note section above (note delay)
0xE -> {
// Pattern delay — first SEx in ascending channel order wins.
if (ts.sexWinningChannel < 0) {
ts.sexWinningChannel = vi
ts.patternDelayRemaining = x
}
}
0xF -> { voice.funkSpeed = x; if (x == 0) voice.funkAccumulator = 0 }
} }
} }
private fun applyTrackerTick(ts: TrackerState, playhead: Playhead) { private fun applyTrackerTick(ts: TrackerState, playhead: Playhead) {
val tickSec = 2.5 / playhead.bpm val tickSec = 2.5 / playhead.bpm
for (voice in ts.voices) { for (voice in ts.voices) {
if (!voice.active) continue if (!voice.active && voice.noteDelayTick < 0) continue
val inst = instruments[voice.instrumentId] val inst = instruments[voice.instrumentId]
if (voice.cutAtTick == ts.tickInRow) { voice.active = false; continue }
if (voice.pitchSlideAmount != 0.0) { // Note cut.
voice.noteVal = (voice.noteVal + voice.pitchSlideAmount).toInt().coerceIn(0, 0xFFFE) if (voice.cutAtTick == ts.tickInRow) {
voice.playbackRate = computePlaybackRate(inst, voice.noteVal) voice.rowVolume = 0; voice.channelVolume = 0
voice.noteWasCut = true
} }
if (voice.volSlidePerTick != 0) {
voice.rowVolume = (voice.rowVolume + voice.volSlidePerTick).coerceIn(0, 63) // Note delay — fire deferred trigger when the requested tick arrives.
if (voice.noteDelayTick == ts.tickInRow) {
triggerNote(voice, voice.delayedNote, voice.delayedInst, voice.delayedVol)
voice.noteDelayTick = -1
} }
if (!voice.active) { advanceEnvelope(voice, inst, tickSec); continue }
// Pitch slides (E/F coarse on tick > 0).
if (ts.tickInRow > 0 && (voice.slideMode == 1 || voice.slideMode == 2)) {
voice.noteVal = (voice.noteVal + voice.slideArg).coerceIn(0, 0xFFFE)
voice.basePitch = voice.noteVal
}
// Tone portamento (G).
if (voice.tonePortaTarget >= 0 && ts.tickInRow > 0) {
val target = voice.tonePortaTarget
val sp = voice.tonePortaSpeed
val delta = if (target > voice.noteVal) sp else -sp
voice.noteVal += delta
if ((delta > 0 && voice.noteVal >= target) || (delta < 0 && voice.noteVal <= target)) {
voice.noteVal = target; voice.tonePortaTarget = -1
}
voice.basePitch = voice.noteVal
}
// Volume slides (D coarse on tick > 0).
if (ts.tickInRow > 0 && voice.slideMode == 5) {
voice.rowVolume = (voice.rowVolume + voice.slideArg).coerceIn(0, 0x3F)
voice.channelVolume = voice.rowVolume
}
// Volume-column slides (selectors 1/2 — per non-first tick).
if (ts.tickInRow > 0) {
if (voice.volColSlideUp != 0) {
voice.rowVolume = (voice.rowVolume + voice.volColSlideUp).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume
}
if (voice.volColSlideDown != 0) {
voice.rowVolume = (voice.rowVolume - voice.volColSlideDown).coerceAtLeast(0); voice.channelVolume = voice.rowVolume
}
if (voice.panColSlideRight != 0) {
voice.channelPan = (voice.channelPan + voice.panColSlideRight).coerceAtMost(0xFF)
voice.rowPan = (voice.channelPan shr 2).coerceIn(0, 63)
}
if (voice.panColSlideLeft != 0) {
voice.channelPan = (voice.channelPan - voice.panColSlideLeft).coerceAtLeast(0)
voice.rowPan = (voice.channelPan shr 2).coerceIn(0, 63)
}
}
// Tremor (I) — gates output volume.
if (voice.tremorOn != 0) {
voice.tremorTickInPhase++
val limit = if (voice.tremorPhaseOn) voice.tremorOnTime else voice.tremorOffTime
if (voice.tremorTickInPhase >= limit) { voice.tremorTickInPhase = 0; voice.tremorPhaseOn = !voice.tremorPhaseOn }
if (!voice.tremorPhaseOn) voice.rowVolume = 0
}
// Vibrato (H/U) — applied as base-pitch overlay.
var pitchToMixer = voice.noteVal
if (voice.vibratoActive) {
val sine = lfoSample(voice.vibratoLfoPos, voice.vibratoWave)
val pitchDelta = (sine * voice.mem.huDepth) shr voice.vibratoFineShift
pitchToMixer = (voice.noteVal + pitchDelta).coerceIn(0, 0xFFFE)
voice.vibratoLfoPos = (voice.vibratoLfoPos + voice.mem.huSpeed * 4) and 0xFF
}
// Glissando (S$1x) — snap pitchToMixer to nearest semitone but leave noteVal smooth.
if (voice.glissandoOn) {
val semis = ((pitchToMixer * 12 + 2048) / 4096)
pitchToMixer = (semis * 4096 / 12).coerceIn(0, 0xFFFE)
}
// Tremolo (R) — modulates output volume around base.
if (voice.tremoloActive) {
val sine = lfoSample(voice.tremoloLfoPos, voice.tremoloWave)
val volDelta = (sine * voice.mem.rDepth) shr 9
voice.rowVolume = (voice.channelVolume + volDelta).coerceIn(0, 0x3F)
voice.tremoloLfoPos = (voice.tremoloLfoPos + voice.mem.rSpeed * 4) and 0xFF
}
// Arpeggio (J) — overrides pitchToMixer for this tick (overlay on basePitch).
if (voice.arpActive) {
val voiceIdx = ts.tickInRow % 3
val arpDelta = when (voiceIdx) { 1 -> voice.arpOff1 shl 8; 2 -> voice.arpOff2 shl 8; else -> 0 }
pitchToMixer = (voice.basePitch + arpDelta).coerceIn(0, 0xFFFE)
voice.lastArpVoice = voiceIdx
}
// Q retrigger.
if (voice.retrigActive && !voice.noteWasCut) {
voice.retrigCounter++
if (voice.retrigCounter >= voice.retrigInterval) {
voice.retrigCounter = 0
voice.samplePos = instruments[voice.instrumentId].samplePlayStart.toDouble()
voice.envIndex = 0; voice.envTimeSec = 0.0
voice.rowVolume = applyRetrigVolMod(voice.rowVolume, voice.retrigVolMod)
voice.channelVolume = voice.rowVolume
}
}
// Update playback rate from final pitchToMixer.
voice.playbackRate = computePlaybackRate(inst, pitchToMixer)
advanceEnvelope(voice, inst, tickSec) advanceEnvelope(voice, inst, tickSec)
} }
// Tempo slide — applied once per tick at the playhead level (any channel that armed it).
for (voice in ts.voices) {
if (voice.tempoSlideDir != 0 && ts.tickInRow > 0) {
val tempoByte = (playhead.bpm - 0x18 + voice.tempoSlideDir * voice.tempoSlideAmount).coerceIn(0, 0xFF)
playhead.bpm = (tempoByte + 0x18).coerceIn(24, 280)
}
}
// Funk repeat (S$Fx) — advance bit-mask per tick on instruments with active funkSpeed.
for (voice in ts.voices) {
if (voice.funkSpeed == 0 || !voice.active) continue
val inst = instruments[voice.instrumentId]
if (inst.sampleLoopEnd <= inst.sampleLoopStart) continue
voice.funkAccumulator += FUNK_TABLE[voice.funkSpeed and 0xF]
while (voice.funkAccumulator >= 0x80) {
voice.funkAccumulator -= 0x80
val loopLen = (inst.sampleLoopEnd - inst.sampleLoopStart).coerceAtLeast(1)
inst.toggleFunkBit(voice.funkWritePos % loopLen)
voice.funkWritePos = (voice.funkWritePos + 1) % loopLen
}
}
} }
private fun applyRetrigVolMod(vol: Int, x: Int): Int = when (x and 0xF) {
0, 8 -> vol
1 -> vol - 0x01; 2 -> vol - 0x02; 3 -> vol - 0x04; 4 -> vol - 0x08; 5 -> vol - 0x10
6 -> vol * 2 / 3
7 -> vol shr 1
9 -> vol + 0x01; 0xA -> vol + 0x02; 0xB -> vol + 0x04; 0xC -> vol + 0x08; 0xD -> vol + 0x10
0xE -> vol * 3 / 2
0xF -> vol shl 1
else -> vol
}.coerceIn(0, 0x3F)
private fun advanceTrackerCue(ts: TrackerState, playhead: Playhead) { private fun advanceTrackerCue(ts: TrackerState, playhead: Playhead) {
val instr = cueSheet[ts.cuePos].instruction val instr = cueSheet[ts.cuePos].instruction
if (instr is PlayInstHalt) { playhead.isPlaying = false; return } if (instr is PlayInstHalt) { playhead.isPlaying = false; return }
@@ -1214,8 +1682,6 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
internal fun generateTrackerAudio(playhead: Playhead): ByteArray? { internal fun generateTrackerAudio(playhead: Playhead): ByteArray? {
val ts = playhead.trackerState ?: return null val ts = playhead.trackerState ?: return null
val bpm = playhead.bpm
val spt = SAMPLING_RATE * 2.5 / bpm
val out = ByteArray(TRACKER_CHUNK * 2) val out = ByteArray(TRACKER_CHUNK * 2)
@@ -1225,6 +1691,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
for (n in 0 until TRACKER_CHUNK) { for (n in 0 until TRACKER_CHUNK) {
// Recompute samples-per-tick every iteration since T/T-slide can mutate BPM mid-row.
val spt = SAMPLING_RATE * 2.5 / playhead.bpm
ts.samplesIntoTick += 1.0 ts.samplesIntoTick += 1.0
if (ts.samplesIntoTick >= spt) { if (ts.samplesIntoTick >= spt) {
ts.samplesIntoTick -= spt ts.samplesIntoTick -= spt
@@ -1232,32 +1700,75 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
ts.tickInRow++ ts.tickInRow++
if (ts.tickInRow >= playhead.tickRate) { if (ts.tickInRow >= playhead.tickRate) {
ts.tickInRow = 0 ts.tickInRow = 0
ts.rowIndex++ advanceRow(ts, playhead)
if (ts.rowIndex >= 64) {
ts.rowIndex = 0
advanceTrackerCue(ts, playhead)
}
applyTrackerRow(ts, playhead)
} }
} }
var mixL = 0.0 var mixL = 0.0
var mixR = 0.0 var mixR = 0.0
val gvol = playhead.globalVolume / 255.0
for (voice in ts.voices) { for (voice in ts.voices) {
if (!voice.active) continue if (!voice.active) continue
val s = fetchTrackerSample(voice, instruments[voice.instrumentId]) val s = fetchTrackerSample(voice, instruments[voice.instrumentId])
val vol = voice.envVolume * voice.rowVolume / 63.0 * playhead.masterVolume / 255.0 val vol = voice.envVolume * voice.rowVolume / 63.0 * gvol * playhead.masterVolume / 255.0
mixL += s * vol * (63 - voice.rowPan) / 63.0 mixL += s * vol * (63 - voice.rowPan) / 63.0
mixR += s * vol * voice.rowPan / 63.0 mixR += s * vol * voice.rowPan / 63.0
} }
out[n * 2] = ((mixL.coerceIn(-1.0, 1.0) * 127 + 128).toInt()).toByte() ts.mixLeft[n] = mixL.toFloat().coerceIn(-1.0f, 1.0f)
out[n * 2 + 1] = ((mixR.coerceIn(-1.0, 1.0) * 127 + 128).toInt()).toByte() ts.mixRight[n] = mixR.toFloat().coerceIn(-1.0f, 1.0f)
}
pcm32fToPcm8(ts.mixLeft, ts.mixRight, TRACKER_CHUNK)
for (n in 0 until TRACKER_CHUNK) {
out[n * 2] = tadDecodedBin[n * 2L]
out[n * 2 + 1] = tadDecodedBin[n * 2L + 1]
} }
return out return out
} }
/**
* Advance to the next row. Resolves pending B/C jumps and pattern-delay repeats.
* Called once when [TrackerState.tickInRow] has just wrapped past [Playhead.tickRate].
*/
private fun advanceRow(ts: TrackerState, playhead: Playhead) {
// Pattern delay (S$Ex): replay the same row patternDelayRemaining more times.
if (ts.patternDelayRemaining > 0) {
ts.patternDelayRemaining--
ts.patternDelayActive = true
applyTrackerRow(ts, playhead)
return
}
ts.patternDelayActive = false
val pendingB = ts.pendingOrderJump
val pendingC = ts.pendingRowJump
ts.pendingOrderJump = -1
ts.pendingRowJump = -1
when {
pendingB >= 0 -> {
ts.cuePos = pendingB.coerceAtMost(1023)
ts.rowIndex = if (pendingC >= 0) pendingC else 0
playhead.position = ts.cuePos
}
pendingC >= 0 -> {
// Pattern break — advance order by one (or honour cue's own instruction), then jump to row.
advanceTrackerCue(ts, playhead)
ts.rowIndex = pendingC.coerceIn(0, 63)
}
else -> {
ts.rowIndex++
if (ts.rowIndex >= 64) {
ts.rowIndex = 0
advanceTrackerCue(ts, playhead)
}
}
}
applyTrackerRow(ts, playhead)
}
internal data class PlayCue( internal data class PlayCue(
val patterns: IntArray = IntArray(20) { 0xFFF }, val patterns: IntArray = IntArray(20) { 0xFFF },
var instruction: PlayInstruction = PlayInstNop var instruction: PlayInstruction = PlayInstNop
@@ -1323,21 +1834,118 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
internal object PlayInstHalt : PlayInstruction(0) internal object PlayInstHalt : PlayInstruction(0)
internal object PlayInstNop : PlayInstruction(0) internal object PlayInstNop : PlayInstruction(0)
/** Per-channel effect memory cohorts and private slots (TAUD_NOTE_EFFECTS.md §6). */
class MemorySlots {
// Shared E/F (pitch slide). Stores the raw arg; mode is recovered from arg layout.
var ef: Int = 0
// G (tone porta) — private speed.
var g: Int = 0
// Shared H/U vibrato — separate speed and depth fields persist across both.
var huSpeed: Int = 0
var huDepth: Int = 0
// R (tremolo) — private speed and depth.
var rSpeed: Int = 0
var rDepth: Int = 0
// Private slots
var d: Int = 0
var i: Int = 0
var j: Int = 0
var o: Int = 0
var q: Int = 0
var tslide: Int = 0
}
class Voice { class Voice {
var active = false var active = false
var instrumentId = 0 var instrumentId = 0
var samplePos = 0.0 var samplePos = 0.0
var playbackRate = 1.0 var playbackRate = 1.0
var forward = true var forward = true
var rowVolume = 63
var rowPan = 32 // Volumes: channel volume is the persistent base; rowVolume tracks per-tick output (set per row from channel volume + volume column).
var channelVolume = 0x3F // $00..$3F (default full)
var rowVolume = 63 // $00..$3F effective output volume after slides
var channelPan = 0x80 // 8-bit; $80 centre. Cell column packs into 6-bit, S$80xx writes the full 8-bit.
var rowPan = 32 // 6-bit pan used by mixer, derived from channelPan
var envIndex = 0 var envIndex = 0
var envTimeSec = 0.0 var envTimeSec = 0.0
var envVolume = 1.0 var envVolume = 1.0
var noteVal = 0xFFFF
var pitchSlideAmount = 0.0 // 4096-TET units per tick; +up, -down // Pitch state (4096-TET units, signed when slid).
var volSlidePerTick = 0 var noteVal = 0xFFFF // The currently sounding base note (no per-row vibrato/arp added)
var basePitch = 0x4000 // Saved pre-effect pitch for vibrato/arp/glissando overlay
// Per-row effect state (set in applyTrackerRow, consumed by applyTrackerTick).
var rowEffect = 0
var rowEffectArg = 0
var slideMode = 0 // 0 = none, 1 = pitch coarse-down, 2 = pitch coarse-up, 3 = porta, 4 = vol-slide modes packed in slideArg
var slideArg = 0 // generic slide arg (volume nibbles or pitch units per tick)
var tonePortaTarget = -1 // -1 if inactive
var tonePortaSpeed = 0
var arpOff1 = 0
var arpOff2 = 0
var arpActive = false
var lastArpVoice = 0 // 0 / 1 / 2 — which arp voice we ended on (J-after-arp pitch carry)
var tremorOn = 0 // 0 = inactive, 1 = active row (use I args)
var tremorOnTime = 1
var tremorOffTime = 1
var tremorPhaseOn = true
var tremorTickInPhase = 0
// Vibrato (H / U) — uses memHU.
var vibratoActive = false
var vibratoLfoPos = 0 // 8-bit phase
var vibratoWave = 0 // 0..3
var vibratoRetrig = true
var vibratoFineShift = 6 // 6 for H, 8 for U
// Tremolo (R) — uses memR.
var tremoloActive = false
var tremoloLfoPos = 0
var tremoloWave = 0
var tremoloRetrig = true
// Glissando flag (S$1x).
var glissandoOn = false
// Q retrigger.
var retrigCounter = 0
var retrigInterval = 0
var retrigVolMod = 0
var retrigActive = false
// Note delay (S$Dx) — buffered trigger (-1 = no delay).
var noteDelayTick = -1
var delayedNote = 0
var delayedInst = 0
var delayedVol = -1
// Note cut (S$Cx).
var cutAtTick = -1 var cutAtTick = -1
var noteWasCut = false // suppresses Q retrigger after cut
// Funk repeat (S$Fx) — non-destructive bit XOR mask is per-instrument; per-channel state tracks accumulator + write pointer.
var funkSpeed = 0 // 0 = off
var funkAccumulator = 0
var funkWritePos = 0
// Pattern loop (S$Bx) — per-channel state.
var loopStartRow = 0
var loopCount = 0
// Tempo slide (T $00xy) — per-channel because T is a per-channel effect, but we apply globally via playhead.
var tempoSlideDir = 0 // 0 = none, -1 = down, +1 = up
var tempoSlideAmount = 0
// Volume / pan column slides (selectors 1/2/3 from TAUD_NOTE_EFFECTS.md §"Volume column effects").
var volColSlideUp = 0
var volColSlideDown = 0
var panColSlideRight = 0
var panColSlideLeft = 0
// Effect-recall memory for this voice.
val mem = MemorySlots()
} }
class TrackerState { class TrackerState {
@@ -1347,6 +1955,21 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var samplesIntoTick = 0.0 var samplesIntoTick = 0.0
var firstRow = true var firstRow = true
val voices = Array(20) { Voice() } val voices = Array(20) { Voice() }
// Pending row-end events (set during a row by B/C; consumed at row end).
var pendingOrderJump = -1 // -1 = none; otherwise the order index to jump to
var pendingRowJump = -1 // -1 = none; otherwise the row index for the next pattern
// Pattern-delay state (S$Ex) — number of additional row-repetitions remaining.
var patternDelayRemaining = 0
var patternDelayActive = false // true while inside a delay block (gates SBx decrement)
// Channel index of the SEx that won this row (lowest channel wins ties).
var sexWinningChannel = -1
// Pre-allocated mix buffers for dither path (reused each audio chunk).
val mixLeft = FloatArray(TRACKER_CHUNK)
val mixRight = FloatArray(TRACKER_CHUNK)
} }
class Playhead( class Playhead(
@@ -1358,11 +1981,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var masterVolume: Int = 0, var masterVolume: Int = 0,
var masterPan: Int = 128, var masterPan: Int = 128,
// var samplingRateMult: ThreeFiveMiniUfloat = ThreeFiveMiniUfloat(32), // var samplingRateMult: ThreeFiveMiniUfloat = ThreeFiveMiniUfloat(32),
var bpm: Int = 120, var bpm: Int = 125, // BPM, derived from tempoByte + 24. Spec default $65 ⇒ 125 BPM.
var tickRate: Int = 6, var tickRate: Int = 6,
var pcmUpload: Boolean = false, var pcmUpload: Boolean = false,
var patBank1: Int = 0, var patBank1: Int = 0,
var patBank2: Int = 0, var patBank2: Int = 0,
var globalVolume: Int = 0x80, // 8-bit, default $80 (spec §5). Mutated by V $xx00.
var pcmQueue: Queue<ByteArray> = Queue<ByteArray>(), var pcmQueue: Queue<ByteArray> = Queue<ByteArray>(),
var pcmQueueSizeIndex: Int = 0, var pcmQueueSizeIndex: Int = 0,
@@ -1443,10 +2067,29 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
pcmUploadLength = 0 pcmUploadLength = 0
isPlaying = false isPlaying = false
pcmQueueSizeIndex = 2 pcmQueueSizeIndex = 2
// Spec §5 defaults — applied on every reset so song-start state is well-defined.
bpm = 125
tickRate = 6
globalVolume = 0x80
trackerState?.let { ts -> trackerState?.let { ts ->
ts.cuePos = 0; ts.rowIndex = 0; ts.tickInRow = 0 ts.cuePos = 0; ts.rowIndex = 0; ts.tickInRow = 0
ts.samplesIntoTick = 0.0; ts.firstRow = true ts.samplesIntoTick = 0.0; ts.firstRow = true
ts.voices.forEach { it.active = false } ts.pendingOrderJump = -1; ts.pendingRowJump = -1
ts.patternDelayRemaining = 0; ts.patternDelayActive = false
ts.sexWinningChannel = -1
ts.voices.forEach {
it.active = false
it.channelVolume = 0x3F
it.rowVolume = 0x3F
it.channelPan = 0x80
it.rowPan = 32
it.glissandoOn = false
it.loopStartRow = 0
it.loopCount = 0
it.funkSpeed = 0
it.funkAccumulator = 0
it.funkWritePos = 0
}
} }
} }
@@ -1509,6 +2152,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
data class TaudInstVolEnv(var volume: Int, var offset: ThreeFiveMiniUfloat) data class TaudInstVolEnv(var volume: Int, var offset: ThreeFiveMiniUfloat)
data class TaudInst( data class TaudInst(
var index: Int,
var samplePtr: Int, // 20-bit number var samplePtr: Int, // 20-bit number
var sampleLength: Int, var sampleLength: Int,
var samplingRate: Int, var samplingRate: Int,
@@ -1519,7 +2164,23 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var loopMode: Int, var loopMode: Int,
var envelopes: Array<TaudInstVolEnv> // first int: volume (0..255), second int: offsets (minifloat indices) var envelopes: Array<TaudInstVolEnv> // first int: volume (0..255), second int: offsets (minifloat indices)
) { ) {
constructor() : this(0, 0, 0, 0, 0, 0, 0, Array(24) { TaudInstVolEnv(0, ThreeFiveMiniUfloat(0)) }) constructor(index: Int) : this(index, 0, 0, 0, 0, 0, 0, 0, Array(24) { TaudInstVolEnv(0, ThreeFiveMiniUfloat(0)) })
// 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.
var funkMask: ByteArray? = null
fun toggleFunkBit(loopOffset: Int) {
val len = (sampleLoopEnd - sampleLoopStart).coerceAtLeast(1)
val mask = funkMask ?: ByteArray((len + 7) / 8).also { funkMask = it }
val idx = loopOffset.coerceIn(0, len - 1)
mask[idx / 8] = (mask[idx / 8].toInt() xor (1 shl (idx and 7))).toByte()
}
fun funkBit(loopOffset: Int): Boolean {
val mask = funkMask ?: return false
val len = (sampleLoopEnd - sampleLoopStart).coerceAtLeast(1)
val idx = loopOffset.coerceIn(0, len - 1)
return (mask[idx / 8].toInt() ushr (idx and 7)) and 1 != 0
}
fun getByte(offset: Int): Byte = when (offset) { fun getByte(offset: Int): Byte = when (offset) {
0 -> samplePtr.toByte() 0 -> samplePtr.toByte()

View File

@@ -2,6 +2,7 @@ package net.torvald.tsvm
import com.badlogic.gdx.Audio import com.badlogic.gdx.Audio
import com.badlogic.gdx.Gdx import com.badlogic.gdx.Gdx
import com.badlogic.gdx.Input
import com.badlogic.gdx.Input.Buttons import com.badlogic.gdx.Input.Buttons
import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.graphics.g2d.SpriteBatch import com.badlogic.gdx.graphics.g2d.SpriteBatch
@@ -13,6 +14,7 @@ import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_WELL
import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.FONT import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.FONT
import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.TINY import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.TINY
import net.torvald.tsvm.peripheral.AudioAdapter import net.torvald.tsvm.peripheral.AudioAdapter
import java.util.BitSet
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.floor import kotlin.math.floor
@@ -25,6 +27,7 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
// Per-playhead view mode: 0=detailed pattern, 1=abridged pattern (stub), 2=super-abridged (stub), 3=cuesheet detail // Per-playhead view mode: 0=detailed pattern, 1=abridged pattern (stub), 2=super-abridged (stub), 3=cuesheet detail
private val scopeMode = IntArray(4) private val scopeMode = IntArray(4)
private val scopeScrollHorz = IntArray(4)
override fun show() { override fun show() {
} }
@@ -33,8 +36,10 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
} }
private var guiClickLatched = arrayOf(false, false, false, false, false, false, false, false) private var guiClickLatched = arrayOf(false, false, false, false, false, false, false, false)
private var guiKeypressLatched = BitSet(256)
override fun update() { override fun update() {
// mouse clicks
if (Gdx.input.isButtonPressed(Buttons.LEFT)) { if (Gdx.input.isButtonPressed(Buttons.LEFT)) {
if (!guiClickLatched[Buttons.LEFT]) { if (!guiClickLatched[Buttons.LEFT]) {
val mx = Gdx.input.x - x val mx = Gdx.input.x - x
@@ -45,7 +50,7 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
val syTop = h - 7 - 115 * i - 8 * FONT.H val syTop = h - 7 - 115 * i - 8 * FONT.H
val syBot = h - 3 - 115 * i val syBot = h - 3 - 115 * i
if (my in syTop..syBot) { if (my in syTop..syBot) {
scopeMode[3 - i] = (scopeMode[3 - i] + 1) % 4 scopeMode[3 - i] = (scopeMode[3 - i] + 1) and 3
break break
} }
} }
@@ -57,9 +62,78 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
else { else {
guiClickLatched[Buttons.LEFT] = false guiClickLatched[Buttons.LEFT] = false
} }
if (Gdx.input.isButtonPressed(Buttons.RIGHT)) {
if (!guiClickLatched[Buttons.RIGHT]) {
val mx = Gdx.input.x - x
val my = Gdx.input.y - y
if (mx in 117..629) {
for (i in 0..3) {
val syTop = h - 7 - 115 * i - 8 * FONT.H
val syBot = h - 3 - 115 * i
if (my in syTop..syBot) {
scopeMode[3 - i] = (scopeMode[3 - i] - 1) and 3
break
}
}
}
guiClickLatched[Buttons.RIGHT] = true
}
}
else {
guiClickLatched[Buttons.RIGHT] = false
}
// keyboard left/right
if (Gdx.input.isKeyPressed(Input.Keys.LEFT)) {
if (!guiKeypressLatched[Input.Keys.LEFT]) {
val mx = Gdx.input.x - x
val my = Gdx.input.y - y
if (mx in 117..629) {
for (i in 0..3) {
val syTop = h - 7 - 115 * i - 8 * FONT.H
val syBot = h - 3 - 115 * i
if (my in syTop..syBot) {
scopeScrollHorz[3 - i] = (scopeScrollHorz[3 - i] - 1).coerceIn(0, 14)
break
}
}
}
guiKeypressLatched[Input.Keys.LEFT] = true
}
}
else {
guiKeypressLatched[Input.Keys.LEFT] = false
}
if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) {
if (!guiKeypressLatched[Input.Keys.RIGHT]) {
val mx = Gdx.input.x - x
val my = Gdx.input.y - y
if (mx in 117..629) {
for (i in 0..3) {
val syTop = h - 7 - 115 * i - 8 * FONT.H
val syBot = h - 3 - 115 * i
if (my in syTop..syBot) {
scopeScrollHorz[3 - i] = (scopeScrollHorz[3 - i] + 1).coerceIn(0, 14)
break
}
}
}
guiKeypressLatched[Input.Keys.RIGHT] = true
}
}
else {
guiKeypressLatched[Input.Keys.RIGHT] = false
}
} }
private val COL_SOUNDSCOPE_BACK = Color(0x081c08ff.toInt()) private val COL_SOUNDSCOPE_BACK = Color(0x081c08ff.toInt())
private val COL_SOUNDSCOPE_FORE = Color(0x80f782ff.toInt()) private val COL_SOUNDSCOPE_FORE = Color(0x80f782ff.toInt())
private val COL_TRACKER_ROW = Color(0x103010ff.toInt()) private val COL_TRACKER_ROW = Color(0x103010ff.toInt())
@@ -187,7 +261,9 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
private fun bipolarCeil(d: Double) = (if (d >= 0.0) ceil(d) else floor(d)).toInt() private fun bipolarCeil(d: Double) = (if (d >= 0.0) ceil(d) else floor(d)).toInt()
private fun bipolarFloor(d: Double) = (if (d >= 0.0) floor(d) else ceil(d)).toInt() private fun bipolarFloor(d: Double) = (if (d >= 0.0) floor(d) else ceil(d)).toInt()
private val VOX_PER_VIEW = arrayOf(5,13,17) private val VOX_PER_VIEW = arrayOf(6,13,20)
private val VOL_SYM = arrayOf('@','^','&',' ')
private val PAN_SYM = arrayOf('@','<','>',' ')
private fun drawSoundscope(audio: AudioAdapter, ahead: AudioAdapter.Playhead, batch: SpriteBatch, index: Int, x: Float, y: Float) { private fun drawSoundscope(audio: AudioAdapter, ahead: AudioAdapter.Playhead, batch: SpriteBatch, index: Int, x: Float, y: Float) {
val gdxadev = ahead.audioDevice val gdxadev = ahead.audioDevice
@@ -272,8 +348,8 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
var cx = x var cx = x
// cursor + cue number // cursor + cue number
batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE
TINY.draw(batch, "${if (here) ">" else " "}${ci.toString(16).padStart(3, '0').uppercase()}|", cx, ry) TINY.draw(batch, "${ci.toString(16).padStart(3, '0').uppercase()}|", cx, ry)
cx += 5 * TINY.W cx += 4 * TINY.W
// voice pattern numbers // voice pattern numbers
for (vi in 0 until 20) { for (vi in 0 until 20) {
@@ -328,7 +404,7 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
*/ */
// Pattern index for each voice in current cue // Pattern index for each voice in current cue
val cuePats = IntArray(VOICES) { vi -> readCuePat12(audio, cuePos, vi) } val cuePats = IntArray(20) { vi -> readCuePat12(audio, cuePos, vi) }
// Pattern rows (right area, 8 rows centred on current row) // Pattern rows (right area, 8 rows centred on current row)
// Layout: > rr NOTE in E.Vo E.Pn Eff ffff [voice1 …] // Layout: > rr NOTE in E.Vo E.Pn Eff ffff [voice1 …]
@@ -349,15 +425,15 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
// cursor + row number (drawn once per row) // cursor + row number (drawn once per row)
batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE
TINY.draw(batch, if (here) ">" else " ", cx, ry) // TINY.draw(batch, if (here) ">" else " ", cx, ry)
cx += TINY.W // cx += TINY.W
TINY.draw(batch, ri.toString().padStart(2, '0').uppercase(), cx, ry) TINY.draw(batch, ri.toString().padStart(2, '0').uppercase(), cx, ry)
cx += 2 * TINY.W cx += 2 * TINY.W
for (vi in 0 until VOICES) { for (vi in scopeScrollHorz[index] until (VOICES + scopeScrollHorz[index]).coerceAtMost(19)) {
val pat12 = cuePats[vi] val pat12 = cuePats[vi]
if (pat12 == 0xFFF) { if (pat12 == 0xFFF) {
if (vi == 0) { if (vi == scopeScrollHorz[index]) {
// disabled voice — dimmed placeholder, same width as a live voice // disabled voice — dimmed placeholder, same width as a live voice
batch.color = COL_SOUNDSCOPE_FORE batch.color = COL_SOUNDSCOPE_FORE
TINY.draw( TINY.draw(
@@ -394,6 +470,10 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
0xFFFE -> "^^^^" 0xFFFE -> "^^^^"
else -> noteVal.toString(16).uppercase().padStart(4, '0') else -> noteVal.toString(16).uppercase().padStart(4, '0')
} }
var instStr = instr.toString(16).padStart(2, '0').uppercase()
if (instr == 0) {
instStr = "@@"
}
// note // note
batch.color = if (here) Color.WHITE else COL_NOTE batch.color = if (here) Color.WHITE else COL_NOTE
@@ -401,13 +481,19 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
cx += 4 * TINY.W cx += 4 * TINY.W
// instrument // instrument
batch.color = if (here) Color.WHITE else COL_INST batch.color = if (here) Color.WHITE else COL_INST
TINY.draw(batch, instr.toString(16).padStart(2, '0').uppercase(), cx, ry) TINY.draw(batch, instStr, cx, ry)
cx += 2 * TINY.W cx += 2 * TINY.W
if (scopeMode[index] == 0) { if (scopeMode[index] == 0) {
// volume // volume
batch.color = if (here) Color.WHITE else COL_VOL batch.color = if (here) Color.WHITE else COL_VOL
TINY.draw(batch, "$volEff.${vol.toString().padStart(2, '0')}", cx, ry) var text = if (volByte == 0xC0) "@@@" else "${VOL_SYM[volEff]}${vol.toString().padStart(2, '0')}"
cx += 4 * TINY.W // is this fine slide?
if (volEff == 3 && vol != 0) {
val dir = if (vol and 32 == 1) '+' else '-'
text = "$dir${(vol and 31).toString().padStart(2,'0').uppercase()}"
}
TINY.draw(batch, text, cx, ry)
cx += 3 * TINY.W
} }
else if (scopeMode[index] == 1) { else if (scopeMode[index] == 1) {
batch.color = if (here) Color.WHITE else COL_VOL batch.color = if (here) Color.WHITE else COL_VOL
@@ -416,20 +502,32 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
} }
// pan // pan
if (scopeMode[index] == 0) { if (scopeMode[index] == 0) {
var text = if (panByte == 0xC0) "@@@" else "${PAN_SYM[panEff]}${pan.toString().padStart(2, '0')}"
// is this fine slide?
if (panEff == 3 && pan != 0) {
val dir = if (pan and 32 == 1) '+' else '-'
text = "$dir${(pan and 31).toString().padStart(2,'0').uppercase()}"
}
batch.color = if (here) Color.WHITE else COL_PAN batch.color = if (here) Color.WHITE else COL_PAN
TINY.draw(batch, "$panEff.${pan.toString().padStart(2, '0')}", cx, ry) TINY.draw(batch, text, cx, ry)
cx += 4 * TINY.W cx += 3 * TINY.W
} }
if (scopeMode[index] == 0) { if (scopeMode[index] == 0) {
var effSymStr = eff.toString(36).uppercase()
var effArgStr = effArg.toString(16).padStart(4, '0').uppercase()
if (eff == 0 && effArg == 0) {
effSymStr = "@@"
effArgStr = "@@@@"
}
// effect opcode // effect opcode
batch.color = if (here) Color.WHITE else COL_EFF batch.color = if (here) Color.WHITE else COL_EFF
TINY.draw(batch, eff.toString(16).padStart(2, '0').uppercase(), cx, ry) TINY.draw(batch, effSymStr, cx, ry)
cx += 2 * TINY.W cx += 1 * TINY.W
}
if (scopeMode[index] == 0) {
// effect argument // effect argument
batch.color = if (here) Color.WHITE else COL_EFFARG batch.color = if (here) Color.WHITE else COL_EFFARG
TINY.draw(batch, effArg.toString(16).padStart(4, '0').uppercase(), cx, ry) TINY.draw(batch, effArgStr, cx, ry)
cx += 4 * TINY.W cx += 4 * TINY.W
} }
} }

View File

@@ -346,6 +346,8 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX:
drawMenu(fbatch, (panelsX - 1f) * windowWidth, (panelsY - 1f) * windowHeight) drawMenu(fbatch, (panelsX - 1f) * windowWidth, (panelsY - 1f) * windowHeight)
} }
private fun drawVMtoCanvas(delta: Float, vm: VM?, pposX: Int, pposY: Int) { private fun drawVMtoCanvas(delta: Float, vm: VM?, pposX: Int, pposY: Int) {
// assuming the reference adapter of 560x448 // assuming the reference adapter of 560x448
val xoff = pposX * windowWidth.toFloat() val xoff = pposX * windowWidth.toFloat()