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}`]}, 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], 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}`]}, 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`]}, 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 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 beatDivPrimary = 4 // TODO read from the Project Data section of the .taud
let beatDivSecondary = 16 let beatDivSecondary = 16
let hasUnsavedChanges = false
// pitchSymLut[pitchInOct] = [symString, octaveOffset] // pitchSymLut[pitchInOct] = [symString, octaveOffset]
// octaveOffset is 1 when pitchInOct is closer to the next octave's root (wraps up) than to any table entry. // 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 // beat indicator
let beatCursorRow = cursorRow 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)) ? let beatInd = (playbackMode != PLAYMODE_NONE && beatCursorRow % beatDivPrimary < (beatDivPrimary >>> 1)) ?
((beatCursorRow % beatDivSecondary < (beatDivPrimary >>> 1)) ? '\u00846u' : '\u00847u') : ((beatCursorRow % beatDivSecondary < (beatDivPrimary >>> 1)) ? '\u00846u' : '\u00847u') :
'' ''
@@ -850,18 +851,22 @@ function drawControlHint() {
[`\u008428u\u008429u`,'Nav'], [`\u008428u\u008429u`,'Nav'],
[`Pg\u008418u`,'Cue'], [`Pg\u008418u`,'Cue'],
['sep'], ['sep'],
['WER','ViewMode'], ['WER','View'],
['sep'],
['Sp','Edit'],
['sep'], ['sep'],
['m','Mute'], ['m','Mute'],
['s','Solo'], ['s','Solo'],
['sep'], ['sep'],
['Tab','Panel'], ['Tab','Panel']
// ['sep'], // ['sep'],
// ['q','Quit'], // ['q','Quit'],
] ]
let hintElemOrders = [ let hintElemOrders = [
[`\u008428u\u008429u`,'Nav'], [`\u008428u\u008429u`,'Nav'],
[`Ent`,'Go to cue'], [`Ent`,'Go to cue'],
['sep'],
['Sp','Edit'],
['sep'], ['sep'],
['Tab','Panel'], ['Tab','Panel'],
// ['sep'], // ['sep'],
@@ -871,14 +876,84 @@ function drawControlHint() {
let hintElemPatterns = [ let hintElemPatterns = [
[`\u008428u\u008429u`,'Nav'], [`\u008428u\u008429u`,'Nav'],
[`Pg\u008418u`,'Ptn'], [`Pg\u008418u`,'Ptn'],
['sep'],
['Sp','Edit'],
['sep'], ['sep'],
['Tab','Panel'], ['Tab','Panel'],
// ['sep'], // ['sep'],
// ['q','Quit'], // ['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']] const hintElemExternal = [['Tab','Panel']]
let hintElems = [hintElemTimeline, hintElemOrders, hintElemPatterns, hintElemExternal, hintElemExternal, hintElemExternal, hintElemExternal] let hintElems = [hintElemTimeline, hintElemOrders, hintElemPatterns, hintElemExternal, hintElemExternal, hintElemExternal, hintElemExternal]
let hintElemPat = [hintElemEditNoteValue, hintElemEditInstValue, hintElemEditVolEff, hintElemEditPanEff, hintElemEditFxSym, hintElemEditFxVal]
// erase current line // erase current line
con.move(SCRH, 1) con.move(SCRH, 1)
@@ -1837,9 +1912,36 @@ function makeExternalPanelDraw(progName) {
function drawProjectContents(wo) { function drawProjectContents(wo) {
fillLine(PTNVIEW_OFFSET_Y - 1, colVoiceHdr, 255) fillLine(PTNVIEW_OFFSET_Y - 1, colVoiceHdr, 255)
for (let y = PTNVIEW_OFFSET_Y; y < SCRH; y++) fillLine(y, colBackPtn, 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) let mixerflag = initialTrackerMixerflags
print('[Project settings — not yet implemented]') 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) {} function externalPanelInput(wo, event) {}
@@ -2148,7 +2250,7 @@ function drawGotoPopup(popup, buf) {
const promptStr = prompts[currentPanel] || 'Number:' const promptStr = prompts[currentPanel] || 'Number:'
con.move(popup.y + 2, popup.x + 2) con.move(popup.y + 2, popup.x + 2)
con.color_pair(colStatus, colPopupBack) con.color_pair(colWHITE, colPopupBack)
print(promptStr + ' ') print(promptStr + ' ')
con.color_pair(230, 240) con.color_pair(230, 240)
print('[' + buf.padEnd(3, '_') + ']') print('[' + buf.padEnd(3, '_') + ']')
@@ -2171,8 +2273,8 @@ function applyGoto(num) {
} }
function openConfirmQuit() { function openConfirmQuit() {
const pw = 25 const pw = 25 + hasUnsavedChanges * 4
const ph = 5 const ph = 5 + hasUnsavedChanges
const px = ((SCRW - pw) / 2 | 0) + 1 const px = ((SCRW - pw) / 2 | 0) + 1
const py = ((SCRH - ph) / 2 | 0) const py = ((SCRH - ph) / 2 | 0)
@@ -2184,11 +2286,17 @@ function openConfirmQuit() {
popup.drawFrame() popup.drawFrame()
con.move(py + 2, px + 2) con.move(py + 2, px + 2)
con.color_pair(colStatus, colPopupBack) con.color_pair(colWHITE, colPopupBack)
print('Exit Microtone? ') print('Exit Microtone? ')
con.color_pair(230, 240) con.color_pair(230, 240)
print('[Y/N]') 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 con.color_pair(colStatus, 255) // reset colour
let result = false let result = false
@@ -2261,6 +2369,7 @@ resetAudioDevice()
taud.uploadTaudFile(fullPathObj.full, 0, PLAYHEAD) taud.uploadTaudFile(fullPathObj.full, 0, PLAYHEAD)
audio.setMasterVolume(PLAYHEAD, 255) audio.setMasterVolume(PLAYHEAD, 255)
audio.setMasterPan(PLAYHEAD, 128) audio.setMasterPan(PLAYHEAD, 128)
const initialTrackerMixerflags = audio.getTrackerMixerFlags(PLAYHEAD)
function isExternalPanel(p) { function isExternalPanel(p) {
return p === VIEW_SAMPLES || p === VIEW_INSTRMNT || p === VIEW_FILE 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 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 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 drive = inFile[0].toUpperCase()
const diskPath = inFile.substring(2) const diskPath = inFile.substring(2)
@@ -107,6 +107,7 @@ function uploadTaudFile(inFile, songIndex, targetPlaydataSlot) {
let numPatsHi = sys.peek(filePtr + entryOff + 6) & 0xFF let numPatsHi = sys.peek(filePtr + entryOff + 6) & 0xFF
let bpmStored = sys.peek(filePtr + entryOff + 7) & 0xFF let bpmStored = sys.peek(filePtr + entryOff + 7) & 0xFF
let tickRate = sys.peek(filePtr + entryOff + 8) & 0xFF let tickRate = sys.peek(filePtr + entryOff + 8) & 0xFF
let mixerflags = sys.peek(filePtr + entryOff + 15) & 0xFF
let bpm = bpmStored + 24 let bpm = bpmStored + 24
let patsToLoad = numPatsLo | (numPatsHi << 8) let patsToLoad = numPatsLo | (numPatsHi << 8)
@@ -130,9 +131,10 @@ function uploadTaudFile(inFile, songIndex, targetPlaydataSlot) {
} }
// -- 8. Configure playhead ------------------------------------------------ // -- 8. Configure playhead ------------------------------------------------
audio.setTrackerMode(targetPlaydataSlot) audio.setTrackerMode(playhead)
audio.setBPM(targetPlaydataSlot, bpm) audio.setBPM(playhead, bpm)
audio.setTickRate(targetPlaydataSlot, tickRate > 0 ? tickRate : 6) audio.setTickRate(playhead, tickRate > 0 ? tickRate : 6)
audio.setTrackerMixerFlags(playhead, mixerflags)
fileHandle.close() fileHandle.close()

View File

@@ -3,6 +3,7 @@
Usage: Usage:
python3 it2taud.py input.it output.taud [-v] [--no-decompress] python3 it2taud.py input.it output.taud [-v] [--no-decompress]
[--no-pf-envelope]
Limits: Limits:
- Up to 20 IT channels (excess dropped; hard error if chunk count - 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 Taud patterns. Pattern-loop (SBx) crossing a chunk boundary is
warned; B/C effects are remapped to new cue indices. warned; B/C effects are remapped to new cue indices.
- IT2.14/IT2.15 compressed samples are decoded unless --no-decompress. - 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 converted to Taud format. NNA actions are ignored. Each IT instrument
resolves to its C-5 canonical sample. 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. - AdLib / OPL instruments are skipped.
Effect support: Effect support:
@@ -33,11 +42,22 @@ import math
import struct import struct
import sys import sys
VERBOSE = False from taud_common import (
set_verbose, vprint,
def vprint(*a, **kw): TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
if VERBOSE: SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE,
print(*a, **kw, file=sys.stderr) 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 ───────────────────────────────────────────────────────────── # ── 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) 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). # 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 # 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. # 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 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) # ThreeFiveMiniUfloat LUT — 256 entries, seconds 0.0..126.0 (must match Kotlin)
_MINUFLOAT_LUT = [ _MINUFLOAT_LUT = [
0.0, 0.03125, 0.0625, 0.09375, 0.125, 0.15625, 0.1875, 0.21875, 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) 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 ────────────────────────────────────────────────────────── # ── IT sample parser ──────────────────────────────────────────────────────────
class ITSample: class ITSample:
@@ -480,7 +428,7 @@ def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list:
is_it215 = bool(s.cvt & 0x04) is_it215 = bool(s.cvt & 0x04)
raw = it214_decompress(data, s.smp_point, s.length, raw = it214_decompress(data, s.smp_point, s.length,
s.is_16bit, is_it215) 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.is_16bit, s.is_stereo, s.name)
s.length = len(s.sample_data) s.length = len(s.sample_data)
s.loop_beg = min(s.loop_beg, s.length) 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)) s.sample_data = bytes(min(s.length, 256))
else: else:
raw = data[s.smp_point : s.smp_point + byte_len] 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.is_16bit, s.is_stereo, s.name)
s.length = len(s.sample_data) s.length = len(s.sample_data)
s.loop_beg = min(s.loop_beg, s.length) 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 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 ────────────────────────────────────────────────────── # ── IT instrument parser ──────────────────────────────────────────────────────
class ITInstrument: class ITInstrument:
__slots__ = ('name', 'dfp', 'gv', 'canonical_sample', 'canonical_volume', __slots__ = ('name', 'dfp', 'gv', 'canonical_sample', 'canonical_volume',
'vol_envelope', 'pan_envelope', 'vol_env_sustain', 'pan_env_sustain') 'vol_envelope', 'pan_envelope', 'vol_env_sustain', 'pan_env_sustain',
# vol_envelope / pan_envelope: list of 8 (value, minifloat_idx) tuples, or None '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) # 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: def parse_instruments(data: bytes, h: ITHeader) -> list:
insts = [] 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_sample = c5_smp # 1-based sample index, 0 = none
inst.canonical_volume = min(inst.gv, 64) 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) # 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 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( inst.vol_envelope, inst.vol_env_sustain = _parse_it_envelope(
data, ptr + 0x130, False, ticks_per_sec) data, ptr + 0x130, False, ticks_per_sec)
inst.pan_envelope, inst.pan_env_sustain = _parse_it_envelope( inst.pan_envelope, inst.pan_env_sustain = _parse_it_envelope(
data, ptr + 0x182, True, ticks_per_sec) data, ptr + 0x182, True, ticks_per_sec)
inst.pf_nodes, inst.pf_flags = _parse_it_pf_envelope_raw(
data, ptr + 0x1D4)
insts.append(inst) insts.append(inst)
return insts return insts
def _parse_it_envelope(data: bytes, env_ptr: int, is_pan: bool, def _parse_it_envelope(data: bytes, env_ptr: int, is_pan: bool,
ticks_per_sec: float) -> tuple: 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 Returns (points_list, sustain_byte) where points_list is a list of
8 (value, minifloat_idx) tuples, or None if envelope not enabled. 12 (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. 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 IT has two loop types: envelope loop (continues forever) and sustain loop
key-off). Taud only has sustain loop semantics. Priority: sustain > env loop. (breaks on key-off). Taud distinguishes them via the 't' flag. Priority
When slb==sle the AudioAdapter holds at that node (no cycling); for slb!=sle when both exist: sustain (because IT plays sustain while held, then env
it cycles between them. loop after release; Taud can only express one).
""" """
if env_ptr + 82 > len(data): if env_ptr + 82 > len(data):
return None, 0 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_env_loop = bool(flags & 0x02)
has_sus_loop = bool(flags & 0x04) 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: if has_sus_loop:
use_lb, use_le = it_slb, it_sle use_lb, use_le = it_slb, it_sle
has_loop = True has_loop = True
is_sustain = True
elif has_env_loop: elif has_env_loop:
use_lb, use_le = it_lpb, it_lpe use_lb, use_le = it_lpb, it_lpe
has_loop = True has_loop = True
vprint(f" envelope loop mapped as sustain loop (approximation: breaks on key-off)") is_sustain = False
else: else:
use_lb = use_le = -1 use_lb = use_le = -1
has_loop = False has_loop = False
is_sustain = False
# Read IT nodes: (int8 value, uint16 tick_pos LE) # Read IT nodes: (int8 value, uint16 tick_pos LE)
nodes = [] nodes = []
@@ -605,23 +760,26 @@ def _parse_it_envelope(data: bytes, env_ptr: int, is_pan: bool,
if not nodes: if not nodes:
return None, 0 return None, 0
# Decimate to 8 nodes if needed, preserving first/last # Decimate to 12 nodes if needed, preserving first/last
decimated = len(nodes) > 8 decimated = len(nodes) > 12
if not decimated: if not decimated:
selected = nodes[:] 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 taud_slb, taud_sle = use_lb, use_le
else: else:
taud_slb = taud_sle = -1 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: 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 taud_slb = taud_sle = -1 # loop indices lost in decimation
if has_loop: 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 = [] points = []
for k in range(8): for k in range(12):
if k < len(selected): if k < len(selected):
val, tick = selected[k] val, tick = selected[k]
if is_pan: if is_pan:
@@ -640,16 +798,99 @@ def _parse_it_envelope(data: bytes, env_ptr: int, is_pan: bool,
mf_idx = 0 mf_idx = 0
points.append((taud_val, mf_idx)) points.append((taud_val, mf_idx))
# Build sustain byte: bit7=enabled, bits[5:3]=end_idx, bits[2:0]=start_idx. # Build sustain byte: bit7=enable (u), bit6=sustain (t), bits[5:3]=end,
# 0 = disabled (no bit7). All (slb, sle) pairs including (0,0) are valid when bit7=1. # 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: 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: else:
sus_byte = 0 sus_byte = 0
return points, sus_byte 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 ───────────────────────────────────────────────────────── # ── IT pattern parser ─────────────────────────────────────────────────────────
class ITRow: class ITRow:
@@ -783,21 +1024,6 @@ def decode_volcol(vc: int):
# ── Effect translator ───────────────────────────────────────────────────────── # ── 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: def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
"""Return (taud_op, taud_arg16, vol_override, pan_override). """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) return (TOP_J, (J_SEMI_TABLE[hi_semi] << 8) | J_SEMI_TABLE[lo_semi], None, None)
if cmd == EFF_K: 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: 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: if cmd == EFF_M:
return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None) return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None)
if cmd == EFF_N: 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: if cmd == EFF_O:
return (TOP_O, (arg & 0xFF) << 8, None, None) return (TOP_O, (arg & 0xFF) << 8, None, None)
if cmd == EFF_P: 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: if cmd == EFF_Q:
return (TOP_Q, (arg & 0xFF) << 8, None, None) 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) ──────────────────────────────── # ── 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, def build_sample_inst_bin_it(samples_or_proxy: list,
envelopes_by_slot: dict = None) -> tuple: envelopes_by_slot: dict = None) -> tuple:
"""samples_or_proxy: list of ITSample | None, indexed 1-based (index 0 unused). """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) 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 8 (value, minifloat_idx) tuples (or None). 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). 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 ratio = SAMPLEBIN_SIZE / total
vprint(f" info: sample bin overflow ({total} bytes); resampling all by {ratio:.4f}") vprint(f" info: sample bin overflow ({total} bytes); resampling all by {ratio:.4f}")
for _, s in pcm_list: 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.sample_data = new_data
s.length = len(new_data) s.length = len(new_data)
s.loop_beg = max(0, int(s.loop_beg * ratio)) 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) struct.pack_into('<H', inst_bin, base + 10, le)
inst_bin[base + 12] = flags_byte 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 env_data = envelopes_by_slot.get(taud_idx) if envelopes_by_slot else None
if env_data and env_data[0]: 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 + 13] = vol_sus & 0xFF
inst_bin[base + 14] = pan_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] = val & 0xFF
inst_bin[base + 16 + k*2 + 1] = mf & 0xFF inst_bin[base + 16 + k*2 + 1] = mf & 0xFF
if pan_env: if pan_env:
for k, (val, mf) in enumerate(pan_env[:8]): for k, (val, mf) in enumerate(pan_env[:12]):
inst_bin[base + 32 + k*2] = val & 0xFF inst_bin[base + 40 + k*2] = val & 0xFF
inst_bin[base + 32 + k*2 + 1] = mf & 0xFF inst_bin[base + 40 + k*2 + 1] = mf & 0xFF
else: else:
for k in range(8): for k in range(12):
inst_bin[base + 32 + k*2] = 0x80 # pan centre inst_bin[base + 40 + k*2] = 0x80 # pan centre
inst_bin[base + 32 + k*2 + 1] = 0x00 # hold inst_bin[base + 40 + k*2 + 1] = 0x00 # hold
else: 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 + 16] = min(s.vol, 63) # value 0-63
inst_bin[base + 17] = 0 # offset 0 = hold inst_bin[base + 17] = 0 # offset 0 = hold
for k in range(8): for k in range(12):
inst_bin[base + 32 + k*2] = 0x80 # pan centre inst_bin[base + 40 + k*2] = 0x80 # pan centre
inst_bin[base + 32 + k*2 + 1] = 0x00 # hold inst_bin[base + 40 + k*2 + 1] = 0x00 # hold
vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}") vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}")
return bytes(sample_bin) + bytes(inst_bin), offsets 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) 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 ───────────────────────────────────────────────────────────── # ── Main assembly ─────────────────────────────────────────────────────────────
def find_initial_bpm_speed(patterns_rows: list, order_list: list, 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 return active
def assemble_taud(h: ITHeader, samples: list, instruments: list, 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 ─────────────────────────────────────────────────── # ── Resolve IT recalls ───────────────────────────────────────────────────
vprint(" resolving IT recalls…") vprint(" resolving IT recalls…")
resolve_it_recalls(patterns_rows, h.order_list, 64, h.link_gef, 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: if h.use_instruments:
# Build a proxy sample list where Taud inst slot = IT inst index, # Build a proxy sample list where Taud inst slot = IT inst index,
# resolved to the canonical sample. Slot 0 unused. # 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) proxy = [None] * (max(len(instruments), 256) + 1)
inst_vols = {} inst_vols = {}
envelopes_by_slot = {} envelopes_by_slot = {}
bake_count = 0
for ii, inst in enumerate(instruments): for ii, inst in enumerate(instruments):
taud_slot = ii + 1 taud_slot = ii + 1
if taud_slot >= 256: break 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 si = inst.canonical_sample - 1 # 0-based sample index
if si < 0 or si >= len(samples) or samples[si] is None: if si < 0 or si >= len(samples) or samples[si] is None:
continue 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) vol64 = min(inst.canonical_volume, 64)
inst_vols[taud_slot] = min(vol64, 0x3F) 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] = ( envelopes_by_slot[taud_slot] = (
inst.vol_envelope, inst.vol_env_sustain, inst.vol_envelope, inst.vol_env_sustain,
inst.pan_envelope, inst.pan_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) sampleinst_raw, _ = build_sample_inst_bin_it(proxy, envelopes_by_slot)
else: else:
# Samples referenced directly; proxy is samples list (0-based, slot 0 unused) # 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 song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
sheet = bytearray(NUM_CUES * CUE_SIZE) sheet = bytearray(NUM_CUES * CUE_SIZE)
for c in range(NUM_CUES): 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 last_active = -1
for cue_idx, ci in enumerate(taud_cue_list): for cue_idx, ci in enumerate(taud_cue_list):
if cue_idx >= NUM_CUES: break if cue_idx >= NUM_CUES: break
base_pat = cue_idx * C base_pat = cue_idx * C
pats = [pat_remap[base_pat + vi] for vi in range(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 last_active = cue_idx
if last_active >= 0: if last_active >= 0:
@@ -1581,7 +1790,6 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
# ── Main ────────────────────────────────────────────────────────────────────── # ── Main ──────────────────────────────────────────────────────────────────────
def main(): def main():
global VERBOSE
ap = argparse.ArgumentParser(description=__doc__, ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter) formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('input', help='Input .it file') 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('-v', '--verbose', action='store_true')
ap.add_argument('--no-decompress', action='store_true', ap.add_argument('--no-decompress', action='store_true',
help='Treat compressed IT samples as silent (debug)') 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() args = ap.parse_args()
VERBOSE = args.verbose set_verbose(args.verbose)
with open(args.input, 'rb') as f: with open(args.input, 'rb') as f:
data = f.read() data = f.read()
@@ -1608,13 +1818,14 @@ def main():
patterns_rows = parse_patterns(data, h) patterns_rows = parse_patterns(data, h)
taud = assemble_taud(h, samples, instruments, patterns_rows, 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: with open(args.output, 'wb') as f:
f.write(taud) f.write(taud)
print(f"wrote {len(taud)} bytes to '{args.output}'") print(f"wrote {len(taud)} bytes to '{args.output}'")
if VERBOSE: if args.verbose:
print(f" magic ok: {taud[:8].hex()}", file=sys.stderr) print(f" magic ok: {taud[:8].hex()}", file=sys.stderr)
sig_off = TAUD_HEADER_SIZE - 14 sig_off = TAUD_HEADER_SIZE - 14
print(f" signature: {taud[sig_off:sig_off+14]}", file=sys.stderr) 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 struct
import sys import sys
VERBOSE = False from taud_common import (
set_verbose, vprint,
def vprint(*a, **kw): TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
if VERBOSE: SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE,
print(*a, **kw, file=sys.stderr) 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 ──────────────────────────────────────────────────────────── # ── S3M constants ────────────────────────────────────────────────────────────
@@ -45,92 +56,8 @@ S3M_NOTE_OFF = 0xFE
S3M_ORDER_SKIP = 0xFE S3M_ORDER_SKIP = 0xFE
S3M_ORDER_END = 0xFF 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 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's single shared memory slot backs these effects.
ST3_SHARED_EFFECTS = frozenset({ ST3_SHARED_EFFECTS = frozenset({
EFF_D, EFF_E, EFF_F, EFF_I, EFF_J, EFF_K, EFF_L, EFF_Q, EFF_R, EFF_S 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)) inst.sample_data = bytes(min(sample_len, 256))
else: else:
raw = data[sample_off:sample_off + sample_len] 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.length = len(inst.sample_data)
inst.loop_begin = min(inst.loop_begin, inst.length) inst.loop_begin = min(inst.loop_begin, inst.length)
inst.loop_end = min(inst.loop_end, 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 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 ─────────────────────────────────────────────────────── # ── S3M pattern parser ───────────────────────────────────────────────────────
class S3MRow: class S3MRow:
@@ -339,28 +235,6 @@ def encode_note(s3m_note: int) -> int:
return max(1, min(0xFFFD, val)) 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: def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
"""Return (taud_op, taud_arg16, vol_override, pan_override). """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: if cmd == EFF_K:
# K = vibrato continuation + vol slide; engine treats K as no-op. # K = vibrato continuation + vol slide; engine treats K as no-op.
# Split into: H $0000 (recall vibrato from HU memory) + vol-col slide. # 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: if cmd == EFF_L:
# L = tone-porta continuation + vol slide; split similarly. # 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: if cmd == EFF_M:
return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None) return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None)
if cmd == EFF_N: 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: if cmd == EFF_O:
return (TOP_O, (arg & 0xFF) << 8, None, None) return (TOP_O, (arg & 0xFF) << 8, None, None)
if cmd == EFF_P: 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: if cmd == EFF_Q:
return (TOP_Q, (arg & 0xFF) << 8, None, None) 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 ──────────────────────────────────────────────────────────── # ── 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: def build_sample_inst_bin(instruments: list) -> tuple:
""" """
Returns (bin_bytes[786432], offsets_list, updated_insts). Returns (bin_bytes[786432], offsets_list, updated_insts).
@@ -571,7 +429,7 @@ def build_sample_inst_bin(instruments: list) -> tuple:
ratio = SAMPLEBIN_SIZE / total ratio = SAMPLEBIN_SIZE / total
vprint(f" info: sample bin overflow ({total} bytes); resampling all by {ratio:.4f}") vprint(f" info: sample bin overflow ({total} bytes); resampling all by {ratio:.4f}")
for _, inst in pcm_insts: 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) old_len = len(inst.sample_data)
inst.sample_data = new_data inst.sample_data = new_data
inst.length = len(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 + 8, ls)
struct.pack_into('<H', inst_bin, base + 10, le) struct.pack_into('<H', inst_bin, base + 10, le)
inst_bin[base + 12] = flags_byte 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) # Volume envelope: hold at instrument volume (clamped to 0x3F)
env_vol = min(inst.volume, 63) env_vol = min(inst.volume, 63)
inst_bin[base + 16] = env_vol # volume 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) 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, def build_cue_sheet(order_list: list, num_pats_s3m: int, num_channels: int,
pat_remap: dict = None) -> bytes: pat_remap: dict = None) -> bytes:
"""Build the 1024×32-byte cue sheet with 12-bit packed pattern numbers.""" """Build the 1024×32-byte cue sheet with 12-bit packed pattern numbers."""
sheet = bytearray(NUM_CUES * CUE_SIZE) sheet = bytearray(NUM_CUES * CUE_SIZE)
# Fill entire sheet with the "all disabled" cue (patterns=0xFFF, instr=0) # Fill entire sheet with the "all disabled" cue (patterns=0xFFF, instr=0)
for c in range(NUM_CUES): 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 cue_idx = 0
last_active = -1 last_active = -1
@@ -785,7 +609,7 @@ def build_cue_sheet(order_list: list, num_pats_s3m: int, num_channels: int,
continue continue
orig = [order * num_channels + v for v in range(num_channels)] orig = [order * num_channels + v for v in range(num_channels)]
pats = [pat_remap[p] if pat_remap else p for p in orig] 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 last_active = cue_idx
cue_idx += 1 cue_idx += 1
@@ -925,7 +749,6 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
# ── Main ───────────────────────────────────────────────────────────────────── # ── Main ─────────────────────────────────────────────────────────────────────
def main(): def main():
global VERBOSE
ap = argparse.ArgumentParser(description=__doc__, ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter) formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('input', help='Input .s3m file') ap.add_argument('input', help='Input .s3m file')
@@ -934,7 +757,7 @@ def main():
help='Print conversion details to stderr') help='Print conversion details to stderr')
args = ap.parse_args() args = ap.parse_args()
VERBOSE = args.verbose set_verbose(args.verbose)
with open(args.input, 'rb') as f: with open(args.input, 'rb') as f:
data = f.read() data = f.read()
@@ -953,7 +776,7 @@ def main():
f.write(taud) f.write(taud)
print(f"wrote {len(taud)} bytes to '{args.output}'") print(f"wrote {len(taud)} bytes to '{args.output}'")
if VERBOSE: if args.verbose:
print(f" magic ok: {taud[:8].hex()}", file=sys.stderr) 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 0b hhhh 00pp
h: sample pointer high bit 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) 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 Bit8 Volume envelope sustain/loops
0b u0 eee sss * Sustain is implemented by enabling 't' flag. FastTracker has no 'Sus Loop' but only 'Sus Point'; use same value for start and end index
s: sustain loop start index 0b ut eee sss
e: sustain loop end index s: sustain/loop start index
u: set to enable the loop e: sustain/loop end index
Bit8 Panning envelope sustain loops t: the loop must sustain (key-off escapes the loop)
0b u0 eee sss u: set to enable the sustain/loop
s: sustain loop start index Bit8 Panning envelope sustain/loops
e: sustain loop end index * Sustain is implemented by enabling 't' flag
u: set to enable the loop 0b ut eee sss
Bit8 Reserved s: sustain/loop start index
Bit16x8 Volume envelopes 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 1: Volume (00..3F)
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely. 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 1: Pan (00..FF)
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely. 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: 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) { fun putPcmDataByPtr(playhead: Int, ptr: Int, length: Int, destOffset: Int) {
getFirstSnd()?.let { getFirstSnd()?.let {
val vkMult = if (ptr >= 0) 1 else -1 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) { private fun advanceEnvelope(voice: Voice, inst: TaudInst, tickSec: Double) {
// Volume envelope // Volume envelope
// sustain byte: bit7=enabled, bits[5:3]=end_idx, bits[2:0]=start_idx // sustain byte: bit7=enable (u), bit6=sustain (t: 1=breaks on key-off,
val vSus = inst.volEnvSustain // 0=loops forever), bits[5:3]=end_idx, bits[2:0]=start_idx
val vSusOn = (vSus and 0x80) != 0 && !voice.keyOff val vSus = inst.volEnvSustain
val vSusStart = vSus and 7 val vEnabled = (vSus and 0x80) != 0
val vSusEnd = (vSus ushr 3) and 7 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) { if (vSusOn && voice.envIndex == vSusEnd && vSusStart == vSusEnd) {
voice.envVolume = (inst.volEnvelopes[7].value / 63.0).coerceIn(0.0, 1.0)
} else if (vSusOn && voice.envIndex == vSusEnd && vSusStart == vSusEnd) {
// slb == sle: hold at this node until key-off (no cycling) // 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) 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 { } else {
val vOffset = inst.volEnvelopes[voice.envIndex].offset.toDouble() val vOffset = inst.volEnvelopes[voice.envIndex].offset.toDouble()
if (vOffset == 0.0) { if (vOffset == 0.0) {
@@ -1204,12 +1213,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (voice.envTimeSec >= vOffset) { if (voice.envTimeSec >= vOffset) {
voice.envTimeSec -= vOffset voice.envTimeSec -= vOffset
val nextIdx = if (vSusOn && voice.envIndex == vSusEnd) vSusStart val nextIdx = if (vSusOn && voice.envIndex == vSusEnd) vSusStart
else (voice.envIndex + 1).coerceAtMost(7) else (voice.envIndex + 1).coerceAtMost(11)
voice.envIndex = nextIdx voice.envIndex = nextIdx
voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0) voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
} else { } else {
val cur = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0) 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) 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) // Pan envelope (only when active for this instrument)
if (!voice.hasPanEnv) return if (!voice.hasPanEnv) return
val pSus = inst.panEnvSustain val pSus = inst.panEnvSustain
val pSusOn = (pSus and 0x80) != 0 && !voice.keyOff val pEnabled = (pSus and 0x80) != 0
val pSusStart = pSus and 7 val pIsSustain = (pSus and 0x40) != 0
val pSusEnd = (pSus ushr 3) and 7 val pSusOn = pEnabled && (!pIsSustain || !voice.keyOff)
val pSusStart = pSus and 7
val pSusEnd = (pSus ushr 3) and 7
if (voice.envPanIndex >= 7) { if (pSusOn && voice.envPanIndex == pSusEnd && pSusStart == pSusEnd) {
voice.envPan = inst.panEnvelopes[7].value / 255.0
} else if (pSusOn && voice.envPanIndex == pSusEnd && pSusStart == pSusEnd) {
// slb == sle: hold at this pan node until key-off // slb == sle: hold at this pan node until key-off
voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0 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 { } else {
val pOffset = inst.panEnvelopes[voice.envPanIndex].offset.toDouble() val pOffset = inst.panEnvelopes[voice.envPanIndex].offset.toDouble()
if (pOffset == 0.0) { if (pOffset == 0.0) {
@@ -1236,12 +1253,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (voice.envPanTimeSec >= pOffset) { if (voice.envPanTimeSec >= pOffset) {
voice.envPanTimeSec -= pOffset voice.envPanTimeSec -= pOffset
val nextIdx = if (pSusOn && voice.envPanIndex == pSusEnd) pSusStart val nextIdx = if (pSusOn && voice.envPanIndex == pSusEnd) pSusStart
else (voice.envPanIndex + 1).coerceAtMost(7) else (voice.envPanIndex + 1).coerceAtMost(11)
voice.envPanIndex = nextIdx voice.envPanIndex = nextIdx
voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0 voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0
} else { } else {
val cur = inst.panEnvelopes[voice.envPanIndex].value / 255.0 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) 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 val gvol = playhead.globalVolume / 255.0
for (voice in ts.voices) { for (voice in ts.voices) {
if (!voice.active || voice.muted) continue if (!voice.active || voice.muted) continue
val s = fetchTrackerSample(voice, instruments[voice.instrumentId]) val voiceInst = instruments[voice.instrumentId]
val vol = voice.envVolume * voice.rowVolume / 63.0 * gvol * playhead.masterVolume / 255.0 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 pan = if (voice.hasPanEnv) {
val envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255) val envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255)
(voice.channelPan + envPanRaw - 128).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, var sampleLoopEnd: Int,
// flags // flags
var loopMode: Int, var loopMode: Int,
var volEnvSustain: Int, // byte 13: 00 eee sss (0 = no sustain loop) 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: 00 eee sss (0 = no sustain loop) var panEnvSustain: Int, // byte 14: ut eee sss (u=enable, t=sustain (1=breaks on key-off, 0=loops forever))
var volEnvelopes: Array<TaudInstEnvPoint>, // 8 points, value 0x00-0x3F var instGlobalVolume: Int, // byte 15: instrument global volume (0..255, 255 = unity)
var panEnvelopes: Array<TaudInstEnvPoint> // 8 points, value 0x00-0xFF (0x80 = centre) 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, constructor(index: Int) : this(index, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF,
Array(8) { TaudInstEnvPoint(0x3F, ThreeFiveMiniUfloat(0)) }, Array(12) { TaudInstEnvPoint(0x3F, ThreeFiveMiniUfloat(0)) },
Array(8) { TaudInstEnvPoint(0x80, ThreeFiveMiniUfloat(0)) }) Array(12) { TaudInstEnvPoint(0x80, ThreeFiveMiniUfloat(0)) })
// Funk repeat (S$Fx00) bit-mask — non-destructive XOR overlay across the loop region. // 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. // 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() 12 -> (samplePtr.ushr(16).and(15).shl(4) or loopMode.and(3)).toByte()
13 -> volEnvSustain.toByte() 13 -> volEnvSustain.toByte()
14 -> panEnvSustain.toByte() 14 -> panEnvSustain.toByte()
15 -> 0 15 -> instGlobalVolume.toByte()
in 16..30 step 2 -> volEnvelopes[(offset - 16) / 2].value.toByte() in 16..38 step 2 -> volEnvelopes[(offset - 16) / 2].value.toByte()
in 17..31 step 2 -> volEnvelopes[(offset - 17) / 2].offset.index.toByte() in 17..39 step 2 -> volEnvelopes[(offset - 17) / 2].offset.index.toByte()
in 32..46 step 2 -> panEnvelopes[(offset - 32) / 2].value.toByte() in 40..62 step 2 -> panEnvelopes[(offset - 40) / 2].value.toByte()
in 33..47 step 2 -> panEnvelopes[(offset - 33) / 2].offset.index.toByte() in 41..63 step 2 -> panEnvelopes[(offset - 41) / 2].offset.index.toByte()
in 48..63 -> 0
else -> throw InternalError("Bad offset $offset") else -> throw InternalError("Bad offset $offset")
} }
@@ -2396,13 +2415,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
13 -> { volEnvSustain = byte } 13 -> { volEnvSustain = byte }
14 -> { panEnvSustain = byte } 14 -> { panEnvSustain = byte }
15 -> {} 15 -> { instGlobalVolume = byte and 0xFF }
in 16..30 step 2 -> volEnvelopes[(offset - 16) / 2].value = byte in 16..38 step 2 -> volEnvelopes[(offset - 16) / 2].value = byte
in 17..31 step 2 -> volEnvelopes[(offset - 17) / 2].offset = ThreeFiveMiniUfloat(byte) in 17..39 step 2 -> volEnvelopes[(offset - 17) / 2].offset = ThreeFiveMiniUfloat(byte)
in 32..46 step 2 -> panEnvelopes[(offset - 32) / 2].value = byte in 40..62 step 2 -> panEnvelopes[(offset - 40) / 2].value = byte
in 33..47 step 2 -> panEnvelopes[(offset - 33) / 2].offset = ThreeFiveMiniUfloat(byte) in 41..63 step 2 -> panEnvelopes[(offset - 41) / 2].offset = ThreeFiveMiniUfloat(byte)
in 48..63 -> {}
else -> throw InternalError("Bad offset $offset") else -> throw InternalError("Bad offset $offset")
} }
} }