mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 13:38:30 +09:00
tracker effects definition
This commit is contained in:
@@ -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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
365
s3m2taud.py
365
s3m2taud.py
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user