xm2taud (wip), separate sustain and loop def

This commit is contained in:
minjaesong
2026-05-06 05:31:55 +09:00
parent 1e482e32a8
commit 60b07a325a
12 changed files with 1954 additions and 378 deletions

View File

@@ -4,3 +4,5 @@ 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
for f in *.mon; python3 mon2taud.py $f assets/disk0/(basename $f .mon).taud; end
for f in *.MON; python3 mon2taud.py $f assets/disk0/(basename $f .MON).taud; end

View File

@@ -956,13 +956,14 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
**Plain.** Sets mixer-wide behaviour flags. Available flags are:
0b 0000 0mfp
0b 0000 Fmfp
- p unset: Linear panning mode (tracker-accurate). Centre panning gets 3 dB boost. Default setting.
- p set: Equal-power panning mode. L/R amplitude is at 0.707 when centre-panned.
- f unset: Linear tone mode. Pitch shift will behave like MIDI/ImpulseTracker/ScreamTracker linear mode. **Coarse and fine E/F arguments are stored as 4096-TET pitch units** and subtracted/added directly from the stored pitch.
- f set: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker default mode. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode.
- Ff = 0: Linear tone mode. Pitch shift will behave like MIDI/ImpulseTracker/ScreamTracker linear mode. **Coarse and fine E/F arguments are stored as 4096-TET pitch units** and subtracted/added directly from the stored pitch.
- Ff = 1: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker default mode. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode.
- Ff = 2: Linear frequency mode. Pitch shift will behave against frequency number.
- m unset: IT fadeout-zero policy. An instrument with stored volume fadeout = 0 does **not** fade out on key-off; the voice plays through until the volume envelope ends it (or never, if there is no envelope).
- m set: FT2 fadeout-zero policy. An instrument with stored volume fadeout = 0 is **cut** on the first tick after key-off (or NNA Note-Fade). Nonzero fadeouts behave identically in both modes — the per-tick decrement is always `fadeout / 65536` in unity-volume units.

View File

