From f84ea5e68a7c45da21b717d9838a637005e5480b Mon Sep 17 00:00:00 2001 From: minjaesong Date: Mon, 20 Apr 2026 01:35:23 +0900 Subject: [PATCH] tracker effects definition --- TAUD_NOTE_EFFECTS.md | 23 +- assets/disk0/tracker_play.js | 4 + s3m2taud.py | 365 ++++++-- terranmon.txt | 70 +- .../torvald/tsvm/peripheral/AudioAdapter.kt | 779 ++++++++++++++++-- .../net/torvald/terrarum/imagefont/tiny.tga | 2 +- .../src/net/torvald/tsvm/AudioMenu.kt | 136 ++- .../src/net/torvald/tsvm/VMEmuExecutable.kt | 2 + 8 files changed, 1247 insertions(+), 134 deletions(-) diff --git a/TAUD_NOTE_EFFECTS.md b/TAUD_NOTE_EFFECTS.md index 80ad5aa..6563a61 100644 --- a/TAUD_NOTE_EFFECTS.md +++ b/TAUD_NOTE_EFFECTS.md @@ -676,24 +676,31 @@ on sample byte read during loop playback: # Volume column effects -The volume column of each cell can carry a secondary effect, encoded as a one-digit selector followed by a two-hex-digit value (`N.$xx`). The two defined selectors are: +Each cell carries a 6-bit value field plus a 2-bit selector field for the volume column. The four selectors are: -- **`0.$xx` — Set volume** to `$xx` (clipped to $3F). Equivalent to a note's default volume. -- **`1.$xy` — Volume slide** by `$xy`, with `$x` as up-slide amount and `$y` as down-slide amount, using the same encoding as D. Fine slides (`1.$Fx` and `1.$xF`) fire on tick 0 only; other values fire on ticks > 0. +- **`0.$xx` — Set volume** to `$xx` (6-bit, $00..$3F). Equivalent to a note's default volume. +- **`1.$xx` — Volume slide up** by `$xx` per non-first tick (6-bit). Volume clamps at $3F. +- **`2.$xx` — Volume slide down** by `$xx` per non-first tick (6-bit). Volume clamps at $00. +- **`3.$Sx` — Fine volume slide** on tick 0 only. The high bit `$S` of the value selects direction (0 = down, 1 = up); the low 5 bits `$x` ($00..$1F) are the magnitude. Equivalent in scale to `D $xF00` / `D $Fy00` but with a 5-bit cap. Fires once per row regardless of speed. Volume-column effects do not consume the main effect slot; a cell can carry both (for instance, a tone portamento in the effect slot and a volume slide in the volume column). +When the converter folds an ST3 K, L, M, or N effect into the volume column, the slide-up / slide-down nibbles map to selectors 1 / 2 (clamped to 6 bits — values above $3F clip). + +NOTE: **`3.00` — is No-op** + --- # Panning column effects -The optional panning column carries its own one-digit selectors: +The panning column uses the same 6-bit value + 2-bit selector layout: -- **`0.$xx` — Set pan** to `$xx` ($00 left, $FF right, $80 centre). Equivalent to S $80xx but without consuming the effect slot. -- **`1.$xy` — Pan slide** by `$xy`, with `$x` as left-slide amount and `$y` as right-slide amount, using the same encoding as D. There is no fine slide, as ST3 does not have panning slides. +- **`0.$xx` — Set pan** (6-bit, $00..$3F mapped onto the channel's 8-bit pan space; $01 = full left, $1F = centre-left, $20 = centre-right, $3F = full right). For 8-bit precision use `S $80xx` instead. +- **`1.$xx` — Pan slide right** by `$xx` per non-first tick. +- **`2.$xx` — Pan slide left** by `$xx` per non-first tick. +- **`3.$Sx` — Fine pan slide** on tick 0 only, same direction-bit encoding as the volume column's selector 3. - -Additional selectors are reserved for future expansion. +NOTE: **`3.00` — is No-op** --- diff --git a/assets/disk0/tracker_play.js b/assets/disk0/tracker_play.js index 1e09a0c..d518522 100644 --- a/assets/disk0/tracker_play.js +++ b/assets/disk0/tracker_play.js @@ -10,6 +10,10 @@ const PLAYHEAD = 0 println("Playing "+fullFilePath.full) +audio.resetParams(PLAYHEAD) +audio.purgeQueue(PLAYHEAD) +audio.stop(PLAYHEAD) + taud.uploadTaudFile(fullFilePath.full, 0, PLAYHEAD) audio.setMasterVolume(PLAYHEAD, 255) audio.setMasterPan(PLAYHEAD, 128) diff --git a/s3m2taud.py b/s3m2taud.py index 3615087..068505f 100644 --- a/s3m2taud.py +++ b/s3m2taud.py @@ -5,18 +5,20 @@ Usage: python3 s3m2taud.py input.s3m output.taud [-v] Limits: - - Up to 15 S3M channels (excess disabled; hard error if pattern count - × channel count > 256). + - Up to 20 S3M channels (excess disabled; hard error if pattern count + × channel count > 4095). - Sample bin is 770048 bytes; if all samples together exceed this, every sample is globally resampled down (with c2spd adjusted) so pitch is preserved. - AdLib instruments are skipped. - - Effects mapped: D (vol-slide), E/F (pitch slide, rough approx), - SC (note-cut), A (initial speed), T (initial BPM). Others dropped. -Pitch-slide approximation: - Amiga-period mode: taud_arg ≈ s3m_arg * 2 (mid-register heuristic) - Linear-slide mode: taud_arg = s3m_arg * 4 (exact) +Effect support: + Full A..Z dispatch per TAUD_NOTE_EFFECTS.md "ProTracker to Taud conversion + table" and "ScreamTracker 3 conversion notes". ST3 shared-memory recalls + (D/E/F/I/J/K/L/Q/R/S with $00 arg) are eagerly resolved per channel. + Cxx is BCD-decoded. K/L are split into H $0000 / G $0000 + volume-column + slide. M/N/X/P fold into volume / pan columns. W (global vol slide) and + Y (panbrello) are dropped with a -v warning. """ import argparse @@ -93,6 +95,44 @@ NOTE_KEYOFF = 0x0000 NOTE_CUT = 0xFFFE TAUD_C3 = 0x4000 +# Taud effect opcode bytes (base-36: 0..9 → 0x00..0x09, A..Z → 0x0A..0x23) +TOP_NONE = 0x00 +TOP_A = 0x0A # set tick speed +TOP_B = 0x0B # jump to order +TOP_C = 0x0C # break to row +TOP_D = 0x0D # volume slide +TOP_E = 0x0E # pitch slide down +TOP_F = 0x0F # pitch slide up +TOP_G = 0x10 # tone porta +TOP_H = 0x11 # vibrato +TOP_I = 0x12 # tremor +TOP_J = 0x13 # microtonal arpeggio +TOP_K = 0x14 # vibrato + vol slide (engine no-op; converter splits) +TOP_L = 0x15 # tone porta + vol slide (engine no-op; converter splits) +TOP_O = 0x18 # sample offset +TOP_Q = 0x1A # retrigger +TOP_R = 0x1B # tremolo +TOP_S = 0x1C # sub-effects +TOP_T = 0x1D # tempo set/slide +TOP_U = 0x1E # fine vibrato +TOP_V = 0x1F # global volume + +# Volume / pan column selectors (2-bit field, packed into top of vol/pan byte). +SEL_SET = 0 # 6-bit value: set vol / pan +SEL_UP = 1 # 6-bit per-tick slide up / right +SEL_DOWN = 2 # 6-bit per-tick slide down / left +SEL_FINE = 3 # 1-bit dir + 5-bit magnitude, fired on tick 0 + +# 12-TET semitone → Taud J-arpeggio byte (high byte of pitch delta). +# byte = round(semitone * 4096 / 12 / 256) = round(semitone * 4 / 3). +J_SEMI_TABLE = [0x00, 0x01, 0x03, 0x04, 0x05, 0x07, 0x08, 0x09, + 0x0B, 0x0C, 0x0D, 0x0F, 0x10, 0x11, 0x13, 0x14] + +# ST3's single shared memory slot backs these effects. +ST3_SHARED_EFFECTS = frozenset({ + EFF_D, EFF_E, EFF_F, EFF_I, EFF_J, EFF_K, EFF_L, EFF_Q, EFF_R, EFF_S +}) + # ── S3M parser ─────────────────────────────────────────────────────────────── @@ -296,31 +336,202 @@ def encode_note(s3m_note: int) -> int: return max(1, min(0xFFFD, val)) -def encode_effect(cmd: int, arg: int, linear: bool) -> tuple: - """Return (taud_op, taud_arg16) or (0, 0) for no-op.""" +def _d_arg_to_col(arg: int): + """Convert an ST3 D-style two-nibble vol/pan slide arg into a column override. + + Returns (selector, value) or None for no-op. Volume column treats + selector 1 as up / 2 as down; pan column reuses 1 = right, 2 = left. + """ + if arg == 0: + return None + hi = (arg >> 4) & 0xF + lo = arg & 0xF + if hi == 0xF and lo > 0: + return (SEL_FINE, lo & 0x1F) # fine slide down (dir bit 0) + if lo == 0xF and hi > 0: + return (SEL_FINE, (hi & 0x1F) | 0x20) # fine slide up (dir bit 1) + if hi > 0 and lo == 0: + return (SEL_UP, hi) + if lo > 0 and hi == 0: + return (SEL_DOWN, lo) + # Both nibbles non-zero, neither $F → ambiguous; ST3 prefers up. + return (SEL_UP, hi) + + +def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple: + """Return (taud_op, taud_arg16, vol_override, pan_override). + + vol/pan_override is None or (selector, value). The caller is responsible + for resolving ST3 zero-arg recalls before this point — see + resolve_st3_recalls(). + """ + if cmd == 0: + return (TOP_NONE, 0, None, None) + + if cmd == EFF_A: + if arg == 0: + return (TOP_NONE, 0, None, None) + return (TOP_A, (arg & 0xFF) << 8, None, None) + + if cmd == EFF_B: + return (TOP_B, arg & 0xFF, None, None) + + if cmd == EFF_C: + # ST3 stores break-row as BCD: $10 means decimal 10. + bcd_row = ((arg >> 4) & 0xF) * 10 + (arg & 0xF) + if bcd_row >= PATTERN_ROWS: + bcd_row = 0 + return (TOP_C, bcd_row & 0xFF, None, None) + if cmd == EFF_D: - # Volume slide: same nibble layout - return (0x0A, arg & 0xFF) - if cmd == EFF_E: - # Porta down - if linear: - targ = min(arg * 4, 0xFFFF) - else: - targ = min(arg * 2, 0xFFFF) - return (0x02, targ) - if cmd == EFF_F: - # Porta up - if linear: - targ = min(arg * 4, 0xFFFF) - else: - targ = min(arg * 2, 0xFFFF) - return (0x01, targ) + # D-style four-form arg passed through verbatim in the high byte. + return (TOP_D, (arg & 0xFF) << 8, None, None) + + if cmd in (EFF_E, EFF_F): + # ST3 slide unit = 1/16 semitone = $0015 Taud units (per spec PT table). + op = TOP_E if cmd == EFF_E else TOP_F + hi = (arg >> 4) & 0xF + lo = arg & 0xF + if hi == 0xF and lo > 0: + return (op, 0xF000 | ((lo * 0x15) & 0xFFF), None, None) + if hi == 0xE and lo > 0: + return (op, 0xF000 | ((lo * 0x05) & 0xFFF), None, None) + return (op, (arg * 0x15) & 0xFFFF, None, None) + + if cmd == EFF_G: + return (TOP_G, (arg * 0x15) & 0xFFFF, None, None) + + if cmd in (EFF_H, EFF_I, EFF_R, EFF_U): + op = {EFF_H: TOP_H, EFF_I: TOP_I, EFF_R: TOP_R, EFF_U: TOP_U}[cmd] + hi = (arg >> 4) & 0xF + lo = arg & 0xF + return (op, ((hi * 0x11) << 8) | (lo * 0x11), None, None) + + if cmd == EFF_J: + hi_semi = (arg >> 4) & 0xF + lo_semi = arg & 0xF + return (TOP_J, (J_SEMI_TABLE[hi_semi] << 8) | J_SEMI_TABLE[lo_semi], + None, None) + + if cmd == EFF_K: + # K = vibrato continuation + vol slide; engine treats K as no-op. + # Split into: H $0000 (recall vibrato from HU memory) + vol-col slide. + return (TOP_H, 0x0000, _d_arg_to_col(arg), None) + + if cmd == EFF_L: + # L = tone-porta continuation + vol slide; split similarly. + return (TOP_G, 0x0000, _d_arg_to_col(arg), None) + + if cmd == EFF_M: + return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None) + + if cmd == EFF_N: + return (TOP_NONE, 0, _d_arg_to_col(arg), None) + + if cmd == EFF_O: + return (TOP_O, (arg & 0xFF) << 8, None, None) + + if cmd == EFF_P: + return (TOP_NONE, 0, None, _d_arg_to_col(arg)) + + if cmd == EFF_Q: + return (TOP_Q, (arg & 0xFF) << 8, None, None) + if cmd == EFF_S: sub = (arg >> 4) & 0xF val = arg & 0xF - if sub == 0xC: # SC - note cut - return (0xEC, val) - return (0x00, 0x0000) + if sub in (0x1, 0x2, 0x3, 0x4, 0xB, 0xC, 0xD, 0xE, 0xF): + return (TOP_S, (sub << 12) | (val << 8), None, None) + if sub == 0x8: + # Coarse pan: nibble-repeat into Taud's S $80xx full-8-bit pan. + return (TOP_S, 0x8000 | (val * 0x11), None, None) + # S0/S5/S6/S7/S9/SA: filter, NNA, sound-control, stereo — drop silently. + return (TOP_NONE, 0, None, None) + + if cmd == EFF_T: + if arg >= 0x20: + return (TOP_T, ((arg - 0x18) & 0xFF) << 8, None, None) + # OpenMPT slide forms: $0y down per tick, $1y up per tick. + return (TOP_T, arg & 0xFF, None, None) + + if cmd == EFF_V: + return (TOP_V, (min(arg * 4, 0xFF) & 0xFF) << 8, None, None) + + if cmd == EFF_W: + vprint(f" dropped W{arg:02X} (global vol slide) at ch{ch} row{row}") + return (TOP_NONE, 0, None, None) + + if cmd == EFF_X: + return (TOP_NONE, 0, None, (SEL_SET, min(arg >> 2, 0x3F))) + + if cmd == EFF_Y: + vprint(f" dropped Y{arg:02X} (panbrello) at ch{ch} row{row}") + return (TOP_NONE, 0, None, None) + + if cmd == EFF_Z: + return (TOP_NONE, 0, None, None) + + return (TOP_NONE, 0, None, None) + + +def resolve_st3_recalls(patterns: list, order_list: list, num_channels: int) -> None: + """In-place: replace ST3 zero-arg recalls with the last non-zero arg. + + ST3 backs D/E/F/I/J/K/L/Q/R/S with a single per-channel memory slot. + Taud's narrower cohort model can't recover this, so we eagerly resolve + by walking patterns in order-list order and rewriting recall args. + + Limitation: patterns reused across multiple order entries are mutated + once (with the memory state from their first visit); subsequent visits + may differ from ST3 if cross-pattern memory state changed in between. + """ + last_arg = [0] * num_channels + for order in order_list: + if order >= S3M_ORDER_END: + break + if order >= len(patterns): + continue + grid = patterns[order] + for r in range(PATTERN_ROWS): + for ch in range(num_channels): + if ch >= len(grid): + continue + row = grid[ch][r] + if row.effect in ST3_SHARED_EFFECTS: + if row.effect_arg == 0: + row.effect_arg = last_arg[ch] + else: + last_arg[ch] = row.effect_arg + + +def warn_st3_quirks(patterns: list, order_list: list, num_channels: int) -> None: + """Emit -v warnings for ST3 quirks Taud handles differently.""" + seen_pats = set() + for order in order_list: + if order >= S3M_ORDER_END: + break + if order >= len(patterns) or order in seen_pats: + continue + seen_pats.add(order) + grid = patterns[order] + for ch in range(min(num_channels, len(grid))): + saw_sbx = saw_sex = False + for r in range(PATTERN_ROWS): + row = grid[ch][r] + if row.effect == EFF_S: + sub = (row.effect_arg >> 4) & 0xF + if sub == 0xB: saw_sbx = True + elif sub == 0xE: saw_sex = True + if saw_sbx and saw_sex: + vprint(f" warning: pattern {order} ch{ch} mixes SBx and SEx " + f"(Taud fixes the ST3 loop-counter bug; loop count may differ)") + for r in range(PATTERN_ROWS): + sex_channels = [ch for ch in range(min(num_channels, len(grid))) + if grid[ch][r].effect == EFF_S + and ((grid[ch][r].effect_arg >> 4) & 0xF) == 0xE] + if len(sex_channels) > 1: + vprint(f" warning: pattern {order} row {r} SEx on multiple " + f"channels {sex_channels} (Taud uses ascending channel order)") # ── Taud builders ──────────────────────────────────────────────────────────── @@ -384,6 +595,7 @@ def build_sample_inst_bin(instruments: list) -> tuple: # Build instrument bin (256 × 64 bytes) inst_bin = bytearray(INSTBIN_SIZE) for i, inst in enumerate(instruments): + taud_idx = i + 1 if i >= 256: break if inst is None or inst.itype != S3M_TYPE_PCM: @@ -399,7 +611,7 @@ def build_sample_inst_bin(instruments: list) -> tuple: loop_mode = 1 if (inst.flags & 1) else 0 flags_byte = (ptr_hi << 4) | (loop_mode & 0x3) # hhhh 00pp - base = i * 64 + base = taud_idx * 64 struct.pack_into(' tuple: inst_bin[base + 17] = 0 # offset minifloat = 0 → hold - vprint(f" instrument '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'") + vprint(f" instrument[{base // 64}] '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'") if inst.c2spd > 65535: vprint(f" warning: sampling rate of '{inst.name}' exceeds 65535 (got '{inst.c2spd}')") @@ -433,24 +645,66 @@ def _default_channel_pan(ch_setting: int) -> int: def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int, - linear_slides: bool) -> bytes: - """Build a 512-byte Taud pattern for one S3M channel.""" + linear_slides: bool, inst_vols: dict = None) -> bytes: + """Build a 512-byte Taud pattern for one S3M channel. + + Volume column: explicit S3M cell vol → SEL_SET; when a note triggers + with no explicit vol, emit SEL_SET using the instrument's default volume + (looked up from inst_vols, a 1-based inst index → 0..63 volume dict). + M/N/K/L overrides apply only when the cell has no explicit vol and no + note trigger. Otherwise SEL_FINE/0 (no-op). + Pan column: row 0 emits SEL_SET = default_pan to position the channel; + other rows default to SEL_FINE/0 unless an X/P/etc effect overrides. + """ + if inst_vols is None: + inst_vols = {} out = bytearray(PATTERN_BYTES) rows = s3m_grid[ch_idx] if ch_idx < len(s3m_grid) else [S3MRow()] * PATTERN_ROWS + last_inst = 0 # 1-based; tracks which instrument is loaded on this channel for r, row in enumerate(rows[:PATTERN_ROWS]): - note = encode_note(row.note) - inst = max(0, row.inst - 1) # S3M 1-based → Taud 0-based - vol = min(row.vol, 63) if row.vol >= 0 else 63 - pan = default_pan - op, arg = encode_effect(row.effect, row.effect_arg, linear_slides) - if row.effect != 0 and op == 0: - eff_name = chr(ord('A') + row.effect - 1) if 1 <= row.effect <= 26 else '?' - vprint(f" dropped effect {eff_name}{row.effect_arg:02X} at ch{ch_idx} row{r}") + note = encode_note(row.note) + inst = row.inst # S3M 1-based → Taud 1-based + + if row.inst > 0: + last_inst = row.inst + + op, arg, vol_override, pan_override = encode_effect( + row.effect, row.effect_arg, ch_idx, r) + + # ── Volume column ── + note_triggers = (row.note not in (S3M_NOTE_EMPTY, S3M_NOTE_OFF)) + if row.vol >= 0: + vol_sel, vol_value = SEL_SET, min(row.vol, 0x3F) + if vol_override is not None and vol_override[0] != SEL_SET: + vprint(f" ch{ch_idx} row{r}: dropped vol slide " + f"(cell already carries explicit volume)") + elif note_triggers and last_inst > 0: + # Note trigger with no explicit vol: use instrument default volume + # so prior channel-vol state doesn't bleed through. + vol_sel = SEL_SET + vol_value = inst_vols.get(last_inst, 0x3F) + elif vol_override is not None: + vol_sel, vol_value = vol_override + else: + vol_sel, vol_value = SEL_FINE, 0 # no-op fine slide + + # ── Pan column ── + if pan_override is not None: + pan_sel, pan_value = pan_override + elif r == 0: + # Position channel to its default pan once per pattern (row 0). + pan_sel, pan_value = SEL_SET, default_pan & 0x3F + else: + pan_sel, pan_value = SEL_FINE, 0 + + vol_byte = (vol_value & 0x3F) | ((vol_sel & 0x3) << 6) + pan_byte = (pan_value & 0x3F) | ((pan_sel & 0x3) << 6) + base = r * 8 struct.pack_into('= NUM_CUES: break @@ -509,11 +764,16 @@ def build_cue_sheet(order_list: list, num_pats_s3m: int, num_channels: int, orig = [order * num_channels + v for v in range(num_channels)] pats = [pat_remap[p] if pat_remap else p for p in orig] sheet[cue_idx*CUE_SIZE : cue_idx*CUE_SIZE+CUE_SIZE] = _encode_cue(pats, 0) + last_active = cue_idx cue_idx += 1 - # Halt at end - if cue_idx < NUM_CUES: - sheet[cue_idx*CUE_SIZE : cue_idx*CUE_SIZE+CUE_SIZE] = _encode_cue([], 0x01) + # Halt on the last active cue (instruction byte at offset 30), so the + # engine stops immediately after that pattern completes with no silent gap. + if last_active >= 0: + sheet[last_active * CUE_SIZE + 30] = 0x01 + elif cue_idx < NUM_CUES: + # Edge case: no active cues at all — halt at cue 0. + sheet[30] = 0x01 return bytes(sheet) @@ -555,6 +815,13 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes: vprint(f" channels: {C}, s3m patterns: {P}, taud patterns: {P*C}") + # Resolve ST3 shared-memory recalls (D/E/F/I/J/K/L/Q/R/S with $00 arg) + # before any per-row encoding, so cohort-aware Taud effects see explicit + # arguments. Mutates patterns in place. + vprint(" resolving ST3 shared-memory recalls…") + resolve_st3_recalls(patterns, h.order_list, 32) + warn_st3_quirks(patterns, h.order_list, 32) + # Build sample+instrument bin vprint(" building sample/instrument bin…") sampleinst_raw, _offsets = build_sample_inst_bin(instruments) @@ -590,11 +857,17 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes: # Pattern bin: for each s3m pattern, for each active channel, 512 bytes vprint(" building pattern bin…") default_pans = [_default_channel_pan(h.channel_settings[ch]) for ch in active_channels] + # 1-based inst index → default volume (0..63) for note-trigger vol injection. + inst_vols = { + i + 1: min(inst.volume, 0x3F) + for i, inst in enumerate(instruments) + if inst is not None and inst.itype == S3M_TYPE_PCM + } pat_bin = bytearray() for pi in range(P): grid = patterns[pi] for vi, ch in enumerate(active_channels): - pat_bin += build_pattern(grid, ch, default_pans[vi], h.linear_slides) + pat_bin += build_pattern(grid, ch, default_pans[vi], h.linear_slides, inst_vols) assert len(pat_bin) == num_taud_pats * PATTERN_BYTES # Deduplicate identical patterns diff --git a/terranmon.txt b/terranmon.txt index fd0769f..fb5d3ab 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -1986,7 +1986,7 @@ Synchronisation between playheads are not guaranteed. Do not play music in multi Memory Space 0..770047 RW: Sample bin (752k) -770048..786431 RW: Instrument bin (256 instruments, 64 bytes each; 16k) +770048..786431 RW: Instrument bin (256 instruments, 64 bytes each; instrument 0 does nothing; 16k) 786432..851967 RW: Play data 1 (currently exposed bank; 64k) 851968..917503 RW: Play data 2 (currently exposed bank; 64k) 917504..983039 RW: TAD Input Buffer (64k) @@ -2022,6 +2022,8 @@ note 0xFFFF: no-op note 0xFFFE: note cut note 0x0000: key-off +inst 0: no instrument change + Sound Adapter MMIO @@ -2211,6 +2213,72 @@ Taud device can queue up to 2 "playdata" in its buffer, which can be interpreted -------------------------------------------------------------------------------- +S3M (ScreamTracker 3) to Taud conversion notes +(Implemented in s3m2taud.py) + +## Instrument indexing + +S3M instrument numbers are 1-based on disk and in pattern cells. Taud's cell instrument byte preserves this: 0 means "no instrument change, reuse whatever was last loaded on this channel"; 1..255 select an instrument slot. The converter passes the raw S3M instrument byte through unchanged (no subtract-1). The instrument bin is written at base = instrument_index * 64, with slot 0 left as an empty/silent entry. + +## Effect encoding + +Taud opcodes are base-36 digit values: digits 0..9 map to bytes 0x00..0x09; letters A..Z map to bytes 0x0A..0x23. Effects are encoded into a 1-byte opcode plus a 2-byte argument. + +## ST3 shared-memory recall (pre-pass) + +ST3 backs effects D, E, F, I, J, K, L, Q, R, and S with a single per-channel memory slot. A $00 argument on any of these recalls the last non-zero argument. Taud uses narrower per-cohort memory, so the converter walks patterns in order-list order (per channel) and replaces every $00-arg recall with the current slot value before encoding. Patterns reused by multiple order entries are mutated once on their first visit; later visits may diverge from the ST3 original if cross-pattern memory state changed, but this is acceptable for typical usage. + +## Cxx BCD decode + +ST3 stores pattern-break row numbers as BCD on disk ($10 means decimal row 10, not hex row 16). The converter decodes: row = (byte >> 4) * 10 + (byte & 0xF). Values that decode to 64 or above clamp to row 0. + +## Pitch slide unit + +ST3's coarse slide unit is 1/16 of a semitone. One semitone in Taud's 4096-TET grid is 4096/12 ≈ 341.33 units. One 1/16 semitone ≈ 21.33 units ≈ $0015. All E/F/G coarse arguments are therefore multiplied by $0015. Fine slide forms ($Fx, $Ex) are packed into Taud's $F0xx fine form after the same per-step scale. + +## J arpeggio (12-TET to 4096-TET) + +ST3 Jxy nibbles are 12-TET semitone offsets (0..15). Taud's J argument uses the high byte of a 16-bit pitch delta; one byte = 256 units ≈ 0.75 semitones. + +Conversion: byte = round(semitones * 4 / 3). + +The full lookup table: + + Semitones 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 + Taud byte $00 $01 $03 $04 $05 $07 $08 $09 $0B $0C $0D $0F $10 $11 $13 $14 + +## K and L effects + +The engine treats K and L as no-ops. The converter splits each into two parts: +K → effect column H $0000 (recall vibrato from HU memory) plus a volume-column slide derived from K's argument +L → effect column G $0000 plus the same volume-column slide. If the S3M cell already carries an explicit volume-column byte, the slide half is dropped with a -v warning. + +## M, N (channel volume), X, P (pan) folding + +M (set channel volume) and N (channel-vol slide) fold into the volume column. X (set pan) and P (pan slide) fold into the pan column. These effects consume no space in the effect slot. W (global vol slide) and Y (panbrello) are dropped with a -v warning. + +## Volume column defaults + +When a note trigger is present in a cell with no explicit S3M volume byte, the converter emits SEL_SET (selector 0) with the instrument's default volume. This prevents the channel's prior volume state from persisting into a fresh note. Cells with no note trigger and no explicit volume emit SEL_FINE value 0 (fine slide of 0 = no-op), which leaves channel volume unchanged. + +## Pan column defaults + +Row 0 of every pattern emits SEL_SET with the channel's default pan (derived from the S3M channel-setting byte: channels 0-7 → left ($10), channels 8-15 → right ($2F), otherwise centre ($1F)). All other rows emit SEL_FINE value 0 (no-op) unless an X, P, or S$8x effect overrides. + +## Cue sheet halt placement + +The halt instruction (byte value 0x01 at cue offset 30) is placed on the last active cue entry, not in a separate empty cue appended after it. This ensures playback stops immediately after the last pattern row completes, with no silent 64-row gap. + +## Tempo mapping + +S3M BPM is stored as a raw decimal value. Taud's initial BPM byte uses a bias of -24 (byte 0x00 = 24 BPM, 0xFF = 279 BPM). Conversion: taud_byte = bpm - 24. The converter also scans row 0 of the first pattern in the order list for A (set speed) and T (set tempo) effects and uses those values in preference to the S3M header defaults. + +## Global volume + +ST3 global volume is 0..$40; Taud's is 0..$FF. Import scale: Taud_vol = ST3_vol × 4 (clamped to $FF). + +-------------------------------------------------------------------------------- + RomBank / RamBank Endianness: Little diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index 3abbf4b..8629d79 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -125,7 +125,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } internal val sampleBin = UnsafeHelper.allocate(770048L, this) - internal val instruments = Array(256) { TaudInst() } + internal val instruments = Array(256) { TaudInst(it) } internal val playdata = Array(4096) { Array(64) { TaudPlayData(0xFFFF, 0, 0, 0, 32, 0, 0, 0) } } internal val playheads: Array internal val cueSheet = Array(1024) { PlayCue() } @@ -1080,14 +1080,80 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { //========================================================================= // Tracker Engine // - // Effect codes (non-canonical MVP; spec is silent on values): - // 0x00: no effect - // 0x01 arg: pitch slide up, arg = 4096-TET units per tick - // 0x02 arg: pitch slide down, arg = 4096-TET units per tick - // 0x0A arg: volume slide, high nibble = up/tick, low nibble = down/tick - // 0xEC arg: note cut at tick (arg & 0xFF) + // Effect opcodes follow base-36 digit values (see TAUD_NOTE_EFFECTS.md): + // 0x00 : no effect + // 0x0A..0x23 : letters A..Z (A=0x0A speed, B=0x0B order jump, + // C=0x0C pattern break, D=0x0D vol slide, E=0x0E pitch + // down, F=0x0F pitch up, G=0x10 tone porta, + // H=0x11 vibrato, I=0x12 tremor, J=0x13 arpeggio, + // K=0x14 K, L=0x15 L, O=0x18 sample offset, + // Q=0x1A retrig, R=0x1B tremolo, S=0x1C subcommands, + // T=0x1D tempo, U=0x1E fine vibrato, V=0x1F global vol). + // K (0x14) and L (0x15) are intentionally no-op in the engine — the + // converter is required to split them into a recall-only H/G plus a + // volume-column slide cell. //========================================================================= + // 64-entry signed sine table (OpenMPT-style). See TAUD_NOTE_EFFECTS.md §H. + private val MOD_SIN_TABLE = intArrayOf( + 0x00, 0x0C, 0x19, 0x25, 0x31, 0x3C, 0x47, 0x51, + 0x5A, 0x62, 0x6A, 0x70, 0x75, 0x7A, 0x7D, 0x7E, + 0x7F, 0x7E, 0x7D, 0x7A, 0x75, 0x70, 0x6A, 0x62, + 0x5A, 0x51, 0x47, 0x3C, 0x31, 0x25, 0x19, 0x0C, + 0x00, -0x0C, -0x19, -0x25, -0x31, -0x3C, -0x47, -0x51, + -0x5A, -0x62, -0x6A, -0x70, -0x75, -0x7A, -0x7D, -0x7E, + -0x7F, -0x7E, -0x7D, -0x7A, -0x75, -0x70, -0x6A, -0x62, + -0x5A, -0x51, -0x47, -0x3C, -0x31, -0x25, -0x19, -0x0C + ) + + // Funk repeat advance table (S $Fx00). See TAUD_NOTE_EFFECTS.md §S$Fx. + private val FUNK_TABLE = intArrayOf( + 0, 5, 6, 7, 8, 0xA, 0xB, 0xD, 0x10, 0x13, 0x16, 0x1A, 0x20, 0x2B, 0x40, 0x80 + ) + + // ST3-style fine-tune Hz reference offsets in 4096-TET units (S $2x00). + private val FINETUNE_OFFSET = intArrayOf( + -0x0154, -0x0132, -0x0111, -0x00E4, -0x00B8, -0x008B, -0x005D, -0x003B, + 0x0000, 0x0023, 0x0046, 0x0074, 0x0098, 0x00C8, 0x00F9, 0x0110 + ) + + // LFO sample for vibrato/tremolo waveforms; pos is the 8-bit phase accumulator. + // See TAUD_NOTE_EFFECTS.md §S$3x for shape semantics. + private fun lfoSample(pos: Int, wave: Int): Int { + val idx = (pos ushr 2) and 0x3F + return when (wave and 3) { + 0 -> MOD_SIN_TABLE[idx] // sine + 1 -> 0x7F - (idx shl 2) // ramp down + 2 -> if (idx < 32) 0x7F else -0x7F // square + else -> ((Math.random() * 256).toInt() and 0xFF) - 0x80 // random + } + } + + // Effect opcode constants (base-36 digit values). + // Letters A..Z map to 0x0A..0x23 (digit value 10..35). + private object EffectOp { + const val OP_NONE = 0x00 + const val OP_A = 0x0A + const val OP_B = 0x0B + const val OP_C = 0x0C + const val OP_D = 0x0D + const val OP_E = 0x0E + const val OP_F = 0x0F + const val OP_G = 0x10 + const val OP_H = 0x11 + const val OP_I = 0x12 + const val OP_J = 0x13 + const val OP_K = 0x14 + const val OP_L = 0x15 + const val OP_O = 0x18 + const val OP_Q = 0x1A + const val OP_R = 0x1B + const val OP_S = 0x1C + const val OP_T = 0x1D + const val OP_U = 0x1E + const val OP_V = 0x1F + } + private fun computePlaybackRate(inst: TaudInst, noteVal: Int): Double = inst.samplingRate.toDouble() / SAMPLING_RATE * 2.0.pow((noteVal - TRACKER_C3) / 4096.0) @@ -1114,6 +1180,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } private fun fetchTrackerSample(voice: Voice, inst: TaudInst): Double { + if (inst.index == 0) return 0.0 + val basePtr = inst.samplePtr val sampleLen = inst.sampleLength.coerceAtLeast(1) val loopStart = inst.sampleLoopStart.toDouble() @@ -1123,8 +1191,17 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1) val i1 = (i0 + 1).coerceAtMost(sampleLen - 1) val frac = voice.samplePos - i0.toDouble() - val s0 = (sampleBin[(basePtr + i0).coerceAtMost(binMax).toLong()].toUint() - 128) / 128.0 - val s1 = (sampleBin[(basePtr + i1).coerceAtMost(binMax).toLong()].toUint() - 128) / 128.0 + var b0 = sampleBin[(basePtr + i0).coerceAtMost(binMax).toLong()].toUint() + var b1 = sampleBin[(basePtr + i1).coerceAtMost(binMax).toLong()].toUint() + // S$Fx funk repeat: XOR the high bit of bytes whose loop-relative index + // is set in funkMask. Only meaningful when the sample has a loop region. + if (inst.funkMask != null && inst.sampleLoopEnd > inst.sampleLoopStart) { + val ls = inst.sampleLoopStart + if (i0 in ls until inst.sampleLoopEnd && inst.funkBit(i0 - ls)) b0 = b0 xor 0x80 + if (i1 in ls until inst.sampleLoopEnd && inst.funkBit(i1 - ls)) b1 = b1 xor 0x80 + } + val s0 = (b0 - 128) / 128.0 + val s1 = (b1 - 128) / 128.0 val sample = s0 + (s1 - s0) * frac if (voice.forward) { @@ -1142,8 +1219,71 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { return sample } + /** + * Trigger a fresh note on [voice]: load the instrument, reset sample position, kick off the envelope. + * Pulled out so S$Dx (note delay) can defer the same logic to a later tick. + */ + private fun triggerNote(voice: Voice, noteVal: Int, instId: Int, volOverride: Int) { + if (instId != 0) voice.instrumentId = instId + val inst = instruments[voice.instrumentId] + voice.samplePos = inst.samplePlayStart.toDouble() + voice.forward = true + voice.active = true + voice.envIndex = 0 + voice.envTimeSec = 0.0 + voice.envVolume = inst.envelopes[0].volume / 255.0 + voice.noteVal = noteVal + voice.basePitch = noteVal + voice.playbackRate = computePlaybackRate(inst, noteVal) + if (volOverride >= 0) { + voice.channelVolume = volOverride.coerceIn(0, 0x3F) + } + voice.rowVolume = voice.channelVolume + voice.noteWasCut = false + // Vibrato/tremolo retrigger: reset LFO position when waveform requests it. + if (voice.vibratoRetrig) voice.vibratoLfoPos = 0 + if (voice.tremoloRetrig) voice.tremoloLfoPos = 0 + } + + private fun applyVolColumn(voice: Voice, value: Int, sel: Int) { + // value is the 6-bit cell field; sel is the 2-bit selector. See TAUD_NOTE_EFFECTS.md + // §"Volume column effects" for the multi-selector encoding. + when (sel) { + 0 -> { voice.channelVolume = value.coerceIn(0, 0x3F); voice.rowVolume = voice.channelVolume } + 1 -> voice.volColSlideUp = value + 2 -> voice.volColSlideDown = value + 3 -> { + if (value == 0) return + + val mag = value and 0x1F + voice.rowVolume = if ((value and 0x20) != 0) (voice.rowVolume + mag).coerceAtMost(0x3F) + else (voice.rowVolume - mag).coerceAtLeast(0) + voice.channelVolume = voice.rowVolume + } + } + } + + private fun applyPanColumn(voice: Voice, value: Int, sel: Int) { + when (sel) { + 0 -> { voice.channelPan = (value shl 2) or (value ushr 4); voice.rowPan = (voice.channelPan shr 2).coerceIn(0, 63) } + 1 -> voice.panColSlideRight = value + 2 -> voice.panColSlideLeft = value + 3 -> { + if (value == 0) return + + val mag = value and 0x1F + voice.channelPan = if ((value and 0x20) != 0) (voice.channelPan + mag).coerceAtMost(0xFF) + else (voice.channelPan - mag).coerceAtLeast(0) + voice.rowPan = (voice.channelPan shr 2).coerceIn(0, 63) + } + } + } + private fun applyTrackerRow(ts: TrackerState, playhead: Playhead) { val cue = cueSheet[ts.cuePos] + // Reset row-scope state before scanning channels. + if (!ts.patternDelayActive) ts.sexWinningChannel = -1 + for (vi in 0..19) { val patNum = cue.patterns[vi] if (patNum == 0xFFF) continue @@ -1151,56 +1291,384 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { val row = playdata[patIdx][ts.rowIndex] val voice = ts.voices[vi] - voice.rowVolume = row.volume - voice.rowPan = row.pan + // Reset per-row transient state. voice.cutAtTick = -1 - voice.pitchSlideAmount = 0.0 - voice.volSlidePerTick = 0 - - when (row.effect) { - 0x01 -> voice.pitchSlideAmount = row.effectArg.toDouble() - 0x02 -> voice.pitchSlideAmount = -row.effectArg.toDouble() - 0x0A -> { val a = row.effectArg and 0xFF; voice.volSlidePerTick = ((a ushr 4) and 0xF) - (a and 0xF) } - 0xEC -> voice.cutAtTick = row.effectArg and 0xFF - } + voice.noteDelayTick = -1 + voice.slideMode = 0 + voice.slideArg = 0 + voice.arpActive = false + voice.tremorOn = 0 + voice.vibratoActive = false + voice.tremoloActive = false + voice.retrigActive = false + voice.tonePortaTarget = -1 + voice.tempoSlideDir = 0 + voice.volColSlideUp = 0; voice.volColSlideDown = 0 + voice.panColSlideRight = 0; voice.panColSlideLeft = 0 + voice.rowEffect = row.effect + voice.rowEffectArg = row.effectArg + // ── Note ── + val toneG = (row.effect == EffectOp.OP_G) when (row.note) { - 0xFFFF -> {} // no-op: continue current note unchanged - 0x0000 -> voice.active = false // key-off (TODO: trigger envelope release phase) - 0xFFFE -> voice.active = false // note cut: immediate silence + 0xFFFF -> {} // no-op + 0x0000 -> voice.active = false // key-off (TODO release envelope) + 0xFFFE -> voice.active = false // note cut else -> { - val inst = instruments[row.instrment] - voice.instrumentId = row.instrment - voice.samplePos = inst.samplePlayStart.toDouble() - voice.forward = true - voice.active = true - voice.envIndex = 0 - voice.envTimeSec = 0.0 - voice.envVolume = inst.envelopes[0].volume / 255.0 - voice.noteVal = row.note - voice.playbackRate = computePlaybackRate(inst, row.note) + if (toneG && voice.active) { + // Tone porta: target the note, do not retrigger sample. + voice.tonePortaTarget = row.note + } else if ((row.effect == EffectOp.OP_S) && ((row.effectArg ushr 12) and 0xF) == 0xD) { + // Note delay: defer trigger to the requested tick. + voice.noteDelayTick = (row.effectArg ushr 8) and 0xF + voice.delayedNote = row.note + voice.delayedInst = row.instrment + voice.delayedVol = if (row.volume >= 0) row.volume else -1 + } else { + triggerNote(voice, row.note, row.instrment, -1) + } } } + + // ── Volume column (selectors per TAUD_NOTE_EFFECTS.md) ── + // The cell already separates value (volume) and selector (volumeEff). + applyVolColumn(voice, row.volume, row.volumeEff) + applyPanColumn(voice, row.pan, row.panEff) + + // ── Effect column ── + applyEffectRow(ts, playhead, voice, vi, row.effect, row.effectArg) + } + } + + /** Resolve a non-zero argument or recall from the cohort memory and return the effective arg. */ + private fun resolveArg(arg: Int, mem: Int): Int = if (arg != 0) arg else mem + + private fun applyEffectRow(ts: TrackerState, playhead: Playhead, voice: Voice, vi: Int, op: Int, rawArg: Int) { + when (op) { + EffectOp.OP_NONE -> {} + EffectOp.OP_A -> { + val tr = (rawArg ushr 8) and 0xFF + if (tr != 0) playhead.tickRate = tr + } + EffectOp.OP_B -> { + // Highest-priority B wins for the row (lowest channel index in spec); first-set wins by ascending channel scan. + if (ts.pendingOrderJump < 0) ts.pendingOrderJump = rawArg.coerceIn(0, 1023) + } + EffectOp.OP_C -> { + if (ts.pendingRowJump < 0) ts.pendingRowJump = rawArg.coerceIn(0, 63) + } + EffectOp.OP_D -> { + val arg = resolveArg(rawArg, voice.mem.d).also { if (rawArg != 0) voice.mem.d = it } + val hi = (arg ushr 8) and 0xFF + val lo = hi and 0x0F + val hin = (hi ushr 4) and 0x0F + when { + hi == 0xFF -> { voice.rowVolume = (voice.rowVolume + 0xF).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume } // DFF quirk: fine up by F + hin == 0xF && lo != 0 -> { voice.rowVolume = (voice.rowVolume - lo).coerceAtLeast(0); voice.channelVolume = voice.rowVolume } + lo == 0xF && hin != 0 -> { voice.rowVolume = (voice.rowVolume + hin).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume } + hin == 0 && lo != 0 -> { voice.slideMode = 5; voice.slideArg = -lo } // slide down per non-first tick + lo == 0 && hin != 0 -> { voice.slideMode = 5; voice.slideArg = hin } // slide up per non-first tick + } + } + EffectOp.OP_E -> { + val arg = resolveArg(rawArg, voice.mem.ef).also { if (rawArg != 0) voice.mem.ef = it } + if ((arg and 0xF000) == 0xF000) { + val mag = arg and 0x0FFF + voice.noteVal = (voice.noteVal - mag).coerceIn(0, 0xFFFE); voice.basePitch = voice.noteVal + voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal) + } else { + voice.slideMode = 1; voice.slideArg = -arg + } + } + EffectOp.OP_F -> { + val arg = resolveArg(rawArg, voice.mem.ef).also { if (rawArg != 0) voice.mem.ef = it } + if ((arg and 0xF000) == 0xF000) { + val mag = arg and 0x0FFF + voice.noteVal = (voice.noteVal + mag).coerceIn(0, 0xFFFE); voice.basePitch = voice.noteVal + voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal) + } else { + voice.slideMode = 2; voice.slideArg = arg + } + } + EffectOp.OP_G -> { + val arg = resolveArg(rawArg, voice.mem.g).also { if (rawArg != 0) voice.mem.g = it } + voice.tonePortaSpeed = arg + // tonePortaTarget was set in note-handling block above (or remains -1). + } + EffectOp.OP_H -> { + val sp = (rawArg ushr 8) and 0xFF + val dp = rawArg and 0xFF + if (sp != 0) voice.mem.huSpeed = sp + if (dp != 0) voice.mem.huDepth = dp + voice.vibratoActive = true + voice.vibratoFineShift = 6 + } + EffectOp.OP_I -> { + val arg = resolveArg(rawArg, voice.mem.i).also { if (rawArg != 0) voice.mem.i = it } + voice.tremorOn = 1 + voice.tremorOnTime = ((arg ushr 8) and 0xFF) + 1 + voice.tremorOffTime = (arg and 0xFF) + 1 + } + EffectOp.OP_J -> { + val arg = resolveArg(rawArg, voice.mem.j).also { if (rawArg != 0) voice.mem.j = it } + voice.arpActive = true + voice.arpOff1 = (arg ushr 8) and 0xFF + voice.arpOff2 = arg and 0xFF + } + EffectOp.OP_K, EffectOp.OP_L -> {} // engine no-op by design (converter splits them) + EffectOp.OP_O -> { + val arg = resolveArg(rawArg, voice.mem.o).also { if (rawArg != 0) voice.mem.o = it } + val inst = instruments[voice.instrumentId] + var off = arg + if (inst.loopMode != 0 && inst.sampleLoopEnd > inst.sampleLoopStart && off > inst.sampleLoopEnd) { + val loopLen = (inst.sampleLoopEnd - inst.sampleLoopStart).coerceAtLeast(1) + off = inst.sampleLoopStart + ((off - inst.sampleLoopStart) % loopLen) + } + voice.samplePos = off.toDouble() + } + EffectOp.OP_Q -> { + val arg = resolveArg(rawArg, voice.mem.q) + val y = arg and 0xFF + if (y != 0) { + voice.mem.q = arg + voice.retrigInterval = y + voice.retrigVolMod = (arg ushr 8) and 0xF + voice.retrigActive = true + // Counter persists across rows per spec. + } + // y == 0 → entire effect ignored, even memory (spec). + } + EffectOp.OP_R -> { + val sp = (rawArg ushr 8) and 0xFF + val dp = rawArg and 0xFF + if (sp != 0) voice.mem.rSpeed = sp + if (dp != 0) voice.mem.rDepth = dp + voice.tremoloActive = true + } + EffectOp.OP_S -> applySEffect(ts, voice, vi, rawArg) + EffectOp.OP_T -> { + val hi = (rawArg ushr 8) and 0xFF + if (hi != 0) { + val tempoByte = hi + playhead.bpm = (tempoByte + 0x18).coerceIn(24, 280) + } else { + val low = rawArg and 0xFF + when (low and 0xF0) { + 0x00 -> { voice.tempoSlideDir = -1; voice.tempoSlideAmount = low and 0x0F; voice.mem.tslide = low } + 0x10 -> { voice.tempoSlideDir = +1; voice.tempoSlideAmount = low and 0x0F; voice.mem.tslide = low } + } + } + } + EffectOp.OP_U -> { + val sp = (rawArg ushr 8) and 0xFF + val dp = rawArg and 0xFF + if (sp != 0) voice.mem.huSpeed = sp + if (dp != 0) voice.mem.huDepth = dp + voice.vibratoActive = true + voice.vibratoFineShift = 8 + } + EffectOp.OP_V -> { + val hi = (rawArg ushr 8) and 0xFF + playhead.globalVolume = hi + } + } + } + + private fun applySEffect(ts: TrackerState, voice: Voice, vi: Int, arg: Int) { + val sub = (arg ushr 12) and 0xF + val x = (arg ushr 8) and 0xF + when (sub) { + 0x1 -> voice.glissandoOn = (x != 0) + 0x2 -> { + voice.noteVal = (voice.noteVal + FINETUNE_OFFSET[x]).coerceIn(0, 0xFFFE) + voice.basePitch = voice.noteVal + voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal) + } + 0x3 -> { voice.vibratoWave = x and 3; voice.vibratoRetrig = (x and 4) == 0 } + 0x4 -> { voice.tremoloWave = x and 3; voice.tremoloRetrig = (x and 4) == 0 } + 0x8 -> { + // S$80xx — full 8-bit pan; arg low byte is the value. + voice.channelPan = arg and 0xFF + voice.rowPan = (voice.channelPan shr 2).coerceIn(0, 63) + } + 0xB -> { + if (x == 0) voice.loopStartRow = ts.rowIndex + else { + if (voice.loopCount == 0) { + voice.loopCount = x + ts.pendingRowJump = voice.loopStartRow + } else if (!ts.patternDelayActive) { + voice.loopCount-- + if (voice.loopCount > 0) ts.pendingRowJump = voice.loopStartRow + } + } + } + 0xC -> if (x != 0) voice.cutAtTick = x + 0xD -> {} // handled in note section above (note delay) + 0xE -> { + // Pattern delay — first SEx in ascending channel order wins. + if (ts.sexWinningChannel < 0) { + ts.sexWinningChannel = vi + ts.patternDelayRemaining = x + } + } + 0xF -> { voice.funkSpeed = x; if (x == 0) voice.funkAccumulator = 0 } } } private fun applyTrackerTick(ts: TrackerState, playhead: Playhead) { val tickSec = 2.5 / playhead.bpm for (voice in ts.voices) { - if (!voice.active) continue + if (!voice.active && voice.noteDelayTick < 0) continue val inst = instruments[voice.instrumentId] - if (voice.cutAtTick == ts.tickInRow) { voice.active = false; continue } - if (voice.pitchSlideAmount != 0.0) { - voice.noteVal = (voice.noteVal + voice.pitchSlideAmount).toInt().coerceIn(0, 0xFFFE) - voice.playbackRate = computePlaybackRate(inst, voice.noteVal) + + // Note cut. + if (voice.cutAtTick == ts.tickInRow) { + voice.rowVolume = 0; voice.channelVolume = 0 + voice.noteWasCut = true } - if (voice.volSlidePerTick != 0) { - voice.rowVolume = (voice.rowVolume + voice.volSlidePerTick).coerceIn(0, 63) + + // Note delay — fire deferred trigger when the requested tick arrives. + if (voice.noteDelayTick == ts.tickInRow) { + triggerNote(voice, voice.delayedNote, voice.delayedInst, voice.delayedVol) + voice.noteDelayTick = -1 } + + if (!voice.active) { advanceEnvelope(voice, inst, tickSec); continue } + + // Pitch slides (E/F coarse on tick > 0). + if (ts.tickInRow > 0 && (voice.slideMode == 1 || voice.slideMode == 2)) { + voice.noteVal = (voice.noteVal + voice.slideArg).coerceIn(0, 0xFFFE) + voice.basePitch = voice.noteVal + } + + // Tone portamento (G). + if (voice.tonePortaTarget >= 0 && ts.tickInRow > 0) { + val target = voice.tonePortaTarget + val sp = voice.tonePortaSpeed + val delta = if (target > voice.noteVal) sp else -sp + voice.noteVal += delta + if ((delta > 0 && voice.noteVal >= target) || (delta < 0 && voice.noteVal <= target)) { + voice.noteVal = target; voice.tonePortaTarget = -1 + } + voice.basePitch = voice.noteVal + } + + // Volume slides (D coarse on tick > 0). + if (ts.tickInRow > 0 && voice.slideMode == 5) { + voice.rowVolume = (voice.rowVolume + voice.slideArg).coerceIn(0, 0x3F) + voice.channelVolume = voice.rowVolume + } + + // Volume-column slides (selectors 1/2 — per non-first tick). + if (ts.tickInRow > 0) { + if (voice.volColSlideUp != 0) { + voice.rowVolume = (voice.rowVolume + voice.volColSlideUp).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume + } + if (voice.volColSlideDown != 0) { + voice.rowVolume = (voice.rowVolume - voice.volColSlideDown).coerceAtLeast(0); voice.channelVolume = voice.rowVolume + } + if (voice.panColSlideRight != 0) { + voice.channelPan = (voice.channelPan + voice.panColSlideRight).coerceAtMost(0xFF) + voice.rowPan = (voice.channelPan shr 2).coerceIn(0, 63) + } + if (voice.panColSlideLeft != 0) { + voice.channelPan = (voice.channelPan - voice.panColSlideLeft).coerceAtLeast(0) + voice.rowPan = (voice.channelPan shr 2).coerceIn(0, 63) + } + } + + // Tremor (I) — gates output volume. + if (voice.tremorOn != 0) { + voice.tremorTickInPhase++ + val limit = if (voice.tremorPhaseOn) voice.tremorOnTime else voice.tremorOffTime + if (voice.tremorTickInPhase >= limit) { voice.tremorTickInPhase = 0; voice.tremorPhaseOn = !voice.tremorPhaseOn } + if (!voice.tremorPhaseOn) voice.rowVolume = 0 + } + + // Vibrato (H/U) — applied as base-pitch overlay. + var pitchToMixer = voice.noteVal + if (voice.vibratoActive) { + val sine = lfoSample(voice.vibratoLfoPos, voice.vibratoWave) + val pitchDelta = (sine * voice.mem.huDepth) shr voice.vibratoFineShift + pitchToMixer = (voice.noteVal + pitchDelta).coerceIn(0, 0xFFFE) + voice.vibratoLfoPos = (voice.vibratoLfoPos + voice.mem.huSpeed * 4) and 0xFF + } + + // Glissando (S$1x) — snap pitchToMixer to nearest semitone but leave noteVal smooth. + if (voice.glissandoOn) { + val semis = ((pitchToMixer * 12 + 2048) / 4096) + pitchToMixer = (semis * 4096 / 12).coerceIn(0, 0xFFFE) + } + + // Tremolo (R) — modulates output volume around base. + if (voice.tremoloActive) { + val sine = lfoSample(voice.tremoloLfoPos, voice.tremoloWave) + val volDelta = (sine * voice.mem.rDepth) shr 9 + voice.rowVolume = (voice.channelVolume + volDelta).coerceIn(0, 0x3F) + voice.tremoloLfoPos = (voice.tremoloLfoPos + voice.mem.rSpeed * 4) and 0xFF + } + + // Arpeggio (J) — overrides pitchToMixer for this tick (overlay on basePitch). + if (voice.arpActive) { + val voiceIdx = ts.tickInRow % 3 + val arpDelta = when (voiceIdx) { 1 -> voice.arpOff1 shl 8; 2 -> voice.arpOff2 shl 8; else -> 0 } + pitchToMixer = (voice.basePitch + arpDelta).coerceIn(0, 0xFFFE) + voice.lastArpVoice = voiceIdx + } + + // Q retrigger. + if (voice.retrigActive && !voice.noteWasCut) { + voice.retrigCounter++ + if (voice.retrigCounter >= voice.retrigInterval) { + voice.retrigCounter = 0 + voice.samplePos = instruments[voice.instrumentId].samplePlayStart.toDouble() + voice.envIndex = 0; voice.envTimeSec = 0.0 + voice.rowVolume = applyRetrigVolMod(voice.rowVolume, voice.retrigVolMod) + voice.channelVolume = voice.rowVolume + } + } + + // Update playback rate from final pitchToMixer. + voice.playbackRate = computePlaybackRate(inst, pitchToMixer) + advanceEnvelope(voice, inst, tickSec) } + + // Tempo slide — applied once per tick at the playhead level (any channel that armed it). + for (voice in ts.voices) { + if (voice.tempoSlideDir != 0 && ts.tickInRow > 0) { + val tempoByte = (playhead.bpm - 0x18 + voice.tempoSlideDir * voice.tempoSlideAmount).coerceIn(0, 0xFF) + playhead.bpm = (tempoByte + 0x18).coerceIn(24, 280) + } + } + + // Funk repeat (S$Fx) — advance bit-mask per tick on instruments with active funkSpeed. + for (voice in ts.voices) { + if (voice.funkSpeed == 0 || !voice.active) continue + val inst = instruments[voice.instrumentId] + if (inst.sampleLoopEnd <= inst.sampleLoopStart) continue + voice.funkAccumulator += FUNK_TABLE[voice.funkSpeed and 0xF] + while (voice.funkAccumulator >= 0x80) { + voice.funkAccumulator -= 0x80 + val loopLen = (inst.sampleLoopEnd - inst.sampleLoopStart).coerceAtLeast(1) + inst.toggleFunkBit(voice.funkWritePos % loopLen) + voice.funkWritePos = (voice.funkWritePos + 1) % loopLen + } + } } + private fun applyRetrigVolMod(vol: Int, x: Int): Int = when (x and 0xF) { + 0, 8 -> vol + 1 -> vol - 0x01; 2 -> vol - 0x02; 3 -> vol - 0x04; 4 -> vol - 0x08; 5 -> vol - 0x10 + 6 -> vol * 2 / 3 + 7 -> vol shr 1 + 9 -> vol + 0x01; 0xA -> vol + 0x02; 0xB -> vol + 0x04; 0xC -> vol + 0x08; 0xD -> vol + 0x10 + 0xE -> vol * 3 / 2 + 0xF -> vol shl 1 + else -> vol + }.coerceIn(0, 0x3F) + private fun advanceTrackerCue(ts: TrackerState, playhead: Playhead) { val instr = cueSheet[ts.cuePos].instruction if (instr is PlayInstHalt) { playhead.isPlaying = false; return } @@ -1214,8 +1682,6 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { internal fun generateTrackerAudio(playhead: Playhead): ByteArray? { val ts = playhead.trackerState ?: return null - val bpm = playhead.bpm - val spt = SAMPLING_RATE * 2.5 / bpm val out = ByteArray(TRACKER_CHUNK * 2) @@ -1225,6 +1691,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } for (n in 0 until TRACKER_CHUNK) { + // Recompute samples-per-tick every iteration since T/T-slide can mutate BPM mid-row. + val spt = SAMPLING_RATE * 2.5 / playhead.bpm ts.samplesIntoTick += 1.0 if (ts.samplesIntoTick >= spt) { ts.samplesIntoTick -= spt @@ -1232,32 +1700,75 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { ts.tickInRow++ if (ts.tickInRow >= playhead.tickRate) { ts.tickInRow = 0 - ts.rowIndex++ - if (ts.rowIndex >= 64) { - ts.rowIndex = 0 - advanceTrackerCue(ts, playhead) - } - applyTrackerRow(ts, playhead) + advanceRow(ts, playhead) } } var mixL = 0.0 var mixR = 0.0 + val gvol = playhead.globalVolume / 255.0 for (voice in ts.voices) { if (!voice.active) continue val s = fetchTrackerSample(voice, instruments[voice.instrumentId]) - val vol = voice.envVolume * voice.rowVolume / 63.0 * playhead.masterVolume / 255.0 + val vol = voice.envVolume * voice.rowVolume / 63.0 * gvol * playhead.masterVolume / 255.0 mixL += s * vol * (63 - voice.rowPan) / 63.0 mixR += s * vol * voice.rowPan / 63.0 } - out[n * 2] = ((mixL.coerceIn(-1.0, 1.0) * 127 + 128).toInt()).toByte() - out[n * 2 + 1] = ((mixR.coerceIn(-1.0, 1.0) * 127 + 128).toInt()).toByte() + ts.mixLeft[n] = mixL.toFloat().coerceIn(-1.0f, 1.0f) + ts.mixRight[n] = mixR.toFloat().coerceIn(-1.0f, 1.0f) + } + + pcm32fToPcm8(ts.mixLeft, ts.mixRight, TRACKER_CHUNK) + for (n in 0 until TRACKER_CHUNK) { + out[n * 2] = tadDecodedBin[n * 2L] + out[n * 2 + 1] = tadDecodedBin[n * 2L + 1] } return out } + /** + * Advance to the next row. Resolves pending B/C jumps and pattern-delay repeats. + * Called once when [TrackerState.tickInRow] has just wrapped past [Playhead.tickRate]. + */ + private fun advanceRow(ts: TrackerState, playhead: Playhead) { + // Pattern delay (S$Ex): replay the same row patternDelayRemaining more times. + if (ts.patternDelayRemaining > 0) { + ts.patternDelayRemaining-- + ts.patternDelayActive = true + applyTrackerRow(ts, playhead) + return + } + ts.patternDelayActive = false + + val pendingB = ts.pendingOrderJump + val pendingC = ts.pendingRowJump + ts.pendingOrderJump = -1 + ts.pendingRowJump = -1 + + when { + pendingB >= 0 -> { + ts.cuePos = pendingB.coerceAtMost(1023) + ts.rowIndex = if (pendingC >= 0) pendingC else 0 + playhead.position = ts.cuePos + } + pendingC >= 0 -> { + // Pattern break — advance order by one (or honour cue's own instruction), then jump to row. + advanceTrackerCue(ts, playhead) + ts.rowIndex = pendingC.coerceIn(0, 63) + } + else -> { + ts.rowIndex++ + if (ts.rowIndex >= 64) { + ts.rowIndex = 0 + advanceTrackerCue(ts, playhead) + } + } + } + applyTrackerRow(ts, playhead) + } + internal data class PlayCue( val patterns: IntArray = IntArray(20) { 0xFFF }, var instruction: PlayInstruction = PlayInstNop @@ -1323,21 +1834,118 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { internal object PlayInstHalt : PlayInstruction(0) internal object PlayInstNop : PlayInstruction(0) + /** Per-channel effect memory cohorts and private slots (TAUD_NOTE_EFFECTS.md §6). */ + class MemorySlots { + // Shared E/F (pitch slide). Stores the raw arg; mode is recovered from arg layout. + var ef: Int = 0 + // G (tone porta) — private speed. + var g: Int = 0 + // Shared H/U vibrato — separate speed and depth fields persist across both. + var huSpeed: Int = 0 + var huDepth: Int = 0 + // R (tremolo) — private speed and depth. + var rSpeed: Int = 0 + var rDepth: Int = 0 + // Private slots + var d: Int = 0 + var i: Int = 0 + var j: Int = 0 + var o: Int = 0 + var q: Int = 0 + var tslide: Int = 0 + } + class Voice { var active = false var instrumentId = 0 var samplePos = 0.0 var playbackRate = 1.0 var forward = true - var rowVolume = 63 - var rowPan = 32 + + // Volumes: channel volume is the persistent base; rowVolume tracks per-tick output (set per row from channel volume + volume column). + var channelVolume = 0x3F // $00..$3F (default full) + var rowVolume = 63 // $00..$3F effective output volume after slides + var channelPan = 0x80 // 8-bit; $80 centre. Cell column packs into 6-bit, S$80xx writes the full 8-bit. + var rowPan = 32 // 6-bit pan used by mixer, derived from channelPan + var envIndex = 0 var envTimeSec = 0.0 var envVolume = 1.0 - var noteVal = 0xFFFF - var pitchSlideAmount = 0.0 // 4096-TET units per tick; +up, -down - var volSlidePerTick = 0 + + // Pitch state (4096-TET units, signed when slid). + var noteVal = 0xFFFF // The currently sounding base note (no per-row vibrato/arp added) + var basePitch = 0x4000 // Saved pre-effect pitch for vibrato/arp/glissando overlay + + // Per-row effect state (set in applyTrackerRow, consumed by applyTrackerTick). + var rowEffect = 0 + var rowEffectArg = 0 + var slideMode = 0 // 0 = none, 1 = pitch coarse-down, 2 = pitch coarse-up, 3 = porta, 4 = vol-slide modes packed in slideArg + var slideArg = 0 // generic slide arg (volume nibbles or pitch units per tick) + var tonePortaTarget = -1 // -1 if inactive + var tonePortaSpeed = 0 + var arpOff1 = 0 + var arpOff2 = 0 + var arpActive = false + var lastArpVoice = 0 // 0 / 1 / 2 — which arp voice we ended on (J-after-arp pitch carry) + var tremorOn = 0 // 0 = inactive, 1 = active row (use I args) + var tremorOnTime = 1 + var tremorOffTime = 1 + var tremorPhaseOn = true + var tremorTickInPhase = 0 + + // Vibrato (H / U) — uses memHU. + var vibratoActive = false + var vibratoLfoPos = 0 // 8-bit phase + var vibratoWave = 0 // 0..3 + var vibratoRetrig = true + var vibratoFineShift = 6 // 6 for H, 8 for U + + // Tremolo (R) — uses memR. + var tremoloActive = false + var tremoloLfoPos = 0 + var tremoloWave = 0 + var tremoloRetrig = true + + // Glissando flag (S$1x). + var glissandoOn = false + + // Q retrigger. + var retrigCounter = 0 + var retrigInterval = 0 + var retrigVolMod = 0 + var retrigActive = false + + // Note delay (S$Dx) — buffered trigger (-1 = no delay). + var noteDelayTick = -1 + var delayedNote = 0 + var delayedInst = 0 + var delayedVol = -1 + + // Note cut (S$Cx). var cutAtTick = -1 + var noteWasCut = false // suppresses Q retrigger after cut + + // Funk repeat (S$Fx) — non-destructive bit XOR mask is per-instrument; per-channel state tracks accumulator + write pointer. + var funkSpeed = 0 // 0 = off + var funkAccumulator = 0 + var funkWritePos = 0 + + // Pattern loop (S$Bx) — per-channel state. + var loopStartRow = 0 + var loopCount = 0 + + // Tempo slide (T $00xy) — per-channel because T is a per-channel effect, but we apply globally via playhead. + var tempoSlideDir = 0 // 0 = none, -1 = down, +1 = up + var tempoSlideAmount = 0 + + // Volume / pan column slides (selectors 1/2/3 from TAUD_NOTE_EFFECTS.md §"Volume column effects"). + var volColSlideUp = 0 + var volColSlideDown = 0 + var panColSlideRight = 0 + var panColSlideLeft = 0 + + // Effect-recall memory for this voice. + val mem = MemorySlots() } class TrackerState { @@ -1347,6 +1955,21 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { var samplesIntoTick = 0.0 var firstRow = true val voices = Array(20) { Voice() } + + // Pending row-end events (set during a row by B/C; consumed at row end). + var pendingOrderJump = -1 // -1 = none; otherwise the order index to jump to + var pendingRowJump = -1 // -1 = none; otherwise the row index for the next pattern + + // Pattern-delay state (S$Ex) — number of additional row-repetitions remaining. + var patternDelayRemaining = 0 + var patternDelayActive = false // true while inside a delay block (gates SBx decrement) + + // Channel index of the SEx that won this row (lowest channel wins ties). + var sexWinningChannel = -1 + + // Pre-allocated mix buffers for dither path (reused each audio chunk). + val mixLeft = FloatArray(TRACKER_CHUNK) + val mixRight = FloatArray(TRACKER_CHUNK) } class Playhead( @@ -1358,11 +1981,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { var masterVolume: Int = 0, var masterPan: Int = 128, // var samplingRateMult: ThreeFiveMiniUfloat = ThreeFiveMiniUfloat(32), - var bpm: Int = 120, + var bpm: Int = 125, // BPM, derived from tempoByte + 24. Spec default $65 ⇒ 125 BPM. var tickRate: Int = 6, var pcmUpload: Boolean = false, var patBank1: Int = 0, var patBank2: Int = 0, + var globalVolume: Int = 0x80, // 8-bit, default $80 (spec §5). Mutated by V $xx00. var pcmQueue: Queue = Queue(), var pcmQueueSizeIndex: Int = 0, @@ -1443,10 +2067,29 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { pcmUploadLength = 0 isPlaying = false pcmQueueSizeIndex = 2 + // Spec §5 defaults — applied on every reset so song-start state is well-defined. + bpm = 125 + tickRate = 6 + globalVolume = 0x80 trackerState?.let { ts -> ts.cuePos = 0; ts.rowIndex = 0; ts.tickInRow = 0 ts.samplesIntoTick = 0.0; ts.firstRow = true - ts.voices.forEach { it.active = false } + ts.pendingOrderJump = -1; ts.pendingRowJump = -1 + ts.patternDelayRemaining = 0; ts.patternDelayActive = false + ts.sexWinningChannel = -1 + ts.voices.forEach { + it.active = false + it.channelVolume = 0x3F + it.rowVolume = 0x3F + it.channelPan = 0x80 + it.rowPan = 32 + it.glissandoOn = false + it.loopStartRow = 0 + it.loopCount = 0 + it.funkSpeed = 0 + it.funkAccumulator = 0 + it.funkWritePos = 0 + } } } @@ -1509,6 +2152,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { data class TaudInstVolEnv(var volume: Int, var offset: ThreeFiveMiniUfloat) data class TaudInst( + var index: Int, + var samplePtr: Int, // 20-bit number var sampleLength: Int, var samplingRate: Int, @@ -1519,7 +2164,23 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { var loopMode: Int, var envelopes: Array // first int: volume (0..255), second int: offsets (minifloat indices) ) { - constructor() : this(0, 0, 0, 0, 0, 0, 0, Array(24) { TaudInstVolEnv(0, ThreeFiveMiniUfloat(0)) }) + constructor(index: Int) : this(index, 0, 0, 0, 0, 0, 0, 0, Array(24) { TaudInstVolEnv(0, ThreeFiveMiniUfloat(0)) }) + + // Funk repeat (S$Fx00) bit-mask — non-destructive XOR overlay across the loop region. + // Lazily allocated; a 1-bit flips the byte, a 0-bit leaves it intact. + var funkMask: ByteArray? = null + fun toggleFunkBit(loopOffset: Int) { + val len = (sampleLoopEnd - sampleLoopStart).coerceAtLeast(1) + val mask = funkMask ?: ByteArray((len + 7) / 8).also { funkMask = it } + val idx = loopOffset.coerceIn(0, len - 1) + mask[idx / 8] = (mask[idx / 8].toInt() xor (1 shl (idx and 7))).toByte() + } + fun funkBit(loopOffset: Int): Boolean { + val mask = funkMask ?: return false + val len = (sampleLoopEnd - sampleLoopStart).coerceAtLeast(1) + val idx = loopOffset.coerceIn(0, len - 1) + return (mask[idx / 8].toInt() ushr (idx and 7)) and 1 != 0 + } fun getByte(offset: Int): Byte = when (offset) { 0 -> samplePtr.toByte() diff --git a/tsvm_executable/src/net/torvald/terrarum/imagefont/tiny.tga b/tsvm_executable/src/net/torvald/terrarum/imagefont/tiny.tga index 0215b3f..b8f4608 100644 --- a/tsvm_executable/src/net/torvald/terrarum/imagefont/tiny.tga +++ b/tsvm_executable/src/net/torvald/terrarum/imagefont/tiny.tga @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:102aaa17902db44d231c108180c6c3308c92310a0cd7bbfafc4670c6e4105e3d +oid sha256:b0e49061110b241a93eaffe573041e8c538db0b9d1dcf9a25d58ddf83cf885da size 15378 diff --git a/tsvm_executable/src/net/torvald/tsvm/AudioMenu.kt b/tsvm_executable/src/net/torvald/tsvm/AudioMenu.kt index c675f03..7494277 100644 --- a/tsvm_executable/src/net/torvald/tsvm/AudioMenu.kt +++ b/tsvm_executable/src/net/torvald/tsvm/AudioMenu.kt @@ -2,6 +2,7 @@ package net.torvald.tsvm import com.badlogic.gdx.Audio import com.badlogic.gdx.Gdx +import com.badlogic.gdx.Input import com.badlogic.gdx.Input.Buttons import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.graphics.g2d.SpriteBatch @@ -13,6 +14,7 @@ import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_WELL import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.FONT import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.TINY import net.torvald.tsvm.peripheral.AudioAdapter +import java.util.BitSet import kotlin.math.abs import kotlin.math.ceil import kotlin.math.floor @@ -25,6 +27,7 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe // Per-playhead view mode: 0=detailed pattern, 1=abridged pattern (stub), 2=super-abridged (stub), 3=cuesheet detail private val scopeMode = IntArray(4) + private val scopeScrollHorz = IntArray(4) override fun show() { } @@ -33,8 +36,10 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe } private var guiClickLatched = arrayOf(false, false, false, false, false, false, false, false) + private var guiKeypressLatched = BitSet(256) override fun update() { + // mouse clicks if (Gdx.input.isButtonPressed(Buttons.LEFT)) { if (!guiClickLatched[Buttons.LEFT]) { val mx = Gdx.input.x - x @@ -45,7 +50,7 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe val syTop = h - 7 - 115 * i - 8 * FONT.H val syBot = h - 3 - 115 * i if (my in syTop..syBot) { - scopeMode[3 - i] = (scopeMode[3 - i] + 1) % 4 + scopeMode[3 - i] = (scopeMode[3 - i] + 1) and 3 break } } @@ -57,9 +62,78 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe else { guiClickLatched[Buttons.LEFT] = false } + if (Gdx.input.isButtonPressed(Buttons.RIGHT)) { + if (!guiClickLatched[Buttons.RIGHT]) { + val mx = Gdx.input.x - x + val my = Gdx.input.y - y + if (mx in 117..629) { + for (i in 0..3) { + val syTop = h - 7 - 115 * i - 8 * FONT.H + val syBot = h - 3 - 115 * i + if (my in syTop..syBot) { + scopeMode[3 - i] = (scopeMode[3 - i] - 1) and 3 + break + } + } + } + + guiClickLatched[Buttons.RIGHT] = true + } + } + else { + guiClickLatched[Buttons.RIGHT] = false + } + + // keyboard left/right + if (Gdx.input.isKeyPressed(Input.Keys.LEFT)) { + if (!guiKeypressLatched[Input.Keys.LEFT]) { + val mx = Gdx.input.x - x + val my = Gdx.input.y - y + + if (mx in 117..629) { + for (i in 0..3) { + val syTop = h - 7 - 115 * i - 8 * FONT.H + val syBot = h - 3 - 115 * i + if (my in syTop..syBot) { + scopeScrollHorz[3 - i] = (scopeScrollHorz[3 - i] - 1).coerceIn(0, 14) + break + } + } + } + + guiKeypressLatched[Input.Keys.LEFT] = true + } + } + else { + guiKeypressLatched[Input.Keys.LEFT] = false + } + if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) { + if (!guiKeypressLatched[Input.Keys.RIGHT]) { + val mx = Gdx.input.x - x + val my = Gdx.input.y - y + + if (mx in 117..629) { + for (i in 0..3) { + val syTop = h - 7 - 115 * i - 8 * FONT.H + val syBot = h - 3 - 115 * i + if (my in syTop..syBot) { + scopeScrollHorz[3 - i] = (scopeScrollHorz[3 - i] + 1).coerceIn(0, 14) + break + } + } + } + + guiKeypressLatched[Input.Keys.RIGHT] = true + } + } + else { + guiKeypressLatched[Input.Keys.RIGHT] = false + } } + + private val COL_SOUNDSCOPE_BACK = Color(0x081c08ff.toInt()) private val COL_SOUNDSCOPE_FORE = Color(0x80f782ff.toInt()) private val COL_TRACKER_ROW = Color(0x103010ff.toInt()) @@ -187,7 +261,9 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe private fun bipolarCeil(d: Double) = (if (d >= 0.0) ceil(d) else floor(d)).toInt() private fun bipolarFloor(d: Double) = (if (d >= 0.0) floor(d) else ceil(d)).toInt() - private val VOX_PER_VIEW = arrayOf(5,13,17) + private val VOX_PER_VIEW = arrayOf(6,13,20) + private val VOL_SYM = arrayOf('@','^','&',' ') + private val PAN_SYM = arrayOf('@','<','>',' ') private fun drawSoundscope(audio: AudioAdapter, ahead: AudioAdapter.Playhead, batch: SpriteBatch, index: Int, x: Float, y: Float) { val gdxadev = ahead.audioDevice @@ -272,8 +348,8 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe var cx = x // cursor + cue number batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE - TINY.draw(batch, "${if (here) ">" else " "}${ci.toString(16).padStart(3, '0').uppercase()}|", cx, ry) - cx += 5 * TINY.W + TINY.draw(batch, "${ci.toString(16).padStart(3, '0').uppercase()}|", cx, ry) + cx += 4 * TINY.W // voice pattern numbers for (vi in 0 until 20) { @@ -328,7 +404,7 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe */ // Pattern index for each voice in current cue - val cuePats = IntArray(VOICES) { vi -> readCuePat12(audio, cuePos, vi) } + val cuePats = IntArray(20) { vi -> readCuePat12(audio, cuePos, vi) } // Pattern rows (right area, 8 rows centred on current row) // Layout: > rr NOTE in E.Vo E.Pn Eff ffff [voice1 …] @@ -349,15 +425,15 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe // cursor + row number (drawn once per row) batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE - TINY.draw(batch, if (here) ">" else " ", cx, ry) - cx += TINY.W +// TINY.draw(batch, if (here) ">" else " ", cx, ry) +// cx += TINY.W TINY.draw(batch, ri.toString().padStart(2, '0').uppercase(), cx, ry) cx += 2 * TINY.W - for (vi in 0 until VOICES) { + for (vi in scopeScrollHorz[index] until (VOICES + scopeScrollHorz[index]).coerceAtMost(19)) { val pat12 = cuePats[vi] if (pat12 == 0xFFF) { - if (vi == 0) { + if (vi == scopeScrollHorz[index]) { // disabled voice — dimmed placeholder, same width as a live voice batch.color = COL_SOUNDSCOPE_FORE TINY.draw( @@ -394,6 +470,10 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe 0xFFFE -> "^^^^" else -> noteVal.toString(16).uppercase().padStart(4, '0') } + var instStr = instr.toString(16).padStart(2, '0').uppercase() + if (instr == 0) { + instStr = "@@" + } // note batch.color = if (here) Color.WHITE else COL_NOTE @@ -401,13 +481,19 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe cx += 4 * TINY.W // instrument batch.color = if (here) Color.WHITE else COL_INST - TINY.draw(batch, instr.toString(16).padStart(2, '0').uppercase(), cx, ry) + TINY.draw(batch, instStr, cx, ry) cx += 2 * TINY.W if (scopeMode[index] == 0) { // volume batch.color = if (here) Color.WHITE else COL_VOL - TINY.draw(batch, "$volEff.${vol.toString().padStart(2, '0')}", cx, ry) - cx += 4 * TINY.W + var text = if (volByte == 0xC0) "@@@" else "${VOL_SYM[volEff]}${vol.toString().padStart(2, '0')}" + // is this fine slide? + if (volEff == 3 && vol != 0) { + val dir = if (vol and 32 == 1) '+' else '-' + text = "$dir${(vol and 31).toString().padStart(2,'0').uppercase()}" + } + TINY.draw(batch, text, cx, ry) + cx += 3 * TINY.W } else if (scopeMode[index] == 1) { batch.color = if (here) Color.WHITE else COL_VOL @@ -416,20 +502,32 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe } // pan if (scopeMode[index] == 0) { + var text = if (panByte == 0xC0) "@@@" else "${PAN_SYM[panEff]}${pan.toString().padStart(2, '0')}" + // is this fine slide? + if (panEff == 3 && pan != 0) { + val dir = if (pan and 32 == 1) '+' else '-' + text = "$dir${(pan and 31).toString().padStart(2,'0').uppercase()}" + } batch.color = if (here) Color.WHITE else COL_PAN - TINY.draw(batch, "$panEff.${pan.toString().padStart(2, '0')}", cx, ry) - cx += 4 * TINY.W + TINY.draw(batch, text, cx, ry) + cx += 3 * TINY.W } if (scopeMode[index] == 0) { + var effSymStr = eff.toString(36).uppercase() + var effArgStr = effArg.toString(16).padStart(4, '0').uppercase() + + if (eff == 0 && effArg == 0) { + effSymStr = "@@" + effArgStr = "@@@@" + } + // effect opcode batch.color = if (here) Color.WHITE else COL_EFF - TINY.draw(batch, eff.toString(16).padStart(2, '0').uppercase(), cx, ry) - cx += 2 * TINY.W - } - if (scopeMode[index] == 0) { + TINY.draw(batch, effSymStr, cx, ry) + cx += 1 * TINY.W // effect argument batch.color = if (here) Color.WHITE else COL_EFFARG - TINY.draw(batch, effArg.toString(16).padStart(4, '0').uppercase(), cx, ry) + TINY.draw(batch, effArgStr, cx, ry) cx += 4 * TINY.W } } diff --git a/tsvm_executable/src/net/torvald/tsvm/VMEmuExecutable.kt b/tsvm_executable/src/net/torvald/tsvm/VMEmuExecutable.kt index 78b613c..9c9fde9 100644 --- a/tsvm_executable/src/net/torvald/tsvm/VMEmuExecutable.kt +++ b/tsvm_executable/src/net/torvald/tsvm/VMEmuExecutable.kt @@ -346,6 +346,8 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX: drawMenu(fbatch, (panelsX - 1f) * windowWidth, (panelsY - 1f) * windowHeight) } + + private fun drawVMtoCanvas(delta: Float, vm: VM?, pposX: Int, pposY: Int) { // assuming the reference adapter of 560x448 val xoff = pposX * windowWidth.toFloat()