mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
xm2taud (wip), separate sustain and loop def
This commit is contained in:
2
2taud.sh
2
2taud.sh
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
295
it2taud.py
295
it2taud.py
@@ -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]
|
||||
|
||||
12
mod2taud.py
12
mod2taud.py
@@ -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
|
||||
|
||||
22
s3m2taud.py
22
s3m2taud.py
@@ -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}')")
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
151
terranmon.txt
151
terranmon.txt
@@ -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
|
||||
|
||||
@@ -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
1376
xm2taud.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user