mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-15 00:44:05 +09:00
taud: midi and sf2 WIP
This commit is contained in:
@@ -3239,26 +3239,76 @@ function decodeInstRecord(rec) {
|
||||
// usedBy[], name }. usedBy is a list of instrument slot numbers (1..255).
|
||||
let samplesCache = null
|
||||
|
||||
// Ixmp ("instrument extra samples") introspection — present once the host VM
|
||||
// exposes the patch read-back API. On an un-rebuilt host it's absent and the
|
||||
// Samples tab simply lists the base-record samples (no patch samples).
|
||||
const hasIxmpAPI = (typeof audio !== 'undefined' &&
|
||||
typeof audio.getInstrumentPatchCount === 'function' &&
|
||||
typeof audio.getInstrumentPatches === 'function')
|
||||
|
||||
// Per-patch on-wire length from its version byte (terranmon.txt §Ixmp; mirrors
|
||||
// taud.mjs#patchLen / AudioJSR223Delegate). 31 common bytes + present blocks.
|
||||
function ixmpPatchLen(ver) {
|
||||
return 31
|
||||
+ ((ver & 0x80) ? 15 : 0) // x: extra-base-info (flags1+flags2+fadeout+cutoff+reson+atten)
|
||||
+ ((ver & 0x02) ? 54 : 0) // v: volume envelope
|
||||
+ ((ver & 0x04) ? 54 : 0) // p: panning envelope
|
||||
+ ((ver & 0x08) ? 54 : 0) // f: filter envelope
|
||||
+ ((ver & 0x10) ? 54 : 0) // P: pitch envelope
|
||||
}
|
||||
|
||||
// Walk instrument `slot`'s Ixmp patches; invoke cb(samplePtr, sampleLen, extra) per
|
||||
// patch. Patch common-byte layout (terranmon.txt §Ixmp): u32 ptr@7, u16 len@11,
|
||||
// u16 playStart@13, loopStart@15, loopEnd@17, rate@19, u8 loopMode@23. No-op without API.
|
||||
function forEachIxmpPatchSample(slot, cb) {
|
||||
if (!hasIxmpAPI) return
|
||||
if (audio.getInstrumentPatchCount(slot) <= 0) return
|
||||
const b = audio.getInstrumentPatches(slot)
|
||||
if (!b || b.length < 31) return
|
||||
const u16 = (o) => (b[o] & 0xFF) | ((b[o+1] & 0xFF) << 8)
|
||||
let o = 0
|
||||
while (o + 31 <= b.length) {
|
||||
const ver = b[o] & 0xFF
|
||||
const len = ixmpPatchLen(ver)
|
||||
if (o + len > b.length) break
|
||||
const ptr = (b[o+7] & 0xFF) | ((b[o+8] & 0xFF) << 8) |
|
||||
((b[o+9] & 0xFF) << 16) | ((b[o+10] & 0xFF) * 0x1000000)
|
||||
cb(ptr, u16(o+11), {
|
||||
c4Rate: u16(o+19), playStart: u16(o+13),
|
||||
loopStart: u16(o+15), loopEnd: u16(o+17),
|
||||
sampleFlags: b[o+23] & 0xFF
|
||||
})
|
||||
o += len
|
||||
}
|
||||
}
|
||||
|
||||
function buildSampleIndex() {
|
||||
const byPtr = new Map()
|
||||
for (let i = 1; i < TAUT_INST_COUNT; i++) {
|
||||
const d = decodeInstRecord(readInstRecord(i))
|
||||
if (d.sampleLen === 0) continue
|
||||
const key = d.samplePtr + ':' + d.sampleLen
|
||||
const addSample = (slot, ptr, len, extra) => {
|
||||
if (len === 0) return
|
||||
const key = ptr + ':' + len
|
||||
if (!byPtr.has(key)) {
|
||||
byPtr.set(key, {
|
||||
ptr: d.samplePtr,
|
||||
len: d.sampleLen,
|
||||
c4Rate: d.c4Rate,
|
||||
playStart: d.playStart,
|
||||
loopStart: d.loopStart,
|
||||
loopEnd: d.loopEnd,
|
||||
sampleFlags:d.sampleFlags,
|
||||
usedBy: [],
|
||||
name: ''
|
||||
byPtr.set(key, Object.assign({
|
||||
ptr: ptr, len: len, c4Rate: 0, playStart: 0,
|
||||
loopStart: 0, loopEnd: 0, sampleFlags: 0, usedBy: [], name: ''
|
||||
}, extra || {}))
|
||||
}
|
||||
const e = byPtr.get(key)
|
||||
if (e.usedBy.indexOf(slot) < 0) e.usedBy.push(slot)
|
||||
}
|
||||
for (let i = 1; i < TAUT_INST_COUNT; i++) {
|
||||
const rec = readInstRecord(i)
|
||||
// Metainstruments (samplePtr high 16 bits == 0xFFFF) carry no sample of their
|
||||
// own — only a layer table — so skip their bogus base pointer here.
|
||||
if (((rec[2] | (rec[3] << 8)) & 0xFFFF) !== 0xFFFF) {
|
||||
const d = decodeInstRecord(rec)
|
||||
addSample(i, d.samplePtr, d.sampleLen, {
|
||||
c4Rate: d.c4Rate, playStart: d.playStart, loopStart: d.loopStart,
|
||||
loopEnd: d.loopEnd, sampleFlags: d.sampleFlags
|
||||
})
|
||||
}
|
||||
byPtr.get(key).usedBy.push(i)
|
||||
// Ixmp patch samples (extra multisamples that velocity/key layers reference).
|
||||
forEachIxmpPatchSample(i, (ptr, slen, ex) => addSample(i, ptr, slen, ex))
|
||||
}
|
||||
const list = Array.from(byPtr.values()).sort((a, b) => a.ptr - b.ptr)
|
||||
const names = (songsMeta && songsMeta.sampleNames) || []
|
||||
@@ -3820,9 +3870,37 @@ function decodeEnvelope(rec, kind) {
|
||||
}
|
||||
}
|
||||
|
||||
// Decode a Metainstrument record (terranmon.txt §"Metainstrument definition"):
|
||||
// byte0 = type (0 = layered), byte1 = layer count, bytes2-3 = 0xFFFF identifier,
|
||||
// then `count` 10-byte layer descriptors from byte4. Each: u8 instIdx, u8 mixOctet
|
||||
// (Perceptually-Significant-Octet dB, 159 = unity), s16 detune (4096-TET),
|
||||
// u16 pitchStart, u16 pitchEnd, u8 volStart, u8 volEnd (0..63).
|
||||
function decodeMetaRecord(rec) {
|
||||
const count = rec[1] & 0xFF
|
||||
const layers = []
|
||||
let o = 4
|
||||
for (let i = 0; i < count && o + 10 <= 256; i++, o += 10) {
|
||||
let det = rec[o+2] | (rec[o+3] << 8); if (det >= 0x8000) det -= 0x10000
|
||||
layers.push({
|
||||
instIdx: rec[o] & 0xFF,
|
||||
mixOctet: rec[o+1] & 0xFF,
|
||||
detune: det,
|
||||
pitchStart: rec[o+4] | (rec[o+5] << 8),
|
||||
pitchEnd: rec[o+6] | (rec[o+7] << 8),
|
||||
volStart: rec[o+8] & 0x3F,
|
||||
volEnd: rec[o+9] & 0x3F
|
||||
})
|
||||
}
|
||||
return { isMeta: true, metaType: rec[0] & 0xFF, layers }
|
||||
}
|
||||
|
||||
// True when a 256-byte record is a Metainstrument (samplePtr high 16 bits == 0xFFFF).
|
||||
function recordIsMeta(rec) { return ((rec[2] | (rec[3] << 8)) & 0xFFFF) === 0xFFFF }
|
||||
|
||||
// Decode the full 256-byte instrument record into a structured object suitable
|
||||
// for display. Field offsets/encodings track terranmon.txt §"Instrument bin".
|
||||
function decodeInstFull(rec) {
|
||||
if (recordIsMeta(rec)) return decodeMetaRecord(rec)
|
||||
const samplePtr = (rec[0]) | (rec[1] << 8) | (rec[2] << 16) | (rec[3] * 0x1000000)
|
||||
const sampleLen = rec[4] | (rec[5] << 8)
|
||||
const c4Rate = rec[6] | (rec[7] << 8)
|
||||
@@ -3845,7 +3923,9 @@ function decodeInstFull(rec) {
|
||||
const defReso = rec[183]
|
||||
let detune = rec[184] | (rec[185] << 8); if (detune >= 0x8000) detune -= 0x10000
|
||||
const instFlag = rec[186]
|
||||
const nna = instFlag & 3
|
||||
// NNA UI value: 0..3 = traditional (bits 0-1); 4 = Key Lift (bit 5 set,
|
||||
// bits 0-1 = 00 — the 0b100 "Nnn" pattern, terranmon byte 186).
|
||||
const nna = ((instFlag >>> 5) & 1) ? 4 : (instFlag & 3)
|
||||
const vibWaveform = (instFlag >>> 2) & 7
|
||||
const vibDepth = rec[187]
|
||||
const vibRate = rec[188]
|
||||
@@ -4051,11 +4131,13 @@ function loopModeNameInst(flags) {
|
||||
const names = ['None', 'Forward', 'Pingpong', 'Oneshot']
|
||||
return names[lp] + (sus ? ' (sustain)' : '')
|
||||
}
|
||||
// Clickable button-group option lists. NNA/DCT use every value; DCA's 4th slot
|
||||
// is reserved (dropped); vibrato exposes the 5 engine-supported waves
|
||||
// (sine/ramp-dn/square/random/ramp-up — see AudioAdapter.advanceAutoVibrato).
|
||||
const NNA_NAMES = ['Cut', 'Off', 'Continue', 'Fade']
|
||||
const DCT_NAMES = ['Off', 'Note', 'Sample', 'Inst.']
|
||||
// Clickable button-group option lists. NNA's 5th option is Key Lift (flag bit 5,
|
||||
// the 0b100 pattern: MIDI-exact key-up — envelope jumps to the release nodes);
|
||||
// DCT uses every value; DCA's 4th slot is reserved (dropped); vibrato exposes
|
||||
// the 5 engine-supported waves (sine/ramp-dn/square/random/ramp-up — see
|
||||
// AudioAdapter.advanceAutoVibrato).
|
||||
const NNA_NAMES = ['Off', 'Cut', 'Cont.', 'Fade', 'Lift']
|
||||
const DCT_NAMES = ['Never', 'Note', 'Sample', 'Inst.']
|
||||
const DCA_OPTIONS = ['Cut', 'Off', 'Fade']
|
||||
const VIB_WF_OPTIONS = ['\u00D8\u00D9', '\u00A5\u00A6', '\u00B4\u00B4', '\u00F3\u00F3', '\u00B5\u00B6']//['Sine', 'Ramp-dn', 'Square', 'Random', 'Ramp-up']
|
||||
|
||||
@@ -4464,7 +4546,10 @@ function drawInstTabGeneral2(e) {
|
||||
y++
|
||||
drawGroupHeader(y++, 'Note actions')
|
||||
// NNA — instFlag (byte 186) bits 0..1; DCT/DCA — dcByte (byte 195) bits 0..1 / 2..3.
|
||||
y += buttonGroupRow(y, ' NNA:', NNA_NAMES, d.nna & 3, (v) => instWriteField(e, 186, 0, 2, v))
|
||||
y += buttonGroupRow(y, ' NNA:', NNA_NAMES, d.nna, (v) => {
|
||||
instWriteField(e, 186, 5, 1, v === 4 ? 1 : 0) // Key Lift bit
|
||||
instWriteField(e, 186, 0, 2, v === 4 ? 0 : v) // traditional nn
|
||||
})
|
||||
y += buttonGroupRow(y, ' DCT:', DCT_NAMES, d.dct & 3, (v) => instWriteField(e, 195, 0, 2, v))
|
||||
y += buttonGroupRow(y, ' DCA:', DCA_OPTIONS, d.dca & 3, (v) => instWriteField(e, 195, 2, 2, v))
|
||||
|
||||
@@ -4707,13 +4792,54 @@ function drawInstTabPitch(e) {
|
||||
})
|
||||
}
|
||||
|
||||
// Metainstrument view (terranmon.txt §"Metainstrument definition"): the record
|
||||
// carries no sample of its own — only a layer table fanned out at trigger time.
|
||||
// One row per layer: target instrument, mix volume (Perceptually-Significant
|
||||
// octet; 159 = unity), sample detune (4096-TET → cents), and the pitch × velocity
|
||||
// rectangle that gates the layer.
|
||||
function drawInstTabMeta(e) {
|
||||
const d = e.decoded
|
||||
let y = INST_BODY_Y
|
||||
drawGroupHeader(y++, 'Metainstrument (' + d.layers.length + ' layer' +
|
||||
(d.layers.length === 1 ? '' : 's') + ')')
|
||||
drawLabelRow(y++, ' Type:', d.metaType === 0 ? 'layered (0)' : '$' + _hex(d.metaType, 2))
|
||||
y++
|
||||
// Column header.
|
||||
con.move(y, INST_RIGHT_X); con.color_pair(colInstGroupHdr, colBackPtn)
|
||||
print(' # Inst Mix Detune Pitch Vel'.substring(0, INST_RIGHT_W))
|
||||
y++
|
||||
const maxRows = INST_BTN_Y - y - 1
|
||||
for (let i = 0; i < d.layers.length && i < maxRows; i++) {
|
||||
const L = d.layers[i]
|
||||
const cents = (L.detune * 1200 / 4096)
|
||||
const mix = (L.mixOctet === 159) ? '$9F=1x' : ('$' + _hex(L.mixOctet, 2))
|
||||
const det = (cents >= 0 ? '+' : '') + cents.toFixed(0) + 'c'
|
||||
const pit = noteToStr(L.pitchStart) + sym.doubledot + noteToStr(L.pitchEnd)
|
||||
const vel = L.volStart + sym.doubledot + L.volEnd
|
||||
con.move(y, INST_RIGHT_X); con.color_pair(colInstLabel, colBackPtn)
|
||||
const num = (i + 1).toString().padStart(2)
|
||||
con.color_pair(colInstValue, colBackPtn)
|
||||
const row = ' ' + num + ' $' + _hex(L.instIdx, 2) +
|
||||
' ' + mix.padEnd(7) +
|
||||
' ' + det.padEnd(8) +
|
||||
' ' + pit.padEnd(11) +
|
||||
' ' + vel
|
||||
print(row.length > INST_RIGHT_W ? row.substring(0, INST_RIGHT_W) : row)
|
||||
y++
|
||||
}
|
||||
if (d.layers.length > maxRows) {
|
||||
con.move(y, INST_RIGHT_X); con.color_pair(colInstGroupHdr, colBackPtn)
|
||||
print(' … ' + (d.layers.length - maxRows) + ' more layer(s)')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit button (bottom row) ───────────────────────────────────────────────
|
||||
function drawInstrumentsEditButton() {
|
||||
const y = INST_BTN_Y
|
||||
con.move(y, INST_RIGHT_X)
|
||||
con.color_pair(colInstGroupHdr, colBackPtn); print('[ E ]')
|
||||
con.color_pair(colInstValue, colBackPtn)
|
||||
const label = ' Edit instrument'
|
||||
const label = ' Advanced Edit'
|
||||
print(label)
|
||||
const rest = INST_RIGHT_W - (5 + label.length)
|
||||
if (rest > 0) print(' '.repeat(rest))
|
||||
@@ -4749,7 +4875,10 @@ function drawInstrumentsContents(wo) {
|
||||
// until after the text tabs are drawn — otherwise plotRect-555 fill at the
|
||||
// end of the body redraw would erase the graph again.
|
||||
clearInstrumentsEnvelopeArea()
|
||||
if (instSubTab === INST_TAB_GEN1) drawInstTabGeneral1(e)
|
||||
// Metainstruments have no sample/envelopes — show their layer table on every
|
||||
// sub-tab (the Gen/env drawers would read absent fields and mis-render).
|
||||
if (e.decoded.isMeta) drawInstTabMeta(e)
|
||||
else if (instSubTab === INST_TAB_GEN1) drawInstTabGeneral1(e)
|
||||
else if (instSubTab === INST_TAB_GEN2) drawInstTabGeneral2(e)
|
||||
else if (instSubTab === INST_TAB_VOL) drawInstTabVolume(e)
|
||||
else if (instSubTab === INST_TAB_PAN) drawInstTabPanning(e)
|
||||
|
||||
@@ -169,7 +169,14 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
if ((sys.peek(filePtr + projOff + i) & 0xFF) !== projMagic[i]) { prjOk = false; break }
|
||||
}
|
||||
if (prjOk) {
|
||||
const PATCH_SIZE = 31
|
||||
// Patches are VARIABLE LENGTH (since 2026-06-13): a version byte (feature
|
||||
// bit-flags 0b x00Pfpvi) + 30 common bytes, then optional x/v/p/f/P blocks.
|
||||
const patchLen = (ver) => 31
|
||||
+ ((ver & 0x80) ? 15 : 0) // x: extra-base-info (u32 flags1 + u32 flags2 + u16 fadeout + u16 cutoff + u16 reson + u8 initialAttenuation octet)
|
||||
+ ((ver & 0x02) ? 54 : 0) // v: volume envelope
|
||||
+ ((ver & 0x04) ? 54 : 0) // p: panning envelope
|
||||
+ ((ver & 0x08) ? 54 : 0) // f: filter envelope
|
||||
+ ((ver & 0x10) ? 54 : 0) // P: pitch envelope
|
||||
let p = projOff + 16 // skip magic(8) + reserved(8)
|
||||
while (p + 8 <= fileSize) {
|
||||
const fc = String.fromCharCode(
|
||||
@@ -179,7 +186,7 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
const payload = p + 8
|
||||
if (payload + secLen > fileSize) break
|
||||
if (fc === 'Ixmp') {
|
||||
// Each entry: Uint8 instId + Uint24 patchCount + (patchCount × PATCH_SIZE) bytes.
|
||||
// Each entry: Uint8 instId + Uint24 patchCount + variable-length patches.
|
||||
let q = payload
|
||||
const qEnd = payload + secLen
|
||||
while (q + 4 <= qEnd) {
|
||||
@@ -188,8 +195,15 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
const cntMid = sys.peek(filePtr + q) & 0xFF; q++
|
||||
const cntHi = sys.peek(filePtr + q) & 0xFF; q++
|
||||
const patchCnt = cntLo | (cntMid << 8) | (cntHi << 16)
|
||||
const blobLen = patchCnt * PATCH_SIZE
|
||||
if (q + blobLen > qEnd) break
|
||||
// Walk the patches to find the blob length (each depends on its version byte).
|
||||
let blobLen = 0, scan = q, ok = true
|
||||
for (let i = 0; i < patchCnt; i++) {
|
||||
if (scan + 31 > qEnd) { ok = false; break }
|
||||
const len = patchLen(sys.peek(filePtr + scan) & 0xFF)
|
||||
if (scan + len > qEnd) { ok = false; break }
|
||||
scan += len; blobLen += len
|
||||
}
|
||||
if (!ok) break
|
||||
let buf = new Array(blobLen)
|
||||
for (let k = 0; k < blobLen; k++) buf[k] = sys.peek(filePtr + q + k) & 0xFF
|
||||
audio.uploadInstrumentPatches(instId, buf)
|
||||
@@ -291,6 +305,35 @@ function captureTrackerDataToFile(outFile) {
|
||||
// Layout: header(32) + compressed(compressedSize) + songTable(1 × TAUD_SONG_ENTRY)
|
||||
let songOffset = TAUD_HEADER_SIZE + compressedSize + 1 * TAUD_SONG_ENTRY
|
||||
|
||||
// -- 6.5 Build Ixmp project-data block (preserves multi-sample instruments)
|
||||
// Without this, saving a song whose instruments carry Ixmp patches (IT/XM
|
||||
// keyboard tables, SF2 imports) would silently collapse every instrument to
|
||||
// its base sample on the next load. Section format per terranmon.txt
|
||||
// §"Project Data" / §"Ixmp": magic(8) + reserved(8) + FourCC + Uint32 len +
|
||||
// repetition of { Uint8 instId, Uint24 count, count × variable-length patches }.
|
||||
let ixmpPayload = []
|
||||
for (let s = 0; s < 256; s++) {
|
||||
const cnt = audio.getInstrumentPatchCount(s)
|
||||
if (cnt <= 0) continue
|
||||
const blob = audio.getInstrumentPatches(s) // flat variable-length patch bytes
|
||||
ixmpPayload.push(s & 0xFF, cnt & 0xFF, (cnt >>> 8) & 0xFF, (cnt >>> 16) & 0xFF)
|
||||
for (let k = 0; k < blob.length; k++) ixmpPayload.push(blob[k] & 0xFF)
|
||||
}
|
||||
let projData = []
|
||||
let projOff = 0
|
||||
if (ixmpPayload.length > 0) {
|
||||
projData = [
|
||||
0x1E, 0x54, 0x61, 0x75, 0x64, 0x50, 0x72, 0x4A, // \x1ETaudPrJ
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // reserved
|
||||
0x49, 0x78, 0x6D, 0x70, // 'Ixmp'
|
||||
(ixmpPayload.length ) & 0xFF,
|
||||
(ixmpPayload.length >>> 8) & 0xFF,
|
||||
(ixmpPayload.length >>> 16) & 0xFF,
|
||||
(ixmpPayload.length >>> 24) & 0xFF,
|
||||
].concat(ixmpPayload)
|
||||
projOff = songOffset + patCompSize + cueCompSize
|
||||
}
|
||||
|
||||
// -- 7. Build header byte array (32 bytes) --------------------------------
|
||||
let sigBytes = new Array(14)
|
||||
for (let i = 0; i < 14; i++)
|
||||
@@ -306,8 +349,11 @@ function captureTrackerDataToFile(outFile) {
|
||||
(compressedSize >>> 8) & 0xFF,
|
||||
(compressedSize >>> 16) & 0xFF,
|
||||
(compressedSize >>> 24) & 0xFF,
|
||||
// project data offset (4) -- not emitted
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
// project data offset (4) -- zero when no Ixmp/etc. to carry
|
||||
(projOff ) & 0xFF,
|
||||
(projOff >>> 8) & 0xFF,
|
||||
(projOff >>> 16) & 0xFF,
|
||||
(projOff >>> 24) & 0xFF,
|
||||
].concat(sigBytes) // 8 + 2 + 4 + 4 + 14 = 32 bytes
|
||||
|
||||
// -- 8. Build song-table row (32 bytes) -----------------------------------
|
||||
@@ -360,6 +406,13 @@ function captureTrackerDataToFile(outFile) {
|
||||
TAUD_HEADER_SIZE + compressedSize + songTable.length + patCompSize)
|
||||
sys.free(cueCompBuf)
|
||||
|
||||
// -- 14. Append project data (Ixmp) at projOff ----------------------------
|
||||
if (projData.length > 0) {
|
||||
let projBuf = sys.malloc(projData.length)
|
||||
for (let k = 0; k < projData.length; k++) sys.poke(projBuf + k, projData[k])
|
||||
fileHandle.pwrite(projBuf, projData.length, projOff)
|
||||
sys.free(projBuf)
|
||||
}
|
||||
|
||||
fileHandle.flush(); fileHandle.close()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user