Compare commits

...

5 Commits

Author SHA1 Message Date
minjaesong
5e6ac17146 song global volume and mixer volume 2026-05-02 19:28:11 +09:00
minjaesong
d2b1e792b9 taud: cue and pattern compression 2026-05-02 19:00:07 +09:00
minjaesong
219ca1e475 IT SusLoop 2026-05-02 14:26:57 +09:00
minjaesong
902ab00132 impl S6x and Wxx cmd 2026-05-02 14:15:34 +09:00
minjaesong
5dc87a80be fix: S Bx00 not working as indtended 2026-05-02 13:51:43 +09:00
11 changed files with 392 additions and 154 deletions

6
2taud.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env fish
for f in *.mod; python3 mod2taud.py $f assets/disk0/(basename $f .mod).taud; end
for f in *.s3m; python3 s3m2taud.py $f assets/disk0/(basename $f .s3m).taud; end
for f in *.it; python3 it2taud.py $f assets/disk0/(basename $f .it).taud; end
for f in *.xm; python3 xm2taud.py $f assets/disk0/(basename $f .xm).taud; end

View File

@@ -666,7 +666,17 @@ ProTracker `E5x` maps to Taud `S $2x00` with the same index meaning.
**Compatibility.** IT `S6x` maps directly.
**Implementation.** TODO
**Implementation.** Maintain a per-row accumulator `fine_delay_extra` on the tracker state, initialised to 0 at the start of every row parse (including pattern-delay repetitions caused by S $Ex). Each S $6x command encountered during the row scan adds `$x` to `fine_delay_extra`. The row then runs for `speed + fine_delay_extra` ticks instead of the usual `speed` ticks before advancing to the next row.
```
on row parse (S $6x):
fine_delay_extra += x # sum across all channels
row ends when:
tick_in_row >= ticks_per_row + fine_delay_extra
```
S $6x and S $Ex are orthogonal: when S $Ex is active the current row repeats `$x` additional times, and each repetition is itself extended by `fine_delay_extra` (re-accumulated from the same row's S $6x commands). There is no memory for S $6x; `$x == 0` is a no-op.
---

View File

@@ -435,14 +435,12 @@ function drawCellAtStyled(y, x, cell, back, style) {
const TAUD_MAGIC = [0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64]
const TAUD_HEADER_SIZE = 32
const TAUD_SONG_ENTRY = 16
const TAUD_SONG_ENTRY = 32
const PATTERN_SIZE = 512
const ROWS_PER_PAT = 64
const NUM_CUES = 1024
const CUE_SIZE = 32
const NUM_VOICES = 20
const NUM_INSTRUMENTS = 256
const INSTRUMENT_SIZE = 64
const CUE_EMPTY = 0xFFF
function _peekU32LE(ptr, off) {
@@ -485,52 +483,55 @@ function loadTaud(filePath, songIndex) {
((sys.peek(ptr + entryOff + 6) & 0xFF) << 8)
const bpmStored = sys.peek(ptr + entryOff + 7) & 0xFF
const tickRate = sys.peek(ptr + entryOff + 8) & 0xFF
const patBinCompSize = _peekU32LE(ptr, entryOff + 18)
const cueSheetCompSize = _peekU32LE(ptr, entryOff + 22)
// Decompress pattern bin
const patBinSize = numPats * PATTERN_SIZE
const patBinPtr = sys.malloc(patBinSize)
gzip.decompFromTo(ptr + songOff, patBinCompSize, patBinPtr)
const patterns = new Array(numPats)
for (let p = 0; p < numPats; p++) {
const ptn = new Uint8Array(PATTERN_SIZE)
for (let k = 0; k < PATTERN_SIZE; k++) {
ptn[k] = sys.peek(ptr + songOff + p * PATTERN_SIZE + k) & 0xFF
ptn[k] = sys.peek(patBinPtr + p * PATTERN_SIZE + k) & 0xFF
}
patterns[p] = ptn
}
sys.free(patBinPtr)
// Decompress cue sheet
const cueSheetSize = NUM_CUES * CUE_SIZE
const cueSheetPtr = sys.malloc(cueSheetSize)
gzip.decompFromTo(ptr + songOff + patBinCompSize, cueSheetCompSize, cueSheetPtr)
const cueBase = songOff + numPats * PATTERN_SIZE
const cues = new Array(NUM_CUES)
let lastActiveCue = -1
for (let c = 0; c < NUM_CUES; c++) {
const ptns = new Array(NUM_VOICES)
for (let i = 0; i < 10; i++) {
const lo = sys.peek(ptr + cueBase + c * CUE_SIZE + i) & 0xFF
const mi = sys.peek(ptr + cueBase + c * CUE_SIZE + 10 + i) & 0xFF
const hi = sys.peek(ptr + cueBase + c * CUE_SIZE + 20 + i) & 0xFF
const lo = sys.peek(cueSheetPtr + c * CUE_SIZE + i) & 0xFF
const mi = sys.peek(cueSheetPtr + c * CUE_SIZE + 10 + i) & 0xFF
const hi = sys.peek(cueSheetPtr + c * CUE_SIZE + 20 + i) & 0xFF
ptns[i*2] = ((hi >> 4) << 8) | ((mi >> 4) << 4) | (lo >> 4)
ptns[i*2+1] = ((hi & 0xF) << 8) | ((mi & 0xF) << 4) | (lo & 0xF)
}
const instr = sys.peek(ptr + cueBase + c * CUE_SIZE + 30) & 0xFF
const instr = sys.peek(cueSheetPtr + c * CUE_SIZE + 30) & 0xFF
cues[c] = { ptns, instr }
for (let v = 0; v < NUM_VOICES; v++) {
if (ptns[v] !== CUE_EMPTY) { lastActiveCue = c; break }
}
}
const instrBase = cueBase + NUM_CUES * CUE_SIZE
const instruments = new Array(NUM_INSTRUMENTS)
for (let n = 0; n < NUM_INSTRUMENTS; n++) {
const instr = new Uint8Array(INSTRUMENT_SIZE)
for (let k = 0; k < INSTRUMENT_SIZE; k++) {
instr[k] = sys.peek(ptr + instrBase + n * INSTRUMENT_SIZE + k) & 0xFF
}
instruments[n] = instr
}
sys.free(cueSheetPtr)
sys.free(ptr)
return {
filePath, version, numSongs, numVoices, numPats,
bpm: (bpmStored + 24) & 0xFF, tickRate,
patterns, cues, lastActiveCue, instruments
patterns, cues, lastActiveCue
}
}
@@ -2370,8 +2371,8 @@ taud.uploadTaudFile(fullPathObj.full, 0, PLAYHEAD)
audio.setMasterVolume(PLAYHEAD, 255)
audio.setMasterPan(PLAYHEAD, 128)
const initialTrackerMixerflags = audio.getTrackerMixerFlags(PLAYHEAD)
//const initialGlobalVolume =
//const initialMixingVolume =
const initialGlobalVolume = audio.getSongGlobalVolume(PLAYHEAD)
const initialMixingVolume = audio.getSongMixingVolume(PLAYHEAD)
function isExternalPanel(p) {
return p === VIEW_SAMPLES || p === VIEW_INSTRMNT || p === VIEW_FILE

View File

@@ -8,8 +8,8 @@
const TAUD_MAGIC = [0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64] // \x1F TSVMaud
const TAUD_VERSION = 1
const TAUD_HEADER_SIZE = 32 // magic(8) + version(1) + numSongs(1) + compSize(4) + rsvd(2) + sig(16)
const TAUD_SONG_ENTRY = 16 // bytes per song-table row (offset(4)+voices(1)+pats(2)+bpm(1)+tick(1)+pad(7))
const TAUD_HEADER_SIZE = 32 // magic(8) + version(1) + numSongs(1) + compSize(4) + projOff(4) + sig(14)
const TAUD_SONG_ENTRY = 32 // see encodeSongEntry / decodeSongEntry below
const SAMPLEINST_SIZE = 786432 // 737280 sample + 49152 instrument (256 × 192)
const PATTERN_SIZE = 512 // bytes per pattern (64 rows × 8 bytes)
const NUM_PATTERNS_MAX = 256
@@ -109,33 +109,47 @@ function uploadTaudFile(inFile, songIndex, playhead) {
let bpmStored = sys.peek(filePtr + entryOff + 7) & 0xFF
let tickRate = sys.peek(filePtr + entryOff + 8) & 0xFF
let mixerflags = sys.peek(filePtr + entryOff + 15) & 0xFF
let songGlobalVolume = sys.peek(filePtr + entryOff + 16) & 0xFF
let songMixingVolume = sys.peek(filePtr + entryOff + 17) & 0xFF
let patBinCompSize = _peekU32LE(filePtr, entryOff + 18)
let cueSheetCompSize = _peekU32LE(filePtr, entryOff + 22)
let bpm = bpmStored + 24
let patsToLoad = numPatsLo | (numPatsHi << 8)
// -- 6. Upload patterns ---------------------------------------------------
let songBase = filePtr + songOffset
// -- 6. Decompress + upload patterns --------------------------------------
let patBinSize = patsToLoad * PATTERN_SIZE
let patBinPtr = sys.malloc(patBinSize)
gzip.decompFromTo(filePtr + songOffset, patBinCompSize, patBinPtr)
let patBytes = new Array(PATTERN_SIZE)
for (let p = 0; p < patsToLoad; p++) {
for (let k = 0; k < PATTERN_SIZE; k++)
patBytes[k] = sys.peek(songBase + p * PATTERN_SIZE + k) & 0xFF
patBytes[k] = sys.peek(patBinPtr + p * PATTERN_SIZE + k) & 0xFF
audio.uploadPattern(p, patBytes)
}
sys.free(patBinPtr)
// -- 7. Decompress + upload cue sheet -------------------------------------
let cueSheetSize = NUM_CUES * CUE_SIZE
let cueSheetPtr = sys.malloc(cueSheetSize)
gzip.decompFromTo(filePtr + songOffset + patBinCompSize, cueSheetCompSize, cueSheetPtr)
// -- 7. Upload cue sheet --------------------------------------------------
let cueBase = songBase + patsToLoad * PATTERN_SIZE
let cueBytes = new Array(CUE_SIZE)
for (let c = 0; c < NUM_CUES; c++) {
for (let k = 0; k < CUE_SIZE; k++)
cueBytes[k] = sys.peek(cueBase + c * CUE_SIZE + k) & 0xFF
cueBytes[k] = sys.peek(cueSheetPtr + c * CUE_SIZE + k) & 0xFF
audio.uploadCue(c, cueBytes)
}
sys.free(cueSheetPtr)
// -- 8. Configure playhead ------------------------------------------------
audio.setTrackerMode(playhead)
audio.setBPM(playhead, bpm)
audio.setTickRate(playhead, tickRate > 0 ? tickRate : 6)
audio.setTrackerMixerFlags(playhead, mixerflags)
audio.setSongGlobalVolume(playhead, songGlobalVolume)
audio.setSongMixingVolume(playhead, songMixingVolume)
fileHandle.close()
@@ -184,18 +198,47 @@ function captureTrackerDataToFile(outFile) {
let numPats = numPatsActual // Uint16, 1-65535
let patsToSave = numPatsActual
// -- 3. BPM / tick-rate from playhead 0 -----------------------------------
// -- 3. BPM / tick-rate / volumes from playhead 0 -------------------------
let bpm = audio.getBPM(0) || 125
let tickRate = audio.getTickRate(0) || 6
let bpmStored = (bpm - 24) & 0xFF
let songGlobalVolume = audio.getSongGlobalVolume(0)
let songMixingVolume = audio.getSongMixingVolume(0)
if (songGlobalVolume === undefined || songGlobalVolume === null) songGlobalVolume = 0x80
if (songMixingVolume === undefined || songMixingVolume === null) songMixingVolume = 0x80
// -- 4. Compute song offset (absolute from file start) --------------------
// -- 4. Compress pattern bin ----------------------------------------------
let patBinSize = patsToSave * PATTERN_SIZE
let patBuf = sys.malloc(patBinSize)
sys.memcpy(memBase - 131072, patBuf, patBinSize)
let patCompBuf = sys.malloc(patBinSize + 4096)
let patCompSize = gzip.compFromTo(patBuf, patBinSize, patCompBuf)
sys.free(patBuf)
// -- 5. Compress cue sheet ------------------------------------------------
// Cue entry c, byte k is at MMIO address 32768 + c*32 + k,
// accessed as sys.peek(baseAddr (32768 + c*32 + k)).
let cueSheetSize = NUM_CUES * CUE_SIZE
let cueBuf = sys.malloc(cueSheetSize)
for (let c = 0; c < NUM_CUES; c++) {
let cueOff = 32768 + c * CUE_SIZE
for (let k = 0; k < CUE_SIZE; k++)
sys.poke(cueBuf + c * CUE_SIZE + k,
sys.peek(baseAddr - (cueOff + k)) & 0xFF)
}
let cueCompBuf = sys.malloc(cueSheetSize + 4096)
let cueCompSize = gzip.compFromTo(cueBuf, cueSheetSize, cueCompBuf)
sys.free(cueBuf)
// -- 6. Compute song offset (absolute from file start) --------------------
// Layout: header(32) + compressed(compressedSize) + songTable(1 × TAUD_SONG_ENTRY)
let songOffset = TAUD_HEADER_SIZE + compressedSize + 1 * TAUD_SONG_ENTRY
// -- 5. Build header byte array (32 bytes) --------------------------------
let sigBytes = new Array(16)
for (let i = 0; i < 16; i++)
// -- 7. Build header byte array (32 bytes) --------------------------------
let sigBytes = new Array(14)
for (let i = 0; i < 14; i++)
sigBytes[i] = i < CAPTURE_SIGNATURE.length ? CAPTURE_SIGNATURE.charCodeAt(i) : 0
let header = [
@@ -203,16 +246,16 @@ function captureTrackerDataToFile(outFile) {
0x1F, 0x54, 0x53, 0x56, 0x4D, 0x61, 0x75, 0x64,
// version, numSongs
TAUD_VERSION, 1,
// compressedSize uint32 LE (4)
// compressedSize uint32 LE (4) -- sample+inst bin
(compressedSize ) & 0xFF,
(compressedSize >>> 8) & 0xFF,
(compressedSize >>> 16) & 0xFF,
(compressedSize >>> 24) & 0xFF,
// reserved (4)
// project data offset (4) -- not emitted
0x00, 0x00, 0x00, 0x00,
].concat(sigBytes) // 8 + 2 + 4 + 2 + 16 = 32 bytes
].concat(sigBytes) // 8 + 2 + 4 + 4 + 14 = 32 bytes
// -- 6. Build song-table row (16 bytes) -----------------------------------
// -- 8. Build song-table row (32 bytes) -----------------------------------
let songTable = [
(songOffset ) & 0xFF,
(songOffset >>> 8) & 0xFF,
@@ -225,37 +268,42 @@ function captureTrackerDataToFile(outFile) {
0x00,0xA0, // basenote (0xA000 -- C9)
0x00,0xAC,0x02,0x46, // basefreq (8363 Hz)
sys.peek(baseAddr - 7), // mixer flags
songGlobalVolume & 0xFF, // global volume
songMixingVolume & 0xFF, // mixing volume
// pattern bin compressed size (4)
(patCompSize ) & 0xFF,
(patCompSize >>> 8) & 0xFF,
(patCompSize >>> 16) & 0xFF,
(patCompSize >>> 24) & 0xFF,
// cue sheet compressed size (4)
(cueCompSize ) & 0xFF,
(cueCompSize >>> 8) & 0xFF,
(cueCompSize >>> 16) & 0xFF,
(cueCompSize >>> 24) & 0xFF,
// reserved (6)
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
]
// -- 7. Write header (creates / truncates file) ---------------------------
// -- 9. Write header (creates / truncates file) ---------------------------
const fileHandle = files.open(outFile)
fileHandle.bwrite(header)
// -- 8. Append compressed sample+inst bin ---------------------------------
fileHandle.pwrite(compBuf, compressedSize, 32)
// -- 10. Append compressed sample+inst bin --------------------------------
fileHandle.pwrite(compBuf, compressedSize, TAUD_HEADER_SIZE)
sys.free(compBuf)
// -- 9. Write song table --------------------------------------------------
// -- 11. Write song table -------------------------------------------------
fileHandle.bwrite(songTable)
// -- 10. Append pattern bin -----------------------------------------------
let patBuf = sys.malloc(patsToSave * PATTERN_SIZE)
sys.memcpy(memBase - 131072, patBuf, patsToSave * PATTERN_SIZE)
fileHandle.pwrite(patBuf, patsToSave * PATTERN_SIZE, 32 + compressedSize + songTable.length)
sys.free(patBuf)
// -- 12. Append compressed pattern bin ------------------------------------
fileHandle.pwrite(patCompBuf, patCompSize,
TAUD_HEADER_SIZE + compressedSize + songTable.length)
sys.free(patCompBuf)
// -- 11. Append cue sheet (all 1024 entries from MMIO space) --------------
// Cue entry c, byte k is at MMIO address 32768 + c*32 + k,
// accessed as sys.peek(baseAddr (32768 + c*32 + k)).
let cueBuf = sys.malloc(NUM_CUES * CUE_SIZE)
for (let c = 0; c < NUM_CUES; c++) {
let cueOff = 32768 + c * CUE_SIZE
for (let k = 0; k < CUE_SIZE; k++)
sys.poke(cueBuf + c * CUE_SIZE + k,
sys.peek(baseAddr - (cueOff + k)) & 0xFF)
}
fileHandle.pwrite(cueBuf, NUM_CUES * CUE_SIZE, 32 + compressedSize + songTable.length + patsToSave * PATTERN_SIZE)
sys.free(cueBuf)
// -- 13. Append compressed cue sheet --------------------------------------
fileHandle.pwrite(cueCompBuf, cueCompSize,
TAUD_HEADER_SIZE + compressedSize + songTable.length + patCompSize)
sys.free(cueCompBuf)
fileHandle.flush(); fileHandle.close()

View File

@@ -25,7 +25,8 @@ Effect support:
A-Z dispatch per TAUD_NOTE_EFFECTS.md. IT-specific: Cxx is binary
(not BCD like ST3). V scales by ×2 (IT 0-128 → Taud 0-255). X is
the full 8-bit IT pan. Y panbrello nibble-repeats. Z (MIDI macro)
dropped. S6x tick-delay dropped. SAx high-offset dropped. S7x NNA /
dropped. S6x fine-pattern-delay forwarded directly to Taud S$6x. SAx
high-offset dropped. S7x NNA /
past-note / envelope toggles forwarded directly (IT sub-codes match
Taud one-to-one). Vol-column pitch-slide / tone-porta / vibrato sub-
commands forwarded to main effect slot when empty; dropped otherwise.
@@ -45,14 +46,14 @@ from taud_common import (
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4,
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I,
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_Y,
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_W, TOP_Y,
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
EFF_A, EFF_B, EFF_C, EFF_D, EFF_E, EFF_F, EFF_G, EFF_H, EFF_I, EFF_J,
EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T,
EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z,
J_SEMI_TABLE,
d_arg_to_col, resample_linear, encode_cue, deduplicate_patterns,
normalise_sample,
normalise_sample, encode_song_entry,
)
@@ -83,6 +84,7 @@ IT_SMP_COMPRESSED = 0x08
IT_SMP_LOOP = 0x10
IT_SMP_SUS_LOOP = 0x20
IT_SMP_PINGPONG = 0x40
IT_SMP_PINGPONG_SUS = 0x80
# Vol-column byte ranges (inclusive lower, inclusive upper)
VC_VOL_LO, VC_VOL_HI = 0, 64
@@ -104,7 +106,9 @@ VC_TPORTA_TABLE = (0, 1, 4, 8, 16, 32, 64, 96, 128, 255)
IT_MEM_EFFECTS = frozenset({
EFF_D, EFF_E, EFF_F, EFF_G, EFF_H, EFF_I, EFF_J,
EFF_K, EFF_L, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R,
EFF_S, EFF_T, EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y,
EFF_S, EFF_T, EFF_U, EFF_X, EFF_Y,
# EFF_V excluded: V00 means literal 0 in IT, not recall.
# EFF_W excluded: Taud engine handles W recall natively (same private-slot semantics).
})
@@ -435,6 +439,8 @@ def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list:
s.length = len(s.sample_data)
s.loop_beg = min(s.loop_beg, s.length)
s.loop_end = min(s.loop_end, s.length)
s.sus_beg = min(s.sus_beg, s.length)
s.sus_end = min(s.sus_end, s.length)
except Exception as e:
vprint(f" warning: '{s.name}' decompression failed ({e}), silent")
else:
@@ -449,6 +455,8 @@ def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list:
s.length = len(s.sample_data)
s.loop_beg = min(s.loop_beg, s.length)
s.loop_end = min(s.loop_end, s.length)
s.sus_beg = min(s.sus_beg, s.length)
s.sus_end = min(s.sus_end, s.length)
samples.append(s)
return samples
@@ -784,7 +792,7 @@ def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0,
- Cxx: binary row number, not BCD
- V: IT global vol 0-128 scaled ×2
- X: IT full 8-bit pan → 6-bit
- S6x, S7x, SAx, SFx handled (mostly dropped)
- S6x: fine pattern delay forwarded; S7x forwarded; SAx/SFx dropped
amiga_mode mirrors the inverse of the IT ``linear_slides`` flag. When
set, E/F coarse pitch-slide arguments are emitted as raw IT period units
@@ -877,8 +885,8 @@ def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0,
pan8 = (val << 4) | val
return (TOP_S, 0x8000 | pan8, None, None)
if sub == 0x6:
vprint(f" dropped S6{val:X} (tick delay) at ch{ch} row{row}")
return (TOP_NONE, 0, None, None)
# IT S6x = fine pattern delay (extends row by x ticks) — maps directly.
return (TOP_S, 0x6000 | (val << 8), None, None)
if sub == 0x7:
# NNA / past-note / envelope on-off — IT S7x maps directly to Taud S $7x00
# (same sub-code table). No payload to translate.
@@ -904,8 +912,8 @@ def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0,
return (TOP_V, (taud_v & 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)
# W$xy: same nibble-pair layout as D, passed in the high byte.
return (TOP_W, (arg & 0xFF) << 8, None, None)
if cmd == EFF_X:
return (TOP_S, 0x8000 | (arg & 0xFF), None, None)
@@ -926,8 +934,7 @@ def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0,
def resolve_it_recalls(patterns_rows: list, order_list: list,
num_channels: int, link_gef: bool,
old_effects: bool = False,
initial_global_vol: int = 128) -> None:
old_effects: bool = False) -> None:
"""Walk in order, resolve zero-arg recalls per-effect-per-channel.
IT effect memory groups:
@@ -940,14 +947,13 @@ def resolve_it_recalls(patterns_rows: list, order_list: list,
they do NOT recall and are suppressed to TOP_NONE. All other effects
still recall normally even in old_effects mode.
V memory is primed with initial_global_vol so a song-leading V $0000
resolves to the header's global volume, not literal zero.
V and W are excluded from IT_MEM_EFFECTS and are not resolved here:
V00 in IT means literal 0 (not recall); W recall is handled natively
by the Taud engine's private W memory slot.
"""
# last_mem[ch][eff_key] = last_non_zero_arg
# eff_key: integer 1-26 for most effects; we merge cohorts by normalising.
last_mem = [{} for _ in range(num_channels)]
for ch in range(num_channels):
last_mem[ch][EFF_V] = initial_global_vol
# Effects that stop rather than recall when arg=0 in old_effects mode (ST3 compat).
# E/F: pitch slide stop. J: arpeggio stop (J00 = return to normal pitch in ST3).
@@ -1127,6 +1133,8 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
s.length = len(new_data)
s.loop_beg = max(0, int(s.loop_beg * ratio))
s.loop_end = max(0, min(int(s.loop_end * ratio), s.length))
s.sus_beg = max(0, int(s.sus_beg * ratio))
s.sus_end = max(0, min(int(s.sus_end * ratio), s.length))
s.c5_speed = max(1, int(s.c5_speed * ratio))
sample_bin = bytearray(SAMPLEBIN_SIZE)
@@ -1141,7 +1149,9 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
offsets[idx] = pos
if n < len(s.sample_data):
vprint(f" warning: '{s.name}' truncated {len(s.sample_data)}{n}")
s.length = n; s.loop_end = min(s.loop_end, n)
s.length = n
s.loop_end = min(s.loop_end, n)
s.sus_end = min(s.sus_end, n)
pos += n
# 192-byte instrument layout (terranmon.txt:1997-2070).
@@ -1167,15 +1177,33 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
ptr = offsets.get(i, 0) & 0xFFFFFFFF
s_len = min(s.length, 65535)
c2spd = min(s.c5_speed, 65535)
# Sustain loop wins over the regular loop because Taud carries one loop
# region. After key-off the engine drops the loop entirely (terranmon.txt:2007).
if s.flags & IT_SMP_SUS_LOOP:
ls = min(s.sus_beg, 65535)
le = min(s.sus_end, 65535)
sustain_bit = 0x4
pingpong_active = bool(s.flags & IT_SMP_PINGPONG_SUS)
has_active_loop = True
elif s.has_loop:
ls = min(s.loop_beg, 65535)
le = min(s.loop_end, 65535)
if s.has_loop and (s.flags & IT_SMP_PINGPONG):
sustain_bit = 0x0
pingpong_active = bool(s.flags & IT_SMP_PINGPONG)
has_active_loop = True
else:
ls = min(s.loop_beg, 65535)
le = min(s.loop_end, 65535)
sustain_bit = 0x0
pingpong_active = False
has_active_loop = False
if has_active_loop and pingpong_active:
loop_mode = 2 # backandforth
elif s.has_loop:
elif has_active_loop:
loop_mode = 1 # forward loop
else:
loop_mode = 0 # no loop
flags_byte = loop_mode & 0x3
flags_byte = (loop_mode & 0x3) | sustain_bit
base = taud_idx * 192
struct.pack_into('<I', inst_bin, base + 0, ptr)
@@ -1487,8 +1515,7 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
# ── Resolve IT recalls ───────────────────────────────────────────────────
vprint(" resolving IT recalls…")
resolve_it_recalls(patterns_rows, h.order_list, 64, h.link_gef,
old_effects=h.old_effects,
initial_global_vol=h.global_vol)
old_effects=h.old_effects)
init_speed, _ = find_initial_bpm_speed(patterns_rows, h.order_list,
h.initial_speed, h.initial_tempo)
@@ -1707,18 +1734,34 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
)
assert len(header) == TAUD_HEADER_SIZE
# Compress pattern bin and cue sheet (per Taud spec)
pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0)
cue_comp = gzip.compress(bytes(sheet), compresslevel=9, mtime=0)
vprint(f" pattern bin: {len(pat_bin)}{len(pat_comp)} bytes (gzip)")
vprint(f" cue sheet: {len(sheet)}{len(cue_comp)} bytes (gzip)")
# flags byte: bit 1 (f) = Amiga pitch-slide mode (IT linear_slides flag inverted)
flags_byte = 0x00 if h.linear_slides else 0x02
song_table = struct.pack('<IBHBBHfB',
song_offset, C, num_taud_pats,
bpm_stored, speed,
0xA000, # C9
8363.0,
flags_byte,
# IT global/mix volumes are 0..128; rescale to Taud's 0..255 (clamped).
global_vol_taud = min(0xFF, round(h.global_vol * 255 / 128))
mixing_vol_taud = min(0xFF, round(h.mix_vol * 255 / 128))
song_table = encode_song_entry(
song_offset=song_offset,
num_voices=C,
num_patterns=num_taud_pats,
bpm_stored=bpm_stored,
tick_rate=speed,
base_note=0xA000, # C9
base_freq=8363.0,
flags_byte=flags_byte,
pat_bin_comp_size=len(pat_comp),
cue_sheet_comp_size=len(cue_comp),
global_vol=global_vol_taud,
mixing_vol=mixing_vol_taud,
)
assert len(song_table) == TAUD_SONG_ENTRY
return header + compressed + song_table + bytes(pat_bin) + bytes(sheet)
return header + compressed + song_table + pat_comp + cue_comp
# ── Main ──────────────────────────────────────────────────────────────────────

View File

@@ -40,6 +40,7 @@ from taud_common import (
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
J_SEMI_TABLE,
d_arg_to_col, resample_linear, encode_cue, deduplicate_patterns,
encode_song_entry,
)
@@ -745,27 +746,36 @@ def assemble_taud(mod: dict) -> bytes:
vprint(f" patterns: {orig_count}{num_taud_pats} unique "
f"({orig_count - num_taud_pats} deduplicated)")
# ProTracker is Amiga-period-based by definition, so we set the f bit so
# the engine applies coarse pitch slides in period space (recovers PT's
# characteristic non-linear pitch character).
flags_byte = 0x02
song_table = struct.pack('<IBHBBHfB',
song_offset,
n_channels,
num_taud_pats,
bpm_stored,
speed,
0xA000,
8363.0,
flags_byte,
)
assert len(song_table) == TAUD_SONG_ENTRY
vprint(" building cue sheet…")
cue_sheet = build_cue_sheet(order_list, n_patterns, n_channels, pat_remap)
assert len(cue_sheet) == NUM_CUES * CUE_SIZE
return header + compressed + song_table + bytes(pat_bin) + cue_sheet
pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0)
cue_comp = gzip.compress(bytes(cue_sheet), compresslevel=9, mtime=0)
vprint(f" pattern bin: {len(pat_bin)}{len(pat_comp)} bytes (gzip)")
vprint(f" cue sheet: {len(cue_sheet)}{len(cue_comp)} bytes (gzip)")
# ProTracker is Amiga-period-based by definition, so we set the f bit so
# the engine applies coarse pitch slides in period space (recovers PT's
# characteristic non-linear pitch character).
flags_byte = 0x02
song_table = encode_song_entry(
song_offset=song_offset,
num_voices=n_channels,
num_patterns=num_taud_pats,
bpm_stored=bpm_stored,
tick_rate=speed,
base_note=0xA000,
base_freq=8363.0,
flags_byte=flags_byte,
pat_bin_comp_size=len(pat_comp),
cue_sheet_comp_size=len(cue_comp),
global_vol=0xFF,
mixing_vol=0xFF,
)
assert len(song_table) == TAUD_SONG_ENTRY
return header + compressed + song_table + pat_comp + cue_comp
# ── Main ─────────────────────────────────────────────────────────────────────

View File

@@ -17,8 +17,9 @@ Effect support:
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) is
dropped with a -v warning. X converts to pan column. Y (panbrello) converts
slide. M/N/X/P fold into volume / pan columns. W (global vol slide)
converts to Taud W (arg in high byte, same encoding as D). X converts to
pan column. Y (panbrello) converts
to Taud Y. S5 selects the panbrello LFO waveform. S8x converts to a pan
column SET of round(x * 4.2), mapping nibble 0-15 directly to pan 0-63.
"""
@@ -36,14 +37,14 @@ from taud_common import (
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4,
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I,
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_Y,
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_W, TOP_Y,
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
EFF_A, EFF_B, EFF_C, EFF_D, EFF_E, EFF_F, EFF_G, EFF_H, EFF_I, EFF_J,
EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T,
EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z,
J_SEMI_TABLE,
d_arg_to_col, resample_linear, encode_cue, deduplicate_patterns,
normalise_sample,
normalise_sample, encode_song_entry,
)
@@ -356,8 +357,8 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0,
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)
# W$xy: same nibble-pair layout as D, passed in the high byte.
return (TOP_W, (arg & 0xFF) << 8, None, None)
if cmd == EFF_X:
return (TOP_S, 0x8000 | (arg & 0xFF), None, None)
@@ -817,28 +818,37 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count)
vprint(f" patterns: {orig_count}{num_taud_pats} unique ({orig_count - num_taud_pats} deduplicated)")
# Song table row (16 bytes): offset(4)+voices(1)+pats(2)+bpm(1)+tick(1)+basenote(2)+basefreq(4)+flags(1)
# Built after dedup so num_taud_pats reflects the unique count.
# flags byte: bit 1 (f) = Amiga pitch-slide mode (mirrors the S3M linear_slides flag inverted)
flags_byte = 0x00 if h.linear_slides else 0x02
song_table = struct.pack('<IBHBBHfB',
song_offset,
C,
num_taud_pats,
bpm_stored,
speed,
0xA000, # C9
8363.0, # Hz
flags_byte,
)
assert len(song_table) == TAUD_SONG_ENTRY
# Cue sheet (using remapped pattern indices)
vprint(" building cue sheet…")
cue_sheet = build_cue_sheet(h.order_list, P, C, pat_remap)
assert len(cue_sheet) == NUM_CUES * CUE_SIZE
return header + compressed + song_table + bytes(pat_bin) + cue_sheet
# Compress pattern bin and cue sheet (per Taud spec)
pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0)
cue_comp = gzip.compress(bytes(cue_sheet), compresslevel=9, mtime=0)
vprint(f" pattern bin: {len(pat_bin)}{len(pat_comp)} bytes (gzip)")
vprint(f" cue sheet: {len(cue_sheet)}{len(cue_comp)} bytes (gzip)")
# Song table row (32 bytes; see encode_song_entry).
# flags byte: bit 1 (f) = Amiga pitch-slide mode (mirrors the S3M linear_slides flag inverted)
flags_byte = 0x00 if h.linear_slides else 0x02
song_table = encode_song_entry(
song_offset=song_offset,
num_voices=C,
num_patterns=num_taud_pats,
bpm_stored=bpm_stored,
tick_rate=speed,
base_note=0xA000, # C9
base_freq=8363.0,
flags_byte=flags_byte,
pat_bin_comp_size=len(pat_comp),
cue_sheet_comp_size=len(cue_comp),
global_vol=0xFF,
mixing_vol=0xFF,
)
assert len(song_table) == TAUD_SONG_ENTRY
return header + compressed + song_table + pat_comp + cue_comp
# ── Main ─────────────────────────────────────────────────────────────────────

View File

@@ -28,8 +28,8 @@ def vprint(*a, **kw) -> None:
TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64])
TAUD_VERSION = 1
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+rsvd(4)+sig(14)
TAUD_SONG_ENTRY = 16 # offset(4)+voices(1)+pats(2)+bpm(1)+tick(1)+basenote(2)+basefreq(4)+flags(1)
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+projOff(4)+sig(14)
TAUD_SONG_ENTRY = 32 # full spec entry (see encode_song_entry)
SAMPLEBIN_SIZE = 737280
INSTBIN_SIZE = 49152 # 256 instruments × 192 bytes
SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE
@@ -67,6 +67,7 @@ TOP_S = 0x1C
TOP_T = 0x1D
TOP_U = 0x1E
TOP_V = 0x1F
TOP_W = 0x20
TOP_Y = 0x22
# Volume / pan column selectors (2-bit field at top of vol/pan byte)
@@ -165,6 +166,39 @@ def deduplicate_patterns(pat_bin: bytes, num_pats: int) -> tuple:
return b''.join(canonical), remap, len(canonical)
def encode_song_entry(song_offset: int, num_voices: int, num_patterns: int,
bpm_stored: int, tick_rate: int,
base_note: int, base_freq: float, flags_byte: int,
pat_bin_comp_size: int, cue_sheet_comp_size: int,
global_vol: int = 0x80, mixing_vol: int = 0x80) -> bytes:
"""Pack a 32-byte Taud song table entry.
Layout:
u32 song_offset, u8 num_voices, u16 num_patterns,
u8 bpm_stored, u8 tick_rate,
u16 base_note, f32 base_freq,
u8 flags, u8 global_vol, u8 mixing_vol,
u32 pat_bin_comp_size, u32 cue_sheet_comp_size,
byte[6] reserved.
"""
entry = struct.pack('<IBHBBHfBBBII',
song_offset,
num_voices & 0xFF,
num_patterns & 0xFFFF,
bpm_stored & 0xFF,
tick_rate & 0xFF,
base_note & 0xFFFF,
float(base_freq),
flags_byte & 0xFF,
global_vol & 0xFF,
mixing_vol & 0xFF,
pat_bin_comp_size & 0xFFFFFFFF,
cue_sheet_comp_size & 0xFFFFFFFF,
) + b'\x00' * 6
assert len(entry) == TAUD_SONG_ENTRY
return entry
def normalise_sample(raw: bytes, signed: bool, is_16bit: bool,
is_stereo: bool, name: str) -> bytes:
"""Return unsigned 8-bit mono sample bytes, downmixing/depthing as needed."""

View File

@@ -2092,14 +2092,14 @@ TODO:
[x] (same context as above) implement S7x command
[x] on playback, panning changes randomly on Taud made by s3m2taud.py and mod2taud.py, but not by it2taud.py (maybe something's off with the instrument exports?)
[x] NNA not disabled for S3M and MOD
[ ] implement S6x command
[ ] `S B000` and `S B100` not working as intended -- on first playback it jumps to the next cue same row, on subsequent playbacks the commands are completely ignored
[ ] implement Wxx command (global volume slide)
[ ] implement sample loop sustain
[ ] cue and pattern compression of the Taud format (taud_common.py, taud.mjs)
[x] `S B000` and `S B100` not working as intended -- on first playback it jumps to the next cue same row, on subsequent playbacks the commands are completely ignored
[x] implement S6x command
[x] implement Wxx command (global volume slide)
[x] implement sample loop sustain
"Caveat: on a foreground voice, key-off (row.note == 0x0000) currently sets voice.active = false at AudioAdapter.kt:1713, which silences the channel immediately. Sustain-loop escape therefore only takes effect on background voices spawned by NNA "Note Off" — which matches the IT idiom of layering a new note over a sustained one. Let me know if you also want the foreground key-off to keep the voice playing through fadeout."
[x] cue and pattern compression of the Taud format (taud_common.py, taud.mjs)
[ ] figure out how IT (8 bits) and FT2 (12 bits) handles volume fadeout numbers, and come up with a compatible Taud spec, then implement
[ ] implement bitcrusher (eff sym '8')
[ ] Figure out why pitch slides are not working right on 2nd_pm.s3m only
Play Data: play data are series of tracker-like instructions, visualised as:
@@ -2318,7 +2318,9 @@ Endianness: Little
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
Uint8 Song mixing volume
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
Byte[14] Reserved
Uint32 Compressed size of PATTERN BIN for this song
Uint32 Compressed size of CUE SHEET for this song
Byte[6] Reserved
Taud device can queue up to 2 "playdata" in its buffer, which can be interpreted as a song.

View File

@@ -139,6 +139,12 @@ class AudioJSR223Delegate(private val vm: VM) {
return getFirstSnd()?.playheads?.get(playhead)?.initialGlobalFlags
}
fun setSongGlobalVolume(playhead: Int, volume: Int) { getPlayhead(playhead)?.globalVolume = volume and 255 }
fun getSongGlobalVolume(playhead: Int) = getPlayhead(playhead)?.globalVolume
fun setSongMixingVolume(playhead: Int, volume: Int) { getPlayhead(playhead)?.mixingVolume = volume and 255 }
fun getSongMixingVolume(playhead: Int) = getPlayhead(playhead)?.mixingVolume
fun putPcmDataByPtr(playhead: Int, ptr: Int, length: Int, destOffset: Int) {
getFirstSnd()?.let {
val vkMult = if (ptr >= 0) 1 else -1

View File

@@ -1454,7 +1454,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (voice.forward) {
voice.samplePos += voice.playbackRate
when (inst.loopMode) {
// When the sustain bit is set, key-off escapes the loop: the sample plays past
// loopEnd until it ends naturally (loopMode 0 semantics).
val effectiveLoopMode =
if (inst.sampleLoopSustain && voice.keyOff) 0 else (inst.loopMode and 3)
when (effectiveLoopMode) {
0 -> if (voice.samplePos >= sampleLen) voice.active = false
1 -> if (voice.samplePos >= loopEnd) voice.samplePos -= (loopEnd - loopStart).coerceAtLeast(1.0)
2 -> if (voice.samplePos >= loopEnd) { voice.samplePos = loopEnd; voice.forward = false }
@@ -1679,6 +1683,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val cue = cueSheet[ts.cuePos]
// Reset row-scope state before scanning channels.
if (!ts.patternDelayActive) ts.sexWinningChannel = -1
ts.finePatternDelayExtra = 0
for (vi in 0..19) {
val patNum = cue.patterns[vi]
@@ -1699,6 +1704,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.panbrelloActive = false
voice.retrigActive = false
voice.tempoSlideDir = 0
voice.wSlideDir = 0
voice.volColSlideUp = 0; voice.volColSlideDown = 0
voice.panColSlideRight = 0; voice.panColSlideLeft = 0
voice.rowEffect = row.effect
@@ -1834,7 +1840,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
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) {
if ((inst.loopMode and 3) != 0 && inst.sampleLoopEnd > inst.sampleLoopStart && off > inst.sampleLoopEnd) {
val loopLen = (inst.sampleLoopEnd - inst.sampleLoopStart).coerceAtLeast(1)
off = inst.sampleLoopStart + ((off - inst.sampleLoopStart) % loopLen)
}
@@ -1885,6 +1891,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val hi = (rawArg ushr 8) and 0xFF
playhead.globalVolume = hi
}
EffectOp.OP_W -> {
val arg = resolveArg(rawArg, voice.mem.w).also { if (rawArg != 0) voice.mem.w = it }
val hi = (arg ushr 8) and 0xFF
val lo = hi and 0x0F
val hin = (hi ushr 4) and 0x0F
when {
hi == 0xFF -> playhead.globalVolume = (playhead.globalVolume + 0xF).coerceAtMost(0xFF) // WFF quirk: fine up by F
hin == 0xF && lo != 0 -> playhead.globalVolume = (playhead.globalVolume - lo).coerceAtLeast(0) // fine down on tick 0
lo == 0xF && hin != 0 -> playhead.globalVolume = (playhead.globalVolume + hin).coerceAtMost(0xFF) // fine up on tick 0
hin == 0 && lo != 0 -> { voice.wSlideDir = -1; voice.wSlideAmount = lo } // coarse down per non-first tick
lo == 0 && hin != 0 -> { voice.wSlideDir = +1; voice.wSlideAmount = hin } // coarse up per non-first tick
}
}
EffectOp.OP_Y -> {
val sp = (rawArg ushr 8) and 0xFF
val dp = rawArg and 0xFF
@@ -1908,6 +1927,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
0x3 -> { voice.vibratoWave = x and 3; voice.vibratoRetrig = (x and 4) == 0 }
0x4 -> { voice.tremoloWave = x and 3; voice.tremoloRetrig = (x and 4) == 0 }
0x5 -> { voice.panbrelloWave = x and 3; voice.panbrelloRetrig = (x and 4) == 0 }
0x6 -> ts.finePatternDelayExtra += x // fine pattern delay: accumulate across channels
0x7 -> when (x) {
// Past-note actions on the channel's background ghosts.
0x0 -> applyPastNoteAction(ts, vi, 0) // Past Note Cut
@@ -1938,9 +1958,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (voice.loopCount == 0) {
voice.loopCount = x
ts.pendingRowJump = voice.loopStartRow
ts.pendingRowJumpLocal = true
} else if (!ts.patternDelayActive) {
voice.loopCount--
if (voice.loopCount > 0) ts.pendingRowJump = voice.loopStartRow
if (voice.loopCount > 0) {
ts.pendingRowJump = voice.loopStartRow
ts.pendingRowJumpLocal = true
}
}
}
}
@@ -2138,6 +2162,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
}
}
// Global volume slide (W coarse) — applied once per non-first tick per armed channel.
if (ts.tickInRow > 0) {
for (voice in ts.voices) {
if (voice.wSlideDir != 0) {
playhead.globalVolume = (playhead.globalVolume + voice.wSlideDir * voice.wSlideAmount).coerceIn(0, 0xFF)
}
}
}
// Funk repeat (S$Fxxxx) — advance bit-mask per tick on instruments with active funkSpeed.
for (voice in ts.voices) {
if (voice.funkSpeed == 0 || !voice.active) continue
@@ -2211,6 +2244,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
playhead.position = ts.cuePos
}
// Per TAUD_NOTE_EFFECTS.md §S$Bx00: on pattern change reset loop_start_row and loop_count.
private fun resetPatternLoopState(ts: TrackerState) {
for (voice in ts.voices) {
voice.loopStartRow = 0
voice.loopCount = 0
}
}
internal fun generateTrackerAudio(playhead: Playhead): ByteArray? {
val ts = playhead.trackerState ?: return null
@@ -2229,7 +2270,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
ts.samplesIntoTick -= spt
applyTrackerTick(ts, playhead)
ts.tickInRow++
if (ts.tickInRow >= playhead.tickRate) {
if (ts.tickInRow >= playhead.tickRate + ts.finePatternDelayExtra) {
ts.tickInRow = 0
advanceRow(ts, playhead)
}
@@ -2238,6 +2279,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var mixL = 0.0
var mixR = 0.0
val gvol = playhead.globalVolume / 255.0
val mvol = playhead.mixingVolume / 255.0
for (voice in ts.voices) {
if (!voice.active || voice.muted) continue
val voiceInst = instruments[voice.instrumentId]
@@ -2248,7 +2290,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Volume envelope is bypassed (treated as unity) when S $77 has disabled it.
val effEnvVol = if (voice.volEnvOn) voice.envVolume else 1.0
val vol = effEnvVol * voice.fadeoutVolume * voice.rowVolume / 63.0 *
swingScale * gvol * instGv * playhead.masterVolume / 255.0
swingScale * gvol * mvol * instGv * playhead.masterVolume / 255.0
val pan = if (voice.hasPanEnv && voice.panEnvOn) {
val envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255)
(voice.channelPan + envPanRaw - 128 + voice.randomPanBias).coerceIn(0, 255)
@@ -2278,7 +2320,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val swingScale = 1.0 + bg.randomVolBias / 255.0
val effEnvVol = if (bg.volEnvOn) bg.envVolume else 1.0
val vol = effEnvVol * bg.fadeoutVolume * bg.rowVolume / 63.0 *
swingScale * gvol * instGv * playhead.masterVolume / 255.0
swingScale * gvol * mvol * instGv * playhead.masterVolume / 255.0
val pan = if (bg.hasPanEnv && bg.panEnvOn) {
val envPanRaw = (bg.envPan * 255.0).roundToInt().coerceIn(0, 255)
(bg.channelPan + envPanRaw - 128 + bg.randomPanBias).coerceIn(0, 255)
@@ -2325,25 +2367,34 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val pendingB = ts.pendingOrderJump
val pendingC = ts.pendingRowJump
val pendingLocal = ts.pendingRowJumpLocal
ts.pendingOrderJump = -1
ts.pendingRowJump = -1
ts.pendingRowJumpLocal = false
when {
pendingB >= 0 -> {
ts.cuePos = pendingB.coerceAtMost(1023)
ts.rowIndex = if (pendingC >= 0) pendingC else 0
playhead.position = ts.cuePos
resetPatternLoopState(ts)
}
pendingC >= 0 && pendingLocal -> {
// S$Bx pattern loop — stay in the current cue, just rewind the row.
ts.rowIndex = pendingC.coerceIn(0, 63)
}
pendingC >= 0 -> {
// Pattern break — advance order by one (or honour cue's own instruction), then jump to row.
// C$xx 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)
resetPatternLoopState(ts)
}
else -> {
ts.rowIndex++
if (ts.rowIndex >= 64) {
ts.rowIndex = 0
advanceTrackerCue(ts, playhead)
resetPatternLoopState(ts)
}
}
}
@@ -2437,6 +2488,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var o: Int = 0
var q: Int = 0
var tslide: Int = 0
var w: Int = 0
}
class Voice {
@@ -2585,6 +2637,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var tempoSlideDir = 0 // 0 = none, -1 = down, +1 = up
var tempoSlideAmount = 0
// Global volume slide (W $xy00) — per-channel, applied to playhead.globalVolume on tick > 0.
var wSlideDir = 0 // 0 = none, -1 = down, +1 = up
var wSlideAmount = 0
// Volume / pan column slides (selectors 1/2/3 from TAUD_NOTE_EFFECTS.md §"Volume column effects").
var volColSlideUp = 0
var volColSlideDown = 0
@@ -2610,6 +2666,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// 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
// Distinguishes S$Bx pattern-loop (stays in current cue) from C$xx pattern-break (advances cue).
var pendingRowJumpLocal = false
// Pattern-delay state (S$Ex) — number of additional row-repetitions remaining.
var patternDelayRemaining = 0
@@ -2618,6 +2676,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Channel index of the SEx that won this row (lowest channel wins ties).
var sexWinningChannel = -1
// Fine pattern delay (S$6x) — extra ticks added to the current row; accumulated across all channels.
var finePatternDelayExtra = 0
// Pre-allocated mix buffers for dither path (reused each audio chunk).
val mixLeft = FloatArray(TRACKER_CHUNK)
val mixRight = FloatArray(TRACKER_CHUNK)
@@ -2643,6 +2704,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var patBank1: Int = 0,
var patBank2: Int = 0,
var globalVolume: Int = 0x80, // 8-bit, default $80 (spec §5). Mutated by V $xx00.
var mixingVolume: Int = 0x80, // 8-bit, default $80 (spec §5). Final-mix scaler, set once per song.
var pcmQueue: Queue<ByteArray> = Queue<ByteArray>(),
var pcmQueueSizeIndex: Int = 0,
@@ -2734,12 +2796,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
bpm = 125
tickRate = 6
globalVolume = 0x80
mixingVolume = 0x80
trackerState?.let { ts ->
ts.cuePos = 0; ts.rowIndex = 0; ts.tickInRow = 0
ts.samplesIntoTick = 0.0; ts.firstRow = true
ts.pendingOrderJump = -1; ts.pendingRowJump = -1
ts.pendingRowJumpLocal = false
ts.patternDelayRemaining = 0; ts.patternDelayActive = false
ts.sexWinningChannel = -1
ts.finePatternDelayExtra = 0
ts.panLaw = initialGlobalFlags and 1
ts.amigaMode = (initialGlobalFlags and 2) != 0
ts.voices.forEach {
@@ -2866,7 +2931,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var samplePlayStart: Int,
var sampleLoopStart: Int,
var sampleLoopEnd: Int,
var loopMode: Int, // byte 14, low 2 bits
var loopMode: Int, // byte 14, low 3 bits (bits 0-1: loop kind, bit 2: sustain)
var volEnvSustain: Int, // bytes 15-16 (16-bit, see flag layout)
var panEnvSustain: Int, // bytes 17-18
var pfEnvSustain: Int, // bytes 19-20 (pitch/filter)
@@ -2899,6 +2964,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
0, 0, 0, 0
)
/** Sample-flag byte 14 bit 2 — when set, the sample loop is a sustain loop:
* it loops while the note is held and is escaped on key-off. */
val sampleLoopSustain: Boolean get() = (loopMode and 0x04) != 0
/** New note action — instrumentFlag bits 0-1.
* 0=note off, 1=note cut, 2=continue, 3=note fade. */
val newNoteAction: Int get() = instrumentFlag and 0x03
@@ -2961,7 +3029,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
12 -> sampleLoopEnd.toByte()
13 -> sampleLoopEnd.ushr(8).toByte()
14 -> (loopMode and 3).toByte()
14 -> (loopMode and 7).toByte()
15 -> volEnvSustain.toByte()
16 -> volEnvSustain.ushr(8).toByte()
17 -> panEnvSustain.toByte()
@@ -3016,7 +3084,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
12 -> { sampleLoopEnd = (sampleLoopEnd and 0xff00) or byte }
13 -> { sampleLoopEnd = (sampleLoopEnd and 0x00ff) or (byte shl 8) }
14 -> { loopMode = byte and 3 }
14 -> { loopMode = byte and 7 }
15 -> { volEnvSustain = (volEnvSustain and 0xff00) or byte }
16 -> { volEnvSustain = (volEnvSustain and 0x00ff) or (byte shl 8) }
17 -> { panEnvSustain = (panEnvSustain and 0xff00) or byte }