mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
Compare commits
5 Commits
2b91251d6e
...
5e6ac17146
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e6ac17146 | ||
|
|
d2b1e792b9 | ||
|
|
219ca1e475 | ||
|
|
902ab00132 | ||
|
|
5dc87a80be |
6
2taud.sh
Executable file
6
2taud.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env fish
|
||||
|
||||
for f in *.mod; python3 mod2taud.py $f assets/disk0/(basename $f .mod).taud; end
|
||||
for f in *.s3m; python3 s3m2taud.py $f assets/disk0/(basename $f .s3m).taud; end
|
||||
for f in *.it; python3 it2taud.py $f assets/disk0/(basename $f .it).taud; end
|
||||
for f in *.xm; python3 xm2taud.py $f assets/disk0/(basename $f .xm).taud; end
|
||||
@@ -666,7 +666,17 @@ ProTracker `E5x` maps to Taud `S $2x00` with the same index meaning.
|
||||
|
||||
**Compatibility.** IT `S6x` maps directly.
|
||||
|
||||
**Implementation.** TODO
|
||||
**Implementation.** Maintain a per-row accumulator `fine_delay_extra` on the tracker state, initialised to 0 at the start of every row parse (including pattern-delay repetitions caused by S $Ex). Each S $6x command encountered during the row scan adds `$x` to `fine_delay_extra`. The row then runs for `speed + fine_delay_extra` ticks instead of the usual `speed` ticks before advancing to the next row.
|
||||
|
||||
```
|
||||
on row parse (S $6x):
|
||||
fine_delay_extra += x # sum across all channels
|
||||
|
||||
row ends when:
|
||||
tick_in_row >= ticks_per_row + fine_delay_extra
|
||||
```
|
||||
|
||||
S $6x and S $Ex are orthogonal: when S $Ex is active the current row repeats `$x` additional times, and each repetition is itself extended by `fine_delay_extra` (re-accumulated from the same row's S $6x commands). There is no memory for S $6x; `$x == 0` is a no-op.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -435,14 +435,12 @@ function drawCellAtStyled(y, x, cell, back, style) {
|
||||
|
||||
const TAUD_MAGIC = [0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64]
|
||||
const TAUD_HEADER_SIZE = 32
|
||||
const TAUD_SONG_ENTRY = 16
|
||||
const TAUD_SONG_ENTRY = 32
|
||||
const PATTERN_SIZE = 512
|
||||
const ROWS_PER_PAT = 64
|
||||
const NUM_CUES = 1024
|
||||
const CUE_SIZE = 32
|
||||
const NUM_VOICES = 20
|
||||
const NUM_INSTRUMENTS = 256
|
||||
const INSTRUMENT_SIZE = 64
|
||||
const CUE_EMPTY = 0xFFF
|
||||
|
||||
function _peekU32LE(ptr, off) {
|
||||
@@ -485,52 +483,55 @@ function loadTaud(filePath, songIndex) {
|
||||
((sys.peek(ptr + entryOff + 6) & 0xFF) << 8)
|
||||
const bpmStored = sys.peek(ptr + entryOff + 7) & 0xFF
|
||||
const tickRate = sys.peek(ptr + entryOff + 8) & 0xFF
|
||||
const patBinCompSize = _peekU32LE(ptr, entryOff + 18)
|
||||
const cueSheetCompSize = _peekU32LE(ptr, entryOff + 22)
|
||||
|
||||
// Decompress pattern bin
|
||||
const patBinSize = numPats * PATTERN_SIZE
|
||||
const patBinPtr = sys.malloc(patBinSize)
|
||||
gzip.decompFromTo(ptr + songOff, patBinCompSize, patBinPtr)
|
||||
|
||||
const patterns = new Array(numPats)
|
||||
for (let p = 0; p < numPats; p++) {
|
||||
const ptn = new Uint8Array(PATTERN_SIZE)
|
||||
for (let k = 0; k < PATTERN_SIZE; k++) {
|
||||
ptn[k] = sys.peek(ptr + songOff + p * PATTERN_SIZE + k) & 0xFF
|
||||
ptn[k] = sys.peek(patBinPtr + p * PATTERN_SIZE + k) & 0xFF
|
||||
}
|
||||
patterns[p] = ptn
|
||||
}
|
||||
sys.free(patBinPtr)
|
||||
|
||||
// Decompress cue sheet
|
||||
const cueSheetSize = NUM_CUES * CUE_SIZE
|
||||
const cueSheetPtr = sys.malloc(cueSheetSize)
|
||||
gzip.decompFromTo(ptr + songOff + patBinCompSize, cueSheetCompSize, cueSheetPtr)
|
||||
|
||||
const cueBase = songOff + numPats * PATTERN_SIZE
|
||||
const cues = new Array(NUM_CUES)
|
||||
let lastActiveCue = -1
|
||||
for (let c = 0; c < NUM_CUES; c++) {
|
||||
const ptns = new Array(NUM_VOICES)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const lo = sys.peek(ptr + cueBase + c * CUE_SIZE + i) & 0xFF
|
||||
const mi = sys.peek(ptr + cueBase + c * CUE_SIZE + 10 + i) & 0xFF
|
||||
const hi = sys.peek(ptr + cueBase + c * CUE_SIZE + 20 + i) & 0xFF
|
||||
const lo = sys.peek(cueSheetPtr + c * CUE_SIZE + i) & 0xFF
|
||||
const mi = sys.peek(cueSheetPtr + c * CUE_SIZE + 10 + i) & 0xFF
|
||||
const hi = sys.peek(cueSheetPtr + c * CUE_SIZE + 20 + i) & 0xFF
|
||||
ptns[i*2] = ((hi >> 4) << 8) | ((mi >> 4) << 4) | (lo >> 4)
|
||||
ptns[i*2+1] = ((hi & 0xF) << 8) | ((mi & 0xF) << 4) | (lo & 0xF)
|
||||
}
|
||||
const instr = sys.peek(ptr + cueBase + c * CUE_SIZE + 30) & 0xFF
|
||||
const instr = sys.peek(cueSheetPtr + c * CUE_SIZE + 30) & 0xFF
|
||||
cues[c] = { ptns, instr }
|
||||
|
||||
for (let v = 0; v < NUM_VOICES; v++) {
|
||||
if (ptns[v] !== CUE_EMPTY) { lastActiveCue = c; break }
|
||||
}
|
||||
}
|
||||
|
||||
const instrBase = cueBase + NUM_CUES * CUE_SIZE
|
||||
const instruments = new Array(NUM_INSTRUMENTS)
|
||||
for (let n = 0; n < NUM_INSTRUMENTS; n++) {
|
||||
const instr = new Uint8Array(INSTRUMENT_SIZE)
|
||||
for (let k = 0; k < INSTRUMENT_SIZE; k++) {
|
||||
instr[k] = sys.peek(ptr + instrBase + n * INSTRUMENT_SIZE + k) & 0xFF
|
||||
}
|
||||
instruments[n] = instr
|
||||
}
|
||||
sys.free(cueSheetPtr)
|
||||
|
||||
sys.free(ptr)
|
||||
|
||||
return {
|
||||
filePath, version, numSongs, numVoices, numPats,
|
||||
bpm: (bpmStored + 24) & 0xFF, tickRate,
|
||||
patterns, cues, lastActiveCue, instruments
|
||||
patterns, cues, lastActiveCue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2370,8 +2371,8 @@ taud.uploadTaudFile(fullPathObj.full, 0, PLAYHEAD)
|
||||
audio.setMasterVolume(PLAYHEAD, 255)
|
||||
audio.setMasterPan(PLAYHEAD, 128)
|
||||
const initialTrackerMixerflags = audio.getTrackerMixerFlags(PLAYHEAD)
|
||||
//const initialGlobalVolume =
|
||||
//const initialMixingVolume =
|
||||
const initialGlobalVolume = audio.getSongGlobalVolume(PLAYHEAD)
|
||||
const initialMixingVolume = audio.getSongMixingVolume(PLAYHEAD)
|
||||
|
||||
function isExternalPanel(p) {
|
||||
return p === VIEW_SAMPLES || p === VIEW_INSTRMNT || p === VIEW_FILE
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
const TAUD_MAGIC = [0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64] // \x1F TSVMaud
|
||||
const TAUD_VERSION = 1
|
||||
const TAUD_HEADER_SIZE = 32 // magic(8) + version(1) + numSongs(1) + compSize(4) + rsvd(2) + sig(16)
|
||||
const TAUD_SONG_ENTRY = 16 // bytes per song-table row (offset(4)+voices(1)+pats(2)+bpm(1)+tick(1)+pad(7))
|
||||
const TAUD_HEADER_SIZE = 32 // magic(8) + version(1) + numSongs(1) + compSize(4) + projOff(4) + sig(14)
|
||||
const TAUD_SONG_ENTRY = 32 // see encodeSongEntry / decodeSongEntry below
|
||||
const SAMPLEINST_SIZE = 786432 // 737280 sample + 49152 instrument (256 × 192)
|
||||
const PATTERN_SIZE = 512 // bytes per pattern (64 rows × 8 bytes)
|
||||
const NUM_PATTERNS_MAX = 256
|
||||
@@ -109,33 +109,47 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
let bpmStored = sys.peek(filePtr + entryOff + 7) & 0xFF
|
||||
let tickRate = sys.peek(filePtr + entryOff + 8) & 0xFF
|
||||
let mixerflags = sys.peek(filePtr + entryOff + 15) & 0xFF
|
||||
let songGlobalVolume = sys.peek(filePtr + entryOff + 16) & 0xFF
|
||||
let songMixingVolume = sys.peek(filePtr + entryOff + 17) & 0xFF
|
||||
let patBinCompSize = _peekU32LE(filePtr, entryOff + 18)
|
||||
let cueSheetCompSize = _peekU32LE(filePtr, entryOff + 22)
|
||||
|
||||
let bpm = bpmStored + 24
|
||||
let patsToLoad = numPatsLo | (numPatsHi << 8)
|
||||
|
||||
// -- 6. Upload patterns ---------------------------------------------------
|
||||
let songBase = filePtr + songOffset
|
||||
// -- 6. Decompress + upload patterns --------------------------------------
|
||||
let patBinSize = patsToLoad * PATTERN_SIZE
|
||||
let patBinPtr = sys.malloc(patBinSize)
|
||||
gzip.decompFromTo(filePtr + songOffset, patBinCompSize, patBinPtr)
|
||||
|
||||
let patBytes = new Array(PATTERN_SIZE)
|
||||
for (let p = 0; p < patsToLoad; p++) {
|
||||
for (let k = 0; k < PATTERN_SIZE; k++)
|
||||
patBytes[k] = sys.peek(songBase + p * PATTERN_SIZE + k) & 0xFF
|
||||
patBytes[k] = sys.peek(patBinPtr + p * PATTERN_SIZE + k) & 0xFF
|
||||
audio.uploadPattern(p, patBytes)
|
||||
}
|
||||
sys.free(patBinPtr)
|
||||
|
||||
// -- 7. Decompress + upload cue sheet -------------------------------------
|
||||
let cueSheetSize = NUM_CUES * CUE_SIZE
|
||||
let cueSheetPtr = sys.malloc(cueSheetSize)
|
||||
gzip.decompFromTo(filePtr + songOffset + patBinCompSize, cueSheetCompSize, cueSheetPtr)
|
||||
|
||||
// -- 7. Upload cue sheet --------------------------------------------------
|
||||
let cueBase = songBase + patsToLoad * PATTERN_SIZE
|
||||
let cueBytes = new Array(CUE_SIZE)
|
||||
for (let c = 0; c < NUM_CUES; c++) {
|
||||
for (let k = 0; k < CUE_SIZE; k++)
|
||||
cueBytes[k] = sys.peek(cueBase + c * CUE_SIZE + k) & 0xFF
|
||||
cueBytes[k] = sys.peek(cueSheetPtr + c * CUE_SIZE + k) & 0xFF
|
||||
audio.uploadCue(c, cueBytes)
|
||||
}
|
||||
sys.free(cueSheetPtr)
|
||||
|
||||
// -- 8. Configure playhead ------------------------------------------------
|
||||
audio.setTrackerMode(playhead)
|
||||
audio.setBPM(playhead, bpm)
|
||||
audio.setTickRate(playhead, tickRate > 0 ? tickRate : 6)
|
||||
audio.setTrackerMixerFlags(playhead, mixerflags)
|
||||
audio.setSongGlobalVolume(playhead, songGlobalVolume)
|
||||
audio.setSongMixingVolume(playhead, songMixingVolume)
|
||||
|
||||
|
||||
fileHandle.close()
|
||||
@@ -184,18 +198,47 @@ function captureTrackerDataToFile(outFile) {
|
||||
let numPats = numPatsActual // Uint16, 1-65535
|
||||
let patsToSave = numPatsActual
|
||||
|
||||
// -- 3. BPM / tick-rate from playhead 0 -----------------------------------
|
||||
// -- 3. BPM / tick-rate / volumes from playhead 0 -------------------------
|
||||
let bpm = audio.getBPM(0) || 125
|
||||
let tickRate = audio.getTickRate(0) || 6
|
||||
let bpmStored = (bpm - 24) & 0xFF
|
||||
let songGlobalVolume = audio.getSongGlobalVolume(0)
|
||||
let songMixingVolume = audio.getSongMixingVolume(0)
|
||||
if (songGlobalVolume === undefined || songGlobalVolume === null) songGlobalVolume = 0x80
|
||||
if (songMixingVolume === undefined || songMixingVolume === null) songMixingVolume = 0x80
|
||||
|
||||
// -- 4. Compute song offset (absolute from file start) --------------------
|
||||
// -- 4. Compress pattern bin ----------------------------------------------
|
||||
let patBinSize = patsToSave * PATTERN_SIZE
|
||||
let patBuf = sys.malloc(patBinSize)
|
||||
sys.memcpy(memBase - 131072, patBuf, patBinSize)
|
||||
|
||||
let patCompBuf = sys.malloc(patBinSize + 4096)
|
||||
let patCompSize = gzip.compFromTo(patBuf, patBinSize, patCompBuf)
|
||||
sys.free(patBuf)
|
||||
|
||||
// -- 5. Compress cue sheet ------------------------------------------------
|
||||
// Cue entry c, byte k is at MMIO address 32768 + c*32 + k,
|
||||
// accessed as sys.peek(baseAddr − (32768 + c*32 + k)).
|
||||
let cueSheetSize = NUM_CUES * CUE_SIZE
|
||||
let cueBuf = sys.malloc(cueSheetSize)
|
||||
for (let c = 0; c < NUM_CUES; c++) {
|
||||
let cueOff = 32768 + c * CUE_SIZE
|
||||
for (let k = 0; k < CUE_SIZE; k++)
|
||||
sys.poke(cueBuf + c * CUE_SIZE + k,
|
||||
sys.peek(baseAddr - (cueOff + k)) & 0xFF)
|
||||
}
|
||||
|
||||
let cueCompBuf = sys.malloc(cueSheetSize + 4096)
|
||||
let cueCompSize = gzip.compFromTo(cueBuf, cueSheetSize, cueCompBuf)
|
||||
sys.free(cueBuf)
|
||||
|
||||
// -- 6. Compute song offset (absolute from file start) --------------------
|
||||
// Layout: header(32) + compressed(compressedSize) + songTable(1 × TAUD_SONG_ENTRY)
|
||||
let songOffset = TAUD_HEADER_SIZE + compressedSize + 1 * TAUD_SONG_ENTRY
|
||||
|
||||
// -- 5. Build header byte array (32 bytes) --------------------------------
|
||||
let sigBytes = new Array(16)
|
||||
for (let i = 0; i < 16; i++)
|
||||
// -- 7. Build header byte array (32 bytes) --------------------------------
|
||||
let sigBytes = new Array(14)
|
||||
for (let i = 0; i < 14; i++)
|
||||
sigBytes[i] = i < CAPTURE_SIGNATURE.length ? CAPTURE_SIGNATURE.charCodeAt(i) : 0
|
||||
|
||||
let header = [
|
||||
@@ -203,16 +246,16 @@ function captureTrackerDataToFile(outFile) {
|
||||
0x1F, 0x54, 0x53, 0x56, 0x4D, 0x61, 0x75, 0x64,
|
||||
// version, numSongs
|
||||
TAUD_VERSION, 1,
|
||||
// compressedSize uint32 LE (4)
|
||||
// compressedSize uint32 LE (4) -- sample+inst bin
|
||||
(compressedSize ) & 0xFF,
|
||||
(compressedSize >>> 8) & 0xFF,
|
||||
(compressedSize >>> 16) & 0xFF,
|
||||
(compressedSize >>> 24) & 0xFF,
|
||||
// reserved (4)
|
||||
// project data offset (4) -- not emitted
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
].concat(sigBytes) // 8 + 2 + 4 + 2 + 16 = 32 bytes
|
||||
].concat(sigBytes) // 8 + 2 + 4 + 4 + 14 = 32 bytes
|
||||
|
||||
// -- 6. Build song-table row (16 bytes) -----------------------------------
|
||||
// -- 8. Build song-table row (32 bytes) -----------------------------------
|
||||
let songTable = [
|
||||
(songOffset ) & 0xFF,
|
||||
(songOffset >>> 8) & 0xFF,
|
||||
@@ -225,37 +268,42 @@ function captureTrackerDataToFile(outFile) {
|
||||
0x00,0xA0, // basenote (0xA000 -- C9)
|
||||
0x00,0xAC,0x02,0x46, // basefreq (8363 Hz)
|
||||
sys.peek(baseAddr - 7), // mixer flags
|
||||
songGlobalVolume & 0xFF, // global volume
|
||||
songMixingVolume & 0xFF, // mixing volume
|
||||
// pattern bin compressed size (4)
|
||||
(patCompSize ) & 0xFF,
|
||||
(patCompSize >>> 8) & 0xFF,
|
||||
(patCompSize >>> 16) & 0xFF,
|
||||
(patCompSize >>> 24) & 0xFF,
|
||||
// cue sheet compressed size (4)
|
||||
(cueCompSize ) & 0xFF,
|
||||
(cueCompSize >>> 8) & 0xFF,
|
||||
(cueCompSize >>> 16) & 0xFF,
|
||||
(cueCompSize >>> 24) & 0xFF,
|
||||
// reserved (6)
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
]
|
||||
|
||||
// -- 7. Write header (creates / truncates file) ---------------------------
|
||||
// -- 9. Write header (creates / truncates file) ---------------------------
|
||||
const fileHandle = files.open(outFile)
|
||||
fileHandle.bwrite(header)
|
||||
|
||||
// -- 8. Append compressed sample+inst bin ---------------------------------
|
||||
fileHandle.pwrite(compBuf, compressedSize, 32)
|
||||
// -- 10. Append compressed sample+inst bin --------------------------------
|
||||
fileHandle.pwrite(compBuf, compressedSize, TAUD_HEADER_SIZE)
|
||||
sys.free(compBuf)
|
||||
|
||||
// -- 9. Write song table --------------------------------------------------
|
||||
// -- 11. Write song table -------------------------------------------------
|
||||
fileHandle.bwrite(songTable)
|
||||
|
||||
// -- 10. Append pattern bin -----------------------------------------------
|
||||
let patBuf = sys.malloc(patsToSave * PATTERN_SIZE)
|
||||
sys.memcpy(memBase - 131072, patBuf, patsToSave * PATTERN_SIZE)
|
||||
fileHandle.pwrite(patBuf, patsToSave * PATTERN_SIZE, 32 + compressedSize + songTable.length)
|
||||
sys.free(patBuf)
|
||||
// -- 12. Append compressed pattern bin ------------------------------------
|
||||
fileHandle.pwrite(patCompBuf, patCompSize,
|
||||
TAUD_HEADER_SIZE + compressedSize + songTable.length)
|
||||
sys.free(patCompBuf)
|
||||
|
||||
// -- 11. Append cue sheet (all 1024 entries from MMIO space) --------------
|
||||
// Cue entry c, byte k is at MMIO address 32768 + c*32 + k,
|
||||
// accessed as sys.peek(baseAddr − (32768 + c*32 + k)).
|
||||
let cueBuf = sys.malloc(NUM_CUES * CUE_SIZE)
|
||||
for (let c = 0; c < NUM_CUES; c++) {
|
||||
let cueOff = 32768 + c * CUE_SIZE
|
||||
for (let k = 0; k < CUE_SIZE; k++)
|
||||
sys.poke(cueBuf + c * CUE_SIZE + k,
|
||||
sys.peek(baseAddr - (cueOff + k)) & 0xFF)
|
||||
}
|
||||
fileHandle.pwrite(cueBuf, NUM_CUES * CUE_SIZE, 32 + compressedSize + songTable.length + patsToSave * PATTERN_SIZE)
|
||||
sys.free(cueBuf)
|
||||
// -- 13. Append compressed cue sheet --------------------------------------
|
||||
fileHandle.pwrite(cueCompBuf, cueCompSize,
|
||||
TAUD_HEADER_SIZE + compressedSize + songTable.length + patCompSize)
|
||||
sys.free(cueCompBuf)
|
||||
|
||||
|
||||
fileHandle.flush(); fileHandle.close()
|
||||
|
||||
99
it2taud.py
99
it2taud.py
@@ -25,7 +25,8 @@ Effect support:
|
||||
A-Z dispatch per TAUD_NOTE_EFFECTS.md. IT-specific: Cxx is binary
|
||||
(not BCD like ST3). V scales by ×2 (IT 0-128 → Taud 0-255). X is
|
||||
the full 8-bit IT pan. Y panbrello nibble-repeats. Z (MIDI macro)
|
||||
dropped. S6x tick-delay dropped. SAx high-offset dropped. S7x NNA /
|
||||
dropped. S6x fine-pattern-delay forwarded directly to Taud S$6x. SAx
|
||||
high-offset dropped. S7x NNA /
|
||||
past-note / envelope toggles forwarded directly (IT sub-codes match
|
||||
Taud one-to-one). Vol-column pitch-slide / tone-porta / vibrato sub-
|
||||
commands forwarded to main effect slot when empty; dropped otherwise.
|
||||
@@ -45,14 +46,14 @@ from taud_common import (
|
||||
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
|
||||
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4,
|
||||
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,
|
||||
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_W, 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,
|
||||
normalise_sample, encode_song_entry,
|
||||
)
|
||||
|
||||
|
||||
@@ -83,6 +84,7 @@ IT_SMP_COMPRESSED = 0x08
|
||||
IT_SMP_LOOP = 0x10
|
||||
IT_SMP_SUS_LOOP = 0x20
|
||||
IT_SMP_PINGPONG = 0x40
|
||||
IT_SMP_PINGPONG_SUS = 0x80
|
||||
|
||||
# Vol-column byte ranges (inclusive lower, inclusive upper)
|
||||
VC_VOL_LO, VC_VOL_HI = 0, 64
|
||||
@@ -104,7 +106,9 @@ VC_TPORTA_TABLE = (0, 1, 4, 8, 16, 32, 64, 96, 128, 255)
|
||||
IT_MEM_EFFECTS = frozenset({
|
||||
EFF_D, EFF_E, EFF_F, EFF_G, EFF_H, EFF_I, EFF_J,
|
||||
EFF_K, EFF_L, 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_S, EFF_T, EFF_U, EFF_X, EFF_Y,
|
||||
# EFF_V excluded: V00 means literal 0 in IT, not recall.
|
||||
# EFF_W excluded: Taud engine handles W recall natively (same private-slot semantics).
|
||||
})
|
||||
|
||||
|
||||
@@ -435,6 +439,8 @@ def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list:
|
||||
s.length = len(s.sample_data)
|
||||
s.loop_beg = min(s.loop_beg, s.length)
|
||||
s.loop_end = min(s.loop_end, s.length)
|
||||
s.sus_beg = min(s.sus_beg, s.length)
|
||||
s.sus_end = min(s.sus_end, s.length)
|
||||
except Exception as e:
|
||||
vprint(f" warning: '{s.name}' decompression failed ({e}), silent")
|
||||
else:
|
||||
@@ -449,6 +455,8 @@ def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list:
|
||||
s.length = len(s.sample_data)
|
||||
s.loop_beg = min(s.loop_beg, s.length)
|
||||
s.loop_end = min(s.loop_end, s.length)
|
||||
s.sus_beg = min(s.sus_beg, s.length)
|
||||
s.sus_end = min(s.sus_end, s.length)
|
||||
samples.append(s)
|
||||
return samples
|
||||
|
||||
@@ -784,7 +792,7 @@ def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0,
|
||||
- Cxx: binary row number, not BCD
|
||||
- V: IT global vol 0-128 scaled ×2
|
||||
- X: IT full 8-bit pan → 6-bit
|
||||
- S6x, S7x, SAx, SFx handled (mostly dropped)
|
||||
- S6x: fine pattern delay forwarded; S7x forwarded; SAx/SFx dropped
|
||||
|
||||
amiga_mode mirrors the inverse of the IT ``linear_slides`` flag. When
|
||||
set, E/F coarse pitch-slide arguments are emitted as raw IT period units
|
||||
@@ -877,8 +885,8 @@ def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0,
|
||||
pan8 = (val << 4) | val
|
||||
return (TOP_S, 0x8000 | pan8, None, None)
|
||||
if sub == 0x6:
|
||||
vprint(f" dropped S6{val:X} (tick delay) at ch{ch} row{row}")
|
||||
return (TOP_NONE, 0, None, None)
|
||||
# IT S6x = fine pattern delay (extends row by x ticks) — maps directly.
|
||||
return (TOP_S, 0x6000 | (val << 8), None, None)
|
||||
if sub == 0x7:
|
||||
# NNA / past-note / envelope on-off — IT S7x maps directly to Taud S $7x00
|
||||
# (same sub-code table). No payload to translate.
|
||||
@@ -904,8 +912,8 @@ def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0,
|
||||
return (TOP_V, (taud_v & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_W:
|
||||
vprint(f" dropped W{arg:02X} (global vol slide) at ch{ch} row{row}")
|
||||
return (TOP_NONE, 0, None, None)
|
||||
# W$xy: same nibble-pair layout as D, passed in the high byte.
|
||||
return (TOP_W, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_X:
|
||||
return (TOP_S, 0x8000 | (arg & 0xFF), None, None)
|
||||
@@ -926,8 +934,7 @@ def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0,
|
||||
|
||||
def resolve_it_recalls(patterns_rows: list, order_list: list,
|
||||
num_channels: int, link_gef: bool,
|
||||
old_effects: bool = False,
|
||||
initial_global_vol: int = 128) -> None:
|
||||
old_effects: bool = False) -> None:
|
||||
"""Walk in order, resolve zero-arg recalls per-effect-per-channel.
|
||||
|
||||
IT effect memory groups:
|
||||
@@ -940,14 +947,13 @@ def resolve_it_recalls(patterns_rows: list, order_list: list,
|
||||
they do NOT recall and are suppressed to TOP_NONE. All other effects
|
||||
still recall normally even in old_effects mode.
|
||||
|
||||
V memory is primed with initial_global_vol so a song-leading V $0000
|
||||
resolves to the header's global volume, not literal zero.
|
||||
V and W are excluded from IT_MEM_EFFECTS and are not resolved here:
|
||||
V00 in IT means literal 0 (not recall); W recall is handled natively
|
||||
by the Taud engine's private W memory slot.
|
||||
"""
|
||||
# last_mem[ch][eff_key] = last_non_zero_arg
|
||||
# eff_key: integer 1-26 for most effects; we merge cohorts by normalising.
|
||||
last_mem = [{} for _ in range(num_channels)]
|
||||
for ch in range(num_channels):
|
||||
last_mem[ch][EFF_V] = initial_global_vol
|
||||
|
||||
# Effects that stop rather than recall when arg=0 in old_effects mode (ST3 compat).
|
||||
# E/F: pitch slide stop. J: arpeggio stop (J00 = return to normal pitch in ST3).
|
||||
@@ -1127,6 +1133,8 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
|
||||
s.length = len(new_data)
|
||||
s.loop_beg = max(0, int(s.loop_beg * ratio))
|
||||
s.loop_end = max(0, min(int(s.loop_end * ratio), s.length))
|
||||
s.sus_beg = max(0, int(s.sus_beg * ratio))
|
||||
s.sus_end = max(0, min(int(s.sus_end * ratio), s.length))
|
||||
s.c5_speed = max(1, int(s.c5_speed * ratio))
|
||||
|
||||
sample_bin = bytearray(SAMPLEBIN_SIZE)
|
||||
@@ -1141,7 +1149,9 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
|
||||
offsets[idx] = pos
|
||||
if n < len(s.sample_data):
|
||||
vprint(f" warning: '{s.name}' truncated {len(s.sample_data)} → {n}")
|
||||
s.length = n; s.loop_end = min(s.loop_end, n)
|
||||
s.length = n
|
||||
s.loop_end = min(s.loop_end, n)
|
||||
s.sus_end = min(s.sus_end, n)
|
||||
pos += n
|
||||
|
||||
# 192-byte instrument layout (terranmon.txt:1997-2070).
|
||||
@@ -1167,15 +1177,33 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
|
||||
ptr = offsets.get(i, 0) & 0xFFFFFFFF
|
||||
s_len = min(s.length, 65535)
|
||||
c2spd = min(s.c5_speed, 65535)
|
||||
# Sustain loop wins over the regular loop because Taud carries one loop
|
||||
# region. After key-off the engine drops the loop entirely (terranmon.txt:2007).
|
||||
if s.flags & IT_SMP_SUS_LOOP:
|
||||
ls = min(s.sus_beg, 65535)
|
||||
le = min(s.sus_end, 65535)
|
||||
sustain_bit = 0x4
|
||||
pingpong_active = bool(s.flags & IT_SMP_PINGPONG_SUS)
|
||||
has_active_loop = True
|
||||
elif s.has_loop:
|
||||
ls = min(s.loop_beg, 65535)
|
||||
le = min(s.loop_end, 65535)
|
||||
if s.has_loop and (s.flags & IT_SMP_PINGPONG):
|
||||
sustain_bit = 0x0
|
||||
pingpong_active = bool(s.flags & IT_SMP_PINGPONG)
|
||||
has_active_loop = True
|
||||
else:
|
||||
ls = min(s.loop_beg, 65535)
|
||||
le = min(s.loop_end, 65535)
|
||||
sustain_bit = 0x0
|
||||
pingpong_active = False
|
||||
has_active_loop = False
|
||||
if has_active_loop and pingpong_active:
|
||||
loop_mode = 2 # backandforth
|
||||
elif s.has_loop:
|
||||
elif has_active_loop:
|
||||
loop_mode = 1 # forward loop
|
||||
else:
|
||||
loop_mode = 0 # no loop
|
||||
flags_byte = loop_mode & 0x3
|
||||
flags_byte = (loop_mode & 0x3) | sustain_bit
|
||||
|
||||
base = taud_idx * 192
|
||||
struct.pack_into('<I', inst_bin, base + 0, ptr)
|
||||
@@ -1487,8 +1515,7 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
||||
# ── Resolve IT recalls ───────────────────────────────────────────────────
|
||||
vprint(" resolving IT recalls…")
|
||||
resolve_it_recalls(patterns_rows, h.order_list, 64, h.link_gef,
|
||||
old_effects=h.old_effects,
|
||||
initial_global_vol=h.global_vol)
|
||||
old_effects=h.old_effects)
|
||||
|
||||
init_speed, _ = find_initial_bpm_speed(patterns_rows, h.order_list,
|
||||
h.initial_speed, h.initial_tempo)
|
||||
@@ -1707,18 +1734,34 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
# Compress pattern bin and cue sheet (per Taud spec)
|
||||
pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0)
|
||||
cue_comp = gzip.compress(bytes(sheet), compresslevel=9, mtime=0)
|
||||
vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)")
|
||||
vprint(f" cue sheet: {len(sheet)} → {len(cue_comp)} bytes (gzip)")
|
||||
|
||||
# flags byte: bit 1 (f) = Amiga pitch-slide mode (IT linear_slides flag inverted)
|
||||
flags_byte = 0x00 if h.linear_slides else 0x02
|
||||
song_table = struct.pack('<IBHBBHfB',
|
||||
song_offset, C, num_taud_pats,
|
||||
bpm_stored, speed,
|
||||
0xA000, # C9
|
||||
8363.0,
|
||||
flags_byte,
|
||||
# IT global/mix volumes are 0..128; rescale to Taud's 0..255 (clamped).
|
||||
global_vol_taud = min(0xFF, round(h.global_vol * 255 / 128))
|
||||
mixing_vol_taud = min(0xFF, round(h.mix_vol * 255 / 128))
|
||||
song_table = encode_song_entry(
|
||||
song_offset=song_offset,
|
||||
num_voices=C,
|
||||
num_patterns=num_taud_pats,
|
||||
bpm_stored=bpm_stored,
|
||||
tick_rate=speed,
|
||||
base_note=0xA000, # C9
|
||||
base_freq=8363.0,
|
||||
flags_byte=flags_byte,
|
||||
pat_bin_comp_size=len(pat_comp),
|
||||
cue_sheet_comp_size=len(cue_comp),
|
||||
global_vol=global_vol_taud,
|
||||
mixing_vol=mixing_vol_taud,
|
||||
)
|
||||
assert len(song_table) == TAUD_SONG_ENTRY
|
||||
|
||||
return header + compressed + song_table + bytes(pat_bin) + bytes(sheet)
|
||||
return header + compressed + song_table + pat_comp + cue_comp
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
44
mod2taud.py
44
mod2taud.py
@@ -40,6 +40,7 @@ from taud_common import (
|
||||
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
|
||||
J_SEMI_TABLE,
|
||||
d_arg_to_col, resample_linear, encode_cue, deduplicate_patterns,
|
||||
encode_song_entry,
|
||||
)
|
||||
|
||||
|
||||
@@ -745,27 +746,36 @@ def assemble_taud(mod: dict) -> bytes:
|
||||
vprint(f" patterns: {orig_count} → {num_taud_pats} unique "
|
||||
f"({orig_count - num_taud_pats} deduplicated)")
|
||||
|
||||
# ProTracker is Amiga-period-based by definition, so we set the f bit so
|
||||
# the engine applies coarse pitch slides in period space (recovers PT's
|
||||
# characteristic non-linear pitch character).
|
||||
flags_byte = 0x02
|
||||
song_table = struct.pack('<IBHBBHfB',
|
||||
song_offset,
|
||||
n_channels,
|
||||
num_taud_pats,
|
||||
bpm_stored,
|
||||
speed,
|
||||
0xA000,
|
||||
8363.0,
|
||||
flags_byte,
|
||||
)
|
||||
assert len(song_table) == TAUD_SONG_ENTRY
|
||||
|
||||
vprint(" building cue sheet…")
|
||||
cue_sheet = build_cue_sheet(order_list, n_patterns, n_channels, pat_remap)
|
||||
assert len(cue_sheet) == NUM_CUES * CUE_SIZE
|
||||
|
||||
return header + compressed + song_table + bytes(pat_bin) + cue_sheet
|
||||
pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0)
|
||||
cue_comp = gzip.compress(bytes(cue_sheet), compresslevel=9, mtime=0)
|
||||
vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)")
|
||||
vprint(f" cue sheet: {len(cue_sheet)} → {len(cue_comp)} bytes (gzip)")
|
||||
|
||||
# ProTracker is Amiga-period-based by definition, so we set the f bit so
|
||||
# the engine applies coarse pitch slides in period space (recovers PT's
|
||||
# characteristic non-linear pitch character).
|
||||
flags_byte = 0x02
|
||||
song_table = encode_song_entry(
|
||||
song_offset=song_offset,
|
||||
num_voices=n_channels,
|
||||
num_patterns=num_taud_pats,
|
||||
bpm_stored=bpm_stored,
|
||||
tick_rate=speed,
|
||||
base_note=0xA000,
|
||||
base_freq=8363.0,
|
||||
flags_byte=flags_byte,
|
||||
pat_bin_comp_size=len(pat_comp),
|
||||
cue_sheet_comp_size=len(cue_comp),
|
||||
global_vol=0xFF,
|
||||
mixing_vol=0xFF,
|
||||
)
|
||||
assert len(song_table) == TAUD_SONG_ENTRY
|
||||
|
||||
return header + compressed + song_table + pat_comp + cue_comp
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
56
s3m2taud.py
56
s3m2taud.py
@@ -17,8 +17,9 @@ Effect support:
|
||||
table" and "ScreamTracker 3 conversion notes". ST3 shared-memory recalls
|
||||
(D/E/F/I/J/K/L/Q/R/S with $00 arg) are eagerly resolved per channel.
|
||||
Cxx is BCD-decoded. K/L are split into H $0000 / G $0000 + volume-column
|
||||
slide. M/N/X/P fold into volume / pan columns. W (global vol slide) is
|
||||
dropped with a -v warning. X converts to pan column. Y (panbrello) converts
|
||||
slide. M/N/X/P fold into volume / pan columns. W (global vol slide)
|
||||
converts to Taud W (arg in high byte, same encoding as D). X converts to
|
||||
pan column. Y (panbrello) converts
|
||||
to Taud Y. S5 selects the panbrello LFO waveform. S8x converts to a pan
|
||||
column SET of round(x * 4.2), mapping nibble 0-15 directly to pan 0-63.
|
||||
"""
|
||||
@@ -36,14 +37,14 @@ from taud_common import (
|
||||
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
|
||||
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4,
|
||||
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,
|
||||
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_W, 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,
|
||||
normalise_sample, encode_song_entry,
|
||||
)
|
||||
|
||||
|
||||
@@ -356,8 +357,8 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0,
|
||||
return (TOP_V, (min(arg * 4, 0xFF) & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_W:
|
||||
vprint(f" dropped W{arg:02X} (global vol slide) at ch{ch} row{row}")
|
||||
return (TOP_NONE, 0, None, None)
|
||||
# W$xy: same nibble-pair layout as D, passed in the high byte.
|
||||
return (TOP_W, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_X:
|
||||
return (TOP_S, 0x8000 | (arg & 0xFF), None, None)
|
||||
@@ -817,28 +818,37 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count)
|
||||
vprint(f" patterns: {orig_count} → {num_taud_pats} unique ({orig_count - num_taud_pats} deduplicated)")
|
||||
|
||||
# Song table row (16 bytes): offset(4)+voices(1)+pats(2)+bpm(1)+tick(1)+basenote(2)+basefreq(4)+flags(1)
|
||||
# Built after dedup so num_taud_pats reflects the unique count.
|
||||
# flags byte: bit 1 (f) = Amiga pitch-slide mode (mirrors the S3M linear_slides flag inverted)
|
||||
flags_byte = 0x00 if h.linear_slides else 0x02
|
||||
song_table = struct.pack('<IBHBBHfB',
|
||||
song_offset,
|
||||
C,
|
||||
num_taud_pats,
|
||||
bpm_stored,
|
||||
speed,
|
||||
0xA000, # C9
|
||||
8363.0, # Hz
|
||||
flags_byte,
|
||||
)
|
||||
assert len(song_table) == TAUD_SONG_ENTRY
|
||||
|
||||
# Cue sheet (using remapped pattern indices)
|
||||
vprint(" building cue sheet…")
|
||||
cue_sheet = build_cue_sheet(h.order_list, P, C, pat_remap)
|
||||
assert len(cue_sheet) == NUM_CUES * CUE_SIZE
|
||||
|
||||
return header + compressed + song_table + bytes(pat_bin) + cue_sheet
|
||||
# Compress pattern bin and cue sheet (per Taud spec)
|
||||
pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0)
|
||||
cue_comp = gzip.compress(bytes(cue_sheet), compresslevel=9, mtime=0)
|
||||
vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)")
|
||||
vprint(f" cue sheet: {len(cue_sheet)} → {len(cue_comp)} bytes (gzip)")
|
||||
|
||||
# Song table row (32 bytes; see encode_song_entry).
|
||||
# flags byte: bit 1 (f) = Amiga pitch-slide mode (mirrors the S3M linear_slides flag inverted)
|
||||
flags_byte = 0x00 if h.linear_slides else 0x02
|
||||
song_table = encode_song_entry(
|
||||
song_offset=song_offset,
|
||||
num_voices=C,
|
||||
num_patterns=num_taud_pats,
|
||||
bpm_stored=bpm_stored,
|
||||
tick_rate=speed,
|
||||
base_note=0xA000, # C9
|
||||
base_freq=8363.0,
|
||||
flags_byte=flags_byte,
|
||||
pat_bin_comp_size=len(pat_comp),
|
||||
cue_sheet_comp_size=len(cue_comp),
|
||||
global_vol=0xFF,
|
||||
mixing_vol=0xFF,
|
||||
)
|
||||
assert len(song_table) == TAUD_SONG_ENTRY
|
||||
|
||||
return header + compressed + song_table + pat_comp + cue_comp
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -28,8 +28,8 @@ def vprint(*a, **kw) -> None:
|
||||
|
||||
TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64])
|
||||
TAUD_VERSION = 1
|
||||
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+rsvd(4)+sig(14)
|
||||
TAUD_SONG_ENTRY = 16 # offset(4)+voices(1)+pats(2)+bpm(1)+tick(1)+basenote(2)+basefreq(4)+flags(1)
|
||||
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+projOff(4)+sig(14)
|
||||
TAUD_SONG_ENTRY = 32 # full spec entry (see encode_song_entry)
|
||||
SAMPLEBIN_SIZE = 737280
|
||||
INSTBIN_SIZE = 49152 # 256 instruments × 192 bytes
|
||||
SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE
|
||||
@@ -67,6 +67,7 @@ TOP_S = 0x1C
|
||||
TOP_T = 0x1D
|
||||
TOP_U = 0x1E
|
||||
TOP_V = 0x1F
|
||||
TOP_W = 0x20
|
||||
TOP_Y = 0x22
|
||||
|
||||
# Volume / pan column selectors (2-bit field at top of vol/pan byte)
|
||||
@@ -165,6 +166,39 @@ def deduplicate_patterns(pat_bin: bytes, num_pats: int) -> tuple:
|
||||
return b''.join(canonical), remap, len(canonical)
|
||||
|
||||
|
||||
def encode_song_entry(song_offset: int, num_voices: int, num_patterns: int,
|
||||
bpm_stored: int, tick_rate: int,
|
||||
base_note: int, base_freq: float, flags_byte: int,
|
||||
pat_bin_comp_size: int, cue_sheet_comp_size: int,
|
||||
global_vol: int = 0x80, mixing_vol: int = 0x80) -> bytes:
|
||||
"""Pack a 32-byte Taud song table entry.
|
||||
|
||||
Layout:
|
||||
u32 song_offset, u8 num_voices, u16 num_patterns,
|
||||
u8 bpm_stored, u8 tick_rate,
|
||||
u16 base_note, f32 base_freq,
|
||||
u8 flags, u8 global_vol, u8 mixing_vol,
|
||||
u32 pat_bin_comp_size, u32 cue_sheet_comp_size,
|
||||
byte[6] reserved.
|
||||
"""
|
||||
entry = struct.pack('<IBHBBHfBBBII',
|
||||
song_offset,
|
||||
num_voices & 0xFF,
|
||||
num_patterns & 0xFFFF,
|
||||
bpm_stored & 0xFF,
|
||||
tick_rate & 0xFF,
|
||||
base_note & 0xFFFF,
|
||||
float(base_freq),
|
||||
flags_byte & 0xFF,
|
||||
global_vol & 0xFF,
|
||||
mixing_vol & 0xFF,
|
||||
pat_bin_comp_size & 0xFFFFFFFF,
|
||||
cue_sheet_comp_size & 0xFFFFFFFF,
|
||||
) + b'\x00' * 6
|
||||
assert len(entry) == TAUD_SONG_ENTRY
|
||||
return entry
|
||||
|
||||
|
||||
def normalise_sample(raw: bytes, signed: bool, is_16bit: bool,
|
||||
is_stereo: bool, name: str) -> bytes:
|
||||
"""Return unsigned 8-bit mono sample bytes, downmixing/depthing as needed."""
|
||||
|
||||
@@ -2092,14 +2092,14 @@ TODO:
|
||||
[x] (same context as above) implement S7x command
|
||||
[x] on playback, panning changes randomly on Taud made by s3m2taud.py and mod2taud.py, but not by it2taud.py (maybe something's off with the instrument exports?)
|
||||
[x] NNA not disabled for S3M and MOD
|
||||
[ ] implement S6x command
|
||||
[ ] `S B000` and `S B100` not working as intended -- on first playback it jumps to the next cue same row, on subsequent playbacks the commands are completely ignored
|
||||
[ ] implement Wxx command (global volume slide)
|
||||
[ ] implement sample loop sustain
|
||||
[ ] cue and pattern compression of the Taud format (taud_common.py, taud.mjs)
|
||||
[x] `S B000` and `S B100` not working as intended -- on first playback it jumps to the next cue same row, on subsequent playbacks the commands are completely ignored
|
||||
[x] implement S6x command
|
||||
[x] implement Wxx command (global volume slide)
|
||||
[x] implement sample loop sustain
|
||||
"Caveat: on a foreground voice, key-off (row.note == 0x0000) currently sets voice.active = false at AudioAdapter.kt:1713, which silences the channel immediately. Sustain-loop escape therefore only takes effect on background voices spawned by NNA "Note Off" — which matches the IT idiom of layering a new note over a sustained one. Let me know if you also want the foreground key-off to keep the voice playing through fadeout."
|
||||
[x] cue and pattern compression of the Taud format (taud_common.py, taud.mjs)
|
||||
[ ] figure out how IT (8 bits) and FT2 (12 bits) handles volume fadeout numbers, and come up with a compatible Taud spec, then implement
|
||||
[ ] implement bitcrusher (eff sym '8')
|
||||
[ ] Figure out why pitch slides are not working right on 2nd_pm.s3m only
|
||||
|
||||
|
||||
Play Data: play data are series of tracker-like instructions, visualised as:
|
||||
@@ -2318,7 +2318,9 @@ Endianness: Little
|
||||
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
|
||||
Uint8 Song mixing volume
|
||||
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
|
||||
Byte[14] Reserved
|
||||
Uint32 Compressed size of PATTERN BIN for this song
|
||||
Uint32 Compressed size of CUE SHEET for this song
|
||||
Byte[6] Reserved
|
||||
|
||||
Taud device can queue up to 2 "playdata" in its buffer, which can be interpreted as a song.
|
||||
|
||||
|
||||
@@ -139,6 +139,12 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
return getFirstSnd()?.playheads?.get(playhead)?.initialGlobalFlags
|
||||
}
|
||||
|
||||
fun setSongGlobalVolume(playhead: Int, volume: Int) { getPlayhead(playhead)?.globalVolume = volume and 255 }
|
||||
fun getSongGlobalVolume(playhead: Int) = getPlayhead(playhead)?.globalVolume
|
||||
|
||||
fun setSongMixingVolume(playhead: Int, volume: Int) { getPlayhead(playhead)?.mixingVolume = volume and 255 }
|
||||
fun getSongMixingVolume(playhead: Int) = getPlayhead(playhead)?.mixingVolume
|
||||
|
||||
fun putPcmDataByPtr(playhead: Int, ptr: Int, length: Int, destOffset: Int) {
|
||||
getFirstSnd()?.let {
|
||||
val vkMult = if (ptr >= 0) 1 else -1
|
||||
|
||||
@@ -1454,7 +1454,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
|
||||
if (voice.forward) {
|
||||
voice.samplePos += voice.playbackRate
|
||||
when (inst.loopMode) {
|
||||
// When the sustain bit is set, key-off escapes the loop: the sample plays past
|
||||
// loopEnd until it ends naturally (loopMode 0 semantics).
|
||||
val effectiveLoopMode =
|
||||
if (inst.sampleLoopSustain && voice.keyOff) 0 else (inst.loopMode and 3)
|
||||
when (effectiveLoopMode) {
|
||||
0 -> if (voice.samplePos >= sampleLen) voice.active = false
|
||||
1 -> if (voice.samplePos >= loopEnd) voice.samplePos -= (loopEnd - loopStart).coerceAtLeast(1.0)
|
||||
2 -> if (voice.samplePos >= loopEnd) { voice.samplePos = loopEnd; voice.forward = false }
|
||||
@@ -1679,6 +1683,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val cue = cueSheet[ts.cuePos]
|
||||
// Reset row-scope state before scanning channels.
|
||||
if (!ts.patternDelayActive) ts.sexWinningChannel = -1
|
||||
ts.finePatternDelayExtra = 0
|
||||
|
||||
for (vi in 0..19) {
|
||||
val patNum = cue.patterns[vi]
|
||||
@@ -1699,6 +1704,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
voice.panbrelloActive = false
|
||||
voice.retrigActive = false
|
||||
voice.tempoSlideDir = 0
|
||||
voice.wSlideDir = 0
|
||||
voice.volColSlideUp = 0; voice.volColSlideDown = 0
|
||||
voice.panColSlideRight = 0; voice.panColSlideLeft = 0
|
||||
voice.rowEffect = row.effect
|
||||
@@ -1834,7 +1840,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val arg = resolveArg(rawArg, voice.mem.o).also { if (rawArg != 0) voice.mem.o = it }
|
||||
val inst = instruments[voice.instrumentId]
|
||||
var off = arg
|
||||
if (inst.loopMode != 0 && inst.sampleLoopEnd > inst.sampleLoopStart && off > inst.sampleLoopEnd) {
|
||||
if ((inst.loopMode and 3) != 0 && inst.sampleLoopEnd > inst.sampleLoopStart && off > inst.sampleLoopEnd) {
|
||||
val loopLen = (inst.sampleLoopEnd - inst.sampleLoopStart).coerceAtLeast(1)
|
||||
off = inst.sampleLoopStart + ((off - inst.sampleLoopStart) % loopLen)
|
||||
}
|
||||
@@ -1885,6 +1891,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val hi = (rawArg ushr 8) and 0xFF
|
||||
playhead.globalVolume = hi
|
||||
}
|
||||
EffectOp.OP_W -> {
|
||||
val arg = resolveArg(rawArg, voice.mem.w).also { if (rawArg != 0) voice.mem.w = it }
|
||||
val hi = (arg ushr 8) and 0xFF
|
||||
val lo = hi and 0x0F
|
||||
val hin = (hi ushr 4) and 0x0F
|
||||
when {
|
||||
hi == 0xFF -> playhead.globalVolume = (playhead.globalVolume + 0xF).coerceAtMost(0xFF) // WFF quirk: fine up by F
|
||||
hin == 0xF && lo != 0 -> playhead.globalVolume = (playhead.globalVolume - lo).coerceAtLeast(0) // fine down on tick 0
|
||||
lo == 0xF && hin != 0 -> playhead.globalVolume = (playhead.globalVolume + hin).coerceAtMost(0xFF) // fine up on tick 0
|
||||
hin == 0 && lo != 0 -> { voice.wSlideDir = -1; voice.wSlideAmount = lo } // coarse down per non-first tick
|
||||
lo == 0 && hin != 0 -> { voice.wSlideDir = +1; voice.wSlideAmount = hin } // coarse up per non-first tick
|
||||
}
|
||||
}
|
||||
EffectOp.OP_Y -> {
|
||||
val sp = (rawArg ushr 8) and 0xFF
|
||||
val dp = rawArg and 0xFF
|
||||
@@ -1908,6 +1927,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
0x3 -> { voice.vibratoWave = x and 3; voice.vibratoRetrig = (x and 4) == 0 }
|
||||
0x4 -> { voice.tremoloWave = x and 3; voice.tremoloRetrig = (x and 4) == 0 }
|
||||
0x5 -> { voice.panbrelloWave = x and 3; voice.panbrelloRetrig = (x and 4) == 0 }
|
||||
0x6 -> ts.finePatternDelayExtra += x // fine pattern delay: accumulate across channels
|
||||
0x7 -> when (x) {
|
||||
// Past-note actions on the channel's background ghosts.
|
||||
0x0 -> applyPastNoteAction(ts, vi, 0) // Past Note Cut
|
||||
@@ -1938,9 +1958,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
if (voice.loopCount == 0) {
|
||||
voice.loopCount = x
|
||||
ts.pendingRowJump = voice.loopStartRow
|
||||
ts.pendingRowJumpLocal = true
|
||||
} else if (!ts.patternDelayActive) {
|
||||
voice.loopCount--
|
||||
if (voice.loopCount > 0) ts.pendingRowJump = voice.loopStartRow
|
||||
if (voice.loopCount > 0) {
|
||||
ts.pendingRowJump = voice.loopStartRow
|
||||
ts.pendingRowJumpLocal = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2138,6 +2162,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
}
|
||||
}
|
||||
|
||||
// Global volume slide (W coarse) — applied once per non-first tick per armed channel.
|
||||
if (ts.tickInRow > 0) {
|
||||
for (voice in ts.voices) {
|
||||
if (voice.wSlideDir != 0) {
|
||||
playhead.globalVolume = (playhead.globalVolume + voice.wSlideDir * voice.wSlideAmount).coerceIn(0, 0xFF)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Funk repeat (S$Fxxxx) — advance bit-mask per tick on instruments with active funkSpeed.
|
||||
for (voice in ts.voices) {
|
||||
if (voice.funkSpeed == 0 || !voice.active) continue
|
||||
@@ -2211,6 +2244,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
playhead.position = ts.cuePos
|
||||
}
|
||||
|
||||
// Per TAUD_NOTE_EFFECTS.md §S$Bx00: on pattern change reset loop_start_row and loop_count.
|
||||
private fun resetPatternLoopState(ts: TrackerState) {
|
||||
for (voice in ts.voices) {
|
||||
voice.loopStartRow = 0
|
||||
voice.loopCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
internal fun generateTrackerAudio(playhead: Playhead): ByteArray? {
|
||||
val ts = playhead.trackerState ?: return null
|
||||
|
||||
@@ -2229,7 +2270,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
ts.samplesIntoTick -= spt
|
||||
applyTrackerTick(ts, playhead)
|
||||
ts.tickInRow++
|
||||
if (ts.tickInRow >= playhead.tickRate) {
|
||||
if (ts.tickInRow >= playhead.tickRate + ts.finePatternDelayExtra) {
|
||||
ts.tickInRow = 0
|
||||
advanceRow(ts, playhead)
|
||||
}
|
||||
@@ -2238,6 +2279,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
var mixL = 0.0
|
||||
var mixR = 0.0
|
||||
val gvol = playhead.globalVolume / 255.0
|
||||
val mvol = playhead.mixingVolume / 255.0
|
||||
for (voice in ts.voices) {
|
||||
if (!voice.active || voice.muted) continue
|
||||
val voiceInst = instruments[voice.instrumentId]
|
||||
@@ -2248,7 +2290,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// Volume envelope is bypassed (treated as unity) when S $77 has disabled it.
|
||||
val effEnvVol = if (voice.volEnvOn) voice.envVolume else 1.0
|
||||
val vol = effEnvVol * voice.fadeoutVolume * voice.rowVolume / 63.0 *
|
||||
swingScale * gvol * instGv * playhead.masterVolume / 255.0
|
||||
swingScale * gvol * mvol * instGv * playhead.masterVolume / 255.0
|
||||
val pan = if (voice.hasPanEnv && voice.panEnvOn) {
|
||||
val envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255)
|
||||
(voice.channelPan + envPanRaw - 128 + voice.randomPanBias).coerceIn(0, 255)
|
||||
@@ -2278,7 +2320,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val swingScale = 1.0 + bg.randomVolBias / 255.0
|
||||
val effEnvVol = if (bg.volEnvOn) bg.envVolume else 1.0
|
||||
val vol = effEnvVol * bg.fadeoutVolume * bg.rowVolume / 63.0 *
|
||||
swingScale * gvol * instGv * playhead.masterVolume / 255.0
|
||||
swingScale * gvol * mvol * instGv * playhead.masterVolume / 255.0
|
||||
val pan = if (bg.hasPanEnv && bg.panEnvOn) {
|
||||
val envPanRaw = (bg.envPan * 255.0).roundToInt().coerceIn(0, 255)
|
||||
(bg.channelPan + envPanRaw - 128 + bg.randomPanBias).coerceIn(0, 255)
|
||||
@@ -2325,25 +2367,34 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
|
||||
val pendingB = ts.pendingOrderJump
|
||||
val pendingC = ts.pendingRowJump
|
||||
val pendingLocal = ts.pendingRowJumpLocal
|
||||
ts.pendingOrderJump = -1
|
||||
ts.pendingRowJump = -1
|
||||
ts.pendingRowJumpLocal = false
|
||||
|
||||
when {
|
||||
pendingB >= 0 -> {
|
||||
ts.cuePos = pendingB.coerceAtMost(1023)
|
||||
ts.rowIndex = if (pendingC >= 0) pendingC else 0
|
||||
playhead.position = ts.cuePos
|
||||
resetPatternLoopState(ts)
|
||||
}
|
||||
pendingC >= 0 && pendingLocal -> {
|
||||
// S$Bx pattern loop — stay in the current cue, just rewind the row.
|
||||
ts.rowIndex = pendingC.coerceIn(0, 63)
|
||||
}
|
||||
pendingC >= 0 -> {
|
||||
// Pattern break — advance order by one (or honour cue's own instruction), then jump to row.
|
||||
// C$xx pattern break — advance order by one (or honour cue's own instruction), then jump to row.
|
||||
advanceTrackerCue(ts, playhead)
|
||||
ts.rowIndex = pendingC.coerceIn(0, 63)
|
||||
resetPatternLoopState(ts)
|
||||
}
|
||||
else -> {
|
||||
ts.rowIndex++
|
||||
if (ts.rowIndex >= 64) {
|
||||
ts.rowIndex = 0
|
||||
advanceTrackerCue(ts, playhead)
|
||||
resetPatternLoopState(ts)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2437,6 +2488,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
var o: Int = 0
|
||||
var q: Int = 0
|
||||
var tslide: Int = 0
|
||||
var w: Int = 0
|
||||
}
|
||||
|
||||
class Voice {
|
||||
@@ -2585,6 +2637,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
var tempoSlideDir = 0 // 0 = none, -1 = down, +1 = up
|
||||
var tempoSlideAmount = 0
|
||||
|
||||
// Global volume slide (W $xy00) — per-channel, applied to playhead.globalVolume on tick > 0.
|
||||
var wSlideDir = 0 // 0 = none, -1 = down, +1 = up
|
||||
var wSlideAmount = 0
|
||||
|
||||
// Volume / pan column slides (selectors 1/2/3 from TAUD_NOTE_EFFECTS.md §"Volume column effects").
|
||||
var volColSlideUp = 0
|
||||
var volColSlideDown = 0
|
||||
@@ -2610,6 +2666,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// Pending row-end events (set during a row by B/C; consumed at row end).
|
||||
var pendingOrderJump = -1 // -1 = none; otherwise the order index to jump to
|
||||
var pendingRowJump = -1 // -1 = none; otherwise the row index for the next pattern
|
||||
// Distinguishes S$Bx pattern-loop (stays in current cue) from C$xx pattern-break (advances cue).
|
||||
var pendingRowJumpLocal = false
|
||||
|
||||
// Pattern-delay state (S$Ex) — number of additional row-repetitions remaining.
|
||||
var patternDelayRemaining = 0
|
||||
@@ -2618,6 +2676,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// Channel index of the SEx that won this row (lowest channel wins ties).
|
||||
var sexWinningChannel = -1
|
||||
|
||||
// Fine pattern delay (S$6x) — extra ticks added to the current row; accumulated across all channels.
|
||||
var finePatternDelayExtra = 0
|
||||
|
||||
// Pre-allocated mix buffers for dither path (reused each audio chunk).
|
||||
val mixLeft = FloatArray(TRACKER_CHUNK)
|
||||
val mixRight = FloatArray(TRACKER_CHUNK)
|
||||
@@ -2643,6 +2704,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
var patBank1: Int = 0,
|
||||
var patBank2: Int = 0,
|
||||
var globalVolume: Int = 0x80, // 8-bit, default $80 (spec §5). Mutated by V $xx00.
|
||||
var mixingVolume: Int = 0x80, // 8-bit, default $80 (spec §5). Final-mix scaler, set once per song.
|
||||
|
||||
var pcmQueue: Queue<ByteArray> = Queue<ByteArray>(),
|
||||
var pcmQueueSizeIndex: Int = 0,
|
||||
@@ -2734,12 +2796,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
bpm = 125
|
||||
tickRate = 6
|
||||
globalVolume = 0x80
|
||||
mixingVolume = 0x80
|
||||
trackerState?.let { ts ->
|
||||
ts.cuePos = 0; ts.rowIndex = 0; ts.tickInRow = 0
|
||||
ts.samplesIntoTick = 0.0; ts.firstRow = true
|
||||
ts.pendingOrderJump = -1; ts.pendingRowJump = -1
|
||||
ts.pendingRowJumpLocal = false
|
||||
ts.patternDelayRemaining = 0; ts.patternDelayActive = false
|
||||
ts.sexWinningChannel = -1
|
||||
ts.finePatternDelayExtra = 0
|
||||
ts.panLaw = initialGlobalFlags and 1
|
||||
ts.amigaMode = (initialGlobalFlags and 2) != 0
|
||||
ts.voices.forEach {
|
||||
@@ -2866,7 +2931,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
var samplePlayStart: Int,
|
||||
var sampleLoopStart: Int,
|
||||
var sampleLoopEnd: Int,
|
||||
var loopMode: Int, // byte 14, low 2 bits
|
||||
var loopMode: Int, // byte 14, low 3 bits (bits 0-1: loop kind, bit 2: sustain)
|
||||
var volEnvSustain: Int, // bytes 15-16 (16-bit, see flag layout)
|
||||
var panEnvSustain: Int, // bytes 17-18
|
||||
var pfEnvSustain: Int, // bytes 19-20 (pitch/filter)
|
||||
@@ -2899,6 +2964,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
0, 0, 0, 0
|
||||
)
|
||||
|
||||
/** Sample-flag byte 14 bit 2 — when set, the sample loop is a sustain loop:
|
||||
* it loops while the note is held and is escaped on key-off. */
|
||||
val sampleLoopSustain: Boolean get() = (loopMode and 0x04) != 0
|
||||
/** New note action — instrumentFlag bits 0-1.
|
||||
* 0=note off, 1=note cut, 2=continue, 3=note fade. */
|
||||
val newNoteAction: Int get() = instrumentFlag and 0x03
|
||||
@@ -2961,7 +3029,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
12 -> sampleLoopEnd.toByte()
|
||||
13 -> sampleLoopEnd.ushr(8).toByte()
|
||||
|
||||
14 -> (loopMode and 3).toByte()
|
||||
14 -> (loopMode and 7).toByte()
|
||||
15 -> volEnvSustain.toByte()
|
||||
16 -> volEnvSustain.ushr(8).toByte()
|
||||
17 -> panEnvSustain.toByte()
|
||||
@@ -3016,7 +3084,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
12 -> { sampleLoopEnd = (sampleLoopEnd and 0xff00) or byte }
|
||||
13 -> { sampleLoopEnd = (sampleLoopEnd and 0x00ff) or (byte shl 8) }
|
||||
|
||||
14 -> { loopMode = byte and 3 }
|
||||
14 -> { loopMode = byte and 7 }
|
||||
15 -> { volEnvSustain = (volEnvSustain and 0xff00) or byte }
|
||||
16 -> { volEnvSustain = (volEnvSustain and 0x00ff) or (byte shl 8) }
|
||||
17 -> { panEnvSustain = (panEnvSustain and 0xff00) or byte }
|
||||
|
||||
Reference in New Issue
Block a user