tracker impl, s3m converter, larger tracker sample bin

This commit is contained in:
minjaesong
2026-04-19 02:52:12 +09:00
parent f02ad1de79
commit bef85f6e2f
12 changed files with 1656 additions and 221 deletions

View File

@@ -0,0 +1,17 @@
const taud = require("taud")
const fullFilePath = _G.shell.resolvePathInput(exec_args[1])
if (fullFilePath === undefined) {
println(`Usage: ${exec_args[0]} path_to.taud`)
return 1
}
const PLAYHEAD = 0
println("Playing "+fullFilePath.full)
taud.uploadTaudFile(fullFilePath.full, 0, PLAYHEAD)
audio.setMasterVolume(PLAYHEAD, 255)
audio.setMasterPan(PLAYHEAD, 128)
audio.setCuePosition(PLAYHEAD, 0)
audio.play(PLAYHEAD)

View File

@@ -0,0 +1,186 @@
// Tracker Mode — Bach's Prelude in C Major (BWV 846)
// Run from the TVDOS shell: js tracker_test.js
// Uploads ~92 patterns on startup (takes a moment).
// -- Note table (12-TET, 4096-TET encoding) ------------------------------------
// C3 = 0x4000; each semitone = 4096/12 ≈ 341.33 steps; each octave = 4096 steps.
// Sharp suffix: s (e.g. Cs3); flat aliases also provided (e.g. Db3 = Cs3).
// Special values: Note.OFF = key-off, Note.CUT = note cut, Note.NOP = no-op.
var Note = (function() {
var SEMITONE = 4096 / 12;
var C3 = 0x4000;
function n(oct, semi) { return Math.round(C3 + (oct - 3) * 4096 + semi * SEMITONE) & 0xFFFF; }
var t = {};
var names = ['C','Cs','D','Ds','E','F','Fs','G','Gs','A','As','B'];
var flats = ['C','Db','D','Eb','E','F','Gb','G','Ab','A','Bb','B'];
for (var oct = 0; oct <= 9; oct++) {
for (var s = 0; s < 12; s++) {
t[names[s] + oct] = n(oct, s);
if (flats[s] !== names[s]) t[flats[s] + oct] = n(oct, s);
}
}
t.OFF = 0x0000; // key-off
t.CUT = 0xFFFE; // note cut (immediate)
t.NOP = 0xFFFF; // no-op (empty row)
return t;
}());
var PLAYHEAD = 0;
// -- 1. Sample: triangle wave (256 samples @ C3) --------------------------------
var SAMPLE_LEN = 256;
var sampleBytes = new Array(SAMPLE_LEN);
for (var i = 0; i < SAMPLE_LEN; i++) {
var phase = (i / SAMPLE_LEN) * 2.0;
var val_ = phase < 1.0 ? phase : 2.0 - phase;
sampleBytes[i] = Math.round(val_ * 254) & 0xFF;
}
var memBase = audio.getMemAddr();
for (var i = 0; i < SAMPLE_LEN; i++) {
sys.poke(memBase - i, sampleBytes[i]);
}
// -- 2. Instrument 0 -----------------------------------------------------------
var instBytes = new Array(64).fill(0);
instBytes[2] = 0; instBytes[3] = 1; // sampleLength = 256
instBytes[4] = 0x00; instBytes[5] = 0x7D; // samplingRate = 32000
instBytes[10] = 0x00; instBytes[11] = 0x01; // sampleLoopEnd = 256 (whole sample)
instBytes[12] = 1; // loopMode = 1 (forward)
instBytes[16] = 255; instBytes[17] = 0; // envelope: vol=255, hold
audio.uploadInstrument(0, instBytes);
// -- 3. Piano-roll builder -----------------------------------------------------
// Source convention: C1=0, C2=12, C3=24, C4=36 (i.e. C3=24, octave every 12).
function midiToTsvm(n) {
var oct = Math.floor(n / 12) + 1;
return Math.round(0x3000 + oct * 4096 + (n % 12) * (4096 / 12)) & 0xFFFF;
}
var noteMap = {}; // absRow → TSVM note value
var rowCursor = 0;
function seq(notes, lens) {
for (var i = 0; i < notes.length; i++) {
noteMap[rowCursor] = midiToTsvm(notes[i]);
rowCursor += lens[i];
}
}
var TD = 3; // rows per note step (= source TICK_DIVISOR)
function prel(n1, n2, n3, n4, n5) {
seq([n1, n2, n3, n4, n5, n3, n4, n5, n1, n2, n3, n4, n5, n3, n4, n5],
[TD+2, TD, TD, TD-1, TD, TD, TD, TD, TD, TD, TD, TD-1, TD, TD, TD, TD]);
}
function end1(n1,n2,n3,n4,n5,n6,n7,n8,n9) {
seq([n1, n2, n3, n4, n5, n6, n5, n4, n5, n7, n8, n7, n8, n9, n8, n9],
[TD+2, TD, TD, TD-1, TD, TD, TD, TD, TD, TD, TD, TD-1, TD, TD, TD, TD]);
}
function end2(n1,n2,n3,n4,n5,n6,n7,n8,n9) {
seq([n1, n2, n3, n4, n5, n6, n5, n4, n5, n4, n3, n4, n7, n8, n9, n7],
[TD+2, TD+1, TD+1, TD+1, TD+1, TD+2, TD+2, TD+2,
TD+3, TD+3, TD+4, TD+4, TD+6, TD+8, TD+12, TD+24]);
}
function end3(ns) {
for (var i = 0; i < ns.length; i++) {
noteMap[rowCursor] = midiToTsvm(ns[i]);
rowCursor += 1;
}
for (var i = 0; i < TD*2; i++) {
noteMap[rowCursor] = Note.NOP
rowCursor += 1;
}
}
// -- 4. Build the piece --------------------------------------------------------
rowCursor = 16 * TD; // 160-row intro silence
prel(24,28,31,36,40);
prel(24,26,33,38,41);
prel(23,26,31,38,41);
prel(24,28,31,36,40);
prel(24,28,33,40,45);
prel(24,26,30,33,38);
prel(23,26,31,38,43);
prel(23,24,28,31,36);
prel(21,24,28,31,36);
prel(14,21,26,30,36);
prel(19,23,26,31,35);
prel(19,22,28,31,37);
prel(17,21,26,33,38);
prel(17,20,26,29,35);
prel(16,19,24,31,36);
prel(16,17,21,24,29);
prel(14,17,21,24,29);
prel( 7,14,19,23,29);
prel(12,16,19,24,28);
prel(12,19,22,24,28);
prel( 5,17,21,24,28);
prel( 6,12,21,24,27);
prel( 8,17,23,24,26);
prel( 7,17,19,23,26);
prel( 7,16,19,24,28);
prel( 7,14,19,24,29);
prel( 7,14,19,23,29);
prel( 7,15,21,24,30);
prel( 7,16,19,24,31);
prel( 7,14,19,24,29);
prel( 7,14,19,23,29);
prel( 0,12,19,22,28);
end1( 0,12,17,21,24,29,21,17,14);
end2( 0,11,31,35,38,41,26,29,28);
end3([0,12,28,31,36]);
noteMap[rowCursor] = Note.OFF; // key-off at start of final silence
rowCursor += 16 * TD - 5; // 155 more rows of silence
var totalRows = rowCursor; // 5836
var NUM_ROWS = 64;
var numPatterns = Math.ceil(totalRows / NUM_ROWS); // 92
// -- 5. Build and upload patterns ----------------------------------------------
for (var p = 0; p < numPatterns; p++) {
var patBytes = new Array(512).fill(0);
for (var r = 0; r < NUM_ROWS; r++) {
var absRow = p * NUM_ROWS + r;
var noteVal = (noteMap[absRow] !== undefined) ? noteMap[absRow] : Note.NOP;
var isOn = (noteVal !== Note.NOP && noteVal !== Note.OFF && noteVal !== Note.CUT);
var off = r * 8;
patBytes[off] = noteVal & 0xFF;
patBytes[off + 1] = (noteVal >> 8) & 0xFF;
patBytes[off + 2] = 0; // instrument 0
patBytes[off + 3] = 63; // volume
patBytes[off + 4] = 31; // pan (centre)
}
audio.uploadPattern(p, patBytes);
}
// -- 6. Cue sheet: one entry per pattern, last halts -------------------------
// Cue format: 32 bytes, 20 voices with 12-bit pattern numbers packed as:
// bytes 0-9: low nybbles (byte i = voice i*2 in hi-nybble, voice i*2+1 in lo-nybble)
// bytes 10-19: mid nybbles (same packing)
// bytes 20-29: high nybbles (same packing)
// byte 30: instruction (0=NOP, 1=Halt)
// Voice 0 plays pattern c; voices 1-19 are disabled (0xFFF).
for (var c = 0; c < numPatterns; c++) {
var cueBytes = new Array(32).fill(0xFF);
// voice 0 = c (12-bit), voice 1 = 0xFFF → byte0=(c&0xF)<<4|0xF
cueBytes[0] = ((c & 0xF) << 4) | 0xF; // lo nybbles v0,v1
cueBytes[10] = (((c >> 4) & 0xF) << 4) | 0xF; // mid nybbles v0,v1
cueBytes[20] = (((c >> 8) & 0xF) << 4) | 0xF; // hi nybbles v0,v1
cueBytes[30] = (c === numPatterns - 1) ? 0x01 : 0;
audio.uploadCue(c, cueBytes);
}
// -- 7. Playback ---------------------------------------------------------------
// BPM=500, tickRate=1: 1 row = 5 ms; 10 rows/step × 16 steps/bar ≈ 75 bars/min.
audio.setTrackerMode(PLAYHEAD);
audio.setBPM(PLAYHEAD, 250);
audio.setTickRate(PLAYHEAD, 6);
audio.setMasterVolume(PLAYHEAD, 255);
audio.setMasterPan(PLAYHEAD, 128);
audio.setCuePosition(PLAYHEAD, 0);
audio.play(PLAYHEAD);
println("Bach's Prelude in C Major -- " + numPatterns + " patterns loaded.");
println("Stop: audio.stop(" + PLAYHEAD + ")");

View File

