mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
tracker effects definition
This commit is contained in:
@@ -676,24 +676,31 @@ on sample byte read during loop playback:
|
||||
|
||||
# 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.
|
||||
- **`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.
|
||||
- **`0.$xx` — Set volume** to `$xx` (6-bit, $00..$3F). Equivalent to a note's default volume.
|
||||
- **`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).
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
- **`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.
|
||||
- **`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.$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.
|
||||
|
||||
|
||||
Additional selectors are reserved for future expansion.
|
||||
NOTE: **`3.00` — is No-op**
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ const PLAYHEAD = 0
|
||||
|
||||
println("Playing "+fullFilePath.full)
|
||||
|
||||
audio.resetParams(PLAYHEAD)
|
||||
audio.purgeQueue(PLAYHEAD)
|
||||
audio.stop(PLAYHEAD)
|
||||
|
||||
taud.uploadTaudFile(fullFilePath.full, 0, PLAYHEAD)
|
||||
audio.setMasterVolume(PLAYHEAD, 255)
|
||||
audio.setMasterPan(PLAYHEAD, 128)
|
||||
|
||||
365
s3m2taud.py
365
s3m2taud.py
@@ -5,18 +5,20 @@ Usage:
|
||||
python3 s3m2taud.py input.s3m output.taud [-v]
|
||||
|
||||
Limits:
|
||||
- Up to 15 S3M channels (excess disabled; hard error if pattern count
|
||||
× channel count > 256).
|
||||
- Up to 20 S3M channels (excess disabled; hard error if pattern count
|
||||
× channel count > 4095).
|
||||
- Sample bin is 770048 bytes; if all samples together exceed this, every
|
||||
sample is globally resampled down (with c2spd adjusted) so pitch is
|
||||
preserved.
|
||||
- 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:
|
||||
Amiga-period mode: taud_arg ≈ s3m_arg * 2 (mid-register heuristic)
|
||||
Linear-slide mode: taud_arg = s3m_arg * 4 (exact)
|
||||
Effect support:
|
||||
Full A..Z dispatch per TAUD_NOTE_EFFECTS.md "ProTracker to Taud conversion
|
||||
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
|
||||
@@ -93,6 +95,44 @@ NOTE_KEYOFF = 0x0000
|
||||
NOTE_CUT = 0xFFFE
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -296,31 +336,202 @@ def encode_note(s3m_note: int) -> int:
|
||||
return max(1, min(0xFFFD, val))
|
||||
|
||||
|
||||
def encode_effect(cmd: int, arg: int, linear: bool) -> tuple:
|
||||
"""Return (taud_op, taud_arg16) or (0, 0) for no-op."""
|
||||
def _d_arg_to_col(arg: int):
|
||||
"""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:
|
||||
# Volume slide: same nibble layout
|
||||
return (0x0A, arg & 0xFF)
|
||||
if cmd == EFF_E:
|
||||
# Porta down
|
||||
if linear:
|
||||
targ = min(arg * 4, 0xFFFF)
|
||||
else:
|
||||
targ = min(arg * 2, 0xFFFF)
|
||||
return (0x02, targ)
|
||||
if cmd == EFF_F:
|
||||
# Porta up
|
||||
if linear:
|
||||
targ = min(arg * 4, 0xFFFF)
|
||||
else:
|
||||
targ = min(arg * 2, 0xFFFF)
|
||||
return (0x01, targ)
|
||||
# D-style four-form arg passed through verbatim in the high byte.
|
||||
return (TOP_D, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd in (EFF_E, EFF_F):
|
||||
# ST3 slide unit = 1/16 semitone = $0015 Taud units (per spec PT table).
|
||||
op = TOP_E if cmd == EFF_E else TOP_F
|
||||
hi = (arg >> 4) & 0xF
|
||||
lo = arg & 0xF
|
||||
if hi == 0xF and lo > 0:
|
||||
return (op, 0xF000 | ((lo * 0x15) & 0xFFF), None, None)
|
||||
if hi == 0xE and lo > 0:
|
||||
return (op, 0xF000 | ((lo * 0x05) & 0xFFF), None, None)
|
||||
return (op, (arg * 0x15) & 0xFFFF, None, None)
|
||||
|
||||
if cmd == EFF_G:
|
||||
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:
|
||||
sub = (arg >> 4) & 0xF
|
||||
val = arg & 0xF
|
||||
if sub == 0xC: # SC - note cut
|
||||
return (0xEC, val)
|
||||
return (0x00, 0x0000)
|
||||
if sub in (0x1, 0x2, 0x3, 0x4, 0xB, 0xC, 0xD, 0xE, 0xF):
|
||||
return (TOP_S, (sub << 12) | (val << 8), None, None)
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
@@ -384,6 +595,7 @@ def build_sample_inst_bin(instruments: list) -> tuple:
|
||||
# Build instrument bin (256 × 64 bytes)
|
||||
inst_bin = bytearray(INSTBIN_SIZE)
|
||||
for i, inst in enumerate(instruments):
|
||||
taud_idx = i + 1
|
||||
if i >= 256:
|
||||
break
|
||||
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
|
||||
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 + 2, s_len)
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
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,
|
||||
linear_slides: bool) -> bytes:
|
||||
"""Build a 512-byte Taud pattern for one S3M channel."""
|
||||
linear_slides: bool, inst_vols: dict = None) -> bytes:
|
||||
"""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)
|
||||
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]):
|
||||
note = encode_note(row.note)
|
||||
inst = max(0, row.inst - 1) # S3M 1-based → Taud 0-based
|
||||
vol = min(row.vol, 63) if row.vol >= 0 else 63
|
||||
pan = default_pan
|
||||
op, arg = encode_effect(row.effect, row.effect_arg, linear_slides)
|
||||
if row.effect != 0 and op == 0:
|
||||
eff_name = chr(ord('A') + row.effect - 1) if 1 <= row.effect <= 26 else '?'
|
||||
vprint(f" dropped effect {eff_name}{row.effect_arg:02X} at ch{ch_idx} row{r}")
|
||||
note = encode_note(row.note)
|
||||
inst = row.inst # S3M 1-based → Taud 1-based
|
||||
|
||||
if row.inst > 0:
|
||||
last_inst = row.inst
|
||||
|
||||
op, arg, vol_override, pan_override = encode_effect(
|
||||
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
|
||||
struct.pack_into('<H', out, base + 0, note)
|
||||
out[base + 2] = inst & 0xFF
|
||||
out[base + 3] = vol & 0x3F
|
||||
out[base + 4] = pan & 0x3F
|
||||
out[base + 3] = vol_byte
|
||||
out[base + 4] = pan_byte
|
||||
out[base + 5] = op & 0xFF
|
||||
struct.pack_into('<H', out, base + 6, arg & 0xFFFF)
|
||||
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)
|
||||
|
||||
cue_idx = 0
|
||||
last_active = -1
|
||||
for order in order_list:
|
||||
if order == S3M_ORDER_END or cue_idx >= NUM_CUES:
|
||||
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)]
|
||||
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)
|
||||
last_active = cue_idx
|
||||
cue_idx += 1
|
||||
|
||||
# Halt at end
|
||||
if cue_idx < NUM_CUES:
|
||||
sheet[cue_idx*CUE_SIZE : cue_idx*CUE_SIZE+CUE_SIZE] = _encode_cue([], 0x01)
|
||||
# Halt on the last active cue (instruction byte at offset 30), so the
|
||||
# engine stops immediately after that pattern completes with no silent gap.
|
||||
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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
# 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
|
||||
vprint(" building sample/instrument bin…")
|
||||
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
|
||||
vprint(" building pattern bin…")
|
||||
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()
|
||||
for pi in range(P):
|
||||
grid = patterns[pi]
|
||||
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
|
||||
|
||||
# Deduplicate identical patterns
|
||||
|
||||
@@ -1986,7 +1986,7 @@ Synchronisation between playheads are not guaranteed. Do not play music in multi
|
||||
Memory Space
|
||||
|
||||
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)
|
||||
851968..917503 RW: Play data 2 (currently exposed bank; 64k)
|
||||
917504..983039 RW: TAD Input Buffer (64k)
|
||||
@@ -2022,6 +2022,8 @@ note 0xFFFF: no-op
|
||||
note 0xFFFE: note cut
|
||||
note 0x0000: key-off
|
||||
|
||||
inst 0: no instrument change
|
||||
|
||||
|
||||
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
|
||||
|
||||
Endianness: Little
|
||||
|
||||
@@ -125,7 +125,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
}
|
||||
|
||||
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 playheads: Array<Playhead>
|
||||
internal val cueSheet = Array(1024) { PlayCue() }
|
||||
@@ -1080,14 +1080,80 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
//=========================================================================
|
||||
// Tracker Engine
|
||||
//
|
||||
// Effect codes (non-canonical MVP; spec is silent on values):
|
||||
// 0x00: no effect
|
||||
// 0x01 arg: pitch slide up, arg = 4096-TET units per tick
|
||||
// 0x02 arg: pitch slide down, arg = 4096-TET units per tick
|
||||
// 0x0A arg: volume slide, high nibble = up/tick, low nibble = down/tick
|
||||
// 0xEC arg: note cut at tick (arg & 0xFF)
|
||||
// Effect opcodes follow base-36 digit values (see TAUD_NOTE_EFFECTS.md):
|
||||
// 0x00 : no effect
|
||||
// 0x0A..0x23 : letters A..Z (A=0x0A speed, B=0x0B order jump,
|
||||
// C=0x0C pattern break, D=0x0D vol slide, E=0x0E pitch
|
||||
// down, F=0x0F pitch up, G=0x10 tone porta,
|
||||
// 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 =
|
||||
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 {
|
||||
if (inst.index == 0) return 0.0
|
||||
|
||||
val basePtr = inst.samplePtr
|
||||
val sampleLen = inst.sampleLength.coerceAtLeast(1)
|
||||
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 i1 = (i0 + 1).coerceAtMost(sampleLen - 1)
|
||||
val frac = voice.samplePos - i0.toDouble()
|
||||
val s0 = (sampleBin[(basePtr + i0).coerceAtMost(binMax).toLong()].toUint() - 128) / 128.0
|
||||
val s1 = (sampleBin[(basePtr + i1).coerceAtMost(binMax).toLong()].toUint() - 128) / 128.0
|
||||
var b0 = sampleBin[(basePtr + i0).coerceAtMost(binMax).toLong()].toUint()
|
||||
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
|
||||
|
||||
if (voice.forward) {
|
||||
@@ -1142,8 +1219,71 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
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) {
|
||||
val cue = cueSheet[ts.cuePos]
|
||||
// Reset row-scope state before scanning channels.
|
||||
if (!ts.patternDelayActive) ts.sexWinningChannel = -1
|
||||
|
||||
for (vi in 0..19) {
|
||||
val patNum = cue.patterns[vi]
|
||||
if (patNum == 0xFFF) continue
|
||||
@@ -1151,56 +1291,384 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val row = playdata[patIdx][ts.rowIndex]
|
||||
val voice = ts.voices[vi]
|
||||
|
||||
voice.rowVolume = row.volume
|
||||
voice.rowPan = row.pan
|
||||
// Reset per-row transient state.
|
||||
voice.cutAtTick = -1
|
||||
voice.pitchSlideAmount = 0.0
|
||||
voice.volSlidePerTick = 0
|
||||
|
||||
when (row.effect) {
|
||||
0x01 -> voice.pitchSlideAmount = row.effectArg.toDouble()
|
||||
0x02 -> voice.pitchSlideAmount = -row.effectArg.toDouble()
|
||||
0x0A -> { val a = row.effectArg and 0xFF; voice.volSlidePerTick = ((a ushr 4) and 0xF) - (a and 0xF) }
|
||||
0xEC -> voice.cutAtTick = row.effectArg and 0xFF
|
||||
}
|
||||
voice.noteDelayTick = -1
|
||||
voice.slideMode = 0
|
||||
voice.slideArg = 0
|
||||
voice.arpActive = false
|
||||
voice.tremorOn = 0
|
||||
voice.vibratoActive = false
|
||||
voice.tremoloActive = false
|
||||
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) {
|
||||
0xFFFF -> {} // no-op: continue current note unchanged
|
||||
0x0000 -> voice.active = false // key-off (TODO: trigger envelope release phase)
|
||||
0xFFFE -> voice.active = false // note cut: immediate silence
|
||||
0xFFFF -> {} // no-op
|
||||
0x0000 -> voice.active = false // key-off (TODO release envelope)
|
||||
0xFFFE -> voice.active = false // note cut
|
||||
else -> {
|
||||
val inst = instruments[row.instrment]
|
||||
voice.instrumentId = row.instrment
|
||||
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 = row.note
|
||||
voice.playbackRate = computePlaybackRate(inst, row.note)
|
||||
if (toneG && voice.active) {
|
||||
// Tone porta: target the note, do not retrigger sample.
|
||||
voice.tonePortaTarget = row.note
|
||||
} else if ((row.effect == EffectOp.OP_S) && ((row.effectArg ushr 12) and 0xF) == 0xD) {
|
||||
// Note delay: defer trigger to the requested tick.
|
||||
voice.noteDelayTick = (row.effectArg ushr 8) and 0xF
|
||||
voice.delayedNote = row.note
|
||||
voice.delayedInst = row.instrment
|
||||
voice.delayedVol = if (row.volume >= 0) row.volume else -1
|
||||
} 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) {
|
||||
val tickSec = 2.5 / playhead.bpm
|
||||
for (voice in ts.voices) {
|
||||
if (!voice.active) continue
|
||||
if (!voice.active && voice.noteDelayTick < 0) continue
|
||||
val inst = instruments[voice.instrumentId]
|
||||
if (voice.cutAtTick == ts.tickInRow) { voice.active = false; continue }
|
||||
if (voice.pitchSlideAmount != 0.0) {
|
||||
voice.noteVal = (voice.noteVal + voice.pitchSlideAmount).toInt().coerceIn(0, 0xFFFE)
|
||||
voice.playbackRate = computePlaybackRate(inst, voice.noteVal)
|
||||
|
||||
// Note cut.
|
||||
if (voice.cutAtTick == ts.tickInRow) {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
val instr = cueSheet[ts.cuePos].instruction
|
||||
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? {
|
||||
val ts = playhead.trackerState ?: return null
|
||||
val bpm = playhead.bpm
|
||||
val spt = SAMPLING_RATE * 2.5 / bpm
|
||||
|
||||
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) {
|
||||
// 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
|
||||
if (ts.samplesIntoTick >= spt) {
|
||||
ts.samplesIntoTick -= spt
|
||||
@@ -1232,32 +1700,75 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
ts.tickInRow++
|
||||
if (ts.tickInRow >= playhead.tickRate) {
|
||||
ts.tickInRow = 0
|
||||
ts.rowIndex++
|
||||
if (ts.rowIndex >= 64) {
|
||||
ts.rowIndex = 0
|
||||
advanceTrackerCue(ts, playhead)
|
||||
}
|
||||
applyTrackerRow(ts, playhead)
|
||||
advanceRow(ts, playhead)
|
||||
}
|
||||
}
|
||||
|
||||
var mixL = 0.0
|
||||
var mixR = 0.0
|
||||
val gvol = playhead.globalVolume / 255.0
|
||||
for (voice in ts.voices) {
|
||||
if (!voice.active) continue
|
||||
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
|
||||
mixR += s * vol * voice.rowPan / 63.0
|
||||
}
|
||||
|
||||
out[n * 2] = ((mixL.coerceIn(-1.0, 1.0) * 127 + 128).toInt()).toByte()
|
||||
out[n * 2 + 1] = ((mixR.coerceIn(-1.0, 1.0) * 127 + 128).toInt()).toByte()
|
||||
ts.mixLeft[n] = mixL.toFloat().coerceIn(-1.0f, 1.0f)
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
val patterns: IntArray = IntArray(20) { 0xFFF },
|
||||
var instruction: PlayInstruction = PlayInstNop
|
||||
@@ -1323,21 +1834,118 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
internal object PlayInstHalt : 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 {
|
||||
var active = false
|
||||
var instrumentId = 0
|
||||
var samplePos = 0.0
|
||||
var playbackRate = 1.0
|
||||
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 envTimeSec = 0.0
|
||||
var envVolume = 1.0
|
||||
var noteVal = 0xFFFF
|
||||
var pitchSlideAmount = 0.0 // 4096-TET units per tick; +up, -down
|
||||
var volSlidePerTick = 0
|
||||
|
||||
// Pitch state (4096-TET units, signed when slid).
|
||||
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 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 {
|
||||
@@ -1347,6 +1955,21 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
var samplesIntoTick = 0.0
|
||||
var firstRow = true
|
||||
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(
|
||||
@@ -1358,11 +1981,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
var masterVolume: Int = 0,
|
||||
var masterPan: Int = 128,
|
||||
// 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 pcmUpload: Boolean = false,
|
||||
var patBank1: 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 pcmQueueSizeIndex: Int = 0,
|
||||
@@ -1443,10 +2067,29 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
pcmUploadLength = 0
|
||||
isPlaying = false
|
||||
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 ->
|
||||
ts.cuePos = 0; ts.rowIndex = 0; ts.tickInRow = 0
|
||||
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 TaudInst(
|
||||
var index: Int,
|
||||
|
||||
var samplePtr: Int, // 20-bit number
|
||||
var sampleLength: Int,
|
||||
var samplingRate: Int,
|
||||
@@ -1519,7 +2164,23 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
var loopMode: Int,
|
||||
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) {
|
||||
0 -> samplePtr.toByte()
|
||||
|
||||
Binary file not shown.
@@ -2,6 +2,7 @@ package net.torvald.tsvm
|
||||
|
||||
import com.badlogic.gdx.Audio
|
||||
import com.badlogic.gdx.Gdx
|
||||
import com.badlogic.gdx.Input
|
||||
import com.badlogic.gdx.Input.Buttons
|
||||
import com.badlogic.gdx.graphics.Color
|
||||
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.TINY
|
||||
import net.torvald.tsvm.peripheral.AudioAdapter
|
||||
import java.util.BitSet
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
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
|
||||
private val scopeMode = IntArray(4)
|
||||
private val scopeScrollHorz = IntArray(4)
|
||||
|
||||
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 guiKeypressLatched = BitSet(256)
|
||||
|
||||
override fun update() {
|
||||
// mouse clicks
|
||||
if (Gdx.input.isButtonPressed(Buttons.LEFT)) {
|
||||
if (!guiClickLatched[Buttons.LEFT]) {
|
||||
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 syBot = h - 3 - 115 * i
|
||||
if (my in syTop..syBot) {
|
||||
scopeMode[3 - i] = (scopeMode[3 - i] + 1) % 4
|
||||
scopeMode[3 - i] = (scopeMode[3 - i] + 1) and 3
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -57,9 +62,78 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
else {
|
||||
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_FORE = Color(0x80f782ff.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 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) {
|
||||
val gdxadev = ahead.audioDevice
|
||||
@@ -272,8 +348,8 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
var cx = x
|
||||
// cursor + cue number
|
||||
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)
|
||||
cx += 5 * TINY.W
|
||||
TINY.draw(batch, "${ci.toString(16).padStart(3, '0').uppercase()}|", cx, ry)
|
||||
cx += 4 * TINY.W
|
||||
|
||||
// voice pattern numbers
|
||||
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
|
||||
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)
|
||||
// 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)
|
||||
batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE
|
||||
TINY.draw(batch, if (here) ">" else " ", cx, ry)
|
||||
cx += TINY.W
|
||||
// TINY.draw(batch, if (here) ">" else " ", cx, ry)
|
||||
// cx += TINY.W
|
||||
TINY.draw(batch, ri.toString().padStart(2, '0').uppercase(), cx, ry)
|
||||
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]
|
||||
if (pat12 == 0xFFF) {
|
||||
if (vi == 0) {
|
||||
if (vi == scopeScrollHorz[index]) {
|
||||
// disabled voice — dimmed placeholder, same width as a live voice
|
||||
batch.color = COL_SOUNDSCOPE_FORE
|
||||
TINY.draw(
|
||||
@@ -394,6 +470,10 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
0xFFFE -> "^^^^"
|
||||
else -> noteVal.toString(16).uppercase().padStart(4, '0')
|
||||
}
|
||||
var instStr = instr.toString(16).padStart(2, '0').uppercase()
|
||||
if (instr == 0) {
|
||||
instStr = "@@"
|
||||
}
|
||||
|
||||
// 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
|
||||
// instrument
|
||||
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
|
||||
if (scopeMode[index] == 0) {
|
||||
// volume
|
||||
batch.color = if (here) Color.WHITE else COL_VOL
|
||||
TINY.draw(batch, "$volEff.${vol.toString().padStart(2, '0')}", cx, ry)
|
||||
cx += 4 * TINY.W
|
||||
var text = if (volByte == 0xC0) "@@@" else "${VOL_SYM[volEff]}${vol.toString().padStart(2, '0')}"
|
||||
// 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) {
|
||||
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
|
||||
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
|
||||
TINY.draw(batch, "$panEff.${pan.toString().padStart(2, '0')}", cx, ry)
|
||||
cx += 4 * TINY.W
|
||||
TINY.draw(batch, text, cx, ry)
|
||||
cx += 3 * TINY.W
|
||||
}
|
||||
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
|
||||
batch.color = if (here) Color.WHITE else COL_EFF
|
||||
TINY.draw(batch, eff.toString(16).padStart(2, '0').uppercase(), cx, ry)
|
||||
cx += 2 * TINY.W
|
||||
}
|
||||
if (scopeMode[index] == 0) {
|
||||
TINY.draw(batch, effSymStr, cx, ry)
|
||||
cx += 1 * TINY.W
|
||||
// effect argument
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +346,8 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX:
|
||||
drawMenu(fbatch, (panelsX - 1f) * windowWidth, (panelsY - 1f) * windowHeight)
|
||||
}
|
||||
|
||||
|
||||
|
||||
private fun drawVMtoCanvas(delta: Float, vm: VM?, pposX: Int, pposY: Int) {
|
||||
// assuming the reference adapter of 560x448
|
||||
val xoff = pposX * windowWidth.toFloat()
|
||||
|
||||
Reference in New Issue
Block a user