diff --git a/assets/disk0/tracker_play.js b/assets/disk0/tracker_play.js new file mode 100644 index 0000000..1e09a0c --- /dev/null +++ b/assets/disk0/tracker_play.js @@ -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) \ No newline at end of file diff --git a/assets/disk0/tracker_test.js b/assets/disk0/tracker_test.js new file mode 100644 index 0000000..1bdb0ed --- /dev/null +++ b/assets/disk0/tracker_test.js @@ -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 + ")"); diff --git a/assets/disk0/tvdos/TVDOS.SYS b/assets/disk0/tvdos/TVDOS.SYS index 2c30b5a..8580cfc 100644 --- a/assets/disk0/tvdos/TVDOS.SYS +++ b/assets/disk0/tvdos/TVDOS.SYS @@ -225,8 +225,9 @@ class TVDOSFileDescriptor { } /** reads the file bytewise and puts it to the specified memory address - * @param count optional -- how many bytes to read - * @param offset optional -- how many bytes to skip initially + * @param ptr -- where the bytes should be dumped + * @param count -- how many bytes to read + * @param offset -- how many bytes to skip initially from the file */ pread(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] - * - @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) { this.driver.pwrite(this, ptr, count, offset) @@ -1024,136 +1027,6 @@ _TVDOS.DRV.FS.NET.exists = (fd) => { 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 = {} diff --git a/assets/disk0/tvdos/include/taud.mjs b/assets/disk0/tvdos/include/taud.mjs new file mode 100644 index 0000000..6d7ae7a --- /dev/null +++ b/assets/disk0/tvdos/include/taud.mjs @@ -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 } diff --git a/s3m2taud.py b/s3m2taud.py new file mode 100644 index 0000000..3615087 --- /dev/null +++ b/s3m2taud.py @@ -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(' 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(' 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('> 1 + else: + s = struct.unpack_from('> 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('= 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(' 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(' 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('> 8) & 0xFF + song_table = struct.pack(' - ph.position = pos and 2047 + ph.position = pos and 1023 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) { - 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) } } - /** 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) { - getFirstSnd()?.cueSheet?.get(idx and 0x7FF)?.let { cue -> - for (i in 0 until minOf(16, bytes.size)) cue.write(i, bytes[i] and 0xFF) + getFirstSnd()?.cueSheet?.get(idx and 0x3FF)?.let { cue -> + for (i in 0 until minOf(32, bytes.size)) cue.write(i, bytes[i] and 0xFF) } } diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index 95ef4bb..3abbf4b 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -57,9 +57,13 @@ private class RenderRunnable(val playhead: AudioAdapter.Playhead) : Runnable { Thread.sleep(1) } - catch (_: InterruptedException) { + catch (e: InterruptedException) { 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 } - internal val sampleBin = UnsafeHelper.allocate(114687L, this) + internal val sampleBin = UnsafeHelper.allocate(770048L, this) 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 - internal val cueSheet = Array(2048) { PlayCue() } + internal val cueSheet = Array(1024) { PlayCue() } internal val pcmBin = arrayOf( 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 { return when (val adi = addr.toInt()) { - in 0..114687 -> sampleBin[addr] - in 114688..131071 -> (adi - 114688).let { instruments[it / 64].getByte(it % 64) } - in 131072..262143 -> (adi - 131072).let { playdata[it / (8*64)][(it / 8) % 64].getByte(it % 8) } - in 262144..327679 -> tadInputBin[addr - 262144] // TAD input buffer (65536 bytes) - in 327680..393215 -> tadDecodedBin[addr - 327680] // TAD decoded output (65536 bytes) - else -> peek(addr % 393216) + in 0..770047 -> sampleBin[addr] + in 770048..786431 -> (adi - 770048).let { instruments[it / 64].getByte(it % 64) } + in 786432..851967 -> { val off = adi - 786432; playdata[playheads[0].patBank1 * 128 + off / 512][(off % 512) / 8].getByte(off % 8) } + in 851968..917503 -> { val off = adi - 851968; playdata[playheads[0].patBank2 * 128 + 128 + off / 512][(off % 512) / 8].getByte(off % 8) } + in 917504..983039 -> tadInputBin[addr - 917504] // TAD input buffer (65536 bytes) + 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 bi = byte.toUint() when (adi) { - in 0..114687 -> { sampleBin[addr] = byte } - in 114688..131071 -> (adi - 114688).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 262144..327679 -> tadInputBin[addr - 262144] = byte // TAD input buffer - in 327680..393215 -> tadDecodedBin[addr - 327680] = byte // TAD decoded output + in 0..770047 -> { sampleBin[addr] = byte } + in 770048..786431 -> (adi - 770048).let { instruments[it / 64].setByte(it % 64, bi) } + in 786432..851967 -> { val off = adi - 786432; playdata[playheads[0].patBank1 * 128 + off / 512][(off % 512) / 8].setByte(off % 8, bi) } + in 851968..917503 -> { val off = adi - 851968; playdata[playheads[0].patBank2 * 128 + 128 + off / 512][(off % 512) / 8].setByte(off % 8, bi) } + 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 4096..4097 -> 0 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] else -> { @@ -364,7 +370,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { in 64..2367 -> { mediaDecodedBin[addr - 64] = byte } in 2368..4095 -> { mediaFrameBin[addr - 2368] = byte } 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 } } @@ -398,7 +404,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { private var mp2Context = mp2Env.initialise() 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) } @@ -1108,11 +1114,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } 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 loopStart = inst.sampleLoopStart.toDouble() 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 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) { val cue = cueSheet[ts.cuePos] - for (vi in 0..14) { - val patIdx = cue.patterns[vi].coerceIn(0, 255) + for (vi in 0..19) { + val patNum = cue.patterns[vi] + if (patNum == 0xFFF) continue + val patIdx = patNum.coerceIn(0, 4095) val row = playdata[patIdx][ts.rowIndex] 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) { val instr = cueSheet[ts.cuePos].instruction + if (instr is PlayInstHalt) { playhead.isPlaying = false; return } ts.cuePos = when (instr) { is PlayInstGoBack -> (ts.cuePos - instr.arg).coerceAtLeast(0) - is PlayInstSkip -> (ts.cuePos + instr.arg).coerceAtMost(2047) - else -> (ts.cuePos + 1).coerceAtMost(2047) + is PlayInstSkip -> (ts.cuePos + instr.arg).coerceAtMost(1023) + else -> (ts.cuePos + 1).coerceAtMost(1023) } playhead.position = ts.cuePos } @@ -1250,28 +1259,60 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } internal data class PlayCue( - val patterns: IntArray = IntArray(15) { it }, + val patterns: IntArray = IntArray(20) { 0xFFF }, 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) { - in 0..14 -> { patterns[index] = byte } - 15 -> { instruction = when { + in 0..9 -> { + 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 in 16..31 -> PlayInstSkip(byte and 15) + byte == 1 -> PlayInstHalt else -> PlayInstNop } } + 31 -> {} else -> throw InternalError("Bad offset $index") } - fun read(index: Int): Byte = when(index) { - in 0..14 -> patterns[index].toByte() - 15 -> { - when (instruction) { - 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}") - } + fun read(index: Int): Byte = when (index) { + in 0..9 -> { + val b = index * 2 + (((patterns[b] and 0xF) shl 4) or (patterns[b + 1] and 0xF)).toByte() } + 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") } } @@ -1279,6 +1320,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { internal open class PlayInstruction(val arg: Int) internal class PlayInstGoBack(arg: Int) : PlayInstruction(arg) internal class PlayInstSkip(arg: Int) : PlayInstruction(arg) + internal object PlayInstHalt : PlayInstruction(0) internal object PlayInstNop : PlayInstruction(0) class Voice { @@ -1304,7 +1346,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { var tickInRow = 0 var samplesIntoTick = 0.0 var firstRow = true - val voices = Array(15) { Voice() } + val voices = Array(20) { Voice() } } class Playhead( @@ -1316,9 +1358,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { var masterVolume: Int = 0, var masterPan: Int = 128, // var samplingRateMult: ThreeFiveMiniUfloat = ThreeFiveMiniUfloat(32), - var bpm: Int = 120, // "stored" as 96 + var bpm: Int = 120, var tickRate: Int = 6, var pcmUpload: Boolean = false, + var patBank1: Int = 0, + var patBank2: Int = 0, var pcmQueue: Queue = Queue(), var pcmQueueSizeIndex: Int = 0, @@ -1350,8 +1394,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { fun read(index: Int): Byte = when (index) { 0 -> position.toByte() 1 -> position.ushr(8).toByte() - 2 -> pcmUploadLength.toByte() - 3 -> pcmUploadLength.ushr(8).toByte() + 2 -> if (isPcmMode) pcmUploadLength.toByte() else patBank1.toByte() + 3 -> if (isPcmMode) pcmUploadLength.ushr(8).toByte() else patBank2.toByte() 4 -> masterVolume.toByte() 5 -> masterPan.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) { 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 {} - 2 -> { pcmUploadLength = (pcmUploadLength and 0xff00) or byte } - 3 -> { pcmUploadLength = (pcmUploadLength and 0x00ff) or (byte shl 8) } + 2 -> if (isPcmMode) { pcmUploadLength = (pcmUploadLength and 0xff00) or byte } else { patBank1 = byte and 0x1F } + 3 -> if (isPcmMode) { pcmUploadLength = (pcmUploadLength and 0x00ff) or (byte shl 8) } else { patBank2 = byte and 0x1F } 4 -> { masterVolume = byte 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) { - 0 -> { samplePtr = (samplePtr and 0x1ff00) or byte } + 0 -> { samplePtr = (samplePtr and 0xfff00) or byte } 1 -> { samplePtr = (samplePtr and 0x000ff) or (byte shl 8) } 2 -> { sampleLength = (sampleLength and 0xff00) or byte } diff --git a/tsvm_executable/src/net/torvald/terrarum/imagefont/TinyAlphNum.kt b/tsvm_executable/src/net/torvald/terrarum/imagefont/TinyAlphNum.kt index 3604a1b..6e09a83 100644 --- a/tsvm_executable/src/net/torvald/terrarum/imagefont/TinyAlphNum.kt +++ b/tsvm_executable/src/net/torvald/terrarum/imagefont/TinyAlphNum.kt @@ -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() +} + +/** + * 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 isColourCodeLow(c: Char) = c.toInt() in 0b110111_0000000000..0b110111_1111111111 diff --git a/tsvm_executable/src/net/torvald/terrarum/imagefont/tiny.tga b/tsvm_executable/src/net/torvald/terrarum/imagefont/tiny.tga new file mode 100644 index 0000000..688e72c --- /dev/null +++ b/tsvm_executable/src/net/torvald/terrarum/imagefont/tiny.tga @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:10ebff7f0e90acc8e0e2a35016a4bf3f2b69a6f225200e385d06cc936d9faedb +size 15378 diff --git a/tsvm_executable/src/net/torvald/tsvm/AudioMenu.kt b/tsvm_executable/src/net/torvald/tsvm/AudioMenu.kt index 7d3d42e..8a5f19e 100644 --- a/tsvm_executable/src/net/torvald/tsvm/AudioMenu.kt +++ b/tsvm_executable/src/net/torvald/tsvm/AudioMenu.kt @@ -1,6 +1,8 @@ package net.torvald.tsvm 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.g2d.SpriteBatch 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_WELL import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.FONT +import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.TINY import net.torvald.tsvm.peripheral.AudioAdapter -import java.lang.Math.pow import kotlin.math.abs import kotlin.math.ceil 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) { + // 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 hide() { } + private var guiClickLatched = arrayOf(false, false, false, false, false, false, false, false) + 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_FORE = Color(0x80f782ff.toInt()) + private val COL_TRACKER_ROW = Color(0x103010ff.toInt()) 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) { @@ -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) 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.masterPan}", x + 84, y + 4*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 + 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 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) { val gdxadev = ahead.audioDevice val bytes = gdxadev.extortField("bytes") @@ -175,7 +239,191 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe } 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 } -} \ No newline at end of file +} diff --git a/tsvm_executable/src/net/torvald/tsvm/VMEmuExecutable.kt b/tsvm_executable/src/net/torvald/tsvm/VMEmuExecutable.kt index eb94121..78b613c 100644 --- a/tsvm_executable/src/net/torvald/tsvm/VMEmuExecutable.kt +++ b/tsvm_executable/src/net/torvald/tsvm/VMEmuExecutable.kt @@ -10,6 +10,7 @@ import com.badlogic.gdx.utils.JsonValue import com.badlogic.gdx.utils.JsonWriter import net.torvald.terrarum.DefaultGL32Shaders import net.torvald.terrarum.FlippingSpriteBatch +import net.torvald.terrarum.imagefont.PatternView import net.torvald.terrarum.imagefont.TinyAlphNum import net.torvald.terrarum.utils.JsonFetcher import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.FONT @@ -25,10 +26,12 @@ class VMEmuExecutableWrapper(val windowWidth: Int, val windowHeight: Int, var pa companion object { lateinit var SQTEX: Texture; private set lateinit var FONT: TinyAlphNum; private set + lateinit var TINY: PatternView; private set } override fun create() { FONT = TinyAlphNum + TINY = PatternView SQTEX = Texture(Gdx.files.internal("net/torvald/tsvm/sq.tga")) executable = VMEmuExecutable(windowWidth, windowHeight, panelsX, panelsY, diskPathRoot) executable.create() @@ -54,6 +57,8 @@ class VMEmuExecutableWrapper(val windowWidth: Int, val windowHeight: Int, var pa // println("App Dispose") executable.dispose() SQTEX.dispose() + FONT.dispose() + TINY.dispose() exitProcess(0) } }