@@ -275,7 +275,7 @@ function noteToStr(note) {
if (note === 0x0000) return sym.keyoff
if (pitchTablePresets[PITCH_PRESET_IDX].table.length === 0) return note.hex04()
const [s, o] = pitchSymLut[note & 0xFFF]
return s + ((note >> 12) - 1 + o)
return s + ((note >> 12) - 1 + o).toString(16) // octave 10 -> 'a'
}
/**
@@ -1109,7 +1109,7 @@ function drawVoiceDetail(isVerticalLayout = false, ptn = null, activeRow = -1, c
let cumLines = []
if (cumState !== null && lowerH > 0) {
const _apo = Math.abs(cumState.pitchOff)
const _psgn = cumState.pitchOff > 0 ? '+' : cumState.pitchOff < 0 ? '-' : '='
const _psgn = cumState.pitchOff > 0 ? '+' : cumState.pitchOff < 0 ? '-' : ' '
const _absN = (cumState.lastNote !== 0xFFFF && cumState.pitchOff !== 0)
? noteToStr(Math.max(0, Math.min(0xFFFE, cumState.lastNote + cumState.pitchOff))) + ' '
: ''
@@ -1319,7 +1319,7 @@ if (fullPathObj === undefined) {
const logofile = files.open("A:/tvdos/bin/tauthdr.r8")
const logoBytes = logofile.bread(); logofile.close()
const logoTexture = new gl.Texture(90, 14, logoBytes)
const logoTexture = new gl.Texture(92, 14, logoBytes)
const buttonfile = files.open("A:/tvdos/bin/tautbtn.r8")
const buttonBytes = buttonfile.bread(); buttonfile.close()
const buttonTexture = new gl.Texture(2, 28, buttonBytes)
@@ -1689,24 +1689,35 @@ function simulateRowState(ptnDat, uptoRow) {
// Note column
const isGRow = (effop === OP_G)
const isNoteDelay = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0xD)
// Track whether this row reloads the channel's default volume. Engine:
// triggerNote() resets channelVolume to 0x3F on fresh triggers, and an
// instrument byte on a tone-porta row also reloads default vol (matches
// schism csf_instrument_change inst_column branch).
let reloadDefaultVol = false
if (note !== 0xFFFF && note !== 0xFFFE) {
if (note === 0x0000) {
// key-off; sample stays referenced
} else if (isGRow) {
portaTarget = note
if (inst !== 0) reloadDefaultVol = true
} else if (isNoteDelay) {
// Delayed trigger: latched but doesn't fire on this row's first tick.
// For "state at end of row" treat as if it triggered.
lastNote = note
pitchOff = 0
portaTarget = -1
reloadDefaultVol = true
} else {
lastNote = note
pitchOff = 0
portaTarget = -1
reloadDefaultVol = true
}
}
if (inst !== 0) lastInst = inst
// Default vol reset must happen before the volume column so a SET selector
// can still override on the same row (engine order: triggerNote → applyVolColumn).
if (reloadDefaultVol) volAbs = 0x3F
// Pre-scan effect column for S$80xx (8-bit pan SET wins over volcol/pancol SET).
const rowHasS80 = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0x8)
@@ -2089,16 +2100,20 @@ function drawProjectContents(wo) {
for (let y = PTNVIEW_OFFSET_Y; y < SCRH; y++) fillLine(y, colBackPtn, 255)
let mixerflag = initialTrackerMixerflags
let flagstrbuf = ''
let flagStrSelected = []
let flagstr = [
['Linear pan','Equal-energy pan'],
['Linear tone','Amiga tone'],
['Linear pan','EquNrg pan'],
['Linear pitch','Amiga pitch', 'Linear freq', ''], // TODO MONOTONE uses linear-freq pitch
['IT fade','FT2 fade'],
]
for (let i = 0; i < flagstr.length; i++) {
let s = flagstr[i][(mixerflag >>> i) & 1 != 0]
if (i > 0) flagstrbuf += ', ';
flagstrbuf += s
if (i != 1 && 1 != 3) {
let s = flagstr[i][(mixerflag >>> i) & 1 != 0]
flagStrSelected.push(s)
}
}
let toneMode = (((mixerflag >>> 1) & 1)) | (((mixerflag >>> 3) & 1) << 1)
flagStrSelected.splice(1, 0, flagstr[1][toneMode])
let projMeta = {
@@ -2106,7 +2121,7 @@ function drawProjectContents(wo) {
Patterns: `${song.numPats}/4095 ($${song.numPats.hex03()})`,
Cues: `${song.lastActiveCue}/1024 ($${song.lastActiveCue.hex03()})`,
Notation: pitchTablePresets[PITCH_PRESET_IDX].name,
Flags: `${flagstrbuf} ($${mixerflag.hex02()})`,
Flags: `${flagStrSelected.join(', ')} ($${mixerflag.hex02()})`,
GlobalVol: initialGlobalVolume,
MixingVol: initialMixingVolume
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 490 B

Binary file not shown.

View File

@@ -54,6 +54,7 @@ from taud_common import (
J_SEMI_TABLE,
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
normalise_sample, encode_song_entry,
CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len,
)
@@ -467,8 +468,9 @@ def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list:
class ITInstrument:
__slots__ = ('name', 'dfp', 'gv', 'canonical_sample', 'canonical_volume',
'vol_envelope', 'pan_envelope', 'vol_env_sustain', 'pan_env_sustain',
'pf_envelope', 'pf_env_sustain', 'pf_is_filter',
'vol_envelope', 'pan_envelope', 'pf_envelope', 'pf_is_filter',
'vol_env_loop', 'pan_env_loop', 'pf_env_loop',
'vol_env_sus', 'pan_env_sus', 'pf_env_sus',
'ifc', 'ifr', 'fadeout', 'pps', 'ppc', 'rv', 'rp', 'nna',
'dct', 'dca')
# vol_envelope / pan_envelope / pf_envelope: list of 25 (value, minifloat_idx) tuples, or None
@@ -538,14 +540,14 @@ def parse_instruments(data: bytes, h: ITHeader) -> list:
# Parse IT envelopes (new-format only, ≥cmwt 0x200)
# Vol envelope at ptr+0x130; pan envelope at ptr+0x182; pf envelope at ptr+0x1D4
ticks_per_sec = max(h.initial_tempo * 2.0 / 5.0, 1.0)
inst.vol_envelope, inst.vol_env_sustain = _parse_it_envelope(
inst.vol_envelope, inst.vol_env_loop, inst.vol_env_sus = _parse_it_envelope(
data, ptr + 0x130, kind='vol', ticks_per_sec=ticks_per_sec)
inst.pan_envelope, inst.pan_env_sustain = _parse_it_envelope(
inst.pan_envelope, inst.pan_env_loop, inst.pan_env_sus = _parse_it_envelope(
data, ptr + 0x182, kind='pan', ticks_per_sec=ticks_per_sec)
# pf envelope: byte 0 bit 7 distinguishes filter (1) from pitch (0).
pf_flag_byte = data[ptr + 0x1D4] if ptr + 0x1D4 < len(data) else 0
inst.pf_is_filter = bool(pf_flag_byte & 0x80)
inst.pf_envelope, inst.pf_env_sustain = _parse_it_envelope(
inst.pf_envelope, inst.pf_env_loop, inst.pf_env_sus = _parse_it_envelope(
data, ptr + 0x1D4, kind=('filter' if inst.pf_is_filter else 'pitch'),
ticks_per_sec=ticks_per_sec)
insts.append(inst)
@@ -555,31 +557,32 @@ def parse_instruments(data: bytes, h: ITHeader) -> list:
def _parse_it_envelope(data: bytes, env_ptr: int, kind: str,
ticks_per_sec: float) -> tuple:
"""Parse one IT envelope block (vol / pan / pitch / filter) into up to 25
Taud (value, minifloat_idx) points + a 16-bit sustain/flags word.
Taud (value, minifloat_idx) points + LOOP word + SUSTAIN word.
Returns (points_list, sus_word). points_list has 25 entries (padded
with hold-zeros) or None if the envelope is disabled.
Returns (points_list, loop_word, sustain_word).
points_list has 25 entries (padded with hold) or None if the envelope is
disabled. loop_word and sustain_word are zero when the corresponding
region is not enabled.
kind:
'vol' — IT 0..64 → Taud 0..63 (byte 1 = volume)
'vol' — IT 0..64 → Taud 0..63
'pan' — IT -32..+32 → Taud 0..255 (0x80 = centre)
'pitch' — IT -32..+32 → Taud 0..255 (0x80 = unity, 1 unit ≈ 1 semitone)
'pitch' — IT -32..+32 → Taud 0..255 (0x80 = unity)
'filter' — IT -32..+32 → Taud 0..255 (0x80 = unity cutoff)
sus_word layout (16 bits, 0b 0ut sssss pcb eeeee):
bit 14 = u (enable sustain/loop)
bit 13 = t (sustain — breaks on key-off when set)
bits 12..8 = sustain/loop start (5-bit index 0..24)
bit 7 = p (vol: fadeout-zero; pan: use default pan; pf: filter mode)
bit 6 = c (envelope carry)
bit 5 = b (use envelope at all)
bits 4..0 = sustain/loop end (5-bit index 0..24)
Word layout (terranmon.txt:2049+ / 2114+):
LOOP word: 0b 0000_0sss_ssXcb_eeeee (X = 'p'/'m' for pan/pf, 0 for vol)
SUSTAIN word: 0b 0000_0sss_ss00b_eeeee
bits 12..8 = start index, bits 4..0 = end index
bit 7 = p (pan: use default pan) / m (pf: pitch=0/filter=1) / 0 (vol)
bit 6 = c (envelope carry — placed in the LOOP word)
bit 5 = b (enable that region)
"""
if env_ptr + 82 > len(data):
return None, 0
return None, 0, 0
flags = data[env_ptr]
if not (flags & 0x01):
return None, 0 # envelope not enabled
return None, 0, 0 # envelope not enabled
num_nodes = max(1, min(data[env_ptr + 1], 25))
it_lpb = data[env_ptr + 2]
@@ -591,20 +594,6 @@ def _parse_it_envelope(data: bytes, env_ptr: int, kind: str,
carry = bool(flags & 0x08)
is_filter = bool(flags & 0x80) and kind in ('pitch', 'filter')
# Priority: sus loop > env loop (Taud carries one loop region).
if has_sus_loop:
use_lb, use_le = it_slb, it_sle
has_loop = True
is_sustain = True
elif has_env_loop:
use_lb, use_le = it_lpb, it_lpe
has_loop = True
is_sustain = False
else:
use_lb = use_le = -1
has_loop = False
is_sustain = False
# Read IT nodes: (int8 value, uint16 tick_pos LE)
nodes = []
for n in range(num_nodes):
@@ -615,14 +604,13 @@ def _parse_it_envelope(data: bytes, env_ptr: int, kind: str,
tick = struct.unpack_from('<H', data, nptr + 1)[0]
nodes.append((val, tick))
if not nodes:
return None, 0
return None, 0, 0
def _to_taud_val(it_val: int) -> int:
if kind == 'vol':
return min(63, max(0, round(it_val * 63 / 64)))
if kind == 'pan':
return min(255, max(0, round((it_val + 32) * 255 / 64)))
# pitch / filter: -32..+32 → 0..255 (0x80 = unity)
return min(255, max(0, round((it_val + 32) * 255 / 64)))
pad_value = (63 if kind == 'vol' else 0x80)
@@ -639,27 +627,34 @@ def _parse_it_envelope(data: bytes, env_ptr: int, kind: str,
delta_sec = max(0.0, (next_tick - tick) / ticks_per_sec)
mf_idx = _nearest_minifloat(delta_sec)
else:
mf_idx = 0 # last real node: hold
mf_idx = 0
else:
# Pad: hold at last real node's value.
taud_val = points[-1][0] if points else pad_value
mf_idx = 0
points.append((taud_val, mf_idx))
# Build 16-bit sus word.
sus_word = 0x0020 # b = 1 (use envelope) — set whenever the envelope is enabled
# Build LOOP word (offsets 15/17/19) and SUSTAIN word (offsets 189/191/193).
# IT distinguishes envelope loop and sustain loop natively; map both
# directly. Bits: 5=b enable, 6=c carry, 7=p (pan default-pan flag) /
# m (pf filter mode); 12..8=start, 4..0=end. SUSTAIN word never carries
# c/p/m — those live in the LOOP word.
loop_word = 0
if has_env_loop and 0 <= it_lpb < 25 and 0 <= it_lpe < 25:
loop_word |= 0x0020 # b: enable LOOP
loop_word |= (it_lpb & 0x1F) << 8
loop_word |= (it_lpe & 0x1F)
if carry:
sus_word |= 0x0040
loop_word |= 0x0040 # c carry — kept in LOOP word
if is_filter:
sus_word |= 0x0080
if has_loop and 0 <= use_lb < 25 and 0 <= use_le < 25:
sus_word |= 0x4000 # u
if is_sustain:
sus_word |= 0x2000 # t
sus_word |= (use_lb & 0x1F) << 8 # sssss
sus_word |= (use_le & 0x1F) # eeeee
loop_word |= 0x0080 # m filter-mode (pf only)
return points, sus_word
sus_word = 0
if has_sus_loop and 0 <= it_slb < 25 and 0 <= it_sle < 25:
sus_word |= 0x0020 # b: enable SUSTAIN
sus_word |= (it_slb & 0x1F) << 8
sus_word |= (it_sle & 0x1F)
return points, loop_word, sus_word
# ── IT pattern parser ─────────────────────────────────────────────────────────
@@ -1008,12 +1003,21 @@ def resolve_it_recalls(patterns_rows: list, order_list: list,
def split_patterns(patterns_rows: list):
"""
Returns (chunks, chunk_map).
Returns (chunks, chunk_map, chunk_lens).
chunks: flat list of 64-row grids (list of 64 × 64-channel ITRow arrays)
chunk_map: list per source pattern of [chunk_idx_0, chunk_idx_1, ...]
chunk_lens: list parallel to chunks giving the real row count of each
chunk (64 for full chunks, < 64 for partial-tail chunks).
The cue builder emits a Taud LEN ($02xx) instruction for
any chunk whose length is < 64.
Patterns ≤ 64 rows produce one chunk of `rows` rows (LEN if rows < 64).
Patterns > 64 rows split into ⌊rows/64⌋ full 64-row chunks plus, if
`rows % 64 != 0`, a final chunk holding the remainder (which gets LEN).
"""
chunks = []
chunk_map = []
chunks = []
chunk_map = []
chunk_lens = []
for pi, (grid, rows) in enumerate(patterns_rows):
if rows == 0:
@@ -1028,7 +1032,10 @@ def split_patterns(patterns_rows: list):
for k in range(n_chunks):
r0 = k * PATTERN_ROWS
r1 = min(r0 + PATTERN_ROWS, rows)
# Build a 64-row grid for this chunk
chunk_len = r1 - r0
# Build a 64-row grid for this chunk (rows past chunk_len are
# silent padding; the engine will stop early via LEN when
# chunk_len < 64).
chunk_grid = []
for ch in range(64):
ch_rows = []
@@ -1041,52 +1048,30 @@ def split_patterns(patterns_rows: list):
ch_rows.append(ITRow())
chunk_grid.append(ch_rows)
# If this is not the last chunk, add a C $0000 on ch0 row (r1-r0-1)
# to immediately break to next order (skip padding rows).
# Only needed when the last real row of this chunk is < 63.
if k < n_chunks - 1:
last_real = r1 - r0 - 1
pad_row = chunk_grid[0][last_real]
if pad_row.effect == 0:
pad_row.effect = EFF_C
pad_row.effect_arg = 0
elif rows < PATTERN_ROWS and n_chunks == 1:
# Single chunk, short pattern → break at last real row
last_real = rows - 1
if last_real < PATTERN_ROWS - 1:
pad_row = chunk_grid[0][last_real]
if pad_row.effect == 0:
pad_row.effect = EFF_C
pad_row.effect_arg = 0
idx = len(chunks)
chunks.append(chunk_grid)
chunk_lens.append(chunk_len)
pat_chunks.append(idx)
chunk_map.append(pat_chunks)
return chunks, chunk_map
return chunks, chunk_map, chunk_lens
def _remap_bc_effects(chunks: list, chunk_map: list,
order_list: list, it_ord_to_taud_cue: dict,
num_channels: int) -> None:
"""Rewrite B/C effects using remapped order indices.
"""Rewrite B (position-jump) effects using remapped order indices.
B effects in all chunks are rewritten to point to the first chunk
of the target IT order. C effects in non-final chunks of a split
pattern get a co-row B to skip remaining chunks.
B effects are rewritten to point to the first chunk of the target IT
order. C effects (pattern break) need no special handling: each
Taud cue carries its own LEN instruction, so a non-final chunk of a
split source pattern simply terminates after its real row count
when LEN < 64 — but full 64-row non-final chunks rely on the C
being emitted by the engine when the source pattern's row pointer
naturally hits a chunk boundary. Since splits at exact multiples of
64 have no LEN gap, no C-skip injection is required.
"""
# For each chunk, record which (it_pat, chunk_k, n_chunks) it came from.
# We build this from chunk_map.
chunk_info = {} # chunk_idx → (it_pat_idx, k, n_chunks)
for pi, pat_chunks in enumerate(chunk_map):
n = len(pat_chunks)
for k, ci in enumerate(pat_chunks):
chunk_info[ci] = (pi, k, n)
for ci, chunk_grid in enumerate(chunks):
pi, k, n = chunk_info.get(ci, (0, 0, 1))
for ch in range(num_channels):
if ch >= len(chunk_grid): continue
for row in chunk_grid[ch]:
@@ -1094,25 +1079,6 @@ def _remap_bc_effects(chunks: list, chunk_map: list,
it_tgt = row.effect_arg
taud_cue = it_ord_to_taud_cue.get(it_tgt, it_tgt)
row.effect_arg = taud_cue & 0xFF
elif row.effect == EFF_C and k < n - 1:
# C in non-final chunk: need B to skip remaining chunks
# Find the cue index immediately after all chunks of this pat
# (the cue right after the last chunk of pi in the order list)
# We store the B in aux_effect; the Taud builder handles it.
skip_cue = _find_post_pat_cue(pi, order_list, chunk_map,
it_ord_to_taud_cue)
if skip_cue is not None:
row.aux_effect = (EFF_B, skip_cue & 0xFF)
def _find_post_pat_cue(pi: int, order_list: list, chunk_map: list,
it_ord_to_taud_cue: dict):
"""Return the Taud cue index that follows ALL chunks of pattern pi in the order list."""
for taud_cue, it_ord in it_ord_to_taud_cue.items():
# Find first Taud cue after the last chunk of pi
pass
# Simpler: walk the Taud cue list (we'll compute it in assemble_taud)
# Return None for now — assemble_taud will do a second pass.
return None
# ── Sample / instrument bin (same as s3m2taud) ────────────────────────────────
@@ -1165,7 +1131,8 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
s.sus_end = min(s.sus_end, n)
pos += n
# 192-byte instrument layout (terranmon.txt:1997-2070).
# 256-byte instrument layout (terranmon.txt:2001+).
INST_STRIDE = 256
USE_ENV_BIT = 0x0020 # b — set whenever the engine should evaluate the envelope
def _write_env(buf: bytearray, base: int, env_pts):
@@ -1216,7 +1183,7 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
loop_mode = 0 # no loop
flags_byte = (loop_mode & 0x3) | sustain_bit
base = taud_idx * 192
base = taud_idx * INST_STRIDE
struct.pack_into('<I', inst_bin, base + 0, ptr)
struct.pack_into('<H', inst_bin, base + 4, s_len)
struct.pack_into('<H', inst_bin, base + 6, c2spd)
@@ -1226,12 +1193,19 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
inst_bin[base + 14] = flags_byte
idata = (instr_data_by_slot or {}).get(taud_idx) or {}
vol_env = idata.get('vol_env')
pan_env = idata.get('pan_env')
pf_env = idata.get('pf_env')
vol_sus = idata.get('vol_sus', USE_ENV_BIT)
pan_sus = idata.get('pan_sus', 0)
pf_sus = idata.get('pf_sus', 0)
vol_env = idata.get('vol_env')
pan_env = idata.get('pan_env')
pf_env = idata.get('pf_env')
# LOOP words live at offsets 15/17/19. SUSTAIN words at 189/191/193.
# When the source has neither loop nor sustain on the volume envelope
# the engine still needs the b flag so the single-point unit envelope
# is evaluated — synthesise USE_ENV_BIT into the LOOP word as a fallback.
vol_env_loop = idata.get('vol_env_loop', USE_ENV_BIT)
vol_env_sus = idata.get('vol_env_sus', 0)
pan_env_loop = idata.get('pan_env_loop', 0)
pan_env_sus = idata.get('pan_env_sus', 0)
pf_env_loop = idata.get('pf_env_loop', 0)
pf_env_sus = idata.get('pf_env_sus', 0)
# Sample-mode default IGV: fold sample default vol (Sv) and sample GV
# into Taud's IGV. Instrument-mode supplies inst_gv pre-folded.
if 'inst_gv' in idata:
@@ -1247,9 +1221,10 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
# effects.c:1261). Clamp defensively to 4095.
fadeout = min(0xFFF, idata.get('fadeout', 0) & 0xFFFF)
struct.pack_into('<H', inst_bin, base + 15, vol_sus & 0xFFFF)
struct.pack_into('<H', inst_bin, base + 17, pan_sus & 0xFFFF)
struct.pack_into('<H', inst_bin, base + 19, pf_sus & 0xFFFF)
# LOOP words at offsets 15/17/19.
struct.pack_into('<H', inst_bin, base + 15, vol_env_loop & 0xFFFF)
struct.pack_into('<H', inst_bin, base + 17, pan_env_loop & 0xFFFF)
struct.pack_into('<H', inst_bin, base + 19, pf_env_loop & 0xFFFF)
if vol_env:
_write_env(inst_bin, base + 21, vol_env)
@@ -1258,8 +1233,10 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
# carried by IGV (byte 171), so the envelope must be a unit multiplier.
inst_bin[base + 21] = 63
inst_bin[base + 22] = 0
# Force engine to use this single point.
struct.pack_into('<H', inst_bin, base + 15, USE_ENV_BIT)
# Force engine to use this single point — set the b bit on the LOOP
# word so the envelope is evaluated even though no wrap region exists.
cur_loop = struct.unpack_from('<H', inst_bin, base + 15)[0]
struct.pack_into('<H', inst_bin, base + 15, cur_loop | USE_ENV_BIT)
if pan_env:
_write_env(inst_bin, base + 71, pan_env)
@@ -1304,13 +1281,16 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
inst_bin[base + 187] = idata.get('vib_depth', 0) & 0xFF
# Byte 188: vibrato rate (0..255 full range, IT samplewise Vir).
inst_bin[base + 188] = idata.get('vib_rate', 0) & 0xFF
# Byte 189: duplicate-check / action (IT-only — bits 0-1 = DCT, bits 2-3 = DCA).
# DCT: 0=off, 1=note, 2=sample, 3=instrument.
# DCA: 0=note cut, 1=note off, 2=note fade.
# SUSTAIN words at offsets 189/191/193.
struct.pack_into('<H', inst_bin, base + 189, vol_env_sus & 0xFFFF)
struct.pack_into('<H', inst_bin, base + 191, pan_env_sus & 0xFFFF)
struct.pack_into('<H', inst_bin, base + 193, pf_env_sus & 0xFFFF)
# Byte 195: duplicate-check / action (IT-only — bits 0-1 = DCT, bits 2-3 = DCA).
# Relocated 2026-05-06 from old offset 189 (now part of the vol sustain word).
dct = idata.get('dct', 0) & 0x03
dca = idata.get('dca', 0) & 0x03
inst_bin[base + 189] = (dca << 2) | dct
# Bytes 190-191: reserved (already zeroed).
inst_bin[base + 195] = (dca << 2) | dct
# Bytes 196..255: reserved (already zeroed).
vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}")
@@ -1331,7 +1311,6 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int,
"""Build a 512-byte Taud pattern for one IT channel from a 64-row chunk grid."""
out = bytearray(PATTERN_BYTES)
rows = chunk_grid[ch_idx] if ch_idx < len(chunk_grid) else [ITRow()] * PATTERN_ROWS
last_inst = 0
last_note_it = -1
for r, cell in enumerate(rows[:PATTERN_ROWS]):
@@ -1360,9 +1339,6 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int,
if cell.note >= 0:
note_taud = encode_note_it(cell.note)
if cell.inst > 0:
last_inst = cell.inst
note_triggers = (0 <= (cell.note if cell.note >= 0 else -1) <= 119)
# ── Volume column ────────────────────────────────────────────────────
@@ -1392,30 +1368,16 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int,
else:
pan_sel, pan_value = SEL_FINE, 0
# Handle aux_effect (B) stored on a C cell for chunk-skip
if cell.aux_effect is not None:
aux_cmd, aux_arg = cell.aux_effect
if aux_cmd == EFF_B and op == TOP_C:
# Encode as B effect; C row break handled by engine's simultaneous B+C
op = TOP_C
# We need to emit both; store the B's target in arg16 high byte
# Taud simultaneous B+C: B sets order, C sets row. Engine handles.
# Encoding: keep op=TOP_C (pattern break), store B target in
# a separate "B command on another channel". We can't encode two
# effects in one cell, so instead just emit the B effect here
# and let the order index point past the remaining chunks.
# This is a best-effort; the engine should honour the lowest-channel B.
op = TOP_B
arg16 = aux_arg & 0xFF
vol_byte = (vol_value & 0x3F) | ((vol_sel & 0x3) << 6)
pan_byte = (pan_value & 0x3F) | ((pan_sel & 0x3) << 6)
taud_inst = last_inst & 0xFF if (note_triggers or cell.inst > 0) else 0
# Preserve cell.inst==0 verbatim — IT semantics: a note row with no
# explicit instrument byte retriggers the channel's currently-loaded
# instrument. Filling in last_inst converts that into an explicit
# instrument-change, which can break NNA / envelope-reset behaviour.
base = r * 8
struct.pack_into('<H', out, base + 0, note_taud)
out[base + 2] = taud_inst
out[base + 2] = cell.inst & 0xFF
out[base + 3] = vol_byte
out[base + 4] = pan_byte
out[base + 5] = op & 0xFF
@@ -1563,7 +1525,7 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
# ── Split patterns into 64-row chunks ────────────────────────────────────
vprint(" splitting patterns…")
chunks, chunk_map = split_patterns(patterns_rows)
chunks, chunk_map, chunk_lens = split_patterns(patterns_rows)
# ── Choose active channels ───────────────────────────────────────────────
active_channels = _active_channels(h, patterns_rows)
@@ -1657,12 +1619,15 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
nna_taud = it_to_taud_nna[inst.nna & 0x03]
instr_data_by_slot[taud_slot] = {
'vol_env': inst.vol_envelope,
'vol_sus': inst.vol_env_sustain,
'pan_env': inst.pan_envelope,
'pan_sus': inst.pan_env_sustain,
'pf_env': inst.pf_envelope,
'pf_sus': inst.pf_env_sustain,
'vol_env': inst.vol_envelope,
'vol_env_loop': inst.vol_env_loop,
'vol_env_sus': inst.vol_env_sus,
'pan_env': inst.pan_envelope,
'pan_env_loop': inst.pan_env_loop,
'pan_env_sus': inst.pan_env_sus,
'pf_env': inst.pf_envelope,
'pf_env_loop': inst.pf_env_loop,
'pf_env_sus': inst.pf_env_sus,
'inst_gv': inst_gv_255,
'fadeout': inst.fadeout,
'vib_speed': vib_speed_taud,
@@ -1739,17 +1704,35 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0)
last_active = -1
len_cue_count = 0
for cue_idx, ci in enumerate(taud_cue_list):
if cue_idx >= NUM_CUES: break
base_pat = cue_idx * C
pats = [pat_remap[base_pat + vi] for vi in range(C)]
sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue(pats, 0)
clen = chunk_lens[ci] if ci < len(chunk_lens) else PATTERN_ROWS
if clen < PATTERN_ROWS:
instr = cue_instruction_len(clen)
len_cue_count += 1
else:
instr = CUE_INST_NOP
sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue(pats, instr)
last_active = cue_idx
if last_active >= 0:
sheet[last_active * CUE_SIZE + 30] = 0x01
# Halt overlays whatever LEN was on this cue. If both apply
# (the song terminates on a partial-tail chunk), the LEN is
# mooted by halt — warn so the user is aware.
b30_existing = sheet[last_active * CUE_SIZE + 30]
if b30_existing == CUE_INST_LEN:
vprint(f" warning: last active cue {last_active} had LEN; "
f"replaced with HALT (partial tail at song terminus)")
sheet[last_active * CUE_SIZE + 30] = CUE_INST_HALT
sheet[last_active * CUE_SIZE + 31] = 0x00
else:
sheet[30] = 0x01
sheet[30] = CUE_INST_HALT
if len_cue_count:
vprint(f" emitted {len_cue_count} LEN cue instruction(s) "
f"for partial-length patterns")
# ── Header ───────────────────────────────────────────────────────────────
sig = (SIGNATURE + b' ' * 14)[:14]

View File

@@ -500,7 +500,8 @@ def build_sample_inst_bin(samples: list) -> tuple:
s.loop_end = min(s.loop_end, n)
pos += n
# New 192-byte instrument layout (terranmon.txt:1997-2070).
# New 256-byte instrument layout (terranmon.txt:2001+).
INST_STRIDE = 256
inst_bin = bytearray(INSTBIN_SIZE)
for i, s in enumerate(samples):
taud_idx = i + 1 # 1-based instrument number
@@ -519,9 +520,11 @@ def build_sample_inst_bin(samples: list) -> tuple:
# Envelope first point is full-scale; per-sample level is carried by
# IGV (byte 171) so the envelope must contribute a unit multiplier.
env_vol = 63
vol_env_flags = 0x0020 # use-envelope bit
# MOD has no envelopes; vol LOOP word b=1 just so the engine evaluates
# the unit envelope. Pan/PF stay disabled.
vol_env_loop = 0x0020 # b enable
base = taud_idx * 192
base = taud_idx * INST_STRIDE
struct.pack_into('<I', inst_bin, base + 0, ptr)
struct.pack_into('<H', inst_bin, base + 4, s_len)
struct.pack_into('<H', inst_bin, base + 6, c2spd)
@@ -529,7 +532,8 @@ def build_sample_inst_bin(samples: list) -> tuple:
struct.pack_into('<H', inst_bin, base + 10, ls)
struct.pack_into('<H', inst_bin, base + 12, le)
inst_bin[base + 14] = flags_byte
struct.pack_into('<H', inst_bin, base + 15, vol_env_flags)
# LOOP words at 15/17/19; SUSTAIN words at 189/191/193 (left zero).
struct.pack_into('<H', inst_bin, base + 15, vol_env_loop)
struct.pack_into('<H', inst_bin, base + 17, 0)
struct.pack_into('<H', inst_bin, base + 19, 0)
inst_bin[base + 21] = env_vol

View File

@@ -476,9 +476,10 @@ def build_sample_inst_bin(instruments: list) -> tuple:
inst.loop_end = min(inst.loop_end, n)
pos += n
# Build instrument bin (256 × 192 bytes)
# New layout (terranmon.txt:1997-2070): u32 sample ptr, ..., 25-point envelopes,
# plus a host of optional fields. S3M doesn't supply most of those — they default to 0.
# Build instrument bin (256 × 256 bytes)
# New layout (terranmon.txt:2001+): LOOP words at 15/17/19, SUSTAIN words at 189/191/193.
# S3M has no envelope sustain or loop, so SUSTAIN words stay zero.
INST_STRIDE = 256
inst_bin = bytearray(INSTBIN_SIZE)
for i, inst in enumerate(instruments):
taud_idx = i + 1
@@ -498,10 +499,10 @@ def build_sample_inst_bin(instruments: list) -> tuple:
# Volume envelope first point is full-scale; per-sample level is carried
# by IGV (byte 171) so the envelope contributes a unit multiplier.
env_vol = 63
# Vol env-flags: enable use-envelope bit (b=1) so engine reads the single point.
vol_env_flags = 0x0020 # b=bit 5
# Vol LOOP word: only b=1 (use envelope) — no actual loop / sustain.
vol_env_loop = 0x0020
base = taud_idx * 192
base = taud_idx * INST_STRIDE
struct.pack_into('<I', inst_bin, base + 0, ptr) # u32 sample pointer
struct.pack_into('<H', inst_bin, base + 4, s_len)
struct.pack_into('<H', inst_bin, base + 6, c2spd) # rate at TAUD_C4
@@ -509,9 +510,10 @@ def build_sample_inst_bin(instruments: list) -> tuple:
struct.pack_into('<H', inst_bin, base + 10, ls)
struct.pack_into('<H', inst_bin, base + 12, le)
inst_bin[base + 14] = flags_byte
struct.pack_into('<H', inst_bin, base + 15, vol_env_flags)
struct.pack_into('<H', inst_bin, base + 17, 0) # pan env-flags
struct.pack_into('<H', inst_bin, base + 19, 0) # pitch/filter env-flags
# LOOP words at 15/17/19; SUSTAIN words at 189/191/193 (left zero).
struct.pack_into('<H', inst_bin, base + 15, vol_env_loop)
struct.pack_into('<H', inst_bin, base + 17, 0)
struct.pack_into('<H', inst_bin, base + 19, 0)
# Volume env point 0: hold at env_vol indefinitely (offset minifloat = 0 → hold).
inst_bin[base + 21] = env_vol
inst_bin[base + 22] = 0
@@ -524,7 +526,7 @@ def build_sample_inst_bin(instruments: list) -> tuple:
inst_bin[base + 183] = 0xFF # filter resonance = off
inst_bin[base + 186] = 1 # NNA: note cut
vprint(f" instrument[{base // 192}] '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'")
vprint(f" instrument[{base // INST_STRIDE}] '{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}')")

View File

@@ -30,8 +30,9 @@ 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)+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
INST_RECORD_SIZE = 256 # widened 2026-05-06 (was 192). 256 inst × 256 = 64K.
SAMPLEBIN_SIZE = 720896 # was 737280; 16K reallocated to inst bin (terranmon.txt:1985-1997)
INSTBIN_SIZE = INST_RECORD_SIZE * 256 # 65536 = 64K
SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE
PATTERN_ROWS = 64
PATTERN_BYTES = PATTERN_ROWS * 8 # 512
@@ -46,6 +47,18 @@ NOTE_KEYOFF = 0x0000
NOTE_CUT = 0xFFFE
TAUD_C4 = 0x5000 # The audio engine's Middle C
# Cue sheet instruction byte (cue offset 30; offset 31 = arg byte for 2-byte forms).
# Per terranmon.txt §"Cue Sheet":
# 00000010 00xxxxxx (LEN) pattern length: rows = (xxxxxx) + 1, range 1..64
# 00000001 (HALT) end of song
# 00000000 (NOP) default 64-row cue
# 1000xxxx yyyyyyyy (BAK) go back 12-bit arg
# 1001xxxx yyyyyyyy (FWD) skip forward 12-bit arg
# 1111xxxx yyyyyyyy (JMP) go to absolute pattern
CUE_INST_NOP = 0x00
CUE_INST_HALT = 0x01
CUE_INST_LEN = 0x02
# Taud effect opcodes (base-36: 0..9 → 0x00..0x09, A..Z → 0x0A..0x23)
TOP_NONE = 0x00
TOP_A = 0x0A
@@ -152,8 +165,13 @@ def rescale_offset_effects(pat_bin: bytes, ratio: float) -> bytes:
return bytes(out)
def encode_cue(patterns12: list, instruction: int) -> bytearray:
"""Encode a 32-byte cue entry for up to 20 voices with 12-bit pattern numbers."""
def encode_cue(patterns12: list, instruction) -> bytearray:
"""Encode a 32-byte cue entry for up to 20 voices with 12-bit pattern numbers.
`instruction` is either an int (legacy single-byte value placed at byte 30,
byte 31 = 0) or a 2-tuple `(byte30, byte31)` for two-byte forms such as
LEN (CUE_INST_LEN with row count - 1).
"""
pats = list(patterns12) + [0xFFF] * NUM_VOICES
pats = pats[:NUM_VOICES]
entry = bytearray(CUE_SIZE)
@@ -162,10 +180,25 @@ def encode_cue(patterns12: list, instruction: int) -> bytearray:
entry[i] = ((v0 & 0xF) << 4) | (v1 & 0xF) # low nybbles
entry[10 + i] = (((v0 >> 4) & 0xF) << 4) | ((v1 >> 4) & 0xF) # mid nybbles
entry[20 + i] = (((v0 >> 8) & 0xF) << 4) | ((v1 >> 8) & 0xF) # high nybbles
entry[30] = instruction & 0xFF
if isinstance(instruction, tuple):
b30, b31 = instruction
entry[30] = b30 & 0xFF
entry[31] = b31 & 0xFF
else:
entry[30] = instruction & 0xFF
return entry
def cue_instruction_len(rows: int) -> tuple:
"""Build the 2-byte LEN cue instruction for `rows` (1..64).
Returns (byte30, byte31) where byte30 = 0x02 and byte31 = (rows - 1) & 0x3F.
"""
if not 1 <= rows <= 64:
raise ValueError(f"LEN row count must be 1..64, got {rows}")
return (CUE_INST_LEN, (rows - 1) & 0x3F)
def deduplicate_patterns(pat_bin: bytes, num_pats: int) -> tuple:
"""Consolidate identical 512-byte Taud patterns into a single copy.

View File

@@ -1985,16 +1985,57 @@ Synchronisation between playheads are not guaranteed. Do not play music in multi
Memory Space
0..737279 RW: Sample bin (720k)
737280..786431 RW: Instrument bin (256 instruments, 192 bytes each; instrument 0 does nothing; 48k)
0..720895 RW: Sample bin (704k)
720896..786431 RW: Instrument bin (256 instruments, 256 bytes each; instrument 0 does nothing; 64k)
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)
983040..1048575 RW: TAD Decode Output (64k)
(Layout note 2026-05-06: sample bin shrunk by 16k and instrument bin widened
by the same amount so all downstream dispatch ranges keep their existing
anchors at 786432. Total memory space stays at exactly 1 MiB.)
Sample bin: just raw sample data thrown in there. You need to keep track of starting point for each sample
Instrument bin: Registry for 256 instruments, formatted as:
The instrument record is 256 bytes wide. Envelopes are described by FOUR
independent regions per envelope (vol / pan / pitch-filter):
1. The 25 envelope nodes (offsets 21 / 71 / 121).
2. The LOOP word (offsets 15 / 17 / 19) — defines an always-active
wrap region. When enabled (b=1) and the envelope position reaches
loop_end, it wraps back to loop_start. Active regardless of key
state. This is the IT/FT2 envelope loop.
3. The SUSTAIN word (offsets 189 / 191 / 193) — defines a wrap
region that is ONLY active while the key is on. When the key
goes off the sustain "releases" and the envelope position is
free to walk past sus_end. Concretely:
- FT2-style "sustain point": store sus_start == sus_end (single
index). Engine wraps that index → itself, so the envelope
holds at the point until key-off.
- IT-style "sustain loop": store sus_start <= sus_end. Engine
wraps sus_end → sus_start while key is on, so the envelope
loops within the sustain range until key-off.
4. (none — there is no separate "release loop"; once sustain releases
the envelope walks forward and is captured by the LOOP region if
the LOOP region exists and the position enters it.)
Priority during playback follows schismtracker player/sndmix.c:480-499:
if SUSTAIN.b == 1 and !key_off : wrap (sus_start, sus_end)
elif LOOP.b == 1 : wrap (loop_start, loop_end)
else : hold at last node
This means SUSTAIN takes precedence over LOOP while the key is on; once
the key is released, LOOP becomes the active wrap region. Setting both
to b=0 disables envelope wrapping entirely (envelope plays once and holds
at its last node).
The b flag is the SOLE enable bit for each region; the historical 't'
(sustain breaks on key-off) and 'u' (sustain/loop enable) flags are NOT
present in this encoding — sustain vs loop is now a structural
distinction (different word at a different offset), not a flag bit.
0 Uint32 Sample Pointer
4 Uint16 Sample length
6 Uint16 Sampling rate at C4 (note number 0x5000)
@@ -2006,41 +2047,35 @@ Instrument bin: Registry for 256 instruments, formatted as:
pp: loop mode. 0-no loop, 1-loop, 2-backandforth, 3-oneshot (ignores note length unless overridden by other notes)
s: loop is sustain (key-off escapes the loop)
- IT: look for sample's SusLoop flag
15 Bit16 Volume envelope sustain/loops and flags
* Sustain is implemented by enabling 't' flag. FastTracker has no 'Sus Loop' but only 'Sus Point'; use same value for start and end index
0b 0ut sssss 0cb eeeee
s: sustain/loop start index
e: sustain/loop end index
b: use envelope
c: envelope carry
t: the loop must sustain (key-off escapes the loop)
u: set to enable the sustain/loop
17 Bit16 Panning envelope sustain/loops and flags
* Sustain is implemented by enabling 't' flag
0b 0ut sssss pcb eeeee
s: sustain/loop start index
e: sustain/loop end index
b: use envelope
c: envelope carry
p: use default pan (see offset 177 "Default pan value" below)
t: the loop must sustain (key-off escapes the loop)
u: set to enable the sustain/loop
19 Bit16 Pitch/Filter envelope sustain/loops and flags
* Sustain is implemented by enabling 't' flag
0b 0ut sssss mcb eeeee
s: sustain/loop start index
e: sustain/loop end index
b: use envelope
c: envelope carry
m: mode (0: on pitch, 1: on filter)
t: the loop must sustain (key-off escapes the loop)
u: set to enable the sustain/loop
15 Bit16 Volume envelope LOOP word
* Always-active wrap region for the volume envelope. See SUSTAIN word at offset 189 for the key-on-only wrap.
0b 000_sssss_0cb_eeeee
s (bits 12..8) : loop start index (0..24)
e (bits 4..0) : loop end index (0..24)
b (bit 5) : enable the LOOP (0 = no envelope loop)
c (bit 6) : envelope carry (cross-trigger envelope position carry)
(bits 7, 13..15 reserved — set to 0)
17 Bit16 Panning envelope LOOP word
* Always-active wrap region for the pan envelope.
0b 000_sssss_pcb_eeeee
s (bits 12..8) : loop start index
e (bits 4..0) : loop end index
b (bit 5) : enable the LOOP
c (bit 6) : envelope carry
p (bit 7) : use default pan (see offset 177 "Default pan value" below).
Independent of LOOP enable; the engine reads this bit
from the LOOP word as the canonical home for envelope-
level meta flags.
(bits 13..15 reserved)
19 Bit16 Pitch/Filter envelope LOOP word
* Always-active wrap region for the pitch/filter envelope.
0b 000_sssss_mcb_eeeee
s (bits 12..8) : loop start index
e (bits 4..0) : loop end index
b (bit 5) : enable the LOOP
c (bit 6) : envelope carry
m (bit 7) : mode — 0 = pitch envelope, 1 = filter envelope
(bits 13..15 reserved)
21 Bit16x25 Volume envelopes
Byte 1: Volume (00..3F)
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
@@ -2090,7 +2125,29 @@ Instrument bin: Registry for 256 instruments, formatted as:
* FastTracker2 has range of 0..16; multiply by (255/16) then round to int
188 Uint8 Vibrato Rate (0..255 full range)
* ImpulseTracker sample config. The spec follows ImpulseTracker precisely
189 Byte[3] Reserved
189 Bit16 Volume envelope SUSTAIN word
* Wrap region active ONLY while key is on. Released on key-off.
* FT2 single-point sustain: store sus_start == sus_end (the engine
wraps that index → itself, so the envelope holds there).
* IT sustain loop: store sus_start <= sus_end (engine wraps the range
while key is on; same shape as the LOOP word).
0b 000_sssss_00b_eeeee
s (bits 12..8) : sustain start index (0..24)
e (bits 4..0) : sustain end index (0..24)
b (bit 5) : enable the SUSTAIN (0 = no sustain wrap)
(bits 6..7, 13..15 reserved — the 'c' carry bit lives in the LOOP word)
191 Bit16 Panning envelope SUSTAIN word
* Same encoding as offset 189, applied to the pan envelope.
0b 000_sssss_00b_eeeee
193 Bit16 Pitch/Filter envelope SUSTAIN word
* Same encoding as offset 189, applied to the pitch/filter envelope.
0b 000_sssss_00b_eeeee
195 Bit8 Duplicate Check / Action (IT-only; FT2 leaves this 0)
0b 0000 dcdt
dt (bits 0..1) : Duplicate Check Type. 0=off, 1=note, 2=sample, 3=instrument.
dc (bits 2..3) : Duplicate Check Action. 0=note cut, 1=note off, 2=note fade.
* Relocated from offset 189 (which is now the volume sustain word) on 2026-05-06.
196..255 Reserved (60 bytes free for future per-instrument fields)
@@ -2115,6 +2172,10 @@ TODO:
[ ] low-number voleffs are too quiet (needs elaboration and test cases)
[x] scale Oxxxx when samples get resampled
[x] implement bitcrusher and overdrive (eff sym '8' and '9')
[x] note trigger with inst and note fx set (e.g. portamento) but no volume set is not getting their default volume but getting what was before instead (SATELL.taud ptn 23) -- and simulateRowState() of taut.js always shows old volume instead of default volume, regardless of note fx's existence
[ ] implement extended tone mode (MONOTONE compat)
[ ] pattern loops stops working after processed once (test with slumberjack.xm)
[ ] how does fadeout=0 work on IT? On XM, the note don't decay at all (that's why there's separate CUT value). Also see what Global Behaviour 'm' flag actually do on Taud (or, which slop AI had fed me *sigh*)
Play Data: play data are series of tracker-like instructions, visualised as:
@@ -2239,10 +2300,12 @@ Play Head Flags
Byte 11..20: 0b miV1 miV2, 0b miV3 miV4, 0b miV5 miV6, ... 0b miV19 miV20
Byte 21..30: 0b hiV1 hiV2, 0b hiV3 hiV4, 0b hiV5 hiV6, ... 0b hiV19 hiV20
Byte 31..32: instruction
1000xxxx yyyyyyyy - Go back 0bxxxxyyyyyyyy patterns
1001xxxx yyyyyyyy - Skip forward 0bxxxxyyyyyyyy patterns
1111xxxx yyyyyyyy - Go to absolute pattern number 0bxxxxyyyyyyyy
00000001 - Halt
1000xxxx yyyyyyyy (BAK000) - Go back 0bxxxxyyyyyyyy patterns
1001xxxx yyyyyyyy (FWD000) - Skip forward 0bxxxxyyyyyyyy patterns
1111xxxx yyyyyyyy (JMP000) - Go to absolute pattern number 0bxxxxyyyyyyyy
00000010 00xxxxxx (LEN 00) - Pattern length for this cue (0..63), where 0: 1 row, 63: 64 rows (decoded by AudioAdapter as of 2026-05-05; emitted by xm2taud / it2taud for non-multiple-of-64 source patterns)
00000001 00000000 - Halt (HALT )
00000001 00111111 - Fadeout (FADOUT) - Gradually decrease global volume such that at row 63 it reaches zero
00000000 - No operation
65536..131071 RW: PCM Sample buffer
@@ -2329,9 +2392,9 @@ Endianness: Little
Uint16 Current Tuning base note (1..65533). A4 (western default) is 0x5C00. C9 (tracker default) is 0xA000. If zero, assume the tracker default value
Float32 Frequency at the base note. Tracker default is 8363.0. If zero, assume the tracker default
Uint8 Flags for Global Behaviour (effect symbol '1')
0b 0000 0mfp
0b 0000 Fmfp
p: panning law (0=linear, 1=equal-power)
f: tone mode (0=linear pitch slides, 1=Amiga period slides)
Ff: tone mode (0=linear pitch slides, 1=Amiga period slides, 2=linear-frequency slides, 3=reserved)
m: fadeout-zero policy (0=IT — stored fadeout 0 means no fadeout;
1=FT2 — stored fadeout 0 means cut on key-off)
Uint8 Song global volume

View File

@@ -135,7 +135,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
const val AMIGA_BASE_PERIOD = 428.0
}
internal val sampleBin = UnsafeHelper.allocate(737280L, this)
// Memory map (terranmon.txt:1985-1997, updated 2026-05-06):
// 0..720895 sample bin (704K, was 737280)
// 720896..786431 instrument bin (256 inst × 256 bytes = 64K)
// 786432.. play data 1 / 2 / TAD blocks (anchors unchanged)
internal val sampleBin = UnsafeHelper.allocate(720896L, this)
internal val instruments = Array(256) { TaudInst(it) }
internal val playdata = Array(4096) { Array(64) { TaudPlayData(0xFFFF, 0, 0, 0, 32, 0, 0, 0) } }
internal val playheads: Array<Playhead>
@@ -307,8 +311,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
override fun peek(addr: Long): Byte {
return when (val adi = addr.toInt()) {
in 0..737279 -> sampleBin[addr]
in 737280..786431 -> (adi - 737280).let { instruments[it / 192].getByte(it % 192) }
in 0..720895 -> sampleBin[addr]
in 720896..786431 -> (adi - 720896).let { instruments[it / 256].getByte(it % 256) }
in 786432..851967 -> { val off = adi - 786432; playdata[playheads[0].patBank1 * 128 + off / 512][(off % 512) / 8].getByte(off % 8) }
in 851968..917503 -> { val off = adi - 851968; playdata[playheads[0].patBank2 * 128 + off / 512][(off % 512) / 8].getByte(off % 8) }
in 917504..983039 -> tadInputBin[addr - 917504] // TAD input buffer (65536 bytes)
@@ -321,8 +325,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val adi = addr.toInt()
val bi = byte.toUint()
when (adi) {
in 0..737279 -> { sampleBin[addr] = byte }
in 737280..786431 -> (adi - 737280).let { instruments[it / 192].setByte(it % 192, bi) }
in 0..720895 -> { sampleBin[addr] = byte }
in 720896..786431 -> (adi - 720896).let { instruments[it / 256].setByte(it % 256, bi) }
in 786432..851967 -> { val off = adi - 786432; playdata[playheads[0].patBank1 * 128 + off / 512][(off % 512) / 8].setByte(off % 8, bi) }
in 851968..917503 -> { val off = adi - 851968; playdata[playheads[0].patBank2 * 128 + off / 512][(off % 512) / 8].setByte(off % 8, bi) }
in 917504..983039 -> tadInputBin[addr - 917504] = byte // TAD input buffer
@@ -1205,33 +1209,60 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
return amigaPeriodToNoteVal(newPeriod)
}
/**
* Resolve the active wrap region for an envelope based on the LOOP and
* SUSTAIN words and key state.
*
* Encoding (terranmon.txt:2049+, 2114+):
* LOOP word (offset 15/17/19): 0b 0000_0sss_ssXcb_eeeee
* SUSTAIN word (offset 189/191/193): 0b 0000_0sss_ss00b_eeeee
* In both, bit 5 = b (enable). bits 12..8 = start, bits 4..0 = end.
*
* Priority (matches schismtracker player/sndmix.c:480-499):
* if SUSTAIN.b and !keyOff : wrap (sus_start, sus_end)
* elif LOOP.b : wrap (loop_start, loop_end)
* else : no wrap (envelope walks forward and holds)
*
* Returns -1 in `wrapEnd` when no wrap is active.
*/
private inline fun resolveEnvWrap(loopWord: Int, sustainWord: Int, keyOff: Boolean,
outRange: IntArray) {
val susB = (sustainWord ushr 5) and 1 != 0
val loopB = (loopWord ushr 5) and 1 != 0
if (susB && !keyOff) {
outRange[0] = (sustainWord ushr 8) and 0x1F
outRange[1] = sustainWord and 0x1F
} else if (loopB) {
outRange[0] = (loopWord ushr 8) and 0x1F
outRange[1] = loopWord and 0x1F
} else {
outRange[0] = -1
outRange[1] = -1
}
}
// Reusable per-envelope wrap-range scratch (avoid per-tick allocation).
private val volWrap = IntArray(2)
private val panWrap = IntArray(2)
private val pfWrap = IntArray(2)
private fun advanceEnvelope(voice: Voice, inst: TaudInst, tickSec: Double) {
// 16-bit envelope-flag layout (terranmon.txt:2007-2030):
// 0b 0ut sssss pcb eeeee
// bit 14 = u (enable sustain/loop)
// bit 13 = t (sustain — 1=breaks on key-off, 0=loops forever)
// bits 12..8 = sustain/loop start index (0..24)
// bit 7 = p (channel-specific flag — fadeout zero / use default pan)
// bit 6 = c (envelope carry)
// bit 5 = b (use envelope at all)
// bits 4..0 = sustain/loop end index (0..24)
val maxIdx = 24
// Volume envelope
val vSus = inst.volEnvSustain
val vUseEnv = (vSus ushr 5) and 1 != 0
if (vUseEnv && voice.volEnvOn) {
val vEnabled = (vSus ushr 14) and 1 != 0
val vIsSustain = (vSus ushr 13) and 1 != 0
val vSusOn = vEnabled && (!vIsSustain || !voice.keyOff)
val vSusStart = (vSus ushr 8) and 0x1F
val vSusEnd = vSus and 0x1F
val vEnvActive = (((inst.volEnvLoop ushr 5) and 1) or ((inst.volEnvSustainWord ushr 5) and 1)) != 0
if (vEnvActive && voice.volEnvOn) {
resolveEnvWrap(inst.volEnvLoop, inst.volEnvSustainWord, voice.keyOff, volWrap)
val wStart = volWrap[0]
val wEnd = volWrap[1]
val wrapping = wStart >= 0
if (vSusOn && voice.envIndex == vSusEnd && vSusStart == vSusEnd) {
if (wrapping && voice.envIndex == wEnd && wStart == wEnd) {
// Hold at the wrap point (FT2 single-point sustain).
voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
} else if (vSusOn && voice.envIndex == vSusEnd) {
} else if (wrapping && voice.envIndex == wEnd) {
voice.envTimeSec = 0.0
voice.envIndex = vSusStart
voice.envIndex = wStart
voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
} else if (voice.envIndex >= maxIdx) {
voice.envVolume = (inst.volEnvelopes[maxIdx].value / 63.0).coerceIn(0.0, 1.0)
@@ -1243,7 +1274,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.envTimeSec += tickSec
if (voice.envTimeSec >= vOffset) {
voice.envTimeSec -= vOffset
val nextIdx = if (vSusOn && voice.envIndex == vSusEnd) vSusStart
val nextIdx = if (wrapping && voice.envIndex == wEnd) wStart
else (voice.envIndex + 1).coerceAtMost(maxIdx)
voice.envIndex = nextIdx
voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
@@ -1258,20 +1289,18 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Pan envelope (only when active for this instrument)
if (!voice.hasPanEnv || !voice.panEnvOn) return
val pSus = inst.panEnvSustain
val pUseEnv = (pSus ushr 5) and 1 != 0
if (!pUseEnv) return
val pEnabled = (pSus ushr 14) and 1 != 0
val pIsSustain = (pSus ushr 13) and 1 != 0
val pSusOn = pEnabled && (!pIsSustain || !voice.keyOff)
val pSusStart = (pSus ushr 8) and 0x1F
val pSusEnd = pSus and 0x1F
val pEnvActive = (((inst.panEnvLoop ushr 5) and 1) or ((inst.panEnvSustainWord ushr 5) and 1)) != 0
if (!pEnvActive) return
resolveEnvWrap(inst.panEnvLoop, inst.panEnvSustainWord, voice.keyOff, panWrap)
val pStart = panWrap[0]
val pEnd = panWrap[1]
val pWrapping = pStart >= 0
if (pSusOn && voice.envPanIndex == pSusEnd && pSusStart == pSusEnd) {
if (pWrapping && voice.envPanIndex == pEnd && pStart == pEnd) {
voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0
} else if (pSusOn && voice.envPanIndex == pSusEnd) {
} else if (pWrapping && voice.envPanIndex == pEnd) {
voice.envPanTimeSec = 0.0
voice.envPanIndex = pSusStart
voice.envPanIndex = pStart
voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0
} else if (voice.envPanIndex >= maxIdx) {
voice.envPan = inst.panEnvelopes[maxIdx].value / 255.0
@@ -1283,7 +1312,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.envPanTimeSec += tickSec
if (voice.envPanTimeSec >= pOffset) {
voice.envPanTimeSec -= pOffset
val nextIdx = if (pSusOn && voice.envPanIndex == pSusEnd) pSusStart
val nextIdx = if (pWrapping && voice.envPanIndex == pEnd) pStart
else (voice.envPanIndex + 1).coerceAtMost(maxIdx)
voice.envPanIndex = nextIdx
voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0
@@ -1303,14 +1332,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
private fun advancePfEnvelope(voice: Voice, inst: TaudInst, tickSec: Double) {
if (!voice.hasPfEnv || !voice.pfEnvOn) return
val maxIdx = 24
val pSus = inst.pfEnvSustain
val pUseEnv = (pSus ushr 5) and 1 != 0
if (!pUseEnv) return
val pEnabled = (pSus ushr 14) and 1 != 0
val pIsSustain = (pSus ushr 13) and 1 != 0
val pSusOn = pEnabled && (!pIsSustain || !voice.keyOff)
val pSusStart = (pSus ushr 8) and 0x1F
val pSusEnd = pSus and 0x1F
val pEnvActive = (((inst.pfEnvLoop ushr 5) and 1) or ((inst.pfEnvSustainWord ushr 5) and 1)) != 0
if (!pEnvActive) return
resolveEnvWrap(inst.pfEnvLoop, inst.pfEnvSustainWord, voice.keyOff, pfWrap)
val pSusStart = pfWrap[0]
val pSusEnd = pfWrap[1]
val pSusOn = pSusStart >= 0
if (pSusOn && voice.envPfIndex == pSusEnd && pSusStart == pSusEnd) {
voice.envPfValue = inst.pfEnvelopes[voice.envPfIndex].value / 255.0
@@ -1523,7 +1550,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val sampleLen = inst.sampleLength.coerceAtLeast(1)
val loopStart = inst.sampleLoopStart.toDouble()
val loopEnd = inst.sampleLoopEnd.toDouble().coerceAtLeast(1.0)
val binMax = 737279 // sampleBin is 737280 bytes (0..737279)
val binMax = 720895 // sampleBin is 720896 bytes (0..720895)
val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1)
val i1 = (i0 + 1).coerceAtMost(sampleLen - 1)
@@ -1578,11 +1605,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.envPanIndex = 0
voice.envPanTimeSec = 0.0
voice.envPan = inst.panEnvelopes[0].value / 255.0
// Pan envelope is active when the `b` (use envelope) flag is set in panEnvSustain.
voice.hasPanEnv = (inst.panEnvSustain ushr 5) and 1 != 0
// Pan envelope is active when EITHER the LOOP word's b bit OR the SUSTAIN word's b bit is set.
voice.hasPanEnv = (((inst.panEnvLoop ushr 5) and 1) or ((inst.panEnvSustainWord ushr 5) and 1)) != 0
// Pitch/filter envelope state.
voice.hasPfEnv = (inst.pfEnvSustain ushr 5) and 1 != 0
voice.envPfIsFilter = (inst.pfEnvSustain ushr 7) and 1 != 0
voice.hasPfEnv = (((inst.pfEnvLoop ushr 5) and 1) or ((inst.pfEnvSustainWord ushr 5) and 1)) != 0
// The pf 'm' mode bit (pitch=0, filter=1) lives in the LOOP word at bit 7.
voice.envPfIsFilter = (inst.pfEnvLoop ushr 7) and 1 != 0
voice.envPfIndex = 0
voice.envPfTimeSec = 0.0
voice.envPfValue = if (voice.hasPfEnv) inst.pfEnvelopes[0].value / 255.0 else 0.5
@@ -1597,8 +1625,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.randomPanBias = if (inst.panSwing != 0)
(Math.random() * (2 * inst.panSwing + 1)).toInt() - inst.panSwing else 0
// Default pan: applied unless the pattern row has already overridden channelPan.
// We treat the pan envelope "p" flag (panEnvSustain bit 7) as "use default pan".
if ((inst.panEnvSustain ushr 7) and 1 != 0) {
// The pan envelope's 'p' flag ("use default pan") lives in the pan LOOP word at bit 7.
if ((inst.panEnvLoop ushr 7) and 1 != 0) {
voice.channelPan = inst.defaultPan
voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63)
}
@@ -1874,12 +1902,26 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// call this an "instrument-only retrigger"; in MOD/S3M/IT the sample
// keeps playing, but the channel's instrument reference advances.
0xFFFF -> { if (row.instrment != 0) voice.instrumentId = row.instrment }
0x0000 -> { voice.keyOff = true; voice.active = false } // key-off; breaks sustain loop
0xFFFE -> voice.active = false // note cut
// Key-off: release sustain; envelope walks past the sustain point and the fadeout
// begins (foreground-voice fade path at line ~2380). The voice deactivates when
// fadeoutVolume reaches 0, or immediately if FT2-mode fadeStep == 0. Setting
// voice.active = false here would defeat both — instruments with sustain points
// and non-zero fadeout (FT2 sustain-then-fade idiom) would be cut on the spot.
0x0000 -> { voice.keyOff = true }
0xFFFE -> voice.active = false // note cut (immediate)
else -> {
if (toneG && voice.active) {
// Tone porta: target the note, do not retrigger sample.
voice.tonePortaTarget = row.note
// Instrument byte on a porta row reloads the channel's default
// volume even though the sample isn't retriggered. Mirrors schism
// csf_instrument_change (effects.c:1302) which writes
// chan->volume = psmp->volume whenever inst_column is set.
if (row.instrment != 0) {
voice.instrumentId = row.instrment
voice.channelVolume = 0x3F
voice.rowVolume = 0x3F
}
} else if ((row.effect == EffectOp.OP_S) && ((row.effectArg ushr 12) and 0xF) == 0xD) {
// Note delay: defer trigger to the requested tick. NNA fires when the
// deferred trigger actually executes, not now.
@@ -2364,14 +2406,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Volume fadeout: after key-off OR Note-Fade NNA, decrement per tick.
// The 12-bit fadeStep is split across volumeFadeoutLow + low nibble of fadeoutHigh.
// Divisor selects per-tracker semantics:
// FT2 mode (fadeoutCutOnZero=true): fadeStep / 65536 per tick — matches FT2 .XM (16-bit accumulator, decrement = stored).
// IT mode (fadeoutCutOnZero=false): fadeStep / 1024 per tick — matches Schism (sndmix.c:331-339 + effects.c:1261:
// accumulator 65536, decrement = (stored<<5)<<1 = stored·64).
// FT2 mode (fadeoutCutOnZero=true): fadeStep / 32768 per tick — matches ft2-clone
// (ft2_replayer.c:387-390, 1469-1481): the FT2 XM
// file format docs claim the accumulator is 16-bit
// (65536), but the actual replayer initialises
// fadeoutVol to 32768 and decrements by stored.
// IT mode (fadeoutCutOnZero=false): fadeStep / 1024 per tick — matches Schism
// (sndmix.c:331-339 + effects.c:1261: accumulator
// 65536, decrement = (stored<<5)<<1 = stored·64).
// Stored 0: FT2 mode cuts on key-off; IT mode leaves voice playing (no fade).
if (voice.keyOff || voice.noteFading) {
val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8)
if (fadeStep > 0) {
val divisor = if (ts.fadeoutCutOnZero) 65536.0 else 1024.0
val divisor = if (ts.fadeoutCutOnZero) 32768.0 else 1024.0
voice.fadeoutVolume = (voice.fadeoutVolume - fadeStep / divisor).coerceAtLeast(0.0)
if (voice.fadeoutVolume <= 0.0) voice.active = false
} else if (ts.fadeoutCutOnZero) {
@@ -2427,7 +2474,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (bg.keyOff || bg.noteFading) {
val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8)
if (fadeStep > 0) {
val divisor = if (ts.fadeoutCutOnZero) 65536.0 else 1024.0
// Divisor must mirror the foreground-voice fade path above
// (FT2 mode: 32768 to match ft2_replayer.c:387-390+1469-1481).
val divisor = if (ts.fadeoutCutOnZero) 32768.0 else 1024.0
bg.fadeoutVolume = (bg.fadeoutVolume - fadeStep / divisor).coerceAtLeast(0.0)
} else if (ts.fadeoutCutOnZero) {
bg.active = false
@@ -2625,7 +2674,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
}
else -> {
ts.rowIndex++
if (ts.rowIndex >= 64) {
// LEN cue instruction shortens the effective row count so the
// engine wraps to the next cue early. Patterns fed by the
// converter are still 64 rows long; rows past `rowLimit` are
// silent padding that we skip here.
val currentInst = cueSheet[ts.cuePos].instruction
val rowLimit = if (currentInst is PlayInstPatLen) currentInst.rows else 64
if (ts.rowIndex >= rowLimit) {
ts.rowIndex = 0
advanceTrackerCue(ts, playhead)
resetPatternLoopState(ts)
@@ -2637,14 +2692,38 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
internal data class PlayCue(
val patterns: IntArray = IntArray(20) { 0xFFF },
var instruction: PlayInstruction = PlayInstNop
var instruction: PlayInstruction = PlayInstNop,
var instByte30: Int = 0,
var instByte31: Int = 0,
) {
// Cue layout (32 bytes, 20 voices, 12-bit pattern numbers):
// bytes 0-9: packed low nybbles (byte i => voice i*2 in hi, voice i*2+1 in lo)
// bytes 10-19: packed mid nybbles (same packing)
// bytes 20-29: packed high nybbles (same packing)
// byte 30: instruction
// byte 31: unused
// byte 30: instruction (low byte)
// byte 31: instruction arg byte (used by 2-byte forms: LEN, BAK, FWD, JMP)
// Decoding rules per terranmon.txt §"Cue Sheet":
// 00000010 00xxxxxx (LEN) pattern length: rows = (xxxxxx) + 1, range 1..64
// 00000001 (HALT) end of song
// 00000000 (NOP) default 64-row cue
// 1000xxxx yyyyyyyy (BAK) go back 12-bit arg
// 1001xxxx yyyyyyyy (FWD) skip forward 12-bit arg
// 1111xxxx yyyyyyyy (JMP) go to absolute pattern (currently unused)
private fun recomputeInstruction() {
val b30 = instByte30
val b31 = instByte31
instruction = when {
b30 == 0x02 -> PlayInstPatLen((b31 and 0x3F) + 1)
b30 == 0x01 -> PlayInstHalt
b30 == 0x00 -> PlayInstNop
// BAK: 1000xxxx yyyyyyyy — 12-bit arg combining b30 low nybble + b31.
(b30 and 0xF0) == 0x80 -> PlayInstGoBack(((b30 and 0xF) shl 8) or (b31 and 0xFF))
// FWD: 1001xxxx yyyyyyyy — 12-bit arg.
(b30 and 0xF0) == 0x90 -> PlayInstSkip(((b30 and 0xF) shl 8) or (b31 and 0xFF))
// JMP: 1111xxxx yyyyyyyy — reserved (decoder TBD).
else -> PlayInstNop
}
}
fun write(index: Int, byte: Int) = when (index) {
in 0..9 -> {
val b = index * 2
@@ -2661,13 +2740,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
patterns[b] = (patterns[b] and 0x0FF) or (((byte ushr 4) and 0xF) shl 8)
patterns[b + 1] = (patterns[b + 1] and 0x0FF) or ((byte and 0xF) shl 8)
}
30 -> { instruction = when {
byte >= 128 -> PlayInstGoBack(byte and 127)
byte in 16..31 -> PlayInstSkip(byte and 15)
byte == 1 -> PlayInstHalt
else -> PlayInstNop
} }
31 -> {}
30 -> { instByte30 = byte and 0xFF; recomputeInstruction() }
31 -> { instByte31 = byte and 0xFF; recomputeInstruction() }
else -> throw InternalError("Bad offset $index")
}
fun read(index: Int): Byte = when (index) {
@@ -2683,13 +2757,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val b = (index - 20) * 2
((((patterns[b] ushr 8) and 0xF) shl 4) or ((patterns[b + 1] ushr 8) and 0xF)).toByte()
}
30 -> when (instruction) {
is PlayInstGoBack -> (0b10000000 or instruction.arg).toByte()
is PlayInstSkip -> (0b00010000 or instruction.arg).toByte()
is PlayInstHalt -> 1
else -> 0
}
31 -> 0
30 -> instByte30.toByte()
31 -> instByte31.toByte()
else -> throw InternalError("Bad offset $index")
}
}
@@ -2697,6 +2766,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
internal open class PlayInstruction(val arg: Int)
internal class PlayInstGoBack(arg: Int) : PlayInstruction(arg)
internal class PlayInstSkip(arg: Int) : PlayInstruction(arg)
internal class PlayInstPatLen(val rows: Int) : PlayInstruction(rows)
internal object PlayInstHalt : PlayInstruction(0)
internal object PlayInstNop : PlayInstruction(0)
@@ -2768,7 +2838,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var envPfIndex = 0
var envPfTimeSec = 0.0
var envPfValue = 0.5 // 0.0..1.0; 0.5 = unity (no pitch shift / unmodulated cutoff)
var envPfIsFilter = false // mirror of inst.pfEnvSustain bit 7 latched at trigger
var envPfIsFilter = false // mirror of inst.pfEnvLoop bit 7 latched at trigger
// Volume fadeout — engaged after key-off, decays to 0 at rate inst.volumeFadeoutLow.
var fadeoutVolume = 1.0
@@ -3143,77 +3213,92 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
data class TaudInstEnvPoint(var value: Int, var offset: ThreeFiveMiniUfloat)
/**
* 192-byte instrument record (terranmon.txt:1997-2070).
* 256-byte instrument record (terranmon.txt:2001+).
*
* Envelopes have FOUR independent regions per envelope (vol/pan/pf):
* - 25 envelope nodes (offsets 21 / 71 / 121).
* - LOOP word (offsets 15 / 17 / 19) — always-active wrap region.
* - SUSTAIN word (offsets 189 / 191 / 193) — wrap region active ONLY
* while key is on; released on key-off.
*
* Priority during playback (matches schismtracker player/sndmix.c:480-499):
* if SUSTAIN.b == 1 and !key_off : wrap (sus_start, sus_end)
* elif LOOP.b == 1 : wrap (loop_start, loop_end)
* else : hold at last node
*
* Layout:
* 0..3 u32 sample pointer
* 4..5 u16 sample length
* 6..7 u16 sampling rate at Middle C (0x5000) // NOTE: Taud treats middle C as C4, but some trackers show you C4 even if they are internally C5. Best practice: copy the value as-is.
* 6..7 u16 sampling rate at Middle C (0x5000)
* 8..9 u16 play start
* 10..11 u16 loop start
* 12..13 u16 loop end
* 14 u8 sample flags (low 2 bits = loop mode 0..3)
* 15..16 u16 volume envelope flags (0b 0ut sssss pcb eeeee)
* 17..18 u16 panning envelope flags
* 19..20 u16 pitch/filter envelope flags
* 21..70 Bit16×25 volume envelope points (value 0x00-0x3F + minifloat dt)
* 71..120 Bit16×25 panning envelope points (value 0x00-0xFF, 0x80=centre)
* 15..16 u16 volume envelope LOOP word (0b 0000_0sss_ss0cb_eeeee)
* 17..18 u16 panning envelope LOOP word (0b 0000_0sss_ssp_cb_eeeee, p=use-default-pan)
* 19..20 u16 pitch/filter envelope LOOP word (0b 0000_0sss_ssm_cb_eeeee, m=mode)
* 21..70 Bit16×25 volume envelope points
* 71..120 Bit16×25 panning envelope points
* 121..170 Bit16×25 pitch/filter envelope points
* 171 u8 instrument global volume
* 172 u8 volume fadeout low bits
* 173 u8 fadeout high bits (low nibble; 0b 0000 ffff)
* 174 u8 volume swing
* 175 u8 vibrato speed (FT2 instrumentwise; IT Vis rescaled to 0..255)
* 176 u8 vibrato sweep (FT2-only ramp ticks; 0 for IT)
* 175 u8 vibrato speed
* 176 u8 vibrato sweep
* 177 u8 default pan
* 178..179 u16 pitch-pan centre (4096-TET)
* 178..179 u16 pitch-pan centre
* 180 s8 pitch-pan separation
* 181 u8 pan swing
* 182 u8 default cutoff
* 183 u8 default resonance
* 184..185 u16 sample detune (4096-TET, signed stored as u16)
* 186 u8 instrument flag (0b 000 www nn — NNA bits 0-1, vib waveform bits 2-4)
* NNA: 00=note off, 01=note cut, 10=continue, 11=note fade
* waveform: 0=sine, 1=ramp-down, 2=square, 3=random, 4=ramp-up (FT2)
* 187 u8 vibrato depth (0..255 full range)
* 188 u8 vibrato rate (0..255 full range — IT samplewise Vir)
* 189 u8 duplicate-check / action (IT-only — 0b 0000 aadd)
* dd = DCT (Duplicate Check Type) 0=off, 1=note, 2=sample, 3=instrument
* aa = DCA (Duplicate Check Action) 0=note cut, 1=note off, 2=note fade
* 190..191 byte[2] reserved
* 184..185 u16 sample detune (signed)
* 186 u8 instrument flag (NNA bits 0-1, vib waveform bits 2-4)
* 187 u8 vibrato depth
* 188 u8 vibrato rate
* 189..190 u16 volume envelope SUSTAIN word (0b 0000_0sss_ss00b_eeeee)
* 191..192 u16 panning envelope SUSTAIN word
* 193..194 u16 pitch/filter envelope SUSTAIN word
* 195 u8 duplicate-check / action (relocated from old offset 189)
* bits 0-1 = DCT, bits 2-3 = DCA
* 196..255 reserved (60 bytes)
*/
data class TaudInst(
var index: Int,
var samplePtr: Int, // 32-bit sample bin offset
var samplePtr: Int,
var sampleLength: Int,
var samplingRate: Int, // rate at MIDDLE_C
var samplingRate: Int,
var samplePlayStart: Int,
var sampleLoopStart: Int,
var sampleLoopEnd: Int,
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)
var instGlobalVolume: Int, // byte 171
var volEnvelopes: Array<TaudInstEnvPoint>, // 25 points
var panEnvelopes: Array<TaudInstEnvPoint>, // 25 points
var pfEnvelopes: Array<TaudInstEnvPoint>, // 25 points (pitch/filter)
var volumeFadeoutLow: Int, // byte 172
var fadeoutHigh: Int, // byte 173 (low nibble — 0b 0000 ffff)
var volumeSwing: Int, // byte 174
var vibratoSpeed: Int, // byte 175
var vibratoSweep: Int, // byte 176 (FT2 ramp ticks)
var defaultPan: Int, // byte 177
var pitchPanCentre: Int, // bytes 178-179
var pitchPanSeparation: Int, // byte 180 (signed)
var panSwing: Int, // byte 181
var defaultCutoff: Int, // byte 182
var defaultResonance: Int, // byte 183
var sampleDetune: Int, // bytes 184-185 (signed 4096-TET stored as u16)
var instrumentFlag: Int, // byte 186 (NNA + vibrato waveform)
var vibratoDepth: Int, // byte 187 (0..255 full range)
var vibratoRate: Int, // byte 188 (IT samplewise Vir)
var dupCheckFlag: Int // byte 189 (DCT bits 0-1, DCA bits 2-3)
var loopMode: Int,
var volEnvLoop: Int, // bytes 15-16 (LOOP word)
var panEnvLoop: Int, // bytes 17-18
var pfEnvLoop: Int, // bytes 19-20
var instGlobalVolume: Int,
var volEnvelopes: Array<TaudInstEnvPoint>,
var panEnvelopes: Array<TaudInstEnvPoint>,
var pfEnvelopes: Array<TaudInstEnvPoint>,
var volumeFadeoutLow: Int,
var fadeoutHigh: Int,
var volumeSwing: Int,
var vibratoSpeed: Int,
var vibratoSweep: Int,
var defaultPan: Int,
var pitchPanCentre: Int,
var pitchPanSeparation: Int,
var panSwing: Int,
var defaultCutoff: Int,
var defaultResonance: Int,
var sampleDetune: Int,
var instrumentFlag: Int,
var vibratoDepth: Int,
var vibratoRate: Int,
var volEnvSustainWord: Int, // bytes 189-190 (SUSTAIN word)
var panEnvSustainWord: Int, // bytes 191-192
var pfEnvSustainWord: Int, // bytes 193-194
var dupCheckFlag: Int // byte 195 (relocated from 189)
) {
constructor(index: Int) : this(
index, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF,
@@ -3221,7 +3306,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
Array(25) { TaudInstEnvPoint(0x80, ThreeFiveMiniUfloat(0)) },
Array(25) { TaudInstEnvPoint(0x80, ThreeFiveMiniUfloat(0)) },
0, 0, 0, 0, 0, 0x80, 0x5000, 0, 0, 0xFF, 0,
0, 0, 0, 0, 0
0, 0, 0, 0, 0, 0, 0, 0
)
/** Sample-flag byte 14 bit 2 — when set, the sample loop is a sustain loop:
@@ -3240,8 +3325,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
/** Duplicate Check Action — 0=note cut, 1=note off, 2=note fade. */
val duplicateCheckAction: Int get() = (dupCheckFlag ushr 2) and 0x03
// Reserved padding at offsets 190..191 (2 bytes per instrument).
private val reserved = ByteArray(2)
// Reserved padding at offsets 196..255 (60 bytes per instrument).
private val reserved = ByteArray(60)
// 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.
@@ -3294,12 +3379,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
13 -> sampleLoopEnd.ushr(8).toByte()
14 -> (loopMode and 7).toByte()
15 -> volEnvSustain.toByte()
16 -> volEnvSustain.ushr(8).toByte()
17 -> panEnvSustain.toByte()
18 -> panEnvSustain.ushr(8).toByte()
19 -> pfEnvSustain.toByte()
20 -> pfEnvSustain.ushr(8).toByte()
15 -> volEnvLoop.toByte()
16 -> volEnvLoop.ushr(8).toByte()
17 -> panEnvLoop.toByte()
18 -> panEnvLoop.ushr(8).toByte()
19 -> pfEnvLoop.toByte()
20 -> pfEnvLoop.ushr(8).toByte()
in 21..70 -> envPointGet(volEnvelopes, 21, offset)
in 71..120 -> envPointGet(panEnvelopes, 71, offset)
@@ -3323,8 +3408,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
186 -> instrumentFlag.toByte()
187 -> vibratoDepth.toByte()
188 -> vibratoRate.toByte()
189 -> dupCheckFlag.toByte()
in 190..191 -> reserved[offset - 190]
189 -> volEnvSustainWord.toByte()
190 -> volEnvSustainWord.ushr(8).toByte()
191 -> panEnvSustainWord.toByte()
192 -> panEnvSustainWord.ushr(8).toByte()
193 -> pfEnvSustainWord.toByte()
194 -> pfEnvSustainWord.ushr(8).toByte()
195 -> dupCheckFlag.toByte()
in 196..255 -> reserved[offset - 196]
else -> throw InternalError("Bad offset $offset")
}
@@ -3350,12 +3441,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
13 -> { sampleLoopEnd = (sampleLoopEnd and 0x00ff) or (byte shl 8) }
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 }
18 -> { panEnvSustain = (panEnvSustain and 0x00ff) or (byte shl 8) }
19 -> { pfEnvSustain = (pfEnvSustain and 0xff00) or byte }
20 -> { pfEnvSustain = (pfEnvSustain and 0x00ff) or (byte shl 8) }
15 -> { volEnvLoop = (volEnvLoop and 0xff00) or byte }
16 -> { volEnvLoop = (volEnvLoop and 0x00ff) or (byte shl 8) }
17 -> { panEnvLoop = (panEnvLoop and 0xff00) or byte }
18 -> { panEnvLoop = (panEnvLoop and 0x00ff) or (byte shl 8) }
19 -> { pfEnvLoop = (pfEnvLoop and 0xff00) or byte }
20 -> { pfEnvLoop = (pfEnvLoop and 0x00ff) or (byte shl 8) }
in 21..70 -> envPointSet(volEnvelopes, 21, offset, byte)
in 71..120 -> envPointSet(panEnvelopes, 71, offset, byte)
@@ -3363,14 +3454,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
171 -> { instGlobalVolume = byte and 0xFF }
172 -> { volumeFadeoutLow = byte and 0xFF }
173 -> { fadeoutHigh = byte and 0x0F } // low nibble only (0b 0000 ffff)
173 -> { fadeoutHigh = byte and 0x0F }
174 -> { volumeSwing = byte and 0xFF }
175 -> { vibratoSpeed = byte and 0xFF }
176 -> { vibratoSweep = byte and 0xFF }
177 -> { defaultPan = byte and 0xFF }
178 -> { pitchPanCentre = (pitchPanCentre and 0xff00) or byte }
179 -> { pitchPanCentre = (pitchPanCentre and 0x00ff) or (byte shl 8) }
180 -> { pitchPanSeparation = byte.toByte().toInt() } // signed
180 -> { pitchPanSeparation = byte.toByte().toInt() }
181 -> { panSwing = byte and 0xFF }
182 -> { defaultCutoff = byte and 0xFF }
183 -> { defaultResonance = byte and 0xFF }
@@ -3379,8 +3470,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
186 -> { instrumentFlag = byte and 0xFF }
187 -> { vibratoDepth = byte and 0xFF }
188 -> { vibratoRate = byte and 0xFF }
189 -> { dupCheckFlag = byte and 0x0F } // DCT (bits 0-1) + DCA (bits 2-3)
in 190..191 -> { reserved[offset - 190] = byte.toByte() }
189 -> { volEnvSustainWord = (volEnvSustainWord and 0xff00) or byte }
190 -> { volEnvSustainWord = (volEnvSustainWord and 0x00ff) or (byte shl 8) }
191 -> { panEnvSustainWord = (panEnvSustainWord and 0xff00) or byte }
192 -> { panEnvSustainWord = (panEnvSustainWord and 0x00ff) or (byte shl 8) }
193 -> { pfEnvSustainWord = (pfEnvSustainWord and 0xff00) or byte }
194 -> { pfEnvSustainWord = (pfEnvSustainWord and 0x00ff) or (byte shl 8) }
195 -> { dupCheckFlag = byte and 0x0F }
in 196..255 -> { reserved[offset - 196] = byte.toByte() }
else -> throw InternalError("Bad offset $offset")
}
}

1376
xm2taud.py Normal file

File diff suppressed because it is too large Load Diff