Compare commits

...

6 Commits

Author SHA1 Message Date
minjaesong
7184392521 2taud converters refactoring 2026-05-01 06:47:35 +09:00
minjaesong
018b9f5eb3 mod2taud.py 2026-05-01 06:34:03 +09:00
minjaesong
bb0810798d taut font update 2026-05-01 01:54:29 +09:00
minjaesong
909f970d60 it2taud: 12 vol/pan envelope nodes; experimental 'filter bake in'
Implemented in it2taud.py:
- _parse_it_pf_envelope_raw() (it2taud.py:677) — parses IT's third envelope at IMPI+0x1D4, keeping all 25 nodes (no decimation), distinguishing pitch vs filter mode via flag bit 7.
- _env_value_at() — tick-time linear interpolation honouring env-loop wrap.
- _clone_sample(), _plan_baked_length() — sample copy and entry + N×loop_len length planner (N up to 16).
- _bake_pitch_envelope() — time-varying linear-interpolated resampling, rate = 2^(env_v/12).
- _bake_filter_envelope() — RBJ 2-pole resonant LP biquad with time-varying coefficients; cutoff mapped 110 Hz (env_v=−32) → ~28 kHz (env_v=+32), Q from inst.ifr ∈ [0.5, 6.0].
- ITInstrument extended with pf_nodes, pf_flags, ifc, ifr. parse_instruments() reads IFC/IFR at IMPI+0x39/0x3A and pf envelope at IMPI+0x1D4.
- assemble_taud() use_instruments branch now substitutes baked copies in the proxy[] list (originals in samples[] stay intact).
- --no-pf-envelope CLI flag for A/B testing; module docstring updated.
2026-05-01 01:48:40 +09:00
minjaesong
80c26c6b35 taud: 12 envelope nodes; taut proj tab 2026-05-01 01:36:04 +09:00
minjaesong
515e0268e6 taut inst: global volume 2026-04-30 21:54:11 +09:00
12 changed files with 1566 additions and 462 deletions

View File

@@ -196,7 +196,7 @@ sym:[`C${sym.accnull}`,`C${sym.sharp}`,`D${sym.accnull}`,`D${sym.sharp}`,`E${sym
sym:[`C${sym.accnull}`,`C${sym.sharp}`,`D${sym.accnull}`,`D${sym.sharp}`,`E${sym.accnull}`,`F${sym.accnull}`,`F${sym.sharp}`,`G${sym.accnull}`,`G${sym.sharp}`,`A${sym.accnull}`,`A${sym.sharp}`,`B${sym.accnull}`]},
10122:{index:10122,name:"Pythagorean Augmented Fourth", table:[0x0,0x134,0x2B8,0x3EC,0x570,0x6A4,0x828,0x95C,0xA90,0xC14,0xD48,0xECC],
sym:[`C${sym.accnull}`,`C${sym.sharp}`,`D${sym.accnull}`,`D${sym.sharp}`,`E${sym.accnull}`,`F${sym.accnull}`,`F${sym.sharp}`,`G${sym.accnull}`,`G${sym.sharp}`,`A${sym.accnull}`,`A${sym.sharp}`,`B${sym.accnull}`]},
10123:{index:10123,name:"Shi'er lu", table:[0x0,0x184,0x2B8,0x43C,0x570,0x6F4,0x828,0x95C,0xAE0,0xC14,0xD98,0xECC],
10123:{index:10123,name:"\u00FC\u00FD\u00FE", table:[0x0,0x184,0x2B8,0x43C,0x570,0x6F4,0x828,0x95C,0xAE0,0xC14,0xD98,0xECC],
sym:[` \u00E0\u00E1`,` \u00E2\u00E3`,` \u00E4\u00E5`,` \u00E6\u00E7`,` \u00E8\u00E9`,` \u00EA\u00EB`,` \u00EC\u00ED`,` \u00EE\u00EF`,` \u00F0\u00F1`,` \u00F2\u00F3`,` \u00F4\u00F5`,` \u00F6\u00F7`]},
@@ -218,6 +218,7 @@ const colBackPtn = 255
let PITCH_PRESET_IDX = 240 // TODO read from the Project Data section of the .taud
let beatDivPrimary = 4 // TODO read from the Project Data section of the .taud
let beatDivSecondary = 16
let hasUnsavedChanges = false
// pitchSymLut[pitchInOct] = [symString, octaveOffset]
// octaveOffset is 1 when pitchInOct is closer to the next octave's root (wraps up) than to any table entry.
@@ -675,7 +676,7 @@ function drawStatusBar() {
// beat indicator
let beatCursorRow = cursorRow
while (beatCursorRow > beatDivSecondary) { beatCursorRow -= beatDivSecondary } // test this behaviour with primary=4, secondary=22 or something
while (beatCursorRow >= beatDivSecondary) { beatCursorRow -= beatDivSecondary }
let beatInd = (playbackMode != PLAYMODE_NONE && beatCursorRow % beatDivPrimary < (beatDivPrimary >>> 1)) ?
((beatCursorRow % beatDivSecondary < (beatDivPrimary >>> 1)) ? '\u00846u' : '\u00847u') :
''
@@ -850,18 +851,22 @@ function drawControlHint() {
[`\u008428u\u008429u`,'Nav'],
[`Pg\u008418u`,'Cue'],
['sep'],
['WER','ViewMode'],
['WER','View'],
['sep'],
['Sp','Edit'],
['sep'],
['m','Mute'],
['s','Solo'],
['sep'],
['Tab','Panel'],
['Tab','Panel']
// ['sep'],
// ['q','Quit'],
]
let hintElemOrders = [
[`\u008428u\u008429u`,'Nav'],
[`Ent`,'Go to cue'],
['sep'],
['Sp','Edit'],
['sep'],
['Tab','Panel'],
// ['sep'],
@@ -871,14 +876,84 @@ function drawControlHint() {
let hintElemPatterns = [
[`\u008428u\u008429u`,'Nav'],
[`Pg\u008418u`,'Ptn'],
['sep'],
['Sp','Edit'],
['sep'],
['Tab','Panel'],
// ['sep'],
// ['q','Quit'],
]
let hintElemEditNoteValue = [ // only enabled in viewmode 'E' or in pattern editor
[`\u008428u\u008429u`,'Nav'],
[`Pg\u008418u`,'Cue'],
['sep'],
[`A${sym.doubledot}G`,'Note'],
[`0${sym.doubledot}9`,'Oct'],
['[]',`Tone\u008418u`],
['sep'],
['#',sym.sharp],
['@','Acc'],
['sep'],
['=','KOff'],
['^','KCut'],
// ['sep'],
// ['Sp','ExitEdit'],
]
let hintElemEditInstValue = [
[`\u008428u\u008429u`,'Nav'],
[`Pg\u008418u`,'Cue'],
['sep'],
[`0${sym.doubledot}9 A${sym.doubledot}F`,'Instrument'],
['sep'],
['Sp','ExitEdit'],
]
let hintElemEditVolEff = [
[`\u008428u\u008429u`,'Nav'],
[`Pg\u008418u`,'Cue'],
['sep'],
['h','Set'],
['j','SlideDn'],
['k','SlideUp'],
['u','FineDn'],
['i','FineUp'],
[`0${sym.doubledot}9 A${sym.doubledot}F`,'Val'],
// ['sep'],
// ['Sp','ExitEdit'],
]
let hintElemEditPanEff = [
[`\u008428u\u008429u`,'Nav'],
[`Pg\u008418u`,'Cue'],
['sep'],
['h','Set'],
['j','SlideL'],
['k','SlideR'],
['u','FineL'],
['i','FineR'],
[`0${sym.doubledot}9 A${sym.doubledot}F`,'Val'],
// ['sep'],
// ['Sp','ExitEdit'],
]
let hintElemEditFxSym = [
[`\u008428u\u008429u`,'Nav'],
[`Pg\u008418u`,'Cue'],
['sep'],
[`0${sym.doubledot}9 A${sym.doubledot}F`,`FxSym`],
['sep'],
['Sp','ExitEdit'],
]
let hintElemEditFxVal = [
[`\u008428u\u008429u`,'Nav'],
[`Pg\u008418u`,'Cue'],
['sep'],
[`0${sym.doubledot}9 A${sym.doubledot}F`,`FxVal`],
['sep'],
['Sp','ExitEdit'],
]
const hintElemExternal = [['Tab','Panel']]
let hintElems = [hintElemTimeline, hintElemOrders, hintElemPatterns, hintElemExternal, hintElemExternal, hintElemExternal, hintElemExternal]
let hintElemPat = [hintElemEditNoteValue, hintElemEditInstValue, hintElemEditVolEff, hintElemEditPanEff, hintElemEditFxSym, hintElemEditFxVal]
// erase current line
con.move(SCRH, 1)
@@ -1837,9 +1912,36 @@ function makeExternalPanelDraw(progName) {
function drawProjectContents(wo) {
fillLine(PTNVIEW_OFFSET_Y - 1, colVoiceHdr, 255)
for (let y = PTNVIEW_OFFSET_Y; y < SCRH; y++) fillLine(y, colBackPtn, 255)
con.move(PTNVIEW_OFFSET_Y + 2, 3)
con.color_pair(colStatus, 255)
print('[Project settings — not yet implemented]')
let mixerflag = initialTrackerMixerflags
let flagstrbuf = ''
let flagstr = [
['Linear pan','Equal-energy pan'],
['Linear tone','Amiga tone'],
]
for (let i = 0; i < flagstr.length; i++) {
let s = flagstr[i][(mixerflag >>> i) & 1 != 0]
if (i > 0) flagstrbuf += ', ';
flagstrbuf += s
}
let projMeta = {
Filename: fullPathObj.string.split('\\').last(),
Patterns: `${song.numPats}/4095 ($${song.numPats.hex03()})`,
Cues: `${song.lastActiveCue}/1024 ($${song.lastActiveCue.hex03()})`,
Notation: pitchTablePresets[PITCH_PRESET_IDX].name,
Flags: `${flagstrbuf} ($${mixerflag.hex02()})`,
}
Object.entries(projMeta).forEach(([key, value], index) => {
con.move(PTNVIEW_OFFSET_Y + index, 2)
con.color_pair(colStatus, 255); print(key)
con.move(PTNVIEW_OFFSET_Y + index, 12)
con.color_pair(colVoiceHdr, colBLACK); print(value)
})
con.color_pair(colStatus, 255) // reset colour
}
function externalPanelInput(wo, event) {}
@@ -2148,7 +2250,7 @@ function drawGotoPopup(popup, buf) {
const promptStr = prompts[currentPanel] || 'Number:'
con.move(popup.y + 2, popup.x + 2)
con.color_pair(colStatus, colPopupBack)
con.color_pair(colWHITE, colPopupBack)
print(promptStr + ' ')
con.color_pair(230, 240)
print('[' + buf.padEnd(3, '_') + ']')
@@ -2171,8 +2273,8 @@ function applyGoto(num) {
}
function openConfirmQuit() {
const pw = 25
const ph = 5
const pw = 25 + hasUnsavedChanges * 4
const ph = 5 + hasUnsavedChanges
const px = ((SCRW - pw) / 2 | 0) + 1
const py = ((SCRH - ph) / 2 | 0)
@@ -2184,11 +2286,17 @@ function openConfirmQuit() {
popup.drawFrame()
con.move(py + 2, px + 2)
con.color_pair(colStatus, colPopupBack)
con.color_pair(colWHITE, colPopupBack)
print('Exit Microtone? ')
con.color_pair(230, 240)
print('[Y/N]')
if (hasUnsavedChanges) {
con.move(py + 3, px + 2)
con.color_pair(colWHITE, colPopupBack)
print('You have unsaved changes.')
}
con.color_pair(colStatus, 255) // reset colour
let result = false
@@ -2261,6 +2369,7 @@ resetAudioDevice()
taud.uploadTaudFile(fullPathObj.full, 0, PLAYHEAD)
audio.setMasterVolume(PLAYHEAD, 255)
audio.setMasterPan(PLAYHEAD, 128)
const initialTrackerMixerflags = audio.getTrackerMixerFlags(PLAYHEAD)
function isExternalPanel(p) {
return p === VIEW_SAMPLES || p === VIEW_INSTRMNT || p === VIEW_FILE

Binary file not shown.

View File

@@ -43,9 +43,9 @@ function _pokeU32LE(ptr, off, v) {
*
* @param inFile Full path with drive letter, e.g. "A:/music/song.taud"
* @param songIndex 0-based index of the song in the SONG TABLE
* @param targetPlaydataSlot Playhead number (0-3) to configure
* @param playhead Playhead number (0-3) to configure
*/
function uploadTaudFile(inFile, songIndex, targetPlaydataSlot) {
function uploadTaudFile(inFile, songIndex, playhead) {
const drive = inFile[0].toUpperCase()
const diskPath = inFile.substring(2)
@@ -107,6 +107,7 @@ function uploadTaudFile(inFile, songIndex, targetPlaydataSlot) {
let numPatsHi = sys.peek(filePtr + entryOff + 6) & 0xFF
let bpmStored = sys.peek(filePtr + entryOff + 7) & 0xFF
let tickRate = sys.peek(filePtr + entryOff + 8) & 0xFF
let mixerflags = sys.peek(filePtr + entryOff + 15) & 0xFF
let bpm = bpmStored + 24
let patsToLoad = numPatsLo | (numPatsHi << 8)
@@ -130,9 +131,10 @@ function uploadTaudFile(inFile, songIndex, targetPlaydataSlot) {
}
// -- 8. Configure playhead ------------------------------------------------
audio.setTrackerMode(targetPlaydataSlot)
audio.setBPM(targetPlaydataSlot, bpm)
audio.setTickRate(targetPlaydataSlot, tickRate > 0 ? tickRate : 6)
audio.setTrackerMode(playhead)
audio.setBPM(playhead, bpm)
audio.setTickRate(playhead, tickRate > 0 ? tickRate : 6)
audio.setTrackerMixerFlags(playhead, mixerflags)
fileHandle.close()

View File

@@ -3,6 +3,7 @@
Usage:
python3 it2taud.py input.it output.taud [-v] [--no-decompress]
[--no-pf-envelope]
Limits:
- Up to 20 IT channels (excess dropped; hard error if chunk count
@@ -11,9 +12,17 @@ Limits:
Taud patterns. Pattern-loop (SBx) crossing a chunk boundary is
warned; B/C effects are remapped to new cue indices.
- IT2.14/IT2.15 compressed samples are decoded unless --no-decompress.
- IT instrument volume/pan envelopes (up to 8 nodes, sustain loops) are
- IT instrument volume/pan envelopes (up to 12 nodes, sustain loops) are
converted to Taud format. NNA actions are ignored. Each IT instrument
resolves to its C-5 canonical sample.
- IT pitch/filter envelope (no Taud equivalent) is BAKED onto a per-
instrument copy of the canonical sample (--no-pf-envelope to disable).
Pitch mode uses time-varying linear-interpolated resampling; filter
mode uses a 2-pole resonant low-pass biquad (RBJ cookbook),
approximate to IT's actual filter. Looped samples are rendered as
`entry + N×loop_len` with the loop reapplied to the tail. Caveat:
the envelope is locked to the sample's playback rate, so playing
the instrument an octave up advances the envelope twice as fast.
- AdLib / OPL instruments are skipped.
Effect support:
@@ -33,11 +42,22 @@ import math
import struct
import sys
VERBOSE = False
def vprint(*a, **kw):
if VERBOSE:
print(*a, **kw, file=sys.stderr)
from taud_common import (
set_verbose, vprint,
TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE,
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C3,
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I,
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_Y,
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
EFF_A, EFF_B, EFF_C, EFF_D, EFF_E, EFF_F, EFF_G, EFF_H, EFF_I, EFF_J,
EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T,
EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z,
J_SEMI_TABLE,
d_arg_to_col, resample_linear, encode_cue, deduplicate_patterns,
normalise_sample,
)
# ── IT constants ─────────────────────────────────────────────────────────────
@@ -82,14 +102,6 @@ VC_VIB_LO, VC_VIB_HI = 203, 212 # vibrato H (depth 1..10)
VC_TPORTA_TABLE = (0, 1, 4, 8, 16, 32, 64, 96, 128, 255)
# IT effect letters (1-based, same numbering as S3M so we can reuse encode_effect)
EFF_A = 1; EFF_B = 2; EFF_C = 3; EFF_D = 4; EFF_E = 5
EFF_F = 6; EFF_G = 7; EFF_H = 8; EFF_I = 9; EFF_J = 10
EFF_K = 11; EFF_L = 12; EFF_M = 13; EFF_N = 14; EFF_O = 15
EFF_P = 16; EFF_Q = 17; EFF_R = 18; EFF_S = 19; EFF_T = 20
EFF_U = 21; EFF_V = 22; EFF_W = 23; EFF_X = 24; EFF_Y = 25
EFF_Z = 26
# IT effects that recall last non-zero arg (per-effect-private, with cohort exceptions).
# V (Set Global Volume) recalls in IT compat mode — the first V $00 resolves to the
# header's global_vol, not literal 0. Without this, songs starting with V $00 silence.
@@ -100,40 +112,10 @@ IT_MEM_EFFECTS = frozenset({
})
# ── Taud constants ────────────────────────────────────────────────────────────
# ── Taud constants (it-specific) ──────────────────────────────────────────────
TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64])
TAUD_VERSION = 1
TAUD_HEADER_SIZE = 32
TAUD_SONG_ENTRY = 16
SAMPLEBIN_SIZE = 770048
INSTBIN_SIZE = 16384
SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE
PATTERN_ROWS = 64
PATTERN_BYTES = PATTERN_ROWS * 8
NUM_PATTERNS_MAX = 4095
NUM_CUES = 1024
CUE_SIZE = 32
NUM_VOICES = 20
SIGNATURE = b'it2taud/TSVM ' # 14 bytes
NOTE_NOP = 0xFFFF
NOTE_KEYOFF = 0x0000
NOTE_CUT = 0xFFFE
TAUD_C3 = 0x4000
TOP_NONE = 0x00
TOP_A = 0x0A; TOP_B = 0x0B; TOP_C = 0x0C; TOP_D = 0x0D
TOP_E = 0x0E; TOP_F = 0x0F; TOP_G = 0x10; TOP_H = 0x11
TOP_I = 0x12; TOP_J = 0x13; TOP_K = 0x14; TOP_L = 0x15
TOP_O = 0x18; TOP_Q = 0x1A; TOP_R = 0x1B; TOP_S = 0x1C
TOP_T = 0x1D; TOP_U = 0x1E; TOP_V = 0x1F; TOP_Y = 0x22
SEL_SET = 0; SEL_UP = 1; SEL_DOWN = 2; SEL_FINE = 3
J_SEMI_TABLE = [0x00, 0x01, 0x03, 0x04, 0x05, 0x07, 0x08, 0x09,
0x0B, 0x0C, 0x0D, 0x0F, 0x10, 0x11, 0x13, 0x14]
# ThreeFiveMiniUfloat LUT — 256 entries, seconds 0.0..126.0 (must match Kotlin)
_MINUFLOAT_LUT = [
0.0, 0.03125, 0.0625, 0.09375, 0.125, 0.15625, 0.1875, 0.21875,
@@ -390,40 +372,6 @@ def it214_decompress(blob: bytes, smp_offset: int, num_samples: int,
return bytes(s & 0xFF for s in out_samples)
# ── Sample normaliser (same as s3m2taud but signed derived from cvt byte) ────
def _normalise_sample(raw: bytes, signed: bool, is_16bit: bool,
is_stereo: bool, name: str) -> bytes:
"""Return unsigned 8-bit mono sample bytes."""
out = []
stride = (2 if is_16bit else 1) * (2 if is_stereo else 1)
i = 0
while i + stride <= len(raw):
if is_16bit:
if is_stereo:
l16 = struct.unpack_from('<h', raw, i)[0]
r16 = struct.unpack_from('<h', raw, i+2)[0]
s = (l16 + r16) >> 1
else:
s = struct.unpack_from('<h', raw, i)[0]
v = (s >> 8) + 128
else:
if is_stereo:
l8 = raw[i]; r8 = raw[i+1]
raw_s = (l8 + r8) // 2
else:
raw_s = raw[i]
if signed:
v = (raw_s ^ 0x80) & 0xFF
else:
v = raw_s
out.append(v & 0xFF)
i += stride
if is_16bit or is_stereo:
vprint(f" info: '{name}' converted to unsigned 8-bit mono ({len(out)} samples)")
return bytes(out)
# ── IT sample parser ──────────────────────────────────────────────────────────
class ITSample:
@@ -480,7 +428,7 @@ def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list:
is_it215 = bool(s.cvt & 0x04)
raw = it214_decompress(data, s.smp_point, s.length,
s.is_16bit, is_it215)
s.sample_data = _normalise_sample(raw, True,
s.sample_data = normalise_sample(raw, True,
s.is_16bit, s.is_stereo, s.name)
s.length = len(s.sample_data)
s.loop_beg = min(s.loop_beg, s.length)
@@ -494,7 +442,7 @@ def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list:
s.sample_data = bytes(min(s.length, 256))
else:
raw = data[s.smp_point : s.smp_point + byte_len]
s.sample_data = _normalise_sample(raw, s.is_signed,
s.sample_data = normalise_sample(raw, s.is_signed,
s.is_16bit, s.is_stereo, s.name)
s.length = len(s.sample_data)
s.loop_beg = min(s.loop_beg, s.length)
@@ -503,13 +451,206 @@ def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list:
return samples
# ── Pitch / filter envelope baker ─────────────────────────────────────────────
# IT instruments carry a third envelope (pitch or filter, distinguished by
# flag bit 7) that has no Taud equivalent. We render its effect onto a
# per-instrument copy of the canonical sample so the instrument can play
# through Taud's normal sample path with the modulation already baked in.
#
# Caveat: the baked envelope's time axis is locked to the sample's playback
# rate, so playing the instrument an octave up advances the envelope twice
# as fast (IT's envelope is wall-clock-time, regardless of note pitch). For
# typical drum/lead use cases this is musically acceptable.
def _clone_sample(src: 'ITSample') -> 'ITSample':
dst = ITSample()
for slot in ITSample.__slots__:
setattr(dst, slot, getattr(src, slot))
dst.sample_data = bytes(src.sample_data)
return dst
def _plan_baked_length(src: 'ITSample', nodes: list, flags: dict,
ticks_per_sec: float) -> tuple:
"""Determine baked output length and loop boundaries.
Non-looped src → (src.length, 0, 0).
Looped src → (entry + N×loop_len, entry, entry + N×loop_len),
with N the smallest integer that covers the envelope's
active duration in seconds. N is clamped to [1, 16].
"""
if not nodes:
return src.length, 0, 0
last_tick = nodes[-1][1]
env_dur_sec = last_tick / ticks_per_sec if ticks_per_sec > 0 else 0.0
env_dur_samples = int(env_dur_sec * src.c5_speed)
if not src.has_loop or src.loop_end <= src.loop_beg:
return src.length, 0, 0
entry = max(0, src.loop_beg)
loop_len = src.loop_end - src.loop_beg
if loop_len <= 0:
return src.length, 0, 0
samples_needed = max(0, env_dur_samples - entry)
n = max(1, (samples_needed + loop_len - 1) // loop_len)
n = min(n, 16)
out_len = entry + n * loop_len
return out_len, entry, out_len
def _read_src_sample(sd: bytes, pos: float, src: 'ITSample') -> float:
"""Linear-interpolated read of `sd` (unsigned u8 PCM) at fractional `pos`,
returning a signed float in roughly [-128, +127]. Honours `src.has_loop`
by wrapping into [loop_beg, loop_end) once `pos >= loop_end`. Past the
end of a non-looped sample, returns 0.0 (silence)."""
if not sd:
return 0.0
if src.has_loop and src.loop_end > src.loop_beg:
if pos >= src.loop_end:
span = src.loop_end - src.loop_beg
pos = src.loop_beg + ((pos - src.loop_beg) % span)
if pos < 0.0:
return 0.0
if pos >= len(sd) - 1:
if pos >= len(sd):
return 0.0
return float(sd[len(sd) - 1] - 128)
i0 = int(pos)
frac = pos - i0
a = sd[i0] - 128
b = sd[i0 + 1] - 128
return a + (b - a) * frac
def _bake_pitch_envelope(src: 'ITSample', nodes: list, flags: dict,
ticks_per_sec: float) -> 'ITSample':
if not src.sample_data:
return src
out_len, lb, le = _plan_baked_length(src, nodes, flags, ticks_per_sec)
tick_per_sample = ticks_per_sec / max(1, src.c5_speed)
sd = src.sample_data
out = bytearray(out_len)
read_pos = 0.0
t_tick = 0.0
for i in range(out_len):
env_v = _env_value_at(t_tick, nodes, flags)
rate = 2.0 ** (env_v / 12.0)
v = _read_src_sample(sd, read_pos, src)
b = int(round(v)) + 128
if b < 0: b = 0
elif b > 255: b = 255
out[i] = b
read_pos += rate
t_tick += tick_per_sample
dst = _clone_sample(src)
dst.sample_data = bytes(out)
dst.length = out_len
dst.loop_beg = lb
dst.loop_end = le
if lb < le:
dst.has_loop = True
dst.flags = (dst.flags | IT_SMP_LOOP) & ~IT_SMP_PINGPONG
else:
dst.has_loop = False
dst.flags = dst.flags & ~(IT_SMP_LOOP | IT_SMP_PINGPONG)
return dst
def _bake_filter_envelope(src: 'ITSample', nodes: list, flags: dict,
ticks_per_sec: float, ifr: int) -> 'ITSample':
"""Time-varying 2-pole resonant low-pass biquad (RBJ cookbook).
Approximates IT's filter; not bit-exact to IT's Pentium routine."""
if not src.sample_data:
return src
out_len, lb, le = _plan_baked_length(src, nodes, flags, ticks_per_sec)
tick_per_sample = ticks_per_sec / max(1, src.c5_speed)
sr = max(1, src.c5_speed)
nyq = sr * 0.5 - 1.0
# Resonance (0..127) → Q ∈ [0.5, 6.0]
Q = 0.5 + (max(0, min(ifr, 127)) / 127.0) * 5.5
sd = src.sample_data
out = bytearray(out_len)
x1 = x2 = 0.0
y1 = y2 = 0.0
t_tick = 0.0
src_len = len(sd)
for i in range(out_len):
env_v = _env_value_at(t_tick, nodes, flags)
# Map env value -32..+32 to cutoff: 110 Hz (closed) to ~28 kHz (open)
cutoff = 110.0 * (2.0 ** ((env_v + 32.0) * 0.125))
if cutoff > nyq: cutoff = nyq
if cutoff < 1.0: cutoff = 1.0
w0 = 2.0 * math.pi * cutoff / sr
cosw = math.cos(w0)
sinw = math.sin(w0)
alpha = sinw / (2.0 * Q)
b0 = (1.0 - cosw) * 0.5
b1 = 1.0 - cosw
b2 = b0
a0 = 1.0 + alpha
a1 = -2.0 * cosw
a2 = 1.0 - alpha
# Read source with looping
in_pos = i
if src.has_loop and src.loop_end > src.loop_beg and in_pos >= src.loop_end:
span = src.loop_end - src.loop_beg
in_pos = src.loop_beg + ((in_pos - src.loop_beg) % span)
if 0 <= in_pos < src_len:
x0 = float(sd[in_pos] - 128)
else:
x0 = 0.0
y0 = (b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2) / a0
x2 = x1; x1 = x0
y2 = y1; y1 = y0
b = int(round(y0)) + 128
if b < 0: b = 0
elif b > 255: b = 255
out[i] = b
t_tick += tick_per_sample
dst = _clone_sample(src)
dst.sample_data = bytes(out)
dst.length = out_len
dst.loop_beg = lb
dst.loop_end = le
if lb < le:
dst.has_loop = True
dst.flags = (dst.flags | IT_SMP_LOOP) & ~IT_SMP_PINGPONG
else:
dst.has_loop = False
dst.flags = dst.flags & ~(IT_SMP_LOOP | IT_SMP_PINGPONG)
return dst
def _bake_pf_envelope(src: 'ITSample', inst, ticks_per_sec: float) -> 'ITSample':
if not inst.pf_nodes or not inst.pf_flags or not inst.pf_flags.get('enabled'):
return src
if inst.pf_flags['is_filter']:
return _bake_filter_envelope(src, inst.pf_nodes, inst.pf_flags,
ticks_per_sec, inst.ifr)
return _bake_pitch_envelope(src, inst.pf_nodes, inst.pf_flags, ticks_per_sec)
# ── IT instrument parser ──────────────────────────────────────────────────────
class ITInstrument:
__slots__ = ('name', 'dfp', 'gv', 'canonical_sample', 'canonical_volume',
'vol_envelope', 'pan_envelope', 'vol_env_sustain', 'pan_env_sustain')
# vol_envelope / pan_envelope: list of 8 (value, minifloat_idx) tuples, or None
'vol_envelope', 'pan_envelope', 'vol_env_sustain', 'pan_env_sustain',
'pf_nodes', 'pf_flags', 'ifc', 'ifr')
# vol_envelope / pan_envelope: list of 12 (value, minifloat_idx) tuples, or None
# vol_env_sustain / pan_env_sustain: int (0 = disabled, else (end<<3)|start)
# pf_nodes: raw list of (int8 value, uint16 tick) tuples (up to 25), or None
# pf_flags: dict {enabled, has_env_loop, has_sus_loop, lpb, lpe, slb, sle,
# is_filter, carry}, or None
# ifc / ifr: initial filter cutoff / resonance (0..127, or 0 if not set)
def parse_instruments(data: bytes, h: ITHeader) -> list:
insts = []
@@ -542,29 +683,40 @@ def parse_instruments(data: bytes, h: ITHeader) -> list:
inst.canonical_sample = c5_smp # 1-based sample index, 0 = none
inst.canonical_volume = min(inst.gv, 64)
# Initial filter cutoff/resonance (high bit = enabled, low 7 bits = value)
ifc_raw = data[ptr + 0x39]
ifr_raw = data[ptr + 0x3A]
inst.ifc = ifc_raw & 0x7F if (ifc_raw & 0x80) else 0
inst.ifr = ifr_raw & 0x7F if (ifr_raw & 0x80) else 0
# Parse IT envelopes (new-format only, ≥cmwt 0x200)
# Vol envelope at ptr+0x130; pan envelope at ptr+0x182
# 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) # tick rate = bpm×2/5 (50 Hz at 125 BPM); speed is ticks-per-row, irrelevant here
inst.vol_envelope, inst.vol_env_sustain = _parse_it_envelope(
data, ptr + 0x130, False, ticks_per_sec)
inst.pan_envelope, inst.pan_env_sustain = _parse_it_envelope(
data, ptr + 0x182, True, ticks_per_sec)
inst.pf_nodes, inst.pf_flags = _parse_it_pf_envelope_raw(
data, ptr + 0x1D4)
insts.append(inst)
return insts
def _parse_it_envelope(data: bytes, env_ptr: int, is_pan: bool,
ticks_per_sec: float) -> tuple:
"""Parse one IT envelope block into 8 Taud (value, minifloat_idx) points.
"""Parse one IT envelope block into 12 Taud (value, minifloat_idx) points.
Returns (points_list, sustain_byte) where points_list is a list of
8 (value, minifloat_idx) tuples, or None if envelope not enabled.
sustain_byte: bit7=enabled, bits[5:3]=end_idx, bits[2:0]=start_idx; 0=disabled.
12 (value, minifloat_idx) tuples, or None if envelope not enabled.
sustain_byte: bit7=enabled (u), bit6=sustain (t: 1=breaks on key-off,
0=loops forever), bits[5:3]=end_idx, bits[2:0]=start_idx; 0=disabled.
Note: sustain byte still uses 3-bit indices (0..7), so loop nodes
referencing indices 8..11 cannot be encoded and fall back to no loop.
IT has two loop types: envelope loop (always on) and sustain loop (breaks on
key-off). Taud only has sustain loop semantics. Priority: sustain > env loop.
When slb==sle the AudioAdapter holds at that node (no cycling); for slb!=sle
it cycles between them.
IT has two loop types: envelope loop (continues forever) and sustain loop
(breaks on key-off). Taud distinguishes them via the 't' flag. Priority
when both exist: sustain (because IT plays sustain while held, then env
loop after release; Taud can only express one).
"""
if env_ptr + 82 > len(data):
return None, 0
@@ -580,17 +732,20 @@ def _parse_it_envelope(data: bytes, env_ptr: int, is_pan: bool,
has_env_loop = bool(flags & 0x02)
has_sus_loop = bool(flags & 0x04)
# Choose which IT loop to map to Taud sustain (priority: sus > env)
# Choose which IT loop to map to Taud (priority: sus > env). The 't' flag
# distinguishes them: t=1 for sustain (breaks on key-off), t=0 for env loop.
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
vprint(f" envelope loop mapped as sustain loop (approximation: breaks on key-off)")
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 = []
@@ -605,23 +760,26 @@ def _parse_it_envelope(data: bytes, env_ptr: int, is_pan: bool,
if not nodes:
return None, 0
# Decimate to 8 nodes if needed, preserving first/last
decimated = len(nodes) > 8
# Decimate to 12 nodes if needed, preserving first/last
decimated = len(nodes) > 12
if not decimated:
selected = nodes[:]
if has_loop and use_lb < len(selected) and use_le < len(selected):
# Sustain byte encodes 3-bit indices; loop nodes ≥8 cannot be referenced.
if has_loop and use_lb < min(len(selected), 8) and use_le < min(len(selected), 8):
taud_slb, taud_sle = use_lb, use_le
else:
taud_slb = taud_sle = -1
if has_loop and (use_lb >= 8 or use_le >= 8):
vprint(f" loop indices ≥8 cannot be encoded in 3-bit sustain field")
else:
selected = [nodes[round(k * (len(nodes) - 1) / 7)] for k in range(8)]
selected = [nodes[round(k * (len(nodes) - 1) / 11)] for k in range(12)]
taud_slb = taud_sle = -1 # loop indices lost in decimation
if has_loop:
vprint(f" loop indices lost due to decimation ({len(nodes)} nodes → 8)")
vprint(f" loop indices lost due to decimation ({len(nodes)} nodes → 12)")
# Build 8 Taud envelope points with delta-time minifloats
# Build 12 Taud envelope points with delta-time minifloats
points = []
for k in range(8):
for k in range(12):
if k < len(selected):
val, tick = selected[k]
if is_pan:
@@ -640,16 +798,99 @@ def _parse_it_envelope(data: bytes, env_ptr: int, is_pan: bool,
mf_idx = 0
points.append((taud_val, mf_idx))
# Build sustain byte: bit7=enabled, bits[5:3]=end_idx, bits[2:0]=start_idx.
# 0 = disabled (no bit7). All (slb, sle) pairs including (0,0) are valid when bit7=1.
# Build sustain byte: bit7=enable (u), bit6=sustain (t), bits[5:3]=end,
# bits[2:0]=start. 0=disabled. t=1 → breaks on key-off (IT sustain loop);
# t=0 → loops forever (IT envelope loop).
if taud_slb >= 0 and taud_sle >= 0:
sus_byte = 0x80 | ((taud_sle & 7) << 3) | (taud_slb & 7)
t_bit = 0x40 if is_sustain else 0x00
sus_byte = 0x80 | t_bit | ((taud_sle & 7) << 3) | (taud_slb & 7)
else:
sus_byte = 0
return points, sus_byte
def _parse_it_pf_envelope_raw(data: bytes, env_ptr: int) -> tuple:
"""Parse the IT pitch/filter envelope keeping all nodes (no decimation).
Returns (nodes, flags) where:
nodes: list of (int8 value, uint16 tick) tuples (up to 25 entries),
values in -32..+32 semitones (pitch) or filter modulation units.
flags: dict {enabled, has_env_loop, has_sus_loop, lpb, lpe, slb, sle,
is_filter, carry}
Returns (None, None) if the envelope is not enabled or out of range.
"""
if env_ptr + 82 > len(data):
return None, None
flags_byte = data[env_ptr]
if not (flags_byte & 0x01):
return None, None
num_nodes = max(1, min(data[env_ptr + 1], 25))
flags = {
'enabled': True,
'has_env_loop': bool(flags_byte & 0x02),
'has_sus_loop': bool(flags_byte & 0x04),
'carry': bool(flags_byte & 0x08),
'is_filter': bool(flags_byte & 0x80),
'lpb': data[env_ptr + 2],
'lpe': data[env_ptr + 3],
'slb': data[env_ptr + 4],
'sle': data[env_ptr + 5],
}
nodes = []
for n in range(num_nodes):
nptr = env_ptr + 6 + n * 3
if nptr + 2 >= len(data):
break
val = struct.unpack_from('b', data, nptr)[0]
tick = struct.unpack_from('<H', data, nptr + 1)[0]
nodes.append((val, tick))
if not nodes:
return None, None
# Clamp loop indices into valid range
for k in ('lpb', 'lpe', 'slb', 'sle'):
flags[k] = min(flags[k], len(nodes) - 1)
return nodes, flags
def _env_value_at(t_tick: float, nodes: list, flags: dict) -> float:
"""Return interpolated envelope value at time `t_tick` (IT envelope ticks).
Honours env-loop wrap. Sus-loop is treated as 'play through once then hold
last value' (no key-off model). Returns nodes[0].value if t_tick is before
the first node, nodes[-1].value if past the last node and no env loop.
"""
if not nodes:
return 0.0
first_tick = nodes[0][1]
if t_tick <= first_tick:
return float(nodes[0][0])
if flags['has_env_loop']:
lpb_tick = nodes[flags['lpb']][1]
lpe_tick = nodes[flags['lpe']][1]
loop_span = lpe_tick - lpb_tick
if loop_span > 0 and t_tick >= lpe_tick:
t_tick = lpb_tick + ((t_tick - lpb_tick) % loop_span)
elif loop_span <= 0 and t_tick >= lpe_tick:
return float(nodes[flags['lpe']][0])
last_tick = nodes[-1][1]
if t_tick >= last_tick:
return float(nodes[-1][0])
# Linear interpolate between bracketing nodes
for k in range(len(nodes) - 1):
a_val, a_t = nodes[k]
b_val, b_t = nodes[k + 1]
if a_t <= t_tick <= b_t:
if b_t == a_t:
return float(a_val)
frac = (t_tick - a_t) / (b_t - a_t)
return a_val + (b_val - a_val) * frac
return float(nodes[-1][0])
# ── IT pattern parser ─────────────────────────────────────────────────────────
class ITRow:
@@ -783,21 +1024,6 @@ def decode_volcol(vc: int):
# ── Effect translator ─────────────────────────────────────────────────────────
def _d_arg_to_col(arg: int):
if arg == 0:
return None
hi = (arg >> 4) & 0xF
lo = arg & 0xF
if hi == 0xF and lo > 0:
return (SEL_FINE, lo & 0x1F)
if lo == 0xF and hi > 0:
return (SEL_FINE, (hi & 0x1F) | 0x20)
if hi > 0 and lo == 0:
return (SEL_UP, hi)
if lo > 0 and hi == 0:
return (SEL_DOWN, lo)
return (SEL_UP, hi)
def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
"""Return (taud_op, taud_arg16, vol_override, pan_override).
@@ -851,22 +1077,22 @@ def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
return (TOP_J, (J_SEMI_TABLE[hi_semi] << 8) | J_SEMI_TABLE[lo_semi], None, None)
if cmd == EFF_K:
return (TOP_H, 0x0000, _d_arg_to_col(arg), None)
return (TOP_H, 0x0000, d_arg_to_col(arg), None)
if cmd == EFF_L:
return (TOP_G, 0x0000, _d_arg_to_col(arg), None)
return (TOP_G, 0x0000, d_arg_to_col(arg), None)
if cmd == EFF_M:
return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None)
if cmd == EFF_N:
return (TOP_NONE, 0, _d_arg_to_col(arg), None)
return (TOP_NONE, 0, d_arg_to_col(arg), None)
if cmd == EFF_O:
return (TOP_O, (arg & 0xFF) << 8, None, None)
if cmd == EFF_P:
return (TOP_NONE, 0, None, _d_arg_to_col(arg))
return (TOP_NONE, 0, None, d_arg_to_col(arg))
if cmd == EFF_Q:
return (TOP_Q, (arg & 0xFF) << 8, None, None)
@@ -1108,26 +1334,13 @@ def _find_post_pat_cue(pi: int, order_list: list, chunk_map: list,
# ── Sample / instrument bin (same as s3m2taud) ────────────────────────────────
def _resample_linear(data: bytes, ratio: float) -> bytes:
if not data:
return data
n_out = max(1, int(len(data) * ratio))
out = bytearray(n_out)
for i in range(n_out):
src = i / ratio
i0 = int(src)
frac = src - i0
i1 = min(i0 + 1, len(data) - 1)
v = data[i0] * (1.0 - frac) + data[i1] * frac
out[i] = int(v + 0.5) & 0xFF
return bytes(out)
def build_sample_inst_bin_it(samples_or_proxy: list,
envelopes_by_slot: dict = None) -> tuple:
"""samples_or_proxy: list of ITSample | None, indexed 1-based (index 0 unused).
envelopes_by_slot: optional dict mapping taud_slot → (vol_env, vol_sus, pan_env, pan_sus)
where vol_env/pan_env are lists of 8 (value, minifloat_idx) tuples (or None).
envelopes_by_slot: optional dict mapping taud_slot → (vol_env, vol_sus, pan_env, pan_sus, inst_gv)
where vol_env/pan_env are lists of 12 (value, minifloat_idx) tuples (or None),
and inst_gv is instrument global volume (0..255, byte 15).
Returns (bin_bytes[SAMPLEINST_SIZE], offsets_dict).
"""
@@ -1140,7 +1353,7 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
ratio = SAMPLEBIN_SIZE / total
vprint(f" info: sample bin overflow ({total} bytes); resampling all by {ratio:.4f}")
for _, s in pcm_list:
new_data = _resample_linear(s.sample_data, ratio)
new_data = resample_linear(s.sample_data, ratio)
s.sample_data = new_data
s.length = len(new_data)
s.loop_beg = max(0, int(s.loop_beg * ratio))
@@ -1191,30 +1404,33 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
struct.pack_into('<H', inst_bin, base + 10, le)
inst_bin[base + 12] = flags_byte
# Write envelope data (new 8-point format)
# Write envelope data (12-point format: vol at +16..+39, pan at +40..+63)
env_data = envelopes_by_slot.get(taud_idx) if envelopes_by_slot else None
if env_data and env_data[0]:
vol_env, vol_sus, pan_env, pan_sus = env_data
vol_env, vol_sus, pan_env, pan_sus, inst_gv = env_data
inst_bin[base + 13] = vol_sus & 0xFF
inst_bin[base + 14] = pan_sus & 0xFF
for k, (val, mf) in enumerate(vol_env[:8]):
inst_bin[base + 15] = inst_gv & 0xFF
for k, (val, mf) in enumerate(vol_env[:12]):
inst_bin[base + 16 + k*2] = val & 0xFF
inst_bin[base + 16 + k*2 + 1] = mf & 0xFF
if pan_env:
for k, (val, mf) in enumerate(pan_env[:8]):
inst_bin[base + 32 + k*2] = val & 0xFF
inst_bin[base + 32 + k*2 + 1] = mf & 0xFF
for k, (val, mf) in enumerate(pan_env[:12]):
inst_bin[base + 40 + k*2] = val & 0xFF
inst_bin[base + 40 + k*2 + 1] = mf & 0xFF
else:
for k in range(8):
inst_bin[base + 32 + k*2] = 0x80 # pan centre
inst_bin[base + 32 + k*2 + 1] = 0x00 # hold
for k in range(12):
inst_bin[base + 40 + k*2] = 0x80 # pan centre
inst_bin[base + 40 + k*2 + 1] = 0x00 # hold
else:
# No instrument envelope: single-point vol, neutral pan
# No instrument envelope: single-point vol, neutral pan, full gv
inst_gv = env_data[4] if env_data else 255
inst_bin[base + 15] = inst_gv & 0xFF
inst_bin[base + 16] = min(s.vol, 63) # value 0-63
inst_bin[base + 17] = 0 # offset 0 = hold
for k in range(8):
inst_bin[base + 32 + k*2] = 0x80 # pan centre
inst_bin[base + 32 + k*2 + 1] = 0x00 # hold
for k in range(12):
inst_bin[base + 40 + k*2] = 0x80 # pan centre
inst_bin[base + 40 + k*2 + 1] = 0x00 # hold
vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}")
return bytes(sample_bin) + bytes(inst_bin), offsets
@@ -1335,32 +1551,6 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int,
return bytes(out)
# ── Cue sheet helpers (adapted from s3m2taud) ─────────────────────────────────
def _encode_cue(patterns12: list, instruction: int) -> bytearray:
pats = list(patterns12) + [0xFFF] * NUM_VOICES
pats = pats[:NUM_VOICES]
entry = bytearray(CUE_SIZE)
for i in range(10):
v0, v1 = pats[i*2], pats[i*2+1]
entry[i] = ((v0 & 0xF) << 4) | (v1 & 0xF)
entry[10 + i] = (((v0 >> 4) & 0xF) << 4) | ((v1 >> 4) & 0xF)
entry[20 + i] = (((v0 >> 8) & 0xF) << 4) | ((v1 >> 8) & 0xF)
entry[30] = instruction & 0xFF
return entry
def deduplicate_patterns(pat_bin: bytes, num_pats: int) -> tuple:
seen = {}; remap = {}; canonical = []
for i in range(num_pats):
pat = pat_bin[i*PATTERN_BYTES:(i+1)*PATTERN_BYTES]
if pat in seen:
remap[i] = seen[pat]
else:
ci = len(canonical)
seen[pat] = ci; remap[i] = ci; canonical.append(pat)
return b''.join(canonical), remap, len(canonical)
# ── Main assembly ─────────────────────────────────────────────────────────────
def find_initial_bpm_speed(patterns_rows: list, order_list: list,
@@ -1406,7 +1596,8 @@ def _active_channels(h: ITHeader, patterns_rows: list) -> list:
return active
def assemble_taud(h: ITHeader, samples: list, instruments: list,
patterns_rows: list, decompress: bool) -> bytes:
patterns_rows: list, decompress: bool,
no_pf_envelope: bool = False) -> bytes:
# ── Resolve IT recalls ───────────────────────────────────────────────────
vprint(" resolving IT recalls…")
resolve_it_recalls(patterns_rows, h.order_list, 64, h.link_gef,
@@ -1471,9 +1662,11 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
if h.use_instruments:
# Build a proxy sample list where Taud inst slot = IT inst index,
# resolved to the canonical sample. Slot 0 unused.
ticks_per_sec = max(h.initial_tempo * 2.0 / 5.0, 1.0)
proxy = [None] * (max(len(instruments), 256) + 1)
inst_vols = {}
envelopes_by_slot = {}
bake_count = 0
for ii, inst in enumerate(instruments):
taud_slot = ii + 1
if taud_slot >= 256: break
@@ -1481,13 +1674,29 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
si = inst.canonical_sample - 1 # 0-based sample index
if si < 0 or si >= len(samples) or samples[si] is None:
continue
proxy[taud_slot] = samples[si]
src_smp = samples[si]
if (not no_pf_envelope and inst.pf_nodes
and inst.pf_flags and inst.pf_flags.get('enabled')):
baked = _bake_pf_envelope(src_smp, inst, ticks_per_sec)
if baked is not src_smp:
bake_count += 1
mode = 'filter' if inst.pf_flags['is_filter'] else 'pitch'
vprint(f" baked pf envelope on inst[{ii+1}] '{inst.name}' "
f"(mode={mode}, src_len={src_smp.length}, "
f"out_len={baked.length})")
src_smp = baked
proxy[taud_slot] = src_smp
vol64 = min(inst.canonical_volume, 64)
inst_vols[taud_slot] = min(vol64, 0x3F)
# IT global volume range is 0..128; rescale to Taud's 0..255.
inst_gv_255 = min(255, round(inst.gv * 255 / 128))
envelopes_by_slot[taud_slot] = (
inst.vol_envelope, inst.vol_env_sustain,
inst.pan_envelope, inst.pan_env_sustain,
inst_gv_255,
)
if bake_count:
vprint(f" pf envelope baking: {bake_count} instrument(s)")
sampleinst_raw, _ = build_sample_inst_bin_it(proxy, envelopes_by_slot)
else:
# Samples referenced directly; proxy is samples list (0-based, slot 0 unused)
@@ -1538,14 +1747,14 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
sheet = bytearray(NUM_CUES * CUE_SIZE)
for c in range(NUM_CUES):
sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = _encode_cue([], 0)
sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0)
last_active = -1
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)
sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue(pats, 0)
last_active = cue_idx
if last_active >= 0:
@@ -1581,7 +1790,6 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
global VERBOSE
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('input', help='Input .it file')
@@ -1589,8 +1797,10 @@ def main():
ap.add_argument('-v', '--verbose', action='store_true')
ap.add_argument('--no-decompress', action='store_true',
help='Treat compressed IT samples as silent (debug)')
ap.add_argument('--no-pf-envelope', action='store_true',
help='Skip baking IT pitch/filter envelope onto sample copies')
args = ap.parse_args()
VERBOSE = args.verbose
set_verbose(args.verbose)
with open(args.input, 'rb') as f:
data = f.read()
@@ -1608,13 +1818,14 @@ def main():
patterns_rows = parse_patterns(data, h)
taud = assemble_taud(h, samples, instruments, patterns_rows,
decompress=not args.no_decompress)
decompress=not args.no_decompress,
no_pf_envelope=args.no_pf_envelope)
with open(args.output, 'wb') as f:
f.write(taud)
print(f"wrote {len(taud)} bytes to '{args.output}'")
if VERBOSE:
if args.verbose:
print(f" magic ok: {taud[:8].hex()}", file=sys.stderr)
sig_off = TAUD_HEADER_SIZE - 14
print(f" signature: {taud[sig_off:sig_off+14]}", file=sys.stderr)

731
mod2taud.py Normal file
View File

@@ -0,0 +1,731 @@
#!/usr/bin/env python3
"""mod2taud.py — Convert ProTracker (.mod) to TSVM Taud (.taud)
Usage:
python3 mod2taud.py input.mod output.taud [-v]
Limits:
- Up to 20 MOD channels (excess disabled; hard error if pattern count
× channel count > 4095).
- Sample bin is 770048 bytes; if all samples together exceed this, every
sample is globally resampled down (with c2spd adjusted) so pitch is
preserved.
Effect support:
Full PT effect dispatch per TAUD_NOTE_EFFECTS.md "ProTracker to Taud
conversion table". PT recalls (effect $00 args) are eagerly resolved
per channel using PT's per-effect private memory model. Cxx folds
into the volume column (0.$xx). Axy / EAx / EBx fold into the volume
column. 8xx and E8x fold into the pan column. Periods convert to Taud
units via log2 against PT period 428 (≡ Taud C3). Sample finetune is
pre-baked into the per-instrument c2spd. Amiga-mode flag is set in
the song-table flags byte so the engine applies coarse pitch slides
in period space.
"""
import argparse
import gzip
import math
import struct
import sys
from taud_common import (
set_verbose, vprint,
TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE,
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C3,
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I,
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_Y,
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
J_SEMI_TABLE,
d_arg_to_col, resample_linear, encode_cue, deduplicate_patterns,
)
# ── MOD constants ────────────────────────────────────────────────────────────
MOD_NUM_SAMPLES = 31
MOD_PATTERN_ROWS = 64
# PT effect numbers (single hex digit 0..F). Effect $E uses sub-nibbles.
PT_E_BASE = 0xE
PT_F = 0xF
# PT effects that have private memory and therefore recall their last
# non-zero argument when re-issued with $00. Top-level effects:
PT_MEM_TOP = frozenset({0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0xA})
# E sub-effects with memory (key is sub-nibble of the E command):
PT_MEM_E_SUB = frozenset({0x1, 0x2, 0xA, 0xB})
# ── Taud constants (mod-specific) ────────────────────────────────────────────
SIGNATURE = b"mod2taud/TSVM " # 14 bytes
# PT period 428 (PT "C-2") corresponds to OpenMPT/IT C-4 which s3m2taud
# anchors to Taud C3 (0x4000). We use the same anchor so MOD/S3M imports
# share a pitch reference.
PT_REFERENCE_PERIOD = 428.0
# ── MOD parser ───────────────────────────────────────────────────────────────
class ModSample:
__slots__ = ('name','length','finetune','volume','loop_begin','loop_end',
'sample_data','c2spd','flags')
class ModRow:
__slots__ = ('period','inst','effect','effect_arg','vol_set')
def __init__(self):
self.period = 0 # 0 = empty / no trigger
self.inst = 0 # 0 = no instrument set
self.effect = 0 # PT effect digit ($0..$F)
self.effect_arg = 0
# PT has no volume column; Cxx folds into vol_set during parsing.
# -1 = no explicit volume.
self.vol_set = -1
def _parse_magic(magic: bytes) -> int:
"""Return number of channels declared by the 4-byte MOD magic."""
if magic in (b'M.K.', b'M!K!', b'FLT4', b'M&K!', b'N.T.'):
return 4
if magic == b'FLT8':
return 8
if magic == b'OCTA' or magic == b'CD81':
return 8
# xCHN (1..9 channels)
if len(magic) == 4 and magic[1:] == b'CHN' and 0x31 <= magic[0] <= 0x39:
return magic[0] - 0x30
# xxCH (10..32 channels)
if len(magic) == 4 and magic[2:] == b'CH' and magic[:2].isdigit():
return int(magic[:2].decode('ascii'))
# xxCN (e.g., 16CN — rare)
if len(magic) == 4 and magic[2:] == b'CN' and magic[:2].isdigit():
return int(magic[:2].decode('ascii'))
return 0
def parse_mod(data: bytes):
if len(data) < 0x43C:
sys.exit("error: file too short to be a ProTracker module")
title = data[0x00:0x14].rstrip(b'\x00').decode('latin-1', errors='replace')
# 31 sample headers
samples = []
for i in range(MOD_NUM_SAMPLES):
base = 0x14 + i * 30
s = ModSample()
s.name = data[base:base+22].rstrip(b'\x00').decode('latin-1', errors='replace')
s.length = struct.unpack_from('>H', data, base + 22)[0] * 2
s.finetune = data[base + 24] & 0x0F # signed nibble 0..15
s.volume = data[base + 25] # 0..64
s.loop_begin = struct.unpack_from('>H', data, base + 26)[0] * 2
loop_len_w = struct.unpack_from('>H', data, base + 28)[0]
loop_len = loop_len_w * 2
s.loop_end = s.loop_begin + loop_len
# Flag bit 0 = looped (loop_len > 2 by convention; loop_len_w == 1 means no loop)
s.flags = 1 if loop_len_w > 1 else 0
if not s.flags:
s.loop_begin = 0
s.loop_end = 0
s.sample_data = b''
s.c2spd = round(8363.0 * (2.0 ** (_signed4(s.finetune) / 96.0)))
samples.append(s)
song_length = data[0x3B6]
# 0x3B7 = restart byte (unused by us)
order_table = list(data[0x3B8:0x438])
magic = data[0x438:0x43C]
n_channels = _parse_magic(magic)
if n_channels == 0:
# Some very old MODs have only 15 samples and no magic. Detect 15-sample MOD.
# Header is 0x14 (title) + 15*30 (samples) = 0x14 + 0x1C2 = 0x1D6.
# Order table at 0x1D6, then 0x1D6+0x80 = 0x256, then patterns directly.
# We don't auto-detect that; require a magic.
sys.exit(f"error: unrecognised MOD magic {magic!r} at 0x438; "
f"expected M.K., M!K!, FLT4, FLT8, xCHN or xxCH")
# Order list: only the first song_length entries are part of the song.
# Pattern count = 1 + max(order_table[0..127]) (scan all 128).
n_patterns = 1 + max(order_table)
pat_data_off = 0x43C
cell_size = 4
pattern_size = MOD_PATTERN_ROWS * n_channels * cell_size
# Parse patterns
patterns = [] # patterns[pat_idx][channel][row] -> ModRow
for pi in range(n_patterns):
grid = [[ModRow() for _ in range(MOD_PATTERN_ROWS)] for _ in range(n_channels)]
base = pat_data_off + pi * pattern_size
if base + pattern_size > len(data):
vprint(f" warning: pattern {pi} truncated; padding with empty rows")
patterns.append(grid)
continue
for r in range(MOD_PATTERN_ROWS):
row_off = base + r * n_channels * cell_size
for ch in range(n_channels):
cell_off = row_off + ch * cell_size
b0 = data[cell_off]
b1 = data[cell_off + 1]
b2 = data[cell_off + 2]
b3 = data[cell_off + 3]
period = ((b0 & 0x0F) << 8) | b1
inst = (b0 & 0xF0) | ((b2 >> 4) & 0x0F)
effect = b2 & 0x0F
arg = b3
cell = grid[ch][r]
cell.period = period
cell.inst = inst
cell.effect = effect
cell.effect_arg = arg
patterns.append(grid)
# Sample data follows pattern data
sample_off = pat_data_off + n_patterns * pattern_size
for s in samples:
if s.length == 0:
continue
n = min(s.length, max(0, len(data) - sample_off))
if n <= 0:
break
raw = data[sample_off:sample_off + n]
# PT samples are signed 8-bit; convert to unsigned by XOR 0x80.
s.sample_data = bytes((b ^ 0x80) for b in raw)
s.length = len(s.sample_data)
if s.flags:
s.loop_begin = min(s.loop_begin, s.length)
s.loop_end = min(s.loop_end, s.length)
sample_off += n
return {
'title': title,
'samples': samples,
'order_list': order_table[:song_length],
'order_full': order_table,
'n_channels': n_channels,
'n_patterns': n_patterns,
'patterns': patterns,
'magic': magic,
}
def _signed4(nibble: int) -> int:
"""Convert a 4-bit unsigned nibble to signed -8..+7."""
return nibble - 16 if nibble >= 8 else nibble
# ── Note encoding (period → Taud) ────────────────────────────────────────────
def period_to_taud_note(period: int) -> int:
if period <= 0:
return NOTE_NOP
val = round(TAUD_C3 + 4096.0 * math.log2(PT_REFERENCE_PERIOD / period))
return max(1, min(0xFFFD, val))
# ── PT effect → Taud effect ──────────────────────────────────────────────────
def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
"""Return (taud_op, taud_arg16, vol_override, pan_override).
The caller is responsible for resolving PT zero-arg recalls before this
point — see resolve_pt_recalls(). cmd is the raw PT digit ($0..$F).
"""
# $0 with arg 0 is a true no-op; $0 with arg != 0 is arpeggio.
if cmd == 0x0:
if arg == 0:
return (TOP_NONE, 0, None, None)
hi = (arg >> 4) & 0xF
lo = arg & 0xF
return (TOP_J, (J_SEMI_TABLE[hi] << 8) | J_SEMI_TABLE[lo], None, None)
if cmd == 0x1:
return (TOP_F, round(arg * 64 / 3) & 0xFFFF, None, None)
if cmd == 0x2:
return (TOP_E, round(arg * 64 / 3) & 0xFFFF, None, None)
if cmd == 0x3:
return (TOP_G, round(arg * 64 / 3) & 0xFFFF, None, None)
if cmd == 0x4:
hi = (arg >> 4) & 0xF
lo = arg & 0xF
return (TOP_H, ((hi * 0x11) << 8) | (lo * 0x11), None, None)
if cmd == 0x5:
# Tone porta + vol slide → Taud L (engine splits internally).
return (TOP_G, 0x0000, d_arg_to_col(arg), None)
if cmd == 0x6:
# Vibrato + vol slide → Taud K.
return (TOP_H, 0x0000, d_arg_to_col(arg), None)
if cmd == 0x7:
hi = (arg >> 4) & 0xF
lo = arg & 0xF
return (TOP_R, ((hi * 0x11) << 8) | (lo * 0x11), None, None)
if cmd == 0x8:
# PT 8xx is fine pan (or unused/sync in some trackers). Map to pan
# column 0.$yy where yy is the upper 6 bits of the 8-bit pan.
return (TOP_NONE, 0, None, (SEL_SET, (arg >> 2) & 0x3F))
if cmd == 0x9:
return (TOP_O, (arg & 0xFF) << 8, None, None)
if cmd == 0xA:
return (TOP_NONE, 0, d_arg_to_col(arg), None)
if cmd == 0xB:
return (TOP_B, arg & 0xFF, None, None)
if cmd == 0xC:
# Caller folds Cxx into vol_set during parsing; this branch is a
# safety net in case a Cxx slips through.
return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None)
if cmd == 0xD:
# PT pattern break is BCD on disk.
bcd_row = ((arg >> 4) & 0xF) * 10 + (arg & 0xF)
if bcd_row >= PATTERN_ROWS:
bcd_row = 0
return (TOP_C, bcd_row & 0xFF, None, None)
if cmd == 0xE:
sub = (arg >> 4) & 0xF
x = arg & 0xF
if sub == 0x0:
# E0x = filter on/off (Amiga LED filter); no Taud equivalent.
return (TOP_NONE, 0, None, None)
if sub == 0x1:
# Fine pitch up.
return (TOP_F, 0xF000 | (round(x * 16 / 3) & 0xFFF), None, None)
if sub == 0x2:
# Fine pitch down.
return (TOP_E, 0xF000 | (round(x * 16 / 3) & 0xFFF), None, None)
if sub == 0x3:
return (TOP_S, 0x1000 | (x << 8), None, None)
if sub == 0x4:
return (TOP_S, 0x3000 | (x << 8), None, None)
if sub == 0x5:
return (TOP_S, 0x2000 | (x << 8), None, None)
if sub == 0x6:
return (TOP_S, 0xB000 | (x << 8), None, None)
if sub == 0x7:
return (TOP_S, 0x4000 | (x << 8), None, None)
if sub == 0x8:
# Coarse pan (4-bit). Map nibble 0..15 to pan 0..63 via × 4.2.
return (TOP_NONE, 0, None, (SEL_SET, round(x * 4.2)))
if sub == 0x9:
return (TOP_Q, (x & 0xF) << 8, None, None)
if sub == 0xA:
# Fine vol slide up.
return (TOP_NONE, 0, (SEL_FINE, (x & 0xF) | 0x20), None)
if sub == 0xB:
# Fine vol slide down.
return (TOP_NONE, 0, (SEL_FINE, x & 0xF), None)
if sub == 0xC:
return (TOP_S, 0xC000 | (x << 8), None, None)
if sub == 0xD:
return (TOP_S, 0xD000 | (x << 8), None, None)
if sub == 0xE:
return (TOP_S, 0xE000 | (x << 8), None, None)
if sub == 0xF:
return (TOP_S, 0xF000 | (x << 8), None, None)
return (TOP_NONE, 0, None, None)
if cmd == 0xF:
if arg < 0x20:
if arg == 0:
return (TOP_NONE, 0, None, None)
return (TOP_A, (arg & 0xFF) << 8, None, None)
return (TOP_T, ((arg - 0x18) & 0xFF) << 8, None, None)
return (TOP_NONE, 0, None, None)
def resolve_pt_recalls(patterns: list, order_list: list, n_channels: int) -> None:
"""In-place: replace PT zero-arg recalls with each effect's last non-zero arg.
PT memory is per-effect-private. Walking patterns in order-list order,
we track each channel's last non-zero arg per memorising effect and
rewrite recall args to make them explicit.
"""
# mem[ch][key] = last_non_zero_arg
# key is either an int (top-level 0..F) or a tuple ('E', sub) for E-subs.
mem = [dict() for _ in range(n_channels)]
for order in order_list:
if order >= 0xFF:
break
if order >= len(patterns):
continue
grid = patterns[order]
for r in range(MOD_PATTERN_ROWS):
for ch in range(n_channels):
if ch >= len(grid):
continue
row = grid[ch][r]
cmd = row.effect
arg = row.effect_arg
if cmd in PT_MEM_TOP:
if arg == 0:
row.effect_arg = mem[ch].get(cmd, 0)
else:
mem[ch][cmd] = arg
elif cmd == 0xE:
sub = (arg >> 4) & 0xF
x = arg & 0xF
if sub in PT_MEM_E_SUB:
key = ('E', sub)
if x == 0:
recalled = mem[ch].get(key, 0)
row.effect_arg = (sub << 4) | (recalled & 0xF)
else:
mem[ch][key] = x
# ── Sample resampling and Taud sample/instrument bin (port of s3m2taud) ──────
def build_sample_inst_bin(samples: list) -> tuple:
"""Returns (bin_bytes[786432], offsets_dict). 1-based indexing."""
pcm = [(i, s) for i, s in enumerate(samples) if s.sample_data]
total = sum(len(s.sample_data) for _, s in pcm)
ratio = 1.0
if total > SAMPLEBIN_SIZE:
ratio = SAMPLEBIN_SIZE / total
vprint(f" info: sample bin overflow ({total} bytes); resampling all by {ratio:.4f}")
for _, s in pcm:
new_data = resample_linear(s.sample_data, ratio)
s.sample_data = new_data
s.length = len(new_data)
s.loop_begin = max(0, int(s.loop_begin * ratio))
s.loop_end = max(0, min(int(s.loop_end * ratio), s.length))
s.c2spd = max(1, int(s.c2spd * ratio))
sample_bin = bytearray(SAMPLEBIN_SIZE)
offsets = {}
pos = 0
for idx, s in pcm:
n = min(len(s.sample_data), SAMPLEBIN_SIZE - pos)
if n <= 0:
vprint(f" warning: sample bin full, dropping '{s.name}'")
offsets[idx] = 0
s.length = 0
continue
sample_bin[pos:pos+n] = s.sample_data[:n]
offsets[idx] = pos
if n < len(s.sample_data):
vprint(f" warning: '{s.name}' truncated from {len(s.sample_data)} to {n}")
s.length = n
s.loop_end = min(s.loop_end, n)
pos += n
inst_bin = bytearray(INSTBIN_SIZE)
for i, s in enumerate(samples):
taud_idx = i + 1 # 1-based instrument number
if i >= 256:
break
if not s.sample_data:
continue
ptr = offsets.get(i, 0)
ptr_lo = ptr & 0xFFFF
ptr_hi = (ptr >> 16)
s_len = min(s.length, 65535)
c2spd = min(s.c2spd, 65535)
ps = 0
ls = min(s.loop_begin, 65535)
le = min(s.loop_end, 65535)
loop_mode = 1 if (s.flags & 1) else 0
flags_byte = (ptr_hi << 4) | (loop_mode & 0x3)
base = taud_idx * 64
struct.pack_into('<H', inst_bin, base + 0, ptr_lo)
struct.pack_into('<H', inst_bin, base + 2, s_len)
struct.pack_into('<H', inst_bin, base + 4, c2spd)
struct.pack_into('<H', inst_bin, base + 6, ps)
struct.pack_into('<H', inst_bin, base + 8, ls)
struct.pack_into('<H', inst_bin, base + 10, le)
inst_bin[base + 12] = flags_byte
inst_bin[base + 15] = 0xFF # global volume — full
env_vol = min(s.volume, 63)
inst_bin[base + 16] = env_vol # envelope hold value
inst_bin[base + 17] = 0 # offset minifloat = 0 → hold
vprint(f" instrument[{taud_idx}] '{s.name}' ptr={ptr} c2spd={s.c2spd} "
f"vol={s.volume} loop=({ls},{le},{'on' if loop_mode else 'off'})")
return bytes(sample_bin) + bytes(inst_bin), offsets
# ── Pattern build ────────────────────────────────────────────────────────────
# PT hard-pans channels in LRRL order: 0=L 1=R 2=R 3=L (and tile for >4).
def _default_channel_pan(ch_idx: int) -> int:
side = (ch_idx % 4)
return 16 if side in (0, 3) else 47
def build_pattern(grid: list, ch_idx: int, default_pan: int,
inst_vols: dict) -> bytes:
"""Build a 512-byte Taud pattern for one MOD channel.
Volume column rules (mirrors s3m2taud):
explicit Cxx vol > note-trigger inst default > instrument-only retrigger
recall > vol_override from effect > no-op.
"""
out = bytearray(PATTERN_BYTES)
rows = grid[ch_idx] if ch_idx < len(grid) else [ModRow()] * MOD_PATTERN_ROWS
last_inst = 0
last_period = 0
last_vol = None
for r, row in enumerate(rows[:MOD_PATTERN_ROWS]):
note_taud = period_to_taud_note(row.period)
note_triggers = (row.period > 0)
if row.inst > 0:
last_inst = row.inst
retrigger = (row.inst > 0
and row.period == 0
and last_period > 0)
op, arg, vol_override, pan_override = encode_effect(
row.effect, row.effect_arg, ch_idx, r)
# ── Volume column ──
if row.vol_set >= 0:
vol_sel, vol_value = SEL_SET, min(row.vol_set, 0x3F)
if vol_override is not None and vol_override[0] != SEL_SET:
vprint(f" ch{ch_idx} row{r}: dropped vol slide "
f"(cell already carries explicit Cxx volume)")
elif note_triggers and last_inst > 0:
vol_sel = SEL_SET
vol_value = inst_vols.get(last_inst, 0x3F)
elif retrigger and last_vol is not None:
vol_sel, vol_value = SEL_SET, last_vol
elif vol_override is not None:
vol_sel, vol_value = vol_override
else:
vol_sel, vol_value = SEL_FINE, 0
if note_triggers:
last_period = row.period
if vol_sel == SEL_SET:
last_vol = vol_value
# ── Pan column ──
if pan_override is not None:
pan_sel, pan_value = pan_override
elif r == 0:
pan_sel, pan_value = SEL_SET, default_pan & 0x3F
else:
pan_sel, pan_value = SEL_FINE, 0
vol_byte = (vol_value & 0x3F) | ((vol_sel & 0x3) << 6)
pan_byte = (pan_value & 0x3F) | ((pan_sel & 0x3) << 6)
base = r * 8
struct.pack_into('<H', out, base + 0, note_taud)
out[base + 2] = row.inst & 0xFF
out[base + 3] = vol_byte
out[base + 4] = pan_byte
out[base + 5] = op & 0xFF
struct.pack_into('<H', out, base + 6, arg & 0xFFFF)
return bytes(out)
def build_cue_sheet(order_list: list, n_pats_mod: int, n_channels: int,
pat_remap: dict = None) -> bytes:
sheet = bytearray(NUM_CUES * CUE_SIZE)
for c in range(NUM_CUES):
sheet[c*CUE_SIZE : c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0)
cue_idx = 0
last_active = -1
for order in order_list:
if order == 0xFF or cue_idx >= NUM_CUES:
break
if order == 0xFE:
continue
if order >= n_pats_mod:
continue
orig = [order * n_channels + v for v in range(n_channels)]
pats = [pat_remap[p] if pat_remap else p for p in orig]
sheet[cue_idx*CUE_SIZE : cue_idx*CUE_SIZE+CUE_SIZE] = encode_cue(pats, 0)
last_active = cue_idx
cue_idx += 1
if last_active >= 0:
sheet[last_active * CUE_SIZE + 30] = 0x01
elif cue_idx < NUM_CUES:
sheet[30] = 0x01
return bytes(sheet)
def find_initial_bpm_speed(patterns: list, order_list: list) -> tuple:
"""Scan first pattern in order for Fxx in row 0 of any channel."""
speed = 6
tempo = 125
for order in order_list:
if order >= 0xFF:
break
if order >= len(patterns):
continue
grid = patterns[order]
for ch_rows in grid:
row = ch_rows[0]
if row.effect == 0xF and row.effect_arg > 0:
if row.effect_arg < 0x20:
speed = row.effect_arg
else:
tempo = row.effect_arg
break
return speed, tempo
def assemble_taud(mod: dict) -> bytes:
samples = mod['samples']
patterns = mod['patterns']
order_list = mod['order_list']
n_channels = mod['n_channels']
n_patterns = mod['n_patterns']
if n_channels > NUM_VOICES:
vprint(f" warning: MOD has {n_channels} channels; truncating to {NUM_VOICES}")
n_channels = NUM_VOICES
if n_patterns * n_channels > NUM_PATTERNS_MAX:
sys.exit(
f"error: {n_patterns} MOD patterns × {n_channels} channels = "
f"{n_patterns*n_channels} > {NUM_PATTERNS_MAX} Taud pattern limit.\n"
f" Reduce the MOD to ≤ {NUM_PATTERNS_MAX // max(n_channels,1)} patterns."
)
vprint(f" channels: {n_channels}, mod patterns: {n_patterns}, "
f"taud patterns: {n_patterns * n_channels}")
# Fold Cxx into row.vol_set so the volume column carries explicit set-volume.
# This is done in-place before recall resolution so Cxx with arg 0 still
# resolves to vol 0 (silence) rather than recalling another effect's memory.
for grid in patterns:
for ch in range(min(n_channels, len(grid))):
for row in grid[ch]:
if row.effect == 0xC:
row.vol_set = min(row.effect_arg, 0x3F)
row.effect = 0
row.effect_arg = 0
vprint(" resolving PT per-effect recalls…")
resolve_pt_recalls(patterns, order_list, n_channels)
vprint(" building sample/instrument bin…")
sampleinst_raw, _offsets = build_sample_inst_bin(samples)
assert len(sampleinst_raw) == SAMPLEINST_SIZE
compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0)
comp_size = len(compressed)
vprint(f" sample+inst bin: {SAMPLEINST_SIZE}{comp_size} bytes (gzip)")
speed, tempo = find_initial_bpm_speed(patterns, order_list)
tempo = max(24, min(280, tempo))
bpm_stored = (tempo - 24) & 0xFF
vprint(f" initial speed={speed}, tempo(BPM)={tempo}")
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
sig = (SIGNATURE + b' ' * 14)[:14]
header = (
TAUD_MAGIC +
bytes([TAUD_VERSION, 1]) +
struct.pack('<I', comp_size) +
b'\x00\x00\x00\x00' +
sig
)
assert len(header) == TAUD_HEADER_SIZE
vprint(" building pattern bin…")
inst_vols = {
i + 1: min(s.volume, 0x3F)
for i, s in enumerate(samples)
if s.sample_data
}
pat_bin = bytearray()
for pi in range(n_patterns):
grid = patterns[pi]
for ch in range(n_channels):
default_pan = _default_channel_pan(ch)
pat_bin += build_pattern(grid, ch, default_pan, inst_vols)
assert len(pat_bin) == n_patterns * n_channels * PATTERN_BYTES
vprint(" deduplicating patterns…")
orig_count = n_patterns * n_channels
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count)
vprint(f" patterns: {orig_count}{num_taud_pats} unique "
f"({orig_count - num_taud_pats} deduplicated)")
# ProTracker is Amiga-period-based by definition, so we set the f bit so
# the engine applies coarse pitch slides in period space (recovers PT's
# characteristic non-linear pitch character).
flags_byte = 0x02
song_table = struct.pack('<IBHBBHfB',
song_offset,
n_channels,
num_taud_pats,
bpm_stored,
speed,
0xA000,
8363.0,
flags_byte,
)
assert len(song_table) == TAUD_SONG_ENTRY
vprint(" building cue sheet…")
cue_sheet = build_cue_sheet(order_list, n_patterns, n_channels, pat_remap)
assert len(cue_sheet) == NUM_CUES * CUE_SIZE
return header + compressed + song_table + bytes(pat_bin) + cue_sheet
# ── Main ─────────────────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('input', help='Input .mod file')
ap.add_argument('output', help='Output .taud file')
ap.add_argument('-v', '--verbose', action='store_true',
help='Print conversion details to stderr')
args = ap.parse_args()
set_verbose(args.verbose)
with open(args.input, 'rb') as f:
data = f.read()
vprint(f"parsing '{args.input}' ({len(data)} bytes)…")
mod = parse_mod(data)
vprint(f" title: '{mod['title']}'")
vprint(f" magic: {mod['magic']!r} ({mod['n_channels']} channels)")
vprint(f" orders={len(mod['order_list'])}, patterns={mod['n_patterns']}, "
f"samples={sum(1 for s in mod['samples'] if s.sample_data)}")
taud = assemble_taud(mod)
with open(args.output, 'wb') as f:
f.write(taud)
print(f"wrote {len(taud)} bytes to '{args.output}'")
if args.verbose:
print(f" magic ok: {taud[:8].hex()}", file=sys.stderr)
if __name__ == '__main__':
main()

View File

@@ -29,11 +29,22 @@ import math
import struct
import sys
VERBOSE = False
def vprint(*a, **kw):
if VERBOSE:
print(*a, **kw, file=sys.stderr)
from taud_common import (
set_verbose, vprint,
TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE,
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C3,
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I,
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_Y,
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
EFF_A, EFF_B, EFF_C, EFF_D, EFF_E, EFF_F, EFF_G, EFF_H, EFF_I, EFF_J,
EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T,
EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z,
J_SEMI_TABLE,
d_arg_to_col, resample_linear, encode_cue, deduplicate_patterns,
normalise_sample,
)
# ── S3M constants ────────────────────────────────────────────────────────────
@@ -45,92 +56,8 @@ S3M_NOTE_OFF = 0xFE
S3M_ORDER_SKIP = 0xFE
S3M_ORDER_END = 0xFF
# S3M effect letters (1-based: 1='A', 2='B', …)
EFF_A = 1 # set speed
EFF_B = 2 # jump to order
EFF_C = 3 # pattern break
EFF_D = 4 # volume slide
EFF_E = 5 # porta down
EFF_F = 6 # porta up
EFF_G = 7 # tone porta
EFF_H = 8 # vibrato
EFF_I = 9 # tremor
EFF_J = 10 # arpeggio
EFF_K = 11 # vibrato+volslide
EFF_L = 12 # porta+volslide
EFF_M = 13 # channel vol
EFF_N = 14 # chan vol slide
EFF_O = 15 # sample offset
EFF_P = 16 # pan slide
EFF_Q = 17 # retrigger
EFF_R = 18 # tremolo
EFF_S = 19 # special (sub-cmds)
EFF_T = 20 # set BPM
EFF_U = 21 # fine vibrato
EFF_V = 22 # global vol
EFF_W = 23 # global vol slide
EFF_X = 24 # set pan
EFF_Y = 25 # panbrello
EFF_Z = 26 # sync
# ── Taud constants ───────────────────────────────────────────────────────────
TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64])
TAUD_VERSION = 1
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+rsvd(2)+sig(16)
TAUD_SONG_ENTRY = 16 # offset(4)+voices(1)+pats(2)+bpm(1)+tick(1)+basenote(2)+basefreq(4)+flags(1)
SAMPLEBIN_SIZE = 770048
INSTBIN_SIZE = 16384 # 256 instruments × 64 bytes
SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE # 786432
PATTERN_ROWS = 64
PATTERN_BYTES = PATTERN_ROWS * 8 # 512
NUM_PATTERNS_MAX = 4095
NUM_CUES = 1024
CUE_SIZE = 32 # packed 12-bit×20 voices + instruction + pad
NUM_VOICES = 20
SIGNATURE = b"s3m2taud/TSVM " # 14 bytes
# Taud note constants
NOTE_NOP = 0xFFFF
NOTE_KEYOFF = 0x0000
NOTE_CUT = 0xFFFE
TAUD_C3 = 0x4000
# Taud effect opcode bytes (base-36: 0..9 → 0x00..0x09, A..Z → 0x0A..0x23)
TOP_NONE = 0x00
TOP_A = 0x0A # set tick speed
TOP_B = 0x0B # jump to order
TOP_C = 0x0C # break to row
TOP_D = 0x0D # volume slide
TOP_E = 0x0E # pitch slide down
TOP_F = 0x0F # pitch slide up
TOP_G = 0x10 # tone porta
TOP_H = 0x11 # vibrato
TOP_I = 0x12 # tremor
TOP_J = 0x13 # microtonal arpeggio
TOP_K = 0x14 # vibrato + vol slide (engine no-op; converter splits)
TOP_L = 0x15 # tone porta + vol slide (engine no-op; converter splits)
TOP_O = 0x18 # sample offset
TOP_Q = 0x1A # retrigger
TOP_R = 0x1B # tremolo
TOP_S = 0x1C # sub-effects
TOP_T = 0x1D # tempo set/slide
TOP_U = 0x1E # fine vibrato
TOP_V = 0x1F # global volume
TOP_Y = 0x22 # panbrello
# Volume / pan column selectors (2-bit field, packed into top of vol/pan byte).
SEL_SET = 0 # 6-bit value: set vol / pan
SEL_UP = 1 # 6-bit per-tick slide up / right
SEL_DOWN = 2 # 6-bit per-tick slide down / left
SEL_FINE = 3 # 1-bit dir + 5-bit magnitude, fired on tick 0
# 12-TET semitone → Taud J-arpeggio byte (high byte of pitch delta).
# byte = round(semitone * 4096 / 12 / 256) = round(semitone * 4 / 3).
J_SEMI_TABLE = [0x00, 0x01, 0x03, 0x04, 0x05, 0x07, 0x08, 0x09,
0x0B, 0x0C, 0x0D, 0x0F, 0x10, 0x11, 0x13, 0x14]
# ST3's single shared memory slot backs these effects.
ST3_SHARED_EFFECTS = frozenset({
EFF_D, EFF_E, EFF_F, EFF_I, EFF_J, EFF_K, EFF_L, EFF_Q, EFF_R, EFF_S
@@ -234,7 +161,7 @@ def parse_instruments(data: bytes, h: S3MHeader) -> list:
inst.sample_data = bytes(min(sample_len, 256))
else:
raw = data[sample_off:sample_off + sample_len]
inst.sample_data = _normalise_sample(raw, inst.signed, is_16bit, is_stereo, inst.name)
inst.sample_data = normalise_sample(raw, inst.signed, is_16bit, is_stereo, inst.name)
inst.length = len(inst.sample_data)
inst.loop_begin = min(inst.loop_begin, inst.length)
inst.loop_end = min(inst.loop_end, inst.length)
@@ -242,37 +169,6 @@ def parse_instruments(data: bytes, h: S3MHeader) -> list:
return insts
def _normalise_sample(raw: bytes, signed: bool, is_16bit: bool, is_stereo: bool, name: str) -> bytes:
"""Return unsigned 8-bit mono sample bytes."""
out = []
stride = (2 if is_16bit else 1) * (2 if is_stereo else 1)
i = 0
while i + stride <= len(raw):
if is_16bit:
if is_stereo:
l16 = struct.unpack_from('<h', raw, i)[0]
r16 = struct.unpack_from('<h', raw, i+2)[0]
s = (l16 + r16) >> 1
else:
s = struct.unpack_from('<h', raw, i)[0]
v = (s >> 8) + 128
else:
if is_stereo:
l8 = raw[i]; r8 = raw[i+1]
raw_s = (l8 + r8) // 2
else:
raw_s = raw[i]
if signed:
v = ((raw_s ^ 0x80) & 0xFF) # signed→unsigned
else:
v = raw_s
out.append(v & 0xFF)
i += stride
if is_16bit or is_stereo:
vprint(f" info: '{name}' converted to unsigned 8-bit mono ({len(out)} samples)")
return bytes(out)
# ── S3M pattern parser ───────────────────────────────────────────────────────
class S3MRow:
@@ -339,28 +235,6 @@ def encode_note(s3m_note: int) -> int:
return max(1, min(0xFFFD, val))
def _d_arg_to_col(arg: int):
"""Convert an ST3 D-style two-nibble vol/pan slide arg into a column override.
Returns (selector, value) or None for no-op. Volume column treats
selector 1 as up / 2 as down; pan column reuses 1 = right, 2 = left.
"""
if arg == 0:
return None
hi = (arg >> 4) & 0xF
lo = arg & 0xF
if hi == 0xF and lo > 0:
return (SEL_FINE, lo & 0x1F) # fine slide down (dir bit 0)
if lo == 0xF and hi > 0:
return (SEL_FINE, (hi & 0x1F) | 0x20) # fine slide up (dir bit 1)
if hi > 0 and lo == 0:
return (SEL_UP, hi)
if lo > 0 and hi == 0:
return (SEL_DOWN, lo)
# Both nibbles non-zero, neither $F → ambiguous; ST3 prefers up.
return (SEL_UP, hi)
def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
"""Return (taud_op, taud_arg16, vol_override, pan_override).
@@ -417,23 +291,23 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
if cmd == EFF_K:
# K = vibrato continuation + vol slide; engine treats K as no-op.
# Split into: H $0000 (recall vibrato from HU memory) + vol-col slide.
return (TOP_H, 0x0000, _d_arg_to_col(arg), None)
return (TOP_H, 0x0000, d_arg_to_col(arg), None)
if cmd == EFF_L:
# L = tone-porta continuation + vol slide; split similarly.
return (TOP_G, 0x0000, _d_arg_to_col(arg), None)
return (TOP_G, 0x0000, d_arg_to_col(arg), None)
if cmd == EFF_M:
return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None)
if cmd == EFF_N:
return (TOP_NONE, 0, _d_arg_to_col(arg), None)
return (TOP_NONE, 0, d_arg_to_col(arg), None)
if cmd == EFF_O:
return (TOP_O, (arg & 0xFF) << 8, None, None)
if cmd == EFF_P:
return (TOP_NONE, 0, None, _d_arg_to_col(arg))
return (TOP_NONE, 0, None, d_arg_to_col(arg))
if cmd == EFF_Q:
return (TOP_Q, (arg & 0xFF) << 8, None, None)
@@ -541,22 +415,6 @@ def warn_st3_quirks(patterns: list, order_list: list, num_channels: int) -> None
# ── Taud builders ────────────────────────────────────────────────────────────
def _resample_linear(data: bytes, ratio: float) -> bytes:
"""Resample bytes by ratio (< 1 = downsample) using linear interpolation."""
if not data:
return data
n_out = max(1, int(len(data) * ratio))
out = bytearray(n_out)
for i in range(n_out):
src = i / ratio
i0 = int(src)
frac = src - i0
i1 = min(i0 + 1, len(data) - 1)
v = data[i0] * (1.0 - frac) + data[i1] * frac
out[i] = int(v + 0.5) & 0xFF
return bytes(out)
def build_sample_inst_bin(instruments: list) -> tuple:
"""
Returns (bin_bytes[786432], offsets_list, updated_insts).
@@ -571,7 +429,7 @@ def build_sample_inst_bin(instruments: list) -> tuple:
ratio = SAMPLEBIN_SIZE / total
vprint(f" info: sample bin overflow ({total} bytes); resampling all by {ratio:.4f}")
for _, inst in pcm_insts:
new_data = _resample_linear(inst.sample_data, ratio)
new_data = resample_linear(inst.sample_data, ratio)
old_len = len(inst.sample_data)
inst.sample_data = new_data
inst.length = len(new_data)
@@ -624,6 +482,7 @@ def build_sample_inst_bin(instruments: list) -> tuple:
struct.pack_into('<H', inst_bin, base + 8, ls)
struct.pack_into('<H', inst_bin, base + 10, le)
inst_bin[base + 12] = flags_byte
inst_bin[base + 15] = 0xFF # instrument global volume (S3M has none → full)
# Volume envelope: hold at instrument volume (clamped to 0x3F)
env_vol = min(inst.volume, 63)
inst_bin[base + 16] = env_vol # volume
@@ -733,48 +592,13 @@ def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int,
return bytes(out)
def deduplicate_patterns(pat_bin: bytes, num_pats: int) -> tuple:
"""
Consolidate identical 512-byte Taud patterns into a single copy.
Returns (deduped_bin, remap, num_unique) where remap[original_idx] = canonical_idx.
"""
seen = {} # pattern_bytes -> canonical_index
remap = {} # original_index -> canonical_index
canonical = []
for i in range(num_pats):
pat = pat_bin[i * PATTERN_BYTES : (i + 1) * PATTERN_BYTES]
if pat in seen:
remap[i] = seen[pat]
else:
ci = len(canonical)
seen[pat] = ci
remap[i] = ci
canonical.append(pat)
return b''.join(canonical), remap, len(canonical)
def _encode_cue(patterns12: list, instruction: int) -> bytearray:
"""Encode a 32-byte cue entry for up to 20 voices with 12-bit pattern numbers."""
# patterns12: list of up to NUM_VOICES 12-bit values (0xFFF = disabled)
pats = list(patterns12) + [0xFFF] * NUM_VOICES
pats = pats[:NUM_VOICES]
entry = bytearray(CUE_SIZE)
for i in range(10): # 10 bytes: 2 voices per byte
v0, v1 = pats[i*2], pats[i*2+1]
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
return entry
def build_cue_sheet(order_list: list, num_pats_s3m: int, num_channels: int,
pat_remap: dict = None) -> bytes:
"""Build the 1024×32-byte cue sheet with 12-bit packed pattern numbers."""
sheet = bytearray(NUM_CUES * CUE_SIZE)
# Fill entire sheet with the "all disabled" cue (patterns=0xFFF, instr=0)
for c in range(NUM_CUES):
sheet[c*CUE_SIZE : c*CUE_SIZE+CUE_SIZE] = _encode_cue([], 0)
sheet[c*CUE_SIZE : c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0)
cue_idx = 0
last_active = -1
@@ -785,7 +609,7 @@ def build_cue_sheet(order_list: list, num_pats_s3m: int, num_channels: int,
continue
orig = [order * num_channels + v for v in range(num_channels)]
pats = [pat_remap[p] if pat_remap else p for p in orig]
sheet[cue_idx*CUE_SIZE : cue_idx*CUE_SIZE+CUE_SIZE] = _encode_cue(pats, 0)
sheet[cue_idx*CUE_SIZE : cue_idx*CUE_SIZE+CUE_SIZE] = encode_cue(pats, 0)
last_active = cue_idx
cue_idx += 1
@@ -925,7 +749,6 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
# ── Main ─────────────────────────────────────────────────────────────────────
def main():
global VERBOSE
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('input', help='Input .s3m file')
@@ -934,7 +757,7 @@ def main():
help='Print conversion details to stderr')
args = ap.parse_args()
VERBOSE = args.verbose
set_verbose(args.verbose)
with open(args.input, 'rb') as f:
data = f.read()
@@ -953,7 +776,7 @@ def main():
f.write(taud)
print(f"wrote {len(taud)} bytes to '{args.output}'")
if VERBOSE:
if args.verbose:
print(f" magic ok: {taud[:8].hex()}", file=sys.stderr)

197
taud_common.py Normal file
View File

@@ -0,0 +1,197 @@
"""taud_common.py — Shared constants and helpers for *2taud converters.
Imported by s3m2taud.py, it2taud.py, and mod2taud.py. Holds the Taud
container constants, the effect-letter index table, and the small set
of helpers (sample resampler, vol/pan column packer, cue encoder,
pattern deduper, sample normaliser) that all three converters used to
duplicate verbatim.
"""
import struct
import sys
# ── Verbose logging (shared across converters via set_verbose) ───────────────
VERBOSE = False
def set_verbose(b: bool) -> None:
global VERBOSE
VERBOSE = bool(b)
def vprint(*a, **kw) -> None:
if VERBOSE:
print(*a, **kw, file=sys.stderr)
# ── Taud container constants ─────────────────────────────────────────────────
TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64])
TAUD_VERSION = 1
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+rsvd(4)+sig(14)
TAUD_SONG_ENTRY = 16 # offset(4)+voices(1)+pats(2)+bpm(1)+tick(1)+basenote(2)+basefreq(4)+flags(1)
SAMPLEBIN_SIZE = 770048
INSTBIN_SIZE = 16384 # 256 instruments × 64 bytes
SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE
PATTERN_ROWS = 64
PATTERN_BYTES = PATTERN_ROWS * 8 # 512
NUM_PATTERNS_MAX = 4095
NUM_CUES = 1024
CUE_SIZE = 32
NUM_VOICES = 20
# Note word sentinels
NOTE_NOP = 0xFFFF
NOTE_KEYOFF = 0x0000
NOTE_CUT = 0xFFFE
TAUD_C3 = 0x4000
# Taud effect opcodes (base-36: 0..9 → 0x00..0x09, A..Z → 0x0A..0x23)
TOP_NONE = 0x00
TOP_A = 0x0A
TOP_B = 0x0B
TOP_C = 0x0C
TOP_D = 0x0D
TOP_E = 0x0E
TOP_F = 0x0F
TOP_G = 0x10
TOP_H = 0x11
TOP_I = 0x12
TOP_J = 0x13
TOP_K = 0x14
TOP_L = 0x15
TOP_O = 0x18
TOP_Q = 0x1A
TOP_R = 0x1B
TOP_S = 0x1C
TOP_T = 0x1D
TOP_U = 0x1E
TOP_V = 0x1F
TOP_Y = 0x22
# Volume / pan column selectors (2-bit field at top of vol/pan byte)
SEL_SET = 0 # 6-bit value: set vol / pan
SEL_UP = 1 # 6-bit per-tick slide up / right
SEL_DOWN = 2 # 6-bit per-tick slide down / left
SEL_FINE = 3 # 1-bit dir + 5-bit magnitude, fired on tick 0
# 12-TET semitone → Taud J-arpeggio byte (high byte of pitch delta).
# byte = round(semitone * 4096 / 12 / 256) = round(semitone * 4 / 3).
J_SEMI_TABLE = [0x00, 0x01, 0x03, 0x04, 0x05, 0x07, 0x08, 0x09,
0x0B, 0x0C, 0x0D, 0x0F, 0x10, 0x11, 0x13, 0x14]
# Effect-letter indices (1-based; A=1..Z=26). Shared by s3m2taud and it2taud.
EFF_A = 1; EFF_B = 2; EFF_C = 3; EFF_D = 4; EFF_E = 5
EFF_F = 6; EFF_G = 7; EFF_H = 8; EFF_I = 9; EFF_J = 10
EFF_K = 11; EFF_L = 12; EFF_M = 13; EFF_N = 14; EFF_O = 15
EFF_P = 16; EFF_Q = 17; EFF_R = 18; EFF_S = 19; EFF_T = 20
EFF_U = 21; EFF_V = 22; EFF_W = 23; EFF_X = 24; EFF_Y = 25
EFF_Z = 26
# ── Helpers ──────────────────────────────────────────────────────────────────
def d_arg_to_col(arg: int):
"""Convert a two-nibble D-style vol/pan slide arg into a column override.
Returns (selector, value) or None for no-op. Volume column treats
selector 1 as up / 2 as down; pan column reuses 1 = right, 2 = left.
Both-nibbles-non-zero (and neither $F) is ambiguous; ST3/PT/IT all
prefer up.
"""
if arg == 0:
return None
hi = (arg >> 4) & 0xF
lo = arg & 0xF
if hi == 0xF and lo > 0:
return (SEL_FINE, lo & 0x1F) # fine slide down (dir bit 0)
if lo == 0xF and hi > 0:
return (SEL_FINE, (hi & 0x1F) | 0x20) # fine slide up (dir bit 1)
if hi > 0 and lo == 0:
return (SEL_UP, hi)
if lo > 0 and hi == 0:
return (SEL_DOWN, lo)
return (SEL_UP, hi)
def resample_linear(data: bytes, ratio: float) -> bytes:
"""Resample bytes by ratio (< 1 = downsample) using linear interpolation."""
if not data:
return data
n_out = max(1, int(len(data) * ratio))
out = bytearray(n_out)
for i in range(n_out):
src = i / ratio
i0 = int(src)
frac = src - i0
i1 = min(i0 + 1, len(data) - 1)
v = data[i0] * (1.0 - frac) + data[i1] * frac
out[i] = int(v + 0.5) & 0xFF
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."""
pats = list(patterns12) + [0xFFF] * NUM_VOICES
pats = pats[:NUM_VOICES]
entry = bytearray(CUE_SIZE)
for i in range(10): # 10 bytes: 2 voices per byte
v0, v1 = pats[i*2], pats[i*2+1]
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
return entry
def deduplicate_patterns(pat_bin: bytes, num_pats: int) -> tuple:
"""Consolidate identical 512-byte Taud patterns into a single copy.
Returns (deduped_bin, remap, num_unique) where remap[original_idx] =
canonical_idx.
"""
seen = {}
remap = {}
canonical = []
for i in range(num_pats):
pat = pat_bin[i * PATTERN_BYTES : (i + 1) * PATTERN_BYTES]
if pat in seen:
remap[i] = seen[pat]
else:
ci = len(canonical)
seen[pat] = ci
remap[i] = ci
canonical.append(pat)
return b''.join(canonical), remap, len(canonical)
def normalise_sample(raw: bytes, signed: bool, is_16bit: bool,
is_stereo: bool, name: str) -> bytes:
"""Return unsigned 8-bit mono sample bytes, downmixing/depthing as needed."""
out = []
stride = (2 if is_16bit else 1) * (2 if is_stereo else 1)
i = 0
while i + stride <= len(raw):
if is_16bit:
if is_stereo:
l16 = struct.unpack_from('<h', raw, i)[0]
r16 = struct.unpack_from('<h', raw, i+2)[0]
s = (l16 + r16) >> 1
else:
s = struct.unpack_from('<h', raw, i)[0]
v = (s >> 8) + 128
else:
if is_stereo:
l8 = raw[i]; r8 = raw[i+1]
raw_s = (l8 + r8) // 2
else:
raw_s = raw[i]
if signed:
v = (raw_s ^ 0x80) & 0xFF
else:
v = raw_s
out.append(v & 0xFF)
i += stride
if is_16bit or is_stereo:
vprint(f" info: '{name}' converted to unsigned 8-bit mono ({len(out)} samples)")
return bytes(out)

View File

@@ -2005,24 +2005,29 @@ Instrument bin: Registry for 256 instruments, formatted as:
0b hhhh 00pp
h: sample pointer high bit
pp: loop mode. 0-no loop, 1-loop, 2-backandforth, 3-oneshot (ignores note length unless overridden by other notes)
Bit8 Volume envelope sustain loops
0b u0 eee sss
s: sustain loop start index
e: sustain loop end index
u: set to enable the loop
Bit8 Panning envelope sustain loops
0b u0 eee sss
s: sustain loop start index
e: sustain loop end index
u: set to enable the loop
Bit8 Reserved
Bit16x8 Volume envelopes
Bit8 Volume envelope sustain/loops
* 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 ut eee sss
s: sustain/loop start index
e: sustain/loop end index
t: the loop must sustain (key-off escapes the loop)
u: set to enable the sustain/loop
Bit8 Panning envelope sustain/loops
* Sustain is implemented by enabling 't' flag
0b ut eee sss
s: sustain/loop start index
e: sustain/loop end index
t: the loop must sustain (key-off escapes the loop)
u: set to enable the sustain/loop
Uint8 Instrument Global Volume (0..255)
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
* FastTracker2 has range of 0..64; multiply by (255/64) then round to int
Bit16x12 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.
Bit16x8 Panning envelopes
Bit16x12 Panning envelopes
Byte 1: Pan (00..FF)
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
Bit16x8 Reserved
Play Data: play data are series of tracker-like instructions, visualised as:

View File

@@ -131,6 +131,14 @@ class AudioJSR223Delegate(private val vm: VM) {
}
}
fun setTrackerMixerFlags(playhead: Int, flags: Int) {
getFirstSnd()?.playheads?.get(playhead)?.initialGlobalFlags = flags
}
fun getTrackerMixerFlags(playhead: Int): Int? {
return getFirstSnd()?.playheads?.get(playhead)?.initialGlobalFlags
}
fun putPcmDataByPtr(playhead: Int, ptr: Int, length: Int, destOffset: Int) {
getFirstSnd()?.let {
val vkMult = if (ptr >= 0) 1 else -1

View File

@@ -1184,17 +1184,26 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
private fun advanceEnvelope(voice: Voice, inst: TaudInst, tickSec: Double) {
// Volume envelope
// sustain byte: bit7=enabled, bits[5:3]=end_idx, bits[2:0]=start_idx
val vSus = inst.volEnvSustain
val vSusOn = (vSus and 0x80) != 0 && !voice.keyOff
val vSusStart = vSus and 7
val vSusEnd = (vSus ushr 3) and 7
// sustain byte: bit7=enable (u), bit6=sustain (t: 1=breaks on key-off,
// 0=loops forever), bits[5:3]=end_idx, bits[2:0]=start_idx
val vSus = inst.volEnvSustain
val vEnabled = (vSus and 0x80) != 0
val vIsSustain = (vSus and 0x40) != 0
// Loop is "active" when enabled AND (it's a forever-loop OR key not yet released)
val vSusOn = vEnabled && (!vIsSustain || !voice.keyOff)
val vSusStart = vSus and 7
val vSusEnd = (vSus ushr 3) and 7
if (voice.envIndex >= 7) {
voice.envVolume = (inst.volEnvelopes[7].value / 63.0).coerceIn(0.0, 1.0)
} else if (vSusOn && voice.envIndex == vSusEnd && vSusStart == vSusEnd) {
if (vSusOn && voice.envIndex == vSusEnd && vSusStart == vSusEnd) {
// slb == sle: hold at this node until key-off (no cycling)
voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
} else if (vSusOn && voice.envIndex == vSusEnd) {
// At sustain-loop end: snap back to start regardless of stored offset.
voice.envTimeSec = 0.0
voice.envIndex = vSusStart
voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
} else if (voice.envIndex >= 11) {
voice.envVolume = (inst.volEnvelopes[11].value / 63.0).coerceIn(0.0, 1.0)
} else {
val vOffset = inst.volEnvelopes[voice.envIndex].offset.toDouble()
if (vOffset == 0.0) {
@@ -1204,12 +1213,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (voice.envTimeSec >= vOffset) {
voice.envTimeSec -= vOffset
val nextIdx = if (vSusOn && voice.envIndex == vSusEnd) vSusStart
else (voice.envIndex + 1).coerceAtMost(7)
else (voice.envIndex + 1).coerceAtMost(11)
voice.envIndex = nextIdx
voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
} else {
val cur = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
val nxt = (inst.volEnvelopes[(voice.envIndex + 1).coerceAtMost(7)].value / 63.0).coerceIn(0.0, 1.0)
val nxt = (inst.volEnvelopes[(voice.envIndex + 1).coerceAtMost(11)].value / 63.0).coerceIn(0.0, 1.0)
voice.envVolume = cur + (nxt - cur) * (voice.envTimeSec / vOffset)
}
}
@@ -1217,16 +1226,24 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Pan envelope (only when active for this instrument)
if (!voice.hasPanEnv) return
val pSus = inst.panEnvSustain
val pSusOn = (pSus and 0x80) != 0 && !voice.keyOff
val pSusStart = pSus and 7
val pSusEnd = (pSus ushr 3) and 7
val pSus = inst.panEnvSustain
val pEnabled = (pSus and 0x80) != 0
val pIsSustain = (pSus and 0x40) != 0
val pSusOn = pEnabled && (!pIsSustain || !voice.keyOff)
val pSusStart = pSus and 7
val pSusEnd = (pSus ushr 3) and 7
if (voice.envPanIndex >= 7) {
voice.envPan = inst.panEnvelopes[7].value / 255.0
} else if (pSusOn && voice.envPanIndex == pSusEnd && pSusStart == pSusEnd) {
if (pSusOn && voice.envPanIndex == pSusEnd && pSusStart == pSusEnd) {
// slb == sle: hold at this pan node until key-off
voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0
} else if (pSusOn && voice.envPanIndex == pSusEnd) {
// At sustain-loop end: snap back to start regardless of stored offset
// (encoder writes mf=0 on the last node by convention).
voice.envPanTimeSec = 0.0
voice.envPanIndex = pSusStart
voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0
} else if (voice.envPanIndex >= 11) {
voice.envPan = inst.panEnvelopes[11].value / 255.0
} else {
val pOffset = inst.panEnvelopes[voice.envPanIndex].offset.toDouble()
if (pOffset == 0.0) {
@@ -1236,12 +1253,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (voice.envPanTimeSec >= pOffset) {
voice.envPanTimeSec -= pOffset
val nextIdx = if (pSusOn && voice.envPanIndex == pSusEnd) pSusStart
else (voice.envPanIndex + 1).coerceAtMost(7)
else (voice.envPanIndex + 1).coerceAtMost(11)
voice.envPanIndex = nextIdx
voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0
} else {
val cur = inst.panEnvelopes[voice.envPanIndex].value / 255.0
val nxt = inst.panEnvelopes[(voice.envPanIndex + 1).coerceAtMost(7)].value / 255.0
val nxt = inst.panEnvelopes[(voice.envPanIndex + 1).coerceAtMost(11)].value / 255.0
voice.envPan = cur + (nxt - cur) * (voice.envPanTimeSec / pOffset)
}
}
@@ -1816,8 +1833,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val gvol = playhead.globalVolume / 255.0
for (voice in ts.voices) {
if (!voice.active || voice.muted) continue
val s = fetchTrackerSample(voice, instruments[voice.instrumentId])
val vol = voice.envVolume * voice.rowVolume / 63.0 * gvol * playhead.masterVolume / 255.0
val voiceInst = instruments[voice.instrumentId]
val s = fetchTrackerSample(voice, voiceInst)
val instGv = voiceInst.instGlobalVolume / 255.0
val vol = voice.envVolume * voice.rowVolume / 63.0 * gvol * instGv * playhead.masterVolume / 255.0
val pan = if (voice.hasPanEnv) {
val envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255)
(voice.channelPan + envPanRaw - 128).coerceIn(0, 255)
@@ -2314,14 +2333,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var sampleLoopEnd: Int,
// flags
var loopMode: Int,
var volEnvSustain: Int, // byte 13: 00 eee sss (0 = no sustain loop)
var panEnvSustain: Int, // byte 14: 00 eee sss (0 = no sustain loop)
var volEnvelopes: Array<TaudInstEnvPoint>, // 8 points, value 0x00-0x3F
var panEnvelopes: Array<TaudInstEnvPoint> // 8 points, value 0x00-0xFF (0x80 = centre)
var volEnvSustain: Int, // byte 13: ut eee sss (u=enable, t=sustain (1=breaks on key-off, 0=loops forever))
var panEnvSustain: Int, // byte 14: ut eee sss (u=enable, t=sustain (1=breaks on key-off, 0=loops forever))
var instGlobalVolume: Int, // byte 15: instrument global volume (0..255, 255 = unity)
var volEnvelopes: Array<TaudInstEnvPoint>, // 12 points, value 0x00-0x3F
var panEnvelopes: Array<TaudInstEnvPoint> // 12 points, value 0x00-0xFF (0x80 = centre)
) {
constructor(index: Int) : this(index, 0, 0, 0, 0, 0, 0, 0, 0, 0,
Array(8) { TaudInstEnvPoint(0x3F, ThreeFiveMiniUfloat(0)) },
Array(8) { TaudInstEnvPoint(0x80, ThreeFiveMiniUfloat(0)) })
constructor(index: Int) : this(index, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF,
Array(12) { TaudInstEnvPoint(0x3F, ThreeFiveMiniUfloat(0)) },
Array(12) { TaudInstEnvPoint(0x80, ThreeFiveMiniUfloat(0)) })
// Funk repeat (S$Fx00) bit-mask — non-destructive XOR overlay across the loop region.
// Lazily allocated; a 1-bit flips the byte, a 0-bit leaves it intact.
@@ -2361,12 +2381,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
12 -> (samplePtr.ushr(16).and(15).shl(4) or loopMode.and(3)).toByte()
13 -> volEnvSustain.toByte()
14 -> panEnvSustain.toByte()
15 -> 0
in 16..30 step 2 -> volEnvelopes[(offset - 16) / 2].value.toByte()
in 17..31 step 2 -> volEnvelopes[(offset - 17) / 2].offset.index.toByte()
in 32..46 step 2 -> panEnvelopes[(offset - 32) / 2].value.toByte()
in 33..47 step 2 -> panEnvelopes[(offset - 33) / 2].offset.index.toByte()
in 48..63 -> 0
15 -> instGlobalVolume.toByte()
in 16..38 step 2 -> volEnvelopes[(offset - 16) / 2].value.toByte()
in 17..39 step 2 -> volEnvelopes[(offset - 17) / 2].offset.index.toByte()
in 40..62 step 2 -> panEnvelopes[(offset - 40) / 2].value.toByte()
in 41..63 step 2 -> panEnvelopes[(offset - 41) / 2].offset.index.toByte()
else -> throw InternalError("Bad offset $offset")
}
@@ -2396,13 +2415,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
}
13 -> { volEnvSustain = byte }
14 -> { panEnvSustain = byte }
15 -> {}
15 -> { instGlobalVolume = byte and 0xFF }
in 16..30 step 2 -> volEnvelopes[(offset - 16) / 2].value = byte
in 17..31 step 2 -> volEnvelopes[(offset - 17) / 2].offset = ThreeFiveMiniUfloat(byte)
in 32..46 step 2 -> panEnvelopes[(offset - 32) / 2].value = byte
in 33..47 step 2 -> panEnvelopes[(offset - 33) / 2].offset = ThreeFiveMiniUfloat(byte)
in 48..63 -> {}
in 16..38 step 2 -> volEnvelopes[(offset - 16) / 2].value = byte
in 17..39 step 2 -> volEnvelopes[(offset - 17) / 2].offset = ThreeFiveMiniUfloat(byte)
in 40..62 step 2 -> panEnvelopes[(offset - 40) / 2].value = byte
in 41..63 step 2 -> panEnvelopes[(offset - 41) / 2].offset = ThreeFiveMiniUfloat(byte)
else -> throw InternalError("Bad offset $offset")
}
}