@@ -225,8 +225,9 @@ class TVDOSFileDescriptor {
} }
/** reads the file bytewise and puts it to the specified memory address /** reads the file bytewise and puts it to the specified memory address
* @param count optional -- how many bytes to read * @param ptr -- where the bytes should be dumped
* @param offset optional -- how many bytes to skip initially * @param count -- how many bytes to read
* @param offset -- how many bytes to skip initially from the file
*/ */
pread(ptr, count, offset) { pread(ptr, count, offset) {
this.driver.pread(this, ptr, count, offset) this.driver.pread(this, ptr, count, offset)
@@ -241,7 +242,9 @@ class TVDOSFileDescriptor {
} }
/** writes the bytes stored in the memory[ptr .. ptr+count-1] to file[offset .. offset+count-1] /** writes the bytes stored in the memory[ptr .. ptr+count-1] to file[offset .. offset+count-1]
* - @param offset is optional * @param ptr -- where the bytes are
* @param count -- how many bytes to write
* @param offset -- position in the file
*/ */
pwrite(ptr, count, offset) { pwrite(ptr, count, offset) {
this.driver.pwrite(this, ptr, count, offset) this.driver.pwrite(this, ptr, count, offset)
@@ -1024,136 +1027,6 @@ _TVDOS.DRV.FS.NET.exists = (fd) => {
return (0 == com.getStatusCode(port[0])) return (0 == com.getStatusCode(port[0]))
} }
///////////////////////////////////////////////////////////////////////////////
// Legacy Serial filesystem, !!pending for removal!!
/*const filesystem = {};
filesystem._toPorts = (driveLetter) => {
if (driveLetter.toUpperCase === undefined) {
throw Error("'"+driveLetter+"' (type: "+typeof driveLetter+") is not a valid drive letter");
}
var port = _TVDOS.DRIVES[driveLetter.toUpperCase()];
if (port === undefined) {
throw Error("Drive letter '" + driveLetter.toUpperCase() + "' does not exist");
}
return port
};
filesystem._close = (portNo) => {
com.sendMessage(portNo, "CLOSE")
}
filesystem._flush = (portNo) => {
com.sendMessage(portNo, "FLUSH")
}
// @return disk status code (0 for successful operation)
// throws if:
// - java.lang.NullPointerException if path is null
// - Error if operation mode is not "R", "W" nor "A"
filesystem.open = (driveLetter, path, operationMode) => {
var port = filesystem._toPorts(driveLetter);
filesystem._flush(port[0]); filesystem._close(port[0]);
var mode = operationMode.toUpperCase();
if (mode != "R" && mode != "W" && mode != "A") {
throw Error("Unknown file opening mode: " + mode);
}
com.sendMessage(port[0], "OPEN"+mode+'"'+path+'",'+port[1]);
return com.getStatusCode(port[0]);
};
filesystem.getFileLen = (driveLetter) => {
var port = filesystem._toPorts(driveLetter);
com.sendMessage(port[0], "GETLEN");
var response = com.getStatusCode(port[0]);
if (135 == response) {
throw Error("File not opened");
}
if (response < 0 || response >= 128) {
throw Error("Reading a file failed with "+response);
}
return Number(com.pullMessage(port[0]));
};
// @return the entire contents of the file in String
filesystem.readAll = (driveLetter) => {
var port = filesystem._toPorts(driveLetter);
com.sendMessage(port[0], "READ");
var response = com.getStatusCode(port[0]);
if (135 == response) {
throw Error("File not opened");
}
if (response < 0 || response >= 128) {
throw Error("Reading a file failed with "+response);
}
return com.pullMessage(port[0]);
};
filesystem.readAllBytes = (driveLetter) => {
var str = filesystem.readAll(driveLetter);
var bytes = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) {
bytes[i] = str.charCodeAt(i);
}
return bytes;
};
filesystem.write = (driveLetter, string) => {
var port = filesystem._toPorts(driveLetter);
com.sendMessage(port[0], "WRITE"+string.length);
var response = com.getStatusCode(port[0]);
if (135 == response) {
throw Error("File not opened");
}
if (response < 0 || response >= 128) {
throw Error("Writing a file failed with "+response);
}
com.sendMessage(port[0], string);
filesystem._flush(port[0]); filesystem._close(port[0]);
};
filesystem.writeBytes = (driveLetter, bytes) => {
var string = btostr(bytes); // no spreading: has length limit
filesystem.write(driveLetter, string);
};
filesystem.isDirectory = (driveLetter) => {
var port = filesystem._toPorts(driveLetter);
com.sendMessage(port[0], "LISTFILES");
var response = com.getStatusCode(port[0]);
return (response === 0);
};
filesystem.mkDir = (driveLetter) => {
var port = filesystem._toPorts(driveLetter);
com.sendMessage(port[0], "MKDIR");
var response = com.getStatusCode(port[0]);
if (response < 0 || response >= 128) {
var status = com.getDeviceStatus(port[0]);
throw Error("Creating a directory failed with ("+response+"): "+status.message+"\n");
}
return (response === 0); // possible status codes: 0 (success), 1 (fail)
};
filesystem.touch = (driveLetter) => {
var port = filesystem._toPorts(driveLetter);
com.sendMessage(port[0], "TOUCH");
var response = com.getStatusCode(port[0]);
return (response === 0);
};
filesystem.mkFile = (driveLetter) => {
var port = filesystem._toPorts(driveLetter);
com.sendMessage(port[0], "MKFILE");
var response = com.getStatusCode(port[0]);
return (response === 0);
};
filesystem.remove = (driveLetter) => {
var port = filesystem._toPorts(driveLetter);
com.sendMessage(port[0], "DELETE");
var response = com.getStatusCode(port[0]);
return (response === 0);
};
Object.freeze(filesystem);*/
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
const files = {} const files = {}

View File

@@ -0,0 +1,259 @@
/*
* 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) + 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 SAMPLEINST_SIZE = 786432 // 770047 sample + 16384 instrument
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 (16 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 targetPlaydataSlot Playhead number (0-3) to configure
*/
function uploadTaudFile(inFile, songIndex, targetPlaydataSlot) {
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 -----------------------
let decompPtr = sys.malloc(SAMPLEINST_SIZE)
gzip.decompFromTo(filePtr + pos, compressedSize, decompPtr)
pos += compressedSize
// Write decompressed data to peripheral memory (backwards addressing:
// peripheral byte k lives at memBase - k).
for (let i = 0; i < SAMPLEINST_SIZE; i++) {
sys.poke(memBase - i, sys.peek(decompPtr + i))
}
sys.free(decompPtr)
// -- 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 bpm = bpmStored + 24
let patsToLoad = numPatsLo | (numPatsHi << 8)
// -- 6. Upload patterns ---------------------------------------------------
let songBase = filePtr + songOffset
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
audio.uploadPattern(p, patBytes)
}
// -- 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
audio.uploadCue(c, cueBytes)
}
// -- 8. Configure playhead ------------------------------------------------
audio.setTrackerMode(targetPlaydataSlot)
audio.setBPM(targetPlaydataSlot, bpm)
audio.setTickRate(targetPlaydataSlot, tickRate > 0 ? tickRate : 6)
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 ------------------------------------
// sys.memcpy(negative_src, positive_dst, len) copies peripheral byte k from
// (memBase - k) into (sampleInstBuf + k).
let sampleInstBuf = sys.malloc(SAMPLEINST_SIZE)
sys.memcpy(memBase, sampleInstBuf, SAMPLEINST_SIZE)
let compBuf = sys.malloc(SAMPLEINST_SIZE + 4096) // headroom for incompressible data
let compressedSize = gzip.compFromTo(sampleInstBuf, SAMPLEINST_SIZE, compBuf)
sys.free(sampleInstBuf)
// -- 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 from playhead 0 -----------------------------------
let bpm = audio.getBPM(0) || 125
let tickRate = audio.getTickRate(0) || 6
let bpmStored = (bpm - 24) & 0xFF
// -- 4. 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++)
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)
(compressedSize ) & 0xFF,
(compressedSize >>> 8) & 0xFF,
(compressedSize >>> 16) & 0xFF,
(compressedSize >>> 24) & 0xFF,
// reserved (2)
0x00, 0x00,
].concat(sigBytes) // 8 + 2 + 4 + 2 + 16 = 32 bytes
// -- 6. Build song-table row (16 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
0,0,0,0,0,0,0, // 7 bytes padding
]
// -- 7. Write header (creates / truncates file) ---------------------------
const fileHandle = files.open(outFile)
fileHandle.bwrite(header)
// -- 8. Append compressed sample+inst bin ---------------------------------
fileHandle.pwrite(compBuf, compressedSize, 32)
sys.free(compBuf)
// -- 9. 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)
// -- 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)
fileHandle.flush(); fileHandle.close()
}
exports = { uploadTaudFile, captureTrackerDataToFile }

664
s3m2taud.py Normal file
View File

@@ -0,0 +1,664 @@
#!/usr/bin/env python3
"""s3m2taud.py — Convert Scream Tracker 3 (.s3m) to TSVM Taud (.taud)
Usage:
python3 s3m2taud.py input.s3m output.taud [-v]
Limits:
- Up to 15 S3M channels (excess disabled; hard error if pattern count
× channel count > 256).
- Sample bin is 770048 bytes; if all samples together exceed this, every
sample is globally resampled down (with c2spd adjusted) so pitch is
preserved.
- AdLib instruments are skipped.
- Effects mapped: D (vol-slide), E/F (pitch slide, rough approx),
SC (note-cut), A (initial speed), T (initial BPM). Others dropped.
Pitch-slide approximation:
Amiga-period mode: taud_arg ≈ s3m_arg * 2 (mid-register heuristic)
Linear-slide mode: taud_arg = s3m_arg * 4 (exact)
"""
import argparse
import gzip
import math
import struct
import sys
VERBOSE = False
def vprint(*a, **kw):
if VERBOSE:
print(*a, **kw, file=sys.stderr)
# ── S3M constants ────────────────────────────────────────────────────────────
S3M_MAGIC = b"SCRM"
S3M_TYPE_PCM = 1
S3M_NOTE_EMPTY = 0xFF
S3M_NOTE_OFF = 0xFE
S3M_ORDER_SKIP = 0xFE
S3M_ORDER_END = 0xFF
# S3M effect letters (1-based: 1='A', 2='B', …)
EFF_A = 1 # set speed
EFF_B = 2 # jump to order
EFF_C = 3 # pattern break
EFF_D = 4 # volume slide
EFF_E = 5 # porta down
EFF_F = 6 # porta up
EFF_G = 7 # tone porta
EFF_H = 8 # vibrato
EFF_I = 9 # tremor
EFF_J = 10 # arpeggio
EFF_K = 11 # vibrato+volslide
EFF_L = 12 # porta+volslide
EFF_M = 13 # channel vol
EFF_N = 14 # chan vol slide
EFF_O = 15 # sample offset
EFF_P = 16 # pan slide
EFF_Q = 17 # retrigger
EFF_R = 18 # tremolo
EFF_S = 19 # special (sub-cmds)
EFF_T = 20 # set BPM
EFF_U = 21 # fine vibrato
EFF_V = 22 # global vol
EFF_W = 23 # global vol slide
EFF_X = 24 # set pan
EFF_Y = 25 # panbrello
EFF_Z = 26 # sync
# ── Taud constants ───────────────────────────────────────────────────────────
TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64])
TAUD_VERSION = 1
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+rsvd(2)+sig(16)
TAUD_SONG_ENTRY = 16 # offset(4)+voices(1)+pats_lo(1)+pats_hi(1)+bpm(1)+tick(1)+pad(7)
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 " # 16 bytes
# Taud note constants
NOTE_NOP = 0xFFFF
NOTE_KEYOFF = 0x0000
NOTE_CUT = 0xFFFE
TAUD_C3 = 0x4000
# ── S3M parser ───────────────────────────────────────────────────────────────
class S3MHeader:
__slots__ = ('title','order_count','inst_count','pat_count',
'flags','cwt_v','sample_type','global_vol',
'initial_speed','initial_tempo','master_vol',
'linear_slides','default_pan_flag',
'channel_settings','pan_values','order_list',
'inst_ptrs','pat_ptrs')
def parse_s3m(data: bytes) -> S3MHeader:
if len(data) < 0x60:
sys.exit("error: file too short to be S3M")
if data[0x2C:0x30] != S3M_MAGIC:
sys.exit("error: not an S3M file (bad magic at 0x2C)")
h = S3MHeader()
h.title = data[0x00:0x1C].rstrip(b'\x00').decode('latin-1', errors='replace')
h.order_count = struct.unpack_from('<H', data, 0x20)[0]
h.inst_count = struct.unpack_from('<H', data, 0x22)[0]
h.pat_count = struct.unpack_from('<H', data, 0x24)[0]
h.flags = struct.unpack_from('<H', data, 0x26)[0]
h.cwt_v = struct.unpack_from('<H', data, 0x28)[0]
h.sample_type = data[0x2B] # 1=signed, 2=unsigned
h.global_vol = data[0x30]
h.initial_speed = data[0x31] # ticks per row
h.initial_tempo = data[0x32] # BPM
h.master_vol = data[0x33]
h.linear_slides = bool(h.flags & 0x40) # flag bit 6 → linear freq slides
h.default_pan_flag = data[0x35] # 0xFC → use pan values
# Channel settings: bytes 0x40..0x5F; bit 7 = disabled
h.channel_settings = list(data[0x40:0x60])
# Order list
off = 0x60
h.order_list = list(data[off:off + h.order_count])
# Instrument parapointers (×16 = file offset)
off2 = off + h.order_count
h.inst_ptrs = [struct.unpack_from('<H', data, off2 + i*2)[0] * 16
for i in range(h.inst_count)]
# Pattern parapointers
off3 = off2 + h.inst_count * 2
h.pat_ptrs = [struct.unpack_from('<H', data, off3 + i*2)[0] * 16
for i in range(h.pat_count)]
# Default pan values (if present)
pan_off = off3 + h.pat_count * 2
h.pan_values = []
if h.default_pan_flag == 0xFC and pan_off + h.inst_count <= len(data):
for i in range(h.inst_count):
h.pan_values.append(data[pan_off + i])
# per-channel pan is in channel settings nibbles (separate from above)
return h
class S3MInstrument:
__slots__ = ('itype','filename','memseg','length','loop_begin','loop_end',
'volume','flags','c2spd','name','sample_data','signed')
def parse_instruments(data: bytes, h: S3MHeader) -> list:
insts = []
for ptr in h.inst_ptrs:
if ptr + 0x50 > len(data):
vprint(f" warning: instrument pointer {ptr:#x} out of range, skipping")
insts.append(None)
continue
inst = S3MInstrument()
inst.itype = data[ptr]
inst.filename = data[ptr+1:ptr+13].rstrip(b'\x00').decode('latin-1', errors='replace')
# memseg: 3 bytes at offsets 0x0D,0x0E,0x0F — high byte first (quirk)
memseg_hi = data[ptr + 0x0D]
memseg_lo = struct.unpack_from('<H', data, ptr + 0x0E)[0]
inst.memseg = (memseg_hi << 16) | memseg_lo
inst.length = struct.unpack_from('<I', data, ptr + 0x10)[0]
inst.loop_begin = struct.unpack_from('<I', data, ptr + 0x14)[0]
inst.loop_end = struct.unpack_from('<I', data, ptr + 0x18)[0]
inst.volume = data[ptr + 0x1C]
inst.flags = data[ptr + 0x1F] # bit0=loop, bit1=stereo, bit2=16bit
inst.c2spd = struct.unpack_from('<I', data, ptr + 0x20)[0] or 8363
inst.name = data[ptr + 0x30:ptr + 0x4C].rstrip(b'\x00').decode('latin-1', errors='replace')
inst.signed = (h.sample_type == 1)
inst.sample_data = b''
if inst.itype == S3M_TYPE_PCM:
sample_off = inst.memseg * 16
sample_len = inst.length
is_16bit = bool(inst.flags & 4)
is_stereo = bool(inst.flags & 2)
if sample_off + sample_len > len(data):
vprint(f" warning: sample '{inst.name}' data out of range, zeroing")
inst.sample_data = bytes(min(sample_len, 256))
else:
raw = data[sample_off:sample_off + sample_len]
inst.sample_data = _normalise_sample(raw, inst.signed, is_16bit, is_stereo, inst.name)
inst.length = len(inst.sample_data)
inst.loop_begin = min(inst.loop_begin, inst.length)
inst.loop_end = min(inst.loop_end, inst.length)
insts.append(inst)
return insts
def _normalise_sample(raw: bytes, signed: bool, is_16bit: bool, is_stereo: bool, name: str) -> bytes:
"""Return unsigned 8-bit mono sample bytes."""
out = []
stride = (2 if is_16bit else 1) * (2 if is_stereo else 1)
i = 0
while i + stride <= len(raw):
if is_16bit:
if is_stereo:
l16 = struct.unpack_from('<h', raw, i)[0]
r16 = struct.unpack_from('<h', raw, i+2)[0]
s = (l16 + r16) >> 1
else:
s = struct.unpack_from('<h', raw, i)[0]
v = (s >> 8) + 128
else:
if is_stereo:
l8 = raw[i]; r8 = raw[i+1]
raw_s = (l8 + r8) // 2
else:
raw_s = raw[i]
if signed:
v = ((raw_s ^ 0x80) & 0xFF) # signed→unsigned
else:
v = raw_s
out.append(v & 0xFF)
i += stride
if is_16bit or is_stereo:
vprint(f" info: '{name}' converted to unsigned 8-bit mono ({len(out)} samples)")
return bytes(out)
# ── S3M pattern parser ───────────────────────────────────────────────────────
class S3MRow:
"""One cell in a pattern grid."""
__slots__ = ('note','inst','vol','effect','effect_arg')
def __init__(self):
self.note = S3M_NOTE_EMPTY # 0xFF=empty, 0xFE=off, else (oct<<4|pitch)
self.inst = 0
self.vol = -1 # -1 = not set (use instrument default)
self.effect = 0 # 1-based letter index (0=none)
self.effect_arg = 0
def parse_patterns(data: bytes, h: S3MHeader) -> list:
"""Returns list[pat_idx] of list[channel][row] → S3MRow."""
patterns = []
for pi, ptr in enumerate(h.pat_ptrs):
# 32 channels × 64 rows
grid = [[S3MRow() for _ in range(PATTERN_ROWS)] for _ in range(32)]
if ptr == 0 or ptr + 2 > len(data):
patterns.append(grid)
continue
pat_len = struct.unpack_from('<H', data, ptr)[0]
end = min(ptr + 2 + pat_len, len(data))
pos = ptr + 2
row = 0
while row < PATTERN_ROWS and pos < end:
b = data[pos]; pos += 1
if b == 0:
row += 1
continue
ch = b & 0x1F
has_n = bool(b & 0x20)
has_v = bool(b & 0x40)
has_e = bool(b & 0x80)
cell = grid[ch][row] if ch < 32 else S3MRow()
if has_n:
if pos + 1 >= end: break
cell.note = data[pos]; pos += 1
cell.inst = data[pos]; pos += 1
if has_v:
if pos >= end: break
cell.vol = data[pos]; pos += 1
if has_e:
if pos + 1 >= end: break
cell.effect = data[pos]; pos += 1
cell.effect_arg = data[pos]; pos += 1
patterns.append(grid)
return patterns
# ── Note / effect encoding ───────────────────────────────────────────────────
def encode_note(s3m_note: int) -> int:
if s3m_note == S3M_NOTE_EMPTY:
return NOTE_NOP
if s3m_note == S3M_NOTE_OFF:
return NOTE_KEYOFF
octave = (s3m_note >> 4) & 0xF
pitch = s3m_note & 0xF
if pitch > 11:
return NOTE_NOP
semitones = (octave - 4) * 12 + pitch
val = round(TAUD_C3 + semitones * 4096 / 12)
return max(1, min(0xFFFD, val))
def encode_effect(cmd: int, arg: int, linear: bool) -> tuple:
"""Return (taud_op, taud_arg16) or (0, 0) for no-op."""
if cmd == EFF_D:
# Volume slide: same nibble layout
return (0x0A, arg & 0xFF)
if cmd == EFF_E:
# Porta down
if linear:
targ = min(arg * 4, 0xFFFF)
else:
targ = min(arg * 2, 0xFFFF)
return (0x02, targ)
if cmd == EFF_F:
# Porta up
if linear:
targ = min(arg * 4, 0xFFFF)
else:
targ = min(arg * 2, 0xFFFF)
return (0x01, targ)
if cmd == EFF_S:
sub = (arg >> 4) & 0xF
val = arg & 0xF
if sub == 0xC: # SC - note cut
return (0xEC, val)
return (0x00, 0x0000)
# ── Taud builders ────────────────────────────────────────────────────────────
def _resample_linear(data: bytes, ratio: float) -> bytes:
"""Resample bytes by ratio (< 1 = downsample) using linear interpolation."""
if not data:
return data
n_out = max(1, int(len(data) * ratio))
out = bytearray(n_out)
for i in range(n_out):
src = i / ratio
i0 = int(src)
frac = src - i0
i1 = min(i0 + 1, len(data) - 1)
v = data[i0] * (1.0 - frac) + data[i1] * frac
out[i] = int(v + 0.5) & 0xFF
return bytes(out)
def build_sample_inst_bin(instruments: list) -> tuple:
"""
Returns (bin_bytes[786432], offsets_list, updated_insts).
Resamples globally if total exceeds SAMPLEBIN_SIZE.
"""
pcm_insts = [(i, inst) for i, inst in enumerate(instruments)
if inst is not None and inst.itype == S3M_TYPE_PCM and inst.sample_data]
total = sum(len(inst.sample_data) for _, inst in pcm_insts)
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 _, inst in pcm_insts:
new_data = _resample_linear(inst.sample_data, ratio)
old_len = len(inst.sample_data)
inst.sample_data = new_data
inst.length = len(new_data)
inst.loop_begin = max(0, int(inst.loop_begin * ratio))
inst.loop_end = max(0, min(int(inst.loop_end * ratio), inst.length))
inst.c2spd = max(1, int(inst.c2spd * ratio))
sample_bin = bytearray(SAMPLEBIN_SIZE)
offsets = {}
pos = 0
for idx, inst in pcm_insts:
n = min(len(inst.sample_data), SAMPLEBIN_SIZE - pos)
if n <= 0:
vprint(f" warning: sample bin full, dropping '{inst.name}'")
offsets[idx] = 0
inst.length = 0
continue
sample_bin[pos:pos+n] = inst.sample_data[:n]
offsets[idx] = pos
if n < len(inst.sample_data):
vprint(f" warning: '{inst.name}' truncated from {len(inst.sample_data)} to {n}")
inst.length = n
inst.loop_end = min(inst.loop_end, n)
pos += n
# Build instrument bin (256 × 64 bytes)
inst_bin = bytearray(INSTBIN_SIZE)
for i, inst in enumerate(instruments):
if i >= 256:
break
if inst is None or inst.itype != S3M_TYPE_PCM:
continue
ptr = offsets.get(i, 0)
ptr_lo = ptr & 0xFFFF
ptr_hi = (ptr >> 16)
s_len = min(inst.length, 65535)
c2spd = min(inst.c2spd, 65535)
ps = 0
ls = min(inst.loop_begin, 65535)
le = min(inst.loop_end, 65535)
loop_mode = 1 if (inst.flags & 1) else 0
flags_byte = (ptr_hi << 4) | (loop_mode & 0x3) # hhhh 00pp
base = i * 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
# Volume envelope: hold at instrument volume (vol*4 clamped to 255)
env_vol = min(inst.volume * 4, 255)
inst_bin[base + 16] = env_vol # volume
inst_bin[base + 17] = 0 # offset minifloat = 0 → hold
vprint(f" instrument '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'")
if inst.c2spd > 65535:
vprint(f" warning: sampling rate of '{inst.name}' exceeds 65535 (got '{inst.c2spd}')")
return bytes(sample_bin) + bytes(inst_bin), offsets
def _default_channel_pan(ch_setting: int) -> int:
"""Return Taud pan 0..63 from S3M channel-setting byte."""
# Bits 4-7 of channel setting are ignored; left/right from bit 3
# Actually the channel type (0-7 left, 8-15 right) encodes stereo side
ch_type = ch_setting & 0x7F
if 0 <= ch_type <= 7:
return 16 # left
elif 8 <= ch_type <= 15:
return 47 # right
return 31 # centre
def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int,
linear_slides: bool) -> bytes:
"""Build a 512-byte Taud pattern for one S3M channel."""
out = bytearray(PATTERN_BYTES)
rows = s3m_grid[ch_idx] if ch_idx < len(s3m_grid) else [S3MRow()] * PATTERN_ROWS
for r, row in enumerate(rows[:PATTERN_ROWS]):
note = encode_note(row.note)
inst = max(0, row.inst - 1) # S3M 1-based → Taud 0-based
vol = min(row.vol, 63) if row.vol >= 0 else 63
pan = default_pan
op, arg = encode_effect(row.effect, row.effect_arg, linear_slides)
if row.effect != 0 and op == 0:
eff_name = chr(ord('A') + row.effect - 1) if 1 <= row.effect <= 26 else '?'
vprint(f" dropped effect {eff_name}{row.effect_arg:02X} at ch{ch_idx} row{r}")
base = r * 8
struct.pack_into('<H', out, base + 0, note)
out[base + 2] = inst & 0xFF
out[base + 3] = vol & 0x3F
out[base + 4] = pan & 0x3F
out[base + 5] = op & 0xFF
struct.pack_into('<H', out, base + 6, arg & 0xFFFF)
return bytes(out)
def deduplicate_patterns(pat_bin: bytes, num_pats: int) -> tuple:
"""
Consolidate identical 512-byte Taud patterns into a single copy.
Returns (deduped_bin, remap, num_unique) where remap[original_idx] = canonical_idx.
"""
seen = {} # pattern_bytes -> canonical_index
remap = {} # original_index -> canonical_index
canonical = []
for i in range(num_pats):
pat = pat_bin[i * PATTERN_BYTES : (i + 1) * PATTERN_BYTES]
if pat in seen:
remap[i] = seen[pat]
else:
ci = len(canonical)
seen[pat] = ci
remap[i] = ci
canonical.append(pat)
return b''.join(canonical), remap, len(canonical)
def _encode_cue(patterns12: list, instruction: int) -> bytearray:
"""Encode a 32-byte cue entry for up to 20 voices with 12-bit pattern numbers."""
# patterns12: list of up to NUM_VOICES 12-bit values (0xFFF = disabled)
pats = list(patterns12) + [0xFFF] * NUM_VOICES
pats = pats[:NUM_VOICES]
entry = bytearray(CUE_SIZE)
for i in range(10): # 10 bytes: 2 voices per byte
v0, v1 = pats[i*2], pats[i*2+1]
entry[i] = ((v0 & 0xF) << 4) | (v1 & 0xF) # low nybbles
entry[10 + i] = (((v0 >> 4) & 0xF) << 4) | ((v1 >> 4) & 0xF) # mid nybbles
entry[20 + i] = (((v0 >> 8) & 0xF) << 4) | ((v1 >> 8) & 0xF) # high nybbles
entry[30] = instruction & 0xFF
return entry
def build_cue_sheet(order_list: list, num_pats_s3m: int, num_channels: int,
pat_remap: dict = None) -> bytes:
"""Build the 1024×32-byte cue sheet with 12-bit packed pattern numbers."""
sheet = bytearray(NUM_CUES * CUE_SIZE)
# Fill entire sheet with the "all disabled" cue (patterns=0xFFF, instr=0)
for c in range(NUM_CUES):
sheet[c*CUE_SIZE : c*CUE_SIZE+CUE_SIZE] = _encode_cue([], 0)
cue_idx = 0
for order in order_list:
if order == S3M_ORDER_END or cue_idx >= NUM_CUES:
break
if order == S3M_ORDER_SKIP:
cue_idx += 1
continue
orig = [order * num_channels + v for v in range(num_channels)]
pats = [pat_remap[p] if pat_remap else p for p in orig]
sheet[cue_idx*CUE_SIZE : cue_idx*CUE_SIZE+CUE_SIZE] = _encode_cue(pats, 0)
cue_idx += 1
# Halt at end
if cue_idx < NUM_CUES:
sheet[cue_idx*CUE_SIZE : cue_idx*CUE_SIZE+CUE_SIZE] = _encode_cue([], 0x01)
return bytes(sheet)
def find_initial_bpm_speed(patterns: list, order_list: list,
default_speed: int, default_tempo: int) -> tuple:
"""Scan first pattern in order for Axx/Txx in row 0 of any channel."""
speed = default_speed or 6
tempo = default_tempo or 125
for order in order_list:
if order >= S3M_ORDER_END:
break
if order >= len(patterns):
continue
grid = patterns[order]
for ch_rows in grid:
row = ch_rows[0]
if row.effect == EFF_A and row.effect_arg > 0:
speed = row.effect_arg
if row.effect == EFF_T and row.effect_arg > 0:
tempo = row.effect_arg
break
return speed, tempo
def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
# Determine active channels (bit7 clear = enabled)
active_channels = [i for i, cs in enumerate(h.channel_settings)
if i < 32 and not (cs & 0x80)][:NUM_VOICES]
C = len(active_channels)
P = len(patterns)
if P * C > NUM_PATTERNS_MAX:
sys.exit(
f"error: {P} S3M patterns × {C} channels = {P*C} > {NUM_PATTERNS_MAX} Taud pattern limit.\n"
f" Reduce the S3M to ≤ {NUM_PATTERNS_MAX // max(C,1)} patterns, or mute "
f"channels to bring active count below {NUM_PATTERNS_MAX // max(P,1) + 1}."
)
vprint(f" channels: {C}, s3m patterns: {P}, taud patterns: {P*C}")
# Build sample+instrument bin
vprint(" building sample/instrument bin…")
sampleinst_raw, _offsets = build_sample_inst_bin(instruments)
assert len(sampleinst_raw) == SAMPLEINST_SIZE
# Compress
compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0)
comp_size = len(compressed)
vprint(f" sample+inst bin: {SAMPLEINST_SIZE}{comp_size} bytes (gzip)")
# Initial BPM / speed
speed, tempo = find_initial_bpm_speed(patterns, h.order_list,
h.initial_speed, h.initial_tempo)
tempo = max(24, min(280, tempo))
bpm_stored = (tempo - 24) & 0xFF
vprint(f" initial speed={speed}, tempo(BPM)={tempo}")
# Song offset = header(32) + compressed + song_table(8)
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
num_taud_pats = P * C
# Header (32 bytes): magic(8)+ver(1)+numSongs(1)+compSize(4)+rsvd(2)+sig(16)
sig = (SIGNATURE + b' ' * 16)[:16]
header = (
TAUD_MAGIC +
bytes([TAUD_VERSION, 1]) +
struct.pack('<I', comp_size) +
b'\x00\x00' +
sig
)
assert len(header) == TAUD_HEADER_SIZE
# Pattern bin: for each s3m pattern, for each active channel, 512 bytes
vprint(" building pattern bin…")
default_pans = [_default_channel_pan(h.channel_settings[ch]) for ch in active_channels]
pat_bin = bytearray()
for pi in range(P):
grid = patterns[pi]
for vi, ch in enumerate(active_channels):
pat_bin += build_pattern(grid, ch, default_pans[vi], h.linear_slides)
assert len(pat_bin) == num_taud_pats * PATTERN_BYTES
# Deduplicate identical patterns
vprint(" deduplicating patterns…")
orig_count = num_taud_pats
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)+patsLo(1)+patsHi(1)+bpm(1)+tick(1)+pad(7)
# Built after dedup so num_taud_pats reflects the unique count.
num_taud_pats_lo = num_taud_pats & 0xFF
num_taud_pats_hi = (num_taud_pats >> 8) & 0xFF
song_table = struct.pack('<IBBBBB',
song_offset,
C,
num_taud_pats_lo,
num_taud_pats_hi,
bpm_stored,
speed,
) + b'\x00' * 7
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
# ── Main ─────────────────────────────────────────────────────────────────────
def main():
global VERBOSE
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('input', help='Input .s3m 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()
VERBOSE = args.verbose
with open(args.input, 'rb') as f:
data = f.read()
vprint(f"parsing '{args.input}' ({len(data)} bytes)…")
h = parse_s3m(data)
vprint(f" title: '{h.title}'")
vprint(f" orders={h.order_count}, instruments={h.inst_count}, patterns={h.pat_count}")
instruments = parse_instruments(data, h)
patterns = parse_patterns(data, h)
taud = assemble_taud(h, instruments, patterns)
with open(args.output, 'wb') as f:
f.write(taud)
print(f"wrote {len(taud)} bytes to '{args.output}'")
if VERBOSE:
print(f" magic ok: {taud[:8].hex()}", file=sys.stderr)
if __name__ == '__main__':
main()

View File

@@ -1985,25 +1985,25 @@ Synchronisation between playheads are not guaranteed. Do not play music in multi
Memory Space Memory Space
0..114687 RW: Sample bin 0..770047 RW: Sample bin (752k)
114688..131071 RW: Instrument bin (256 instruments, 64 bytes each) 770048..786431 RW: Instrument bin (256 instruments, 64 bytes each; 16k)
131072..196607 RW: Play data 1 786432..851967 RW: Play data 1 (currently exposed bank; 64k)
196608..262143 RW: Play data 2 851968..917503 RW: Play data 2 (currently exposed bank; 64k)
262144..327679 RW: TAD Input Buffer 917504..983039 RW: TAD Input Buffer (64k)
327680..393215 RW: TAD Decode Output 983040..1048575 RW: TAD Decode Output (64k)
Sample bin: just raw sample data thrown in there. You need to keep track of starting point for each sample Sample bin: just raw sample data thrown in there. You need to keep track of starting point for each sample
Instrument bin: Registry for 256 instruments, formatted as: Instrument bin: Registry for 256 instruments, formatted as:
Uint16 Sample Pointer Uint16 Sample Pointer
Uint16 Sample length Uint16 Sample length
Uint16 Sampling rate at C3 Uint16 Sampling rate at C3 (note number 0x4000)
Uint16 Play Start (usually 0 but not always) Uint16 Play Start (usually 0 but not always)
Uint16 Loop Start (can be smaller than Play Start) Uint16 Loop Start (can be smaller than Play Start)
Uint16 Loop End Uint16 Loop End
Bit32 Flags Bit32 Flags
0b hhhh 00pp 0b hhhh 00pp
h: sample pointer high bit (only the lowest bit is used, keep upper 3 bits unset) 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)
Bit16x24 Volume envelopes Bit16x24 Volume envelopes
Byte 1: Volume Byte 1: Volume
@@ -2012,7 +2012,7 @@ Instrument bin: Registry for 256 instruments, formatted as:
Play Data: play data are series of tracker-like instructions, visualised as: Play Data: play data are series of tracker-like instructions, visualised as:
rr||NOTE|Ins|E.Vol|E.Pan|EE.ff| rr||NOTE|Ins|E.Vol|E.Pan|EE.ff|
63||FFFF|255|3 63|3 63|FF FFFF| (8 bytes per line, 512 bytes per pattern, 256 patterns on 128 kB block) 63||FFFF|255|3 63|3 63|FF FFFF| (8 bytes per line, 512 bytes per pattern, 128 patterns on 64 kB bank, 32 banks available (pattern 0xFFF -- bank 31, pattern 127 is a sentinel value for no-pattern))
notes are tuned as 4096 Tone-Equal Temperament. Tuning is set per-sample using their Sampling rate value. notes are tuned as 4096 Tone-Equal Temperament. Tuning is set per-sample using their Sampling rate value.
@@ -2025,13 +2025,19 @@ note 0x0000: key-off
Sound Adapter MMIO Sound Adapter MMIO
0..1 RW: Play head #0 position (how many samples has been queued) 0..1 RW: Play head #0 position
PCM mode: number of buffers uploaded and received by the adapter (writing does nothing)
Tracker mode: current position in the cuesheet (writing changes current position in the cuesheet and resets pattern cursor back to zero)
2..3 RW: Play head #0 length param 2..3 RW: Play head #0 length param
PCM mode: length of the samples to upload to the speaker
Tracker mode:
Byte 2: Play data 1 bank
Byte 3: Play data 2 bank
4 RW: Play head #0 master volume 4 RW: Play head #0 master volume
5 RW: Play head #0 master pan 5 RW: Play head #0 master pan
6..9 RW: Play head #0 flags 6..9 RW: Play head #0 flags (see below)
10..11 RW:Play head #1 position (how many samples has been queued) 10..11 RW:Play head #1 position
12..13 RW:Play head #1 length param 12..13 RW:Play head #1 length param
14 RW: Play head #1 master volume 14 RW: Play head #1 master volume
15 RW: Play head #1 master pan 15 RW: Play head #1 master pan
@@ -2043,17 +2049,16 @@ Sound Adapter MMIO
Write 16 to initialise the MP2 context (call this before the decoding of NEW music) Write 16 to initialise the MP2 context (call this before the decoding of NEW music)
Write 1 to decode the frame as MP2 Write 1 to decode the frame as MP2
When called with byte 17, initialisation will precede before the decoding Calling with more than one bit set will result in UNDEFINED BEHAVIOUR
41 RO: MP2 Decoder Status 41 RO: MP2 Decoder Status
Non-zero value indicates the decoder is busy Non-zero value indicates the decoder is busy. Different value may indicate different decoder status.
42 WO: TAD Decoder Control 42 WO: TAD Decoder Control
Write 1 to decode TAD data Write 1 to decode TAD data
43 RW: TAD Quality 43 RW: TAD Quality
Must be set to appropriate value before decoding Must be set to appropriate value before decoding
44 RW: TAD Decoder Status 44 RW: TAD Decoder Status
Non-zero value indicates the decoder is busy. Different value may indicate different decoder status.
45 RW: Select PCM Bin for playhead (writing causes side effects) 45 RW: Select PCM Bin for playhead (writing causes side effects)
64..2367 RW: MP2 Decoded Samples (unsigned 8-bit stereo) 64..2367 RW: MP2 Decoded Samples (unsigned 8-bit stereo)
@@ -2065,14 +2070,6 @@ Sound Hardware Info
- Bit depth: 8 bits/sample, unsigned - Bit depth: 8 bits/sample, unsigned
- Always operate in stereo (mono samples must be expanded to stereo before uploading) - Always operate in stereo (mono samples must be expanded to stereo before uploading)
Play Head Position
- Tracker mode: Cuesheet Counter
- PCM mode: Number of buffers uploaded and received by the adapter
Length Param
PCM Mode: length of the samples to upload to the speaker
Tracker mode: unused
Play Head Flags Play Head Flags
Byte 1 Byte 1
- 0b mrqp ssss - 0b mrqp ssss
@@ -2117,18 +2114,20 @@ Play Head Flags
If the queue is full, any more uploads will be silently discarded. If the queue is full, any more uploads will be silently discarded.
32768..65535 RW: Cue Sheet (2048 cues) 32768..65535 RW: Cue Sheet (1024 cues)
Byte 1..15: pattern number for voice 1..15 Byte 1..10: Pattern number low nybble for voice 1..20
Byte 16: instruction Byte 11..20: Pattern number middle nybble for voice 1..20
1 xxxxxxx - Go back (128, 1-127) patterns to form a loop Byte 21..30: Pattern number high nybble for voice 1..20
01 xxxxxx - To recap:
001 xxxxx - Byte 1..10: 0b loV1 loV2, 0b loV3 loV4, 0b loV5 loV6, ... 0b loV19 loV20
0001 xxxx - Skip (16, 1-15) patterns Byte 11..20: 0b miV1 miV2, 0b miV3 miV4, 0b miV5 miV6, ... 0b miV19 miV20
00001 xxx - Byte 21..30: 0b hiV1 hiV2, 0b hiV3 hiV4, 0b hiV5 hiV6, ... 0b hiV19 hiV20
000001 xx - Byte 31..32: instruction
0000001 x - 1000xxxx yyyyyyyy - Go back 0bxxxxyyyyyyyy patterns
0000000 1 - 1001xxxx yyyyyyyy - Skip forward 0bxxxxyyyyyyyy patterns
0000000 0 - No operation 1111xxxx yyyyyyyy - Go to absolute pattern number 0bxxxxyyyyyyyy
00000001 - Halt
00000000 - No operation
65536..131071 RW: PCM Sample buffer 65536..131071 RW: PCM Sample buffer
@@ -2168,7 +2167,43 @@ Table of 3.5 Minifloat values (CSV)
11111,0.96875,1.96875,3.9375,7.875,15.75,31.5,63,126 11111,0.96875,1.96875,3.9375,7.875,15.75,31.5,63,126
LSB LSB
--------------------------------------------------------------------------------
Taud serialisation format
This is a file format for Taud tracker data.
# File Structure
\x1F T S V M a u d
[HEADER]
[SAMPLE+INSTRUMENT BIN IMAGE (GZip or Zstd compressed. Read 4-byte magic to determine)]
[SONG TABLE]
[PATTERN BIN for SONG 0]
[CUE SHEET for SONG 0]
[PATTERN BIN for SONG 1]
[CUE SHEET for SONG 1]
[PATTERN BIN for SONG 2]
[CUE SHEET for SONG 2]
...
## Header
Byte[8] Magic
Uint8 Format version (always 1)
Uint8 Number of songs in SONG TABLE
Uint32 Compressed size of SAMPLE+INST section (used to calculate offset to SONG TABLE)
Uint16 Reserved for future versions
Byte[16]Tracker/Converter signature
## SONG TABLE
Rows of 16 bytes:
Uint32 Song offset
Uint8 Number of voices
Uint16 Number of patterns (0 is invalid. pattern bin length = numPats * 8 bytes)
Uint8 Initial BPM (bias of -24. 0x00=24, 0xFF=280)
Uint8 Initial Tickrate(0 is invalid)
Byte[7] Reserved for future versions
Taud device can queue up to 2 "playdata" in its buffer, which can be interpreted as a song.
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------

View File

@@ -83,7 +83,7 @@ class AudioJSR223Delegate(private val vm: VM) {
fun setCuePosition(playhead: Int, pos: Int) { fun setCuePosition(playhead: Int, pos: Int) {
getPlayhead(playhead)?.let { ph -> getPlayhead(playhead)?.let { ph ->
ph.position = pos and 2047 ph.position = pos and 1023
ph.trackerState?.cuePos = ph.position ph.trackerState?.cuePos = ph.position
} }
} }
@@ -96,17 +96,17 @@ class AudioJSR223Delegate(private val vm: VM) {
} }
} }
/** Upload 512 bytes (64 rows × 8 bytes) defining pattern `slot` (0-255). */ /** Upload 512 bytes (64 rows × 8 bytes) defining pattern `slot` (0-4094). */
fun uploadPattern(slot: Int, bytes: IntArray) { fun uploadPattern(slot: Int, bytes: IntArray) {
getFirstSnd()?.playdata?.get(slot and 0xFF)?.let { pat -> getFirstSnd()?.playdata?.get(slot and 0xFFF)?.let { pat ->
for (i in 0 until minOf(512, bytes.size)) pat[i / 8].setByte(i % 8, bytes[i] and 0xFF) for (i in 0 until minOf(512, bytes.size)) pat[i / 8].setByte(i % 8, bytes[i] and 0xFF)
} }
} }
/** Upload 16 bytes defining cue entry `idx` (0-2047): bytes 0-14 = pattern numbers for voices 0-14, byte 15 = instruction. */ /** Upload 32 bytes defining cue entry `idx` (0-1023): packed 12-bit pattern numbers for 20 voices + instruction. */
fun uploadCue(idx: Int, bytes: IntArray) { fun uploadCue(idx: Int, bytes: IntArray) {
getFirstSnd()?.cueSheet?.get(idx and 0x7FF)?.let { cue -> getFirstSnd()?.cueSheet?.get(idx and 0x3FF)?.let { cue ->
for (i in 0 until minOf(16, bytes.size)) cue.write(i, bytes[i] and 0xFF) for (i in 0 until minOf(32, bytes.size)) cue.write(i, bytes[i] and 0xFF)
} }
} }

View File

@@ -57,9 +57,13 @@ private class RenderRunnable(val playhead: AudioAdapter.Playhead) : Runnable {
Thread.sleep(1) Thread.sleep(1)
} }
catch (_: InterruptedException) { catch (e: InterruptedException) {
Thread.currentThread().interrupt() Thread.currentThread().interrupt()
} }
catch (e: Exception) {
System.err.println("[AudioAdapter] RenderRunnable crashed: $e")
e.printStackTrace()
}
} }
} }
} }
@@ -120,11 +124,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
const val TRACKER_C3 = 0x4000 const val TRACKER_C3 = 0x4000
} }
internal val sampleBin = UnsafeHelper.allocate(114687L, this) internal val sampleBin = UnsafeHelper.allocate(770048L, this)
internal val instruments = Array(256) { TaudInst() } internal val instruments = Array(256) { TaudInst() }
internal val playdata = Array(256) { Array(64) { TaudPlayData(0xFFFF, 0, 0, 0, 32, 0, 0, 0) } } internal val playdata = Array(4096) { Array(64) { TaudPlayData(0xFFFF, 0, 0, 0, 32, 0, 0, 0) } }
internal val playheads: Array<Playhead> internal val playheads: Array<Playhead>
internal val cueSheet = Array(2048) { PlayCue() } internal val cueSheet = Array(1024) { PlayCue() }
internal val pcmBin = arrayOf( internal val pcmBin = arrayOf(
UnsafeHelper.allocate(65536L, this), UnsafeHelper.allocate(65536L, this),
UnsafeHelper.allocate(65536L, this), UnsafeHelper.allocate(65536L, this),
@@ -292,12 +296,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
override fun peek(addr: Long): Byte { override fun peek(addr: Long): Byte {
return when (val adi = addr.toInt()) { return when (val adi = addr.toInt()) {
in 0..114687 -> sampleBin[addr] in 0..770047 -> sampleBin[addr]
in 114688..131071 -> (adi - 114688).let { instruments[it / 64].getByte(it % 64) } in 770048..786431 -> (adi - 770048).let { instruments[it / 64].getByte(it % 64) }
in 131072..262143 -> (adi - 131072).let { playdata[it / (8*64)][(it / 8) % 64].getByte(it % 8) } in 786432..851967 -> { val off = adi - 786432; playdata[playheads[0].patBank1 * 128 + off / 512][(off % 512) / 8].getByte(off % 8) }
in 262144..327679 -> tadInputBin[addr - 262144] // TAD input buffer (65536 bytes) in 851968..917503 -> { val off = adi - 851968; playdata[playheads[0].patBank2 * 128 + 128 + off / 512][(off % 512) / 8].getByte(off % 8) }
in 327680..393215 -> tadDecodedBin[addr - 327680] // TAD decoded output (65536 bytes) in 917504..983039 -> tadInputBin[addr - 917504] // TAD input buffer (65536 bytes)
else -> peek(addr % 393216) in 983040..1048575 -> tadDecodedBin[addr - 983040] // TAD decoded output (65536 bytes)
else -> peek(addr % 1048576)
} }
} }
@@ -305,11 +310,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val adi = addr.toInt() val adi = addr.toInt()
val bi = byte.toUint() val bi = byte.toUint()
when (adi) { when (adi) {
in 0..114687 -> { sampleBin[addr] = byte } in 0..770047 -> { sampleBin[addr] = byte }
in 114688..131071 -> (adi - 114688).let { instruments[it / 64].setByte(it % 64, bi) } in 770048..786431 -> (adi - 770048).let { instruments[it / 64].setByte(it % 64, bi) }
in 131072..262143 -> (adi - 131072).let { playdata[it / (8*64)][(it / 8) % 64].setByte(it % 8, bi) } in 786432..851967 -> { val off = adi - 786432; playdata[playheads[0].patBank1 * 128 + off / 512][(off % 512) / 8].setByte(off % 8, bi) }
in 262144..327679 -> tadInputBin[addr - 262144] = byte // TAD input buffer in 851968..917503 -> { val off = adi - 851968; playdata[playheads[0].patBank2 * 128 + 128 + off / 512][(off % 512) / 8].setByte(off % 8, bi) }
in 327680..393215 -> tadDecodedBin[addr - 327680] = byte // TAD decoded output in 917504..983039 -> tadInputBin[addr - 917504] = byte // TAD input buffer
in 983040..1048575 -> tadDecodedBin[addr - 983040] = byte // TAD decoded output
} }
} }
@@ -330,7 +336,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
in 2368..4095 -> mediaFrameBin[addr - 2368] in 2368..4095 -> mediaFrameBin[addr - 2368]
in 4096..4097 -> 0 in 4096..4097 -> 0
in 32768..65535 -> (adi - 32768).let { in 32768..65535 -> (adi - 32768).let {
cueSheet[it / 16].read(it % 16) cueSheet[it / 32].read(it % 32)
} }
in 65536..131071 -> pcmBin[selectedPcmBin][addr - 65536] in 65536..131071 -> pcmBin[selectedPcmBin][addr - 65536]
else -> { else -> {
@@ -364,7 +370,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
in 64..2367 -> { mediaDecodedBin[addr - 64] = byte } in 64..2367 -> { mediaDecodedBin[addr - 64] = byte }
in 2368..4095 -> { mediaFrameBin[addr - 2368] = byte } in 2368..4095 -> { mediaFrameBin[addr - 2368] = byte }
in 32768..65535 -> { (adi - 32768).let { in 32768..65535 -> { (adi - 32768).let {
cueSheet[it / 16].write(it % 16, bi) cueSheet[it / 32].write(it % 32, bi)
} } } }
in 65536..131071 -> { pcmBin[selectedPcmBin][addr - 65536] = byte } in 65536..131071 -> { pcmBin[selectedPcmBin][addr - 65536] = byte }
} }
@@ -398,7 +404,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
private var mp2Context = mp2Env.initialise() private var mp2Context = mp2Env.initialise()
private fun decodeMp2() { private fun decodeMp2() {
val periMmioBase = vm.findPeriSlotNum(this)!! * -131072 - 1L val periMmioBase = vm.findPeriSlotNum(this)!! * -786432 - 1L
mp2Env.decodeFrameU8(mp2Context, periMmioBase - 2368, true, periMmioBase - 64) mp2Env.decodeFrameU8(mp2Context, periMmioBase - 2368, true, periMmioBase - 64)
} }
@@ -1108,11 +1114,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
private fun fetchTrackerSample(voice: Voice, inst: TaudInst): Double { private fun fetchTrackerSample(voice: Voice, inst: TaudInst): Double {
val basePtr = inst.samplePtr and 0x1FFFF val basePtr = inst.samplePtr
val sampleLen = inst.sampleLength.coerceAtLeast(1) val sampleLen = inst.sampleLength.coerceAtLeast(1)
val loopStart = inst.sampleLoopStart.toDouble() val loopStart = inst.sampleLoopStart.toDouble()
val loopEnd = inst.sampleLoopEnd.toDouble().coerceAtLeast(1.0) val loopEnd = inst.sampleLoopEnd.toDouble().coerceAtLeast(1.0)
val binMax = 114686 // sampleBin is 114687 bytes (0..114686) val binMax = 770047 // sampleBin is 770048 bytes (0..770047)
val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1) val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1)
val i1 = (i0 + 1).coerceAtMost(sampleLen - 1) val i1 = (i0 + 1).coerceAtMost(sampleLen - 1)
@@ -1138,8 +1144,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
private fun applyTrackerRow(ts: TrackerState, playhead: Playhead) { private fun applyTrackerRow(ts: TrackerState, playhead: Playhead) {
val cue = cueSheet[ts.cuePos] val cue = cueSheet[ts.cuePos]
for (vi in 0..14) { for (vi in 0..19) {
val patIdx = cue.patterns[vi].coerceIn(0, 255) val patNum = cue.patterns[vi]
if (patNum == 0xFFF) continue
val patIdx = patNum.coerceIn(0, 4095)
val row = playdata[patIdx][ts.rowIndex] val row = playdata[patIdx][ts.rowIndex]
val voice = ts.voices[vi] val voice = ts.voices[vi]
@@ -1195,10 +1203,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
private fun advanceTrackerCue(ts: TrackerState, playhead: Playhead) { private fun advanceTrackerCue(ts: TrackerState, playhead: Playhead) {
val instr = cueSheet[ts.cuePos].instruction val instr = cueSheet[ts.cuePos].instruction
if (instr is PlayInstHalt) { playhead.isPlaying = false; return }
ts.cuePos = when (instr) { ts.cuePos = when (instr) {
is PlayInstGoBack -> (ts.cuePos - instr.arg).coerceAtLeast(0) is PlayInstGoBack -> (ts.cuePos - instr.arg).coerceAtLeast(0)
is PlayInstSkip -> (ts.cuePos + instr.arg).coerceAtMost(2047) is PlayInstSkip -> (ts.cuePos + instr.arg).coerceAtMost(1023)
else -> (ts.cuePos + 1).coerceAtMost(2047) else -> (ts.cuePos + 1).coerceAtMost(1023)
} }
playhead.position = ts.cuePos playhead.position = ts.cuePos
} }
@@ -1250,28 +1259,60 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
internal data class PlayCue( internal data class PlayCue(
val patterns: IntArray = IntArray(15) { it }, val patterns: IntArray = IntArray(20) { 0xFFF },
var instruction: PlayInstruction = PlayInstNop var instruction: PlayInstruction = PlayInstNop
) { ) {
// Cue layout (32 bytes, 20 voices, 12-bit pattern numbers):
// bytes 0-9: packed low nybbles (byte i => voice i*2 in hi, voice i*2+1 in lo)
// bytes 10-19: packed mid nybbles (same packing)
// bytes 20-29: packed high nybbles (same packing)
// byte 30: instruction
// byte 31: unused
fun write(index: Int, byte: Int) = when (index) { fun write(index: Int, byte: Int) = when (index) {
in 0..14 -> { patterns[index] = byte } in 0..9 -> {
15 -> { instruction = when { val b = index * 2
patterns[b] = (patterns[b] and 0xFF0) or ((byte ushr 4) and 0xF)
patterns[b + 1] = (patterns[b + 1] and 0xFF0) or (byte and 0xF)
}
in 10..19 -> {
val b = (index - 10) * 2
patterns[b] = (patterns[b] and 0xF0F) or (((byte ushr 4) and 0xF) shl 4)
patterns[b + 1] = (patterns[b + 1] and 0xF0F) or ((byte and 0xF) shl 4)
}
in 20..29 -> {
val b = (index - 20) * 2
patterns[b] = (patterns[b] and 0x0FF) or (((byte ushr 4) and 0xF) shl 8)
patterns[b + 1] = (patterns[b + 1] and 0x0FF) or ((byte and 0xF) shl 8)
}
30 -> { instruction = when {
byte >= 128 -> PlayInstGoBack(byte and 127) byte >= 128 -> PlayInstGoBack(byte and 127)
byte in 16..31 -> PlayInstSkip(byte and 15) byte in 16..31 -> PlayInstSkip(byte and 15)
byte == 1 -> PlayInstHalt
else -> PlayInstNop else -> PlayInstNop
} } } }
31 -> {}
else -> throw InternalError("Bad offset $index") else -> throw InternalError("Bad offset $index")
} }
fun read(index: Int): Byte = when(index) { fun read(index: Int): Byte = when (index) {
in 0..14 -> patterns[index].toByte() in 0..9 -> {
15 -> { val b = index * 2
when (instruction) { (((patterns[b] and 0xF) shl 4) or (patterns[b + 1] and 0xF)).toByte()
is PlayInstGoBack -> (0b10000000 or instruction.arg).toByte()
is PlayInstSkip -> (0b00010000 or instruction.arg).toByte()
is PlayInstNop -> 0
else -> throw InternalError("Bad instruction ${instruction.javaClass.simpleName}")
}
} }
in 10..19 -> {
val b = (index - 10) * 2
((((patterns[b] ushr 4) and 0xF) shl 4) or ((patterns[b + 1] ushr 4) and 0xF)).toByte()
}
in 20..29 -> {
val b = (index - 20) * 2
((((patterns[b] ushr 8) and 0xF) shl 4) or ((patterns[b + 1] ushr 8) and 0xF)).toByte()
}
30 -> when (instruction) {
is PlayInstGoBack -> (0b10000000 or instruction.arg).toByte()
is PlayInstSkip -> (0b00010000 or instruction.arg).toByte()
is PlayInstHalt -> 1
else -> 0
}
31 -> 0
else -> throw InternalError("Bad offset $index") else -> throw InternalError("Bad offset $index")
} }
} }
@@ -1279,6 +1320,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
internal open class PlayInstruction(val arg: Int) internal open class PlayInstruction(val arg: Int)
internal class PlayInstGoBack(arg: Int) : PlayInstruction(arg) internal class PlayInstGoBack(arg: Int) : PlayInstruction(arg)
internal class PlayInstSkip(arg: Int) : PlayInstruction(arg) internal class PlayInstSkip(arg: Int) : PlayInstruction(arg)
internal object PlayInstHalt : PlayInstruction(0)
internal object PlayInstNop : PlayInstruction(0) internal object PlayInstNop : PlayInstruction(0)
class Voice { class Voice {
@@ -1304,7 +1346,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var tickInRow = 0 var tickInRow = 0
var samplesIntoTick = 0.0 var samplesIntoTick = 0.0
var firstRow = true var firstRow = true
val voices = Array(15) { Voice() } val voices = Array(20) { Voice() }
} }
class Playhead( class Playhead(
@@ -1316,9 +1358,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var masterVolume: Int = 0, var masterVolume: Int = 0,
var masterPan: Int = 128, var masterPan: Int = 128,
// var samplingRateMult: ThreeFiveMiniUfloat = ThreeFiveMiniUfloat(32), // var samplingRateMult: ThreeFiveMiniUfloat = ThreeFiveMiniUfloat(32),
var bpm: Int = 120, // "stored" as 96 var bpm: Int = 120,
var tickRate: Int = 6, var tickRate: Int = 6,
var pcmUpload: Boolean = false, var pcmUpload: Boolean = false,
var patBank1: Int = 0,
var patBank2: Int = 0,
var pcmQueue: Queue<ByteArray> = Queue<ByteArray>(), var pcmQueue: Queue<ByteArray> = Queue<ByteArray>(),
var pcmQueueSizeIndex: Int = 0, var pcmQueueSizeIndex: Int = 0,
@@ -1350,8 +1394,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
fun read(index: Int): Byte = when (index) { fun read(index: Int): Byte = when (index) {
0 -> position.toByte() 0 -> position.toByte()
1 -> position.ushr(8).toByte() 1 -> position.ushr(8).toByte()
2 -> pcmUploadLength.toByte() 2 -> if (isPcmMode) pcmUploadLength.toByte() else patBank1.toByte()
3 -> pcmUploadLength.ushr(8).toByte() 3 -> if (isPcmMode) pcmUploadLength.ushr(8).toByte() else patBank2.toByte()
4 -> masterVolume.toByte() 4 -> masterVolume.toByte()
5 -> masterPan.toByte() 5 -> masterPan.toByte()
6 -> (isPcmMode.toInt(7) or isPlaying.toInt(4) or pcmQueueSizeIndex.and(15)).toByte() 6 -> (isPcmMode.toInt(7) or isPlaying.toInt(4) or pcmQueueSizeIndex.and(15)).toByte()
@@ -1366,8 +1410,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
when (index) { when (index) {
0 -> if (!isPcmMode) { position = (position and 0xff00) or byte; trackerState?.cuePos = position } else {} 0 -> if (!isPcmMode) { position = (position and 0xff00) or byte; trackerState?.cuePos = position } else {}
1 -> if (!isPcmMode) { position = (position and 0x00ff) or (byte shl 8); trackerState?.cuePos = position } else {} 1 -> if (!isPcmMode) { position = (position and 0x00ff) or (byte shl 8); trackerState?.cuePos = position } else {}
2 -> { pcmUploadLength = (pcmUploadLength and 0xff00) or byte } 2 -> if (isPcmMode) { pcmUploadLength = (pcmUploadLength and 0xff00) or byte } else { patBank1 = byte and 0x1F }
3 -> { pcmUploadLength = (pcmUploadLength and 0x00ff) or (byte shl 8) } 3 -> if (isPcmMode) { pcmUploadLength = (pcmUploadLength and 0x00ff) or (byte shl 8) } else { patBank2 = byte and 0x1F }
4 -> { 4 -> {
masterVolume = byte masterVolume = byte
audioDevice.setVolume(masterVolume / 255f) audioDevice.setVolume(masterVolume / 255f)
@@ -1504,7 +1548,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
fun setByte(offset: Int, byte: Int) = when (offset) { fun setByte(offset: Int, byte: Int) = when (offset) {
0 -> { samplePtr = (samplePtr and 0x1ff00) or byte } 0 -> { samplePtr = (samplePtr and 0xfff00) or byte }
1 -> { samplePtr = (samplePtr and 0x000ff) or (byte shl 8) } 1 -> { samplePtr = (samplePtr and 0x000ff) or (byte shl 8) }
2 -> { sampleLength = (sampleLength and 0xff00) or byte } 2 -> { sampleLength = (sampleLength and 0xff00) or byte }

View File

@@ -85,6 +85,107 @@ object TinyAlphNum : BitmapFont() {
private fun isColourCodeHigh(c: Char) = c.toInt() in 0b110110_1111000000..0b110110_1111111111
private fun isColourCodeLow(c: Char) = c.toInt() in 0b110111_0000000000..0b110111_1111111111
private fun getColour(charHigh: Char, charLow: Char): Color { // input: 0x10ARGB, out: RGBA8888
val codePoint = Character.toCodePoint(charHigh, charLow)
if (colourBuffer.containsKey(codePoint))
return colourBuffer[codePoint]!!
val a = codePoint.and(0xF000).ushr(12)
val r = codePoint.and(0x0F00).ushr(8)
val g = codePoint.and(0x00F0).ushr(4)
val b = codePoint.and(0x000F)
val col = Color(r.shl(28) or r.shl(24) or g.shl(20) or g.shl(16) or b.shl(12) or b.shl(8) or a.shl(4) or a)
colourBuffer[codePoint] = col
return col
}
private val colourBuffer = HashMap<Int, Color>()
}
/**
* Created by minjaesong on 2026-04-19.
*/
object PatternView : BitmapFont() {
internal val W = 5
internal val H = 6
internal val fontSheet = TextureRegionPack(Gdx.files.internal("net/torvald/terrarum/imagefont/tiny.tga"), W, H)
init {
setOwnsTexture(true)
setUseIntegerPositions(true)
}
fun getWidth(str: String): Int {
var l = 0
for (char in str) {
if (!isColourCodeHigh(char) && !isColourCodeLow(char)) {
l += 1
}
}
return W * l
}
lateinit var colMain: Color
lateinit var colShadow: Color
override fun draw(batch: Batch, text: CharSequence, x: Float, y: Float): GlyphLayout? {
val originalColour = batch.color.cpy()
colMain = batch.color.cpy()
colShadow = colMain.cpy().mul(0.5f, 0.5f, 0.5f, 1f)
val x = x.roundToInt().toFloat()
val y = y.roundToInt().toFloat()
var charsPrinted = 0
text.forEachIndexed { index, c ->
if (isColourCodeHigh(c)) {
val cchigh = c
val cclow = text[index + 1]
val colour = getColour(cchigh, cclow)
colMain = colour
colShadow = colMain.cpy().mul(0.5f, 0.5f, 0.5f, 1f)
}
else if (c in 0.toChar()..255.toChar()) {
batch.color = colShadow
batch.draw(fontSheet.get(c.toInt() % 16, c.toInt() / 16), x + charsPrinted * W + 1, y)
batch.draw(fontSheet.get(c.toInt() % 16, c.toInt() / 16), x + charsPrinted * W, y + 1)
batch.draw(fontSheet.get(c.toInt() % 16, c.toInt() / 16), x + charsPrinted * W + 1, y + 1)
batch.color = colMain
batch.draw(fontSheet.get(c.toInt() % 16, c.toInt() / 16), x + charsPrinted * W, y)
charsPrinted += 1
}
}
batch.color = originalColour
return null
}
fun drawRalign(batch: Batch, text: CharSequence, x: Float, y: Float): GlyphLayout? {
return draw(batch, text, x - W*text.length, y)
}
override fun getLineHeight() = H.toFloat()
override fun getCapHeight() = getLineHeight()
override fun getXHeight() = getLineHeight()
private fun isColourCodeHigh(c: Char) = c.toInt() in 0b110110_1111000000..0b110110_1111111111 private fun isColourCodeHigh(c: Char) = c.toInt() in 0b110110_1111000000..0b110110_1111111111
private fun isColourCodeLow(c: Char) = c.toInt() in 0b110111_0000000000..0b110111_1111111111 private fun isColourCodeLow(c: Char) = c.toInt() in 0b110111_0000000000..0b110111_1111111111

Binary file not shown.

View File

@@ -1,6 +1,8 @@
package net.torvald.tsvm package net.torvald.tsvm
import com.badlogic.gdx.Audio import com.badlogic.gdx.Audio
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.Input.Buttons
import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.graphics.g2d.SpriteBatch import com.badlogic.gdx.graphics.g2d.SpriteBatch
import net.torvald.reflection.extortField import net.torvald.reflection.extortField
@@ -9,8 +11,8 @@ import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_ACTIVE3
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_HIGHLIGHT2 import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_HIGHLIGHT2
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_WELL import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_WELL
import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.FONT import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.FONT
import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.TINY
import net.torvald.tsvm.peripheral.AudioAdapter import net.torvald.tsvm.peripheral.AudioAdapter
import java.lang.Math.pow
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.floor import kotlin.math.floor
@@ -21,19 +23,70 @@ import kotlin.math.roundToInt
*/ */
class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMenu(parent, x, y, w, h) { class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMenu(parent, x, y, w, h) {
// Per-playhead view mode: 0=detailed pattern, 1=abridged pattern (stub), 2=super-abridged (stub), 3=cuesheet detail
private val scopeMode = IntArray(4)
override fun show() { override fun show() {
} }
override fun hide() { override fun hide() {
} }
private var guiClickLatched = arrayOf(false, false, false, false, false, false, false, false)
override fun update() { override fun update() {
if (Gdx.input.isButtonPressed(Buttons.LEFT)) {
if (!guiClickLatched[Buttons.LEFT]) {
val mx = Gdx.input.x - x
val my = Gdx.input.y - y
if (mx in 117..629) {
for (i in 0..3) {
val syTop = h - 7 - 115 * i - 8 * FONT.H
val syBot = h - 3 - 115 * i
if (my in syTop..syBot) {
scopeMode[3 - i] = (scopeMode[3 - i] + 1) % 4
break
}
}
}
guiClickLatched[Buttons.LEFT] = true
}
}
else {
guiClickLatched[Buttons.LEFT] = false
}
} }
private val COL_SOUNDSCOPE_BACK = Color(0x081c08ff.toInt()) private val COL_SOUNDSCOPE_BACK = Color(0x081c08ff.toInt())
private val COL_SOUNDSCOPE_FORE = Color(0x80f782ff.toInt()) private val COL_SOUNDSCOPE_FORE = Color(0x80f782ff.toInt())
private val COL_TRACKER_ROW = Color(0x103010ff.toInt())
private val STR_PLAY = "\u00D2\u00D3" private val STR_PLAY = "\u00D2\u00D3"
// Pattern field colours (loosely following MilkyTracker scheme)
private val COL_NOTE = Color(1f, 1f, 1f, 1f) // white
private val COL_INST = Color(0x6BB5FFff.toInt()) // sky blue
private val COL_VOL = Color(0x80FF50ff.toInt()) // lime
private val COL_PAN = Color(0xFFC040ff.toInt()) // amber
private val COL_EFF = Color(0xFF50FFff.toInt()) // magenta
private val COL_EFFARG = Color(0xFFAF7Fff.toInt()) // apricot
// Voice colours for cue-sheet view — 10-colour palette cycling across 20 voices
private val COL_VOICE_PALETTE = arrayOf(
Color(0xC0C0C0ff.toInt()), // 0: silver
Color(0xFF8080ff.toInt()), // 1: salmon
Color(0xFFBF60ff.toInt()), // 2: tangerine
Color(0xFFFF70ff.toInt()), // 3: yellow
Color(0x80FF80ff.toInt()), // 4: lime
Color(0x60EEEEff.toInt()), // 5: aqua
Color(0x80A0FFff.toInt()), // 6: periwinkle
Color(0xD080FFff.toInt()), // 7: orchid
Color(0xFF80C0ff.toInt()), // 8: pink
Color(0xA0D0A0ff.toInt()), // 9: sage
)
override fun render(batch: SpriteBatch) { override fun render(batch: SpriteBatch) {
@@ -111,7 +164,7 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
FONT.draw(batch, "Tickrate", x, y + 6*FONT.H) FONT.draw(batch, "Tickrate", x, y + 6*FONT.H)
batch.color = COL_ACTIVE3 batch.color = COL_ACTIVE3
FONT.drawRalign(batch, "${ahead.position}", x + 84, y + 2*FONT.H) FONT.drawRalign(batch, "${ahead.trackerState?.cuePos}:${ahead.trackerState?.rowIndex?.toString(16)?.uppercase()?.padStart(2,'0')}", x + 84, y + 2*FONT.H)
FONT.drawRalign(batch, "${ahead.masterVolume}", x + 84, y + 3*FONT.H) FONT.drawRalign(batch, "${ahead.masterVolume}", x + 84, y + 3*FONT.H)
FONT.drawRalign(batch, "${ahead.masterPan}", x + 84, y + 4*FONT.H) FONT.drawRalign(batch, "${ahead.masterPan}", x + 84, y + 4*FONT.H)
FONT.drawRalign(batch, "${ahead.bpm}", x + 84, y + 5*FONT.H) FONT.drawRalign(batch, "${ahead.bpm}", x + 84, y + 5*FONT.H)
@@ -122,9 +175,20 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
fun Int.u16Tos16() = if (this > 32767) this - 65536 else this fun Int.u16Tos16() = if (this > 32767) this - 65536 else this
private fun readCuePat12(audio: AudioAdapter, ci: Int, vi: Int): Int {
val byteGroup = vi / 2
val shift = if (vi % 2 == 0) 4 else 0
val lo = (audio.mmio_read(32768L + ci * 32 + byteGroup ).toUint() ushr shift) and 0xF
val mid = (audio.mmio_read(32768L + ci * 32 + 10 + byteGroup).toUint() ushr shift) and 0xF
val hi = (audio.mmio_read(32768L + ci * 32 + 20 + byteGroup).toUint() ushr shift) and 0xF
return (hi shl 8) or (mid shl 4) or lo
}
private fun bipolarCeil(d: Double) = (if (d >= 0.0) ceil(d) else floor(d)).toInt() private fun bipolarCeil(d: Double) = (if (d >= 0.0) ceil(d) else floor(d)).toInt()
private fun bipolarFloor(d: Double) = (if (d >= 0.0) floor(d) else ceil(d)).toInt() private fun bipolarFloor(d: Double) = (if (d >= 0.0) floor(d) else ceil(d)).toInt()
private val VOX_PER_VIEW = arrayOf(5,8,16)
private fun drawSoundscope(audio: AudioAdapter, ahead: AudioAdapter.Playhead, batch: SpriteBatch, index: Int, x: Float, y: Float) { private fun drawSoundscope(audio: AudioAdapter, ahead: AudioAdapter.Playhead, batch: SpriteBatch, index: Int, x: Float, y: Float) {
val gdxadev = ahead.audioDevice val gdxadev = ahead.audioDevice
val bytes = gdxadev.extortField<ByteArray>("bytes") val bytes = gdxadev.extortField<ByteArray>("bytes")
@@ -175,7 +239,191 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
} }
else { else {
// Tracker pattern visualiser.
// Modes: 0=detailed pattern, 1=abridged (stub), 2=super-abridged (stub), 3=cuesheet detail
val ts = ahead.trackerState
if (ts == null) {
batch.color = COL_SOUNDSCOPE_FORE
FONT.draw(batch, "No tracker state", x, y + 4)
} else {
val cuePos = ts.cuePos
val rowIdx = ts.rowIndex
val ROWS = 17
val PTN_MAX_ROWS = 63
when (scopeMode[index]) {
// ── Mode 3: Cue-sheet detail ─────────────────────────────────────
3 -> {
// Layout per row: >NNN|p00p01…p19|INS
// Voice pattern numbers are colour-coded; no spaces (colour provides separation).
val cueFirst = (cuePos - ROWS / 2).coerceAtLeast(0).coerceAtMost(1023 - ROWS + 1)
for (r in 0 until ROWS) {
val ci = cueFirst + r
if (ci > 1023) break
val here = ci == cuePos
val ry = y + 4 + r * TINY.H
if (here) {
batch.color = COL_TRACKER_ROW
batch.fillRect(x, ry, 512, TINY.H)
}
var cx = x
// cursor + cue number
batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE
TINY.draw(batch, "${if (here) ">" else " "}${ci.toString(16).padStart(3, '0').uppercase()}|", cx, ry)
cx += 5 * TINY.W
// voice pattern numbers
for (vi in 0 until 20) {
if (vi > 0) { cx += TINY.W }
val pat = readCuePat12(audio, ci, vi)
val patStr = if (pat == 0xFFF) "---"
else pat.toString(16).padStart(3, '0').uppercase()
batch.color = if (here) Color.WHITE else COL_VOICE_PALETTE[vi % COL_VOICE_PALETTE.size]
TINY.draw(batch, patStr, cx, ry)
cx += 3 * TINY.W
}
// instruction
val instrByte = audio.mmio_read(32768L + ci * 32 + 30).toUint()
val instrStr3 = when {
instrByte == 0x00 -> " " // no-op
instrByte == 0x01 -> "HALT"
instrByte and 0x80 != 0 -> "BACK ${(instrByte and 0x7F).toString(16).padStart(2, '0').uppercase()}"
instrByte and 0xF0 == 0x10 -> "FWRD ${(instrByte and 0x0F).toString(16).uppercase()}"
else -> "?${instrByte.toString(16).padStart(2, '0').uppercase()}"
}
batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE
TINY.draw(batch, "|$instrStr3", cx, ry)
}
}
// ── Mode 0: Detailed pattern with colour-coded fields ────────────
// ── Mode 1: Abridged pattern with colour-coded fields ────────────
// ── Mode 2: Super-abridged pattern with colour-coded fields ────────────
0, 1, 2 -> {
val cueW = 4 * TINY.W
val sepW = TINY.W
// val patX = x + cueW + sepW
val patX = x
val VOICES = VOX_PER_VIEW[scopeMode[index]]
// Abridged cue sheet (left column, 8 entries centred on current cue)
/*val cueFirst = (cuePos - ROWS / 2).coerceAtLeast(0).coerceAtMost(1023 - ROWS + 1)
for (r in 0 until ROWS) {
val ci = cueFirst + r
if (ci > 1023) break
val here = ci == cuePos
batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE
TINY.draw(batch,
"${if (here) ">" else " "}${ci.toString(16).padStart(3, '0').uppercase()}",
x, y + 4 + r * TINY.H)
}
// Vertical separator
batch.color = COL_SOUNDSCOPE_FORE
for (r in 0 until ROWS) TINY.draw(batch, "|", x + cueW, y + 4 + r * TINY.H)
*/
// Pattern index for each voice in current cue
val cuePats = IntArray(VOICES) { vi -> readCuePat12(audio, cuePos, vi) }
// Pattern rows (right area, 8 rows centred on current row)
// Layout: > rr NOTE in E.Vo E.Pn Eff ffff [voice1 …]
// 1 2 4 2 4 4 2 4
val rowFirst = (rowIdx - ROWS / 2).coerceAtLeast(0).coerceAtMost(PTN_MAX_ROWS - ROWS + 1)
for (r in 0 until ROWS) {
val ri = rowFirst + r
if (ri > PTN_MAX_ROWS) break
val here = ri == rowIdx
val ry = y + 4 + r * TINY.H
if (here) {
batch.color = COL_TRACKER_ROW
batch.fillRect(patX, ry, 512 - cueW - sepW, TINY.H)
}
var cx = patX
// cursor + row number (drawn once per row)
batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE
TINY.draw(batch, if (here) ">" else " ", cx, ry)
cx += TINY.W
TINY.draw(batch, ri.toString().padStart(2, '0').uppercase(), cx, ry)
cx += 2 * TINY.W
for (vi in 0 until VOICES) {
val pat12 = cuePats[vi]
if (pat12 == 0xFFF) {
// disabled voice — dimmed placeholder, same width as a live voice
batch.color = COL_SOUNDSCOPE_FORE
TINY.draw(batch, "(NO PATTERN DATA OR REACHED THE END OF THE SONG) ", cx, ry)
} else {
val localPat = pat12 and 0xFF
val base = if (localPat < 128) 786432L + localPat * 512 + ri * 8
else 851968L + (localPat - 128) * 512 + ri * 8
val noteLo = audio.peek(base + 0).toUint()
val noteHi = audio.peek(base + 1).toUint()
val noteVal = noteLo or (noteHi shl 8)
val instr = audio.peek(base + 2).toUint()
val volByte = audio.peek(base + 3).toUint()
val panByte = audio.peek(base + 4).toUint()
val eff = audio.peek(base + 5).toUint()
val eaLo = audio.peek(base + 6).toUint()
val eaHi = audio.peek(base + 7).toUint()
val vol = volByte and 63
val volEff = (volByte ushr 6) and 3
val pan = panByte and 63
val panEff = (panByte ushr 6) and 3
val effArg = eaLo or (eaHi shl 8)
val noteStr = when (noteVal) {
0xFFFF -> "@@@@"
0x0000 -> "===="
0xFFFE -> "^^^^"
else -> noteVal.toString(16).uppercase().padStart(4, '0')
}
// note
batch.color = if (here) Color.WHITE else COL_NOTE
TINY.draw(batch, noteStr, cx, ry)
cx += 4 * TINY.W
// instrument
batch.color = if (here) Color.WHITE else COL_INST
TINY.draw(batch, instr.toString(16).padStart(2, '0').uppercase(), cx, ry)
cx += 2 * TINY.W
if (scopeMode[index] == 0) {
// volume
batch.color = if (here) Color.WHITE else COL_VOL
TINY.draw(batch, "$volEff.${vol.toString().padStart(2, '0')}", cx, ry)
cx += 4 * TINY.W
}
// pan
if (scopeMode[index] == 0) {
batch.color = if (here) Color.WHITE else COL_PAN
TINY.draw(batch, "$panEff.${pan.toString().padStart(2, '0')}", cx, ry)
cx += 4 * TINY.W
}
if (scopeMode[index] < 2) {
// effect opcode
batch.color = if (here) Color.WHITE else COL_EFF
TINY.draw(batch, eff.toString(16).padStart(2, '0').uppercase(), cx, ry)
cx += 2 * TINY.W
// effect argument
batch.color = if (here) Color.WHITE else COL_EFFARG
TINY.draw(batch, effArg.toString(16).padStart(4, '0').uppercase(), cx, ry)
cx += 4 * TINY.W
}
}
}
}
}
}
}
} }
} }
} }
@@ -184,4 +432,4 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
} }
} }

View File

@@ -10,6 +10,7 @@ import com.badlogic.gdx.utils.JsonValue
import com.badlogic.gdx.utils.JsonWriter import com.badlogic.gdx.utils.JsonWriter
import net.torvald.terrarum.DefaultGL32Shaders import net.torvald.terrarum.DefaultGL32Shaders
import net.torvald.terrarum.FlippingSpriteBatch import net.torvald.terrarum.FlippingSpriteBatch
import net.torvald.terrarum.imagefont.PatternView
import net.torvald.terrarum.imagefont.TinyAlphNum import net.torvald.terrarum.imagefont.TinyAlphNum
import net.torvald.terrarum.utils.JsonFetcher import net.torvald.terrarum.utils.JsonFetcher
import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.FONT import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.FONT
@@ -25,10 +26,12 @@ class VMEmuExecutableWrapper(val windowWidth: Int, val windowHeight: Int, var pa
companion object { companion object {
lateinit var SQTEX: Texture; private set lateinit var SQTEX: Texture; private set
lateinit var FONT: TinyAlphNum; private set lateinit var FONT: TinyAlphNum; private set
lateinit var TINY: PatternView; private set
} }
override fun create() { override fun create() {
FONT = TinyAlphNum FONT = TinyAlphNum
TINY = PatternView
SQTEX = Texture(Gdx.files.internal("net/torvald/tsvm/sq.tga")) SQTEX = Texture(Gdx.files.internal("net/torvald/tsvm/sq.tga"))
executable = VMEmuExecutable(windowWidth, windowHeight, panelsX, panelsY, diskPathRoot) executable = VMEmuExecutable(windowWidth, windowHeight, panelsX, panelsY, diskPathRoot)
executable.create() executable.create()
@@ -54,6 +57,8 @@ class VMEmuExecutableWrapper(val windowWidth: Int, val windowHeight: Int, var pa
// println("App Dispose") // println("App Dispose")
executable.dispose() executable.dispose()
SQTEX.dispose() SQTEX.dispose()
FONT.dispose()
TINY.dispose()
exitProcess(0) exitProcess(0)
} }
} }