taud: cue and pattern compression

This commit is contained in:
minjaesong
2026-05-02 19:00:07 +09:00
parent 219ca1e475
commit d2b1e792b9
7 changed files with 212 additions and 109 deletions

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
}
}

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,27 +109,39 @@ 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 // TODO use it
let songMixingVolume = sys.peek(filePtr + entryOff + 17) & 0xFF // TODO use it
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
let patBytes = new Array(PATTERN_SIZE)
// -- 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)
@@ -189,13 +201,38 @@ function captureTrackerDataToFile(outFile) {
let tickRate = audio.getTickRate(0) || 6
let bpmStored = (bpm - 24) & 0xFF
// -- 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 +240,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,
@@ -222,40 +259,45 @@ function captureTrackerDataToFile(outFile) {
numPats & 0xFF, (numPats >>> 8) & 0xFF, // numPatterns Uint16 LE
bpmStored, // BPM with 24 bias
tickRate, // initial tick-rate
0x00,0xA0, // basenote (0xA000 -- C9)
0x00,0xAC,0x02,0x46, // basefreq (8363 Hz)
sys.peek(baseAddr - 7), // mixer flags
0x00,0xA0, // basenote (0xA000 -- C9)
0x00,0xAC,0x02,0x46, // basefreq (8363 Hz)
sys.peek(baseAddr - 7), // mixer flags
0x80, // global volume (default)
0x80, // mixing volume (default)
// 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

@@ -53,7 +53,7 @@ from taud_common import (
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,
)
@@ -1734,18 +1734,29 @@ 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,
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),
)
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,34 @@ 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),
)
assert len(song_table) == TAUD_SONG_ENTRY
return header + compressed + song_table + pat_comp + cue_comp
# ── Main ─────────────────────────────────────────────────────────────────────

View File

@@ -44,7 +44,7 @@ from taud_common import (
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,
)
@@ -818,28 +818,35 @@ 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),
)
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
@@ -166,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

@@ -2097,10 +2097,9 @@ TODO:
[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."
[ ] cue and pattern compression of the Taud format (taud_common.py, taud.mjs)
[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:
@@ -2319,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.