mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 21:44:04 +09:00
322 lines
14 KiB
JavaScript
322 lines
14 KiB
JavaScript
/*
|
||
* LibTaud — Helper functions for interaction between Taud format and TSVM Tracker
|
||
* Requires TVDOS to function.
|
||
* @author CuriousTorvald
|
||
*/
|
||
|
||
// ── Format constants ────────────────────────────────────────────────────────
|
||
|
||
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) + projOff(4) + sig(14)
|
||
const TAUD_SONG_ENTRY = 32 // see encodeSongEntry / decodeSongEntry below
|
||
// Sample+instrument image: 8 MB sample pool (banked, 16 × 512 K) + 64 K instrument bin = 8256 kB total.
|
||
// (terranmon.txt:1985-1997, 2533-2564 — bank-switched via MMIO 46.)
|
||
const SAMPLE_BANK_SIZE = 524288 // 512 K — size of the sample-bin window
|
||
const SAMPLE_BANK_COUNT = 16 // 16 banks × 512 K = 8 MB
|
||
const SAMPLEBIN_SIZE = SAMPLE_BANK_SIZE * SAMPLE_BANK_COUNT // 8 MB
|
||
const INSTBIN_SIZE = 65536 // 256 inst × 256 bytes
|
||
const SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE // 8454144 = 8256 kB
|
||
const SAMPLEBIN_WINDOW_OFFSET = 0 // peripheral memory window for the active sample bank
|
||
const INSTBIN_WINDOW_OFFSET = 720896 // peripheral memory offset of instrument bin
|
||
const PATTERN_SIZE = 512 // bytes per pattern (64 rows × 8 bytes)
|
||
const NUM_PATTERNS_MAX = 256
|
||
const NUM_CUES = 1024
|
||
const CUE_SIZE = 32 // bytes per cue entry (packed 12-bit×20 voices + instruction + pad)
|
||
|
||
// Signature written into the file (14 bytes, space-padded)
|
||
const CAPTURE_SIGNATURE = "LibTaud/TSVM "
|
||
|
||
// ── Internal helpers ────────────────────────────────────────────────────────
|
||
|
||
function _peekU32LE(ptr, off) {
|
||
return ((sys.peek(ptr+off) & 0xFF) ) |
|
||
((sys.peek(ptr+off+1) & 0xFF) << 8 ) |
|
||
((sys.peek(ptr+off+2) & 0xFF) << 16 ) |
|
||
((sys.peek(ptr+off+3) & 0xFF) * 0x1000000) // avoid sign-extend
|
||
}
|
||
|
||
function _pokeU32LE(ptr, off, v) {
|
||
sys.poke(ptr+off, (v ) & 0xFF)
|
||
sys.poke(ptr+off+1, (v >>> 8) & 0xFF)
|
||
sys.poke(ptr+off+2, (v >>> 16) & 0xFF)
|
||
sys.poke(ptr+off+3, (v >>> 24) & 0xFF)
|
||
}
|
||
|
||
// ── uploadTaudFile ──────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Load one song from a Taud file into the tracker hardware and configure the
|
||
* given playhead ready to play.
|
||
*
|
||
* @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 playhead Playhead number (0-3) to configure
|
||
*/
|
||
function uploadTaudFile(inFile, songIndex, playhead) {
|
||
const drive = inFile[0].toUpperCase()
|
||
const diskPath = inFile.substring(2)
|
||
|
||
const memBase = audio.getMemAddr()
|
||
|
||
// -- 1. Read whole file into VM memory ------------------------------------
|
||
const fileHandle = files.open(inFile)
|
||
|
||
if (!fileHandle.exists) {
|
||
throw Error("taud: file not exists")
|
||
}
|
||
|
||
const fileSize = fileHandle.size
|
||
const filePtr = sys.malloc(fileSize)
|
||
fileHandle.pread(filePtr, fileSize, 0)
|
||
|
||
let pos = 0
|
||
|
||
// -- 2. Verify magic ------------------------------------------------------
|
||
for (let i = 0; i < 8; i++) {
|
||
let magicc = sys.peek(filePtr + i)
|
||
if (magicc !== TAUD_MAGIC[i]) {
|
||
sys.free(filePtr)
|
||
throw Error("taud: bad magic byte " + magicc.toString(16) + " at index " + i)
|
||
}
|
||
}
|
||
pos = 8
|
||
|
||
// -- 3. Parse header ------------------------------------------------------
|
||
// version(1) + numSongs(1) + compressedSize(4) + rsvd(2) + signature(16) = 24 bytes
|
||
let version = sys.peek(filePtr + pos) & 0xFF; pos++
|
||
let numSongs = sys.peek(filePtr + pos) & 0xFF; pos++
|
||
let compressedSize = _peekU32LE(filePtr, pos); pos += 4
|
||
pos += 18 // skip reserved(2) + signature(16)
|
||
// pos == 32 == TAUD_HEADER_SIZE
|
||
|
||
if (songIndex < 0 || songIndex >= numSongs) {
|
||
sys.free(filePtr)
|
||
throw Error("taud: songIndex " + songIndex + " out of range (numSongs=" + numSongs + ")")
|
||
}
|
||
|
||
// -- 4. Decompress and upload sample+instrument bin -----------------------
|
||
// The decompressed image is 8256 kB (8 MB samples bank-major + 64 K instruments)
|
||
// which exceeds the 8 MB user-space cap, so we route through a hardware helper
|
||
// that decompresses straight into the adapter's native sample/instrument
|
||
// storage instead of staging a buffer in user memory.
|
||
audio.uploadSampleInstBlob(filePtr + pos, compressedSize)
|
||
audio.setSampleBank(0)
|
||
pos += compressedSize
|
||
|
||
// -- 5. Parse song-table entry for the requested song --------------------
|
||
let entryOff = pos + songIndex * TAUD_SONG_ENTRY
|
||
let songOffset = _peekU32LE(filePtr, entryOff)
|
||
let numVoices = sys.peek(filePtr + entryOff + 4) & 0xFF
|
||
let numPatsLo = sys.peek(filePtr + entryOff + 5) & 0xFF
|
||
let numPatsHi = sys.peek(filePtr + entryOff + 6) & 0xFF
|
||
let bpmStored = sys.peek(filePtr + entryOff + 7) & 0xFF
|
||
let tickRate = sys.peek(filePtr + entryOff + 8) & 0xFF
|
||
let mixerflags = sys.peek(filePtr + entryOff + 15) & 0xFF
|
||
let 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. 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(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)
|
||
|
||
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(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()
|
||
sys.free(filePtr)
|
||
}
|
||
|
||
// ── captureTrackerDataToFile ────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Dump the current tracker hardware state (sample bin, instruments, patterns
|
||
* in bank 0, cue sheet) to a single-song Taud file. BPM and tick-rate are
|
||
* taken from playhead 0.
|
||
*
|
||
* @param outFile Full path with drive letter, e.g. "A:/music/out.taud"
|
||
*/
|
||
function captureTrackerDataToFile(outFile) {
|
||
const drive = outFile[0].toUpperCase()
|
||
const diskPath = outFile.substring(2)
|
||
|
||
const memBase = audio.getMemAddr()
|
||
const baseAddr = audio.getBaseAddr()
|
||
|
||
// -- 1. Compress sample+instrument bin ------------------------------------
|
||
// The 8256 kB raw image (8 MB samples + 64 K instruments) cannot fit in the
|
||
// 8 MB user space, so we hand the entire compress step to a hardware helper
|
||
// that reads directly out of the adapter's native sample/instrument storage.
|
||
// Realistic sample data compresses well under both gzip and zstd; we cap the
|
||
// destination at "uncompressed size + 8 K" headroom which suffices for any
|
||
// sane musical content.
|
||
const COMP_BUF_CAP = 1024 * 1024 * 4 // 4 MiB cap for compressed sample+inst blob
|
||
let compBuf = sys.malloc(COMP_BUF_CAP)
|
||
let compressedSize = audio.captureSampleInstBlob(compBuf, COMP_BUF_CAP)
|
||
if (compressedSize > COMP_BUF_CAP) {
|
||
sys.free(compBuf)
|
||
throw Error("taud: compressed sample+inst blob exceeded " + COMP_BUF_CAP + " bytes (got " + compressedSize + ")")
|
||
}
|
||
|
||
// -- 2. Find last non-empty pattern in bank 0 (all-zero = uninitialized) --
|
||
let numPatsActual = 0
|
||
outer: for (let p = NUM_PATTERNS_MAX - 1; p >= 0; p--) {
|
||
let patBase = 131072 + p * PATTERN_SIZE // offset within peripheral memory space
|
||
for (let k = 0; k < PATTERN_SIZE; k++) {
|
||
if ((sys.peek(memBase - (patBase + k)) & 0xFF) !== 0) {
|
||
numPatsActual = p + 1
|
||
break outer
|
||
}
|
||
}
|
||
}
|
||
if (numPatsActual === 0) numPatsActual = 1 // always emit at least one pattern slot
|
||
|
||
let numPats = numPatsActual // Uint16, 1-65535
|
||
let patsToSave = numPatsActual
|
||
|
||
// -- 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. 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
|
||
|
||
// -- 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 = [
|
||
// Magic (8)
|
||
0x1F, 0x54, 0x53, 0x56, 0x4D, 0x61, 0x75, 0x64,
|
||
// version, numSongs
|
||
TAUD_VERSION, 1,
|
||
// compressedSize uint32 LE (4) -- sample+inst bin
|
||
(compressedSize ) & 0xFF,
|
||
(compressedSize >>> 8) & 0xFF,
|
||
(compressedSize >>> 16) & 0xFF,
|
||
(compressedSize >>> 24) & 0xFF,
|
||
// project data offset (4) -- not emitted
|
||
0x00, 0x00, 0x00, 0x00,
|
||
].concat(sigBytes) // 8 + 2 + 4 + 4 + 14 = 32 bytes
|
||
|
||
// -- 8. Build song-table row (32 bytes) -----------------------------------
|
||
let songTable = [
|
||
(songOffset ) & 0xFF,
|
||
(songOffset >>> 8) & 0xFF,
|
||
(songOffset >>> 16) & 0xFF,
|
||
(songOffset >>> 24) & 0xFF,
|
||
20, // numVoices
|
||
numPats & 0xFF, (numPats >>> 8) & 0xFF, // numPatterns Uint16 LE
|
||
bpmStored, // BPM with −24 bias
|
||
tickRate, // initial tick-rate
|
||
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,
|
||
]
|
||
|
||
// -- 9. Write header (creates / truncates file) ---------------------------
|
||
const fileHandle = files.open(outFile)
|
||
fileHandle.bwrite(header)
|
||
|
||
// -- 10. Append compressed sample+inst bin --------------------------------
|
||
fileHandle.pwrite(compBuf, compressedSize, TAUD_HEADER_SIZE)
|
||
sys.free(compBuf)
|
||
|
||
// -- 11. Write song table -------------------------------------------------
|
||
fileHandle.bwrite(songTable)
|
||
|
||
// -- 12. Append compressed pattern bin ------------------------------------
|
||
fileHandle.pwrite(patCompBuf, patCompSize,
|
||
TAUD_HEADER_SIZE + compressedSize + songTable.length)
|
||
sys.free(patCompBuf)
|
||
|
||
// -- 13. Append compressed cue sheet --------------------------------------
|
||
fileHandle.pwrite(cueCompBuf, cueCompSize,
|
||
TAUD_HEADER_SIZE + compressedSize + songTable.length + patCompSize)
|
||
sys.free(cueCompBuf)
|
||
|
||
|
||
fileHandle.flush(); fileHandle.close()
|
||
}
|
||
|
||
exports = { uploadTaudFile, captureTrackerDataToFile }
|