taud: midi and sf2 WIP

This commit is contained in:
minjaesong
2026-06-14 00:53:33 +09:00
parent db63c3cfdd
commit db44b6b523
7 changed files with 3243 additions and 226 deletions

View File

@@ -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)

View File

@@ -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()
}