Compare commits

..

3 Commits

Author SHA1 Message Date
minjaesong
1d3b5ce8aa taut: persistent funk visualisation 2026-05-30 01:33:28 +09:00
minjaesong
9e8af96c32 taut: sample dedup 2026-05-29 15:01:55 +09:00
minjaesong
43e5baadf4 taut: realtime waveform update for funk repeat simulation 2026-05-29 14:02:55 +09:00
4 changed files with 210 additions and 60 deletions

View File

@@ -3217,6 +3217,13 @@ const colSmpUsedHdr = colVoiceHdr
const colSmpUsedFg = colInst
const colSmpWaveLine = 77 // bright cyan-ish; visible on dark bg
const colSmpWaveMid = 246 // dim grey for zero-line
const colSmpWaveFunk = 221 // orange — loop bytes live-inverted by funk repeat (S$Fx)
// Funk-repeat introspection API (getVoiceFunkSpeed / getInstrumentFunkMask) ships with this
// feature; on an un-rebuilt host VM it's absent and the waveform stays the stored sample.
const hasFunkAPI = (typeof audio !== 'undefined' &&
typeof audio.getVoiceFunkSpeed === 'function' &&
typeof audio.getInstrumentFunkMask === 'function')
let smpListScroll = 0
let smpListCursor = 0
@@ -3337,16 +3344,16 @@ function drawSamplesProperties() {
let smpUsedScroll = 0
function drawSamplesUsedBy() {
const rightW = SCRW - SMP_RIGHT_X + 1
con.move(SMP_USED_Y, SMP_RIGHT_X)
con.color_pair(colSmpUsedHdr, colBackPtn)
print('Used by instruments:'.padEnd(rightW))
const s = (samplesCache && samplesCache[smpListCursor]) || null
const used = s ? s.usedBy : []
const names = (songsMeta && songsMeta.instNames) || []
const visible = SMP_USED_LIST_H
const rightW = SCRW - SMP_RIGHT_X + 1
con.move(SMP_USED_Y, SMP_RIGHT_X)
con.color_pair(colSmpUsedHdr, colBackPtn)
print(`Used by instruments (${used.length}):`.padEnd(rightW))
if (smpUsedScroll > Math.max(0, used.length - visible))
smpUsedScroll = Math.max(0, used.length - visible)
if (smpUsedScroll < 0) smpUsedScroll = 0
@@ -3372,10 +3379,11 @@ function drawSamplesUsedBy() {
}
// ── Waveform rendering ──────────────────────────────────────────────────────
// Renders one sample under the right panel as a min/max envelope, using the
// graphics layer. Samples are unsigned 8-bit; bank-switch is required because
// only 512 K of the 8 MB pool is mapped at a time. We restore bank 0 (the
// playback-expected default) when done.
// Renders one sample under the right panel as baseline-filled bars (each bar is
// a plotRect anchored at the zero line, extending to the sample amplitude),
// using the graphics layer. Samples are unsigned 8-bit; bank-switch is required
// because only 512 K of the 8 MB pool is mapped at a time. We restore bank 0
// (the playback-expected default) when done.
// Pixel rect occupied by the waveform inside the Samples viewer. Both the
// waveform painter and the leave-Samples cleanup need to reach for the same
@@ -3394,6 +3402,46 @@ function clearSampleWaveformArea() {
graphics.plotRect(r.x-2, r.y-2, r.w+4, r.h+4, 255) // 255 = transparent
}
// Instrument slot of an active voice that's funk-repeating (S$Fx) one of the sample's `usedBy`
// instruments, or -1. Returns -1 when not playing / no funking voice / API absent. Drives the
// per-frame repaint cadence only — the *displayed* mask comes from funkMaskForSample, which also
// honours masks that persist after the funking voice has gone idle.
function findFunkInstForSample(usedBy) {
if (!hasFunkAPI) return -1
const numVox = (song && song.numVoices) ? song.numVoices : NUM_VOICES
for (let v = 0; v < numVox; v++) {
if (!audio.getVoiceActive(PLAYHEAD, v)) continue
if (audio.getVoiceFunkSpeed(PLAYHEAD, v) <= 0) continue
const inst = audio.getVoiceInstrument(PLAYHEAD, v)
if (usedBy.indexOf(inst) >= 0) return inst
}
return -1
}
// Funk XOR mask to DISPLAY for this sample, or null. The per-instrument mask persists in the engine
// for the whole playback session (cleared only on stop-and-replay), so once a loop has been
// funk-inverted the overlay must stay even after the funking voice goes idle — matching ProTracker,
// whose destructive EFx edits never revert until the song is reloaded. Prefer an actively-funking
// instrument (its mask is live this frame); otherwise show any usedBy instrument that still carries
// a non-empty mask from earlier in the session.
function funkMaskForSample(usedBy, activeInst) {
if (!hasFunkAPI) return null
if (activeInst > 0) {
const m = audio.getInstrumentFunkMask(activeInst)
if (m && m.length > 0) return m
}
for (let i = 0; i < usedBy.length; i++) {
const m = audio.getInstrumentFunkMask(usedBy[i])
if (m && m.length > 0) return m
}
return null
}
// Whether a voice was actively funk-repeating the displayed sample on the last paint. Drives the
// per-frame repaint cadence in tickFunkWaveform (repaint while the live mask changes, plus one
// settling frame after it stops). The painted overlay itself persists — the engine keeps the mask.
let funkWaveLast = false
function drawSampleWaveform() {
const r = sampleWaveformRect()
const wx0 = r.x, wy0 = r.y, wW = r.w, wH = r.h
@@ -3402,53 +3450,104 @@ function drawSampleWaveform() {
clearSampleWaveformArea()
const s = (samplesCache && samplesCache[smpListCursor]) || null
if (!s || s.len === 0) return
if (!s || s.len === 0) { funkWaveLast = false; return }
const bankIdxFirst = (s.ptr / TAUT_SBANK_SIZE) | 0
const bankOff = s.ptr - bankIdxFirst * TAUT_SBANK_SIZE
const memBase = audio.getMemAddr()
const prevBank = audio.getSampleBank() || 0
// Centre line
graphics.plotRect(wx0, wy0 + (wH >>> 1), wW, 1, colSmpWaveMid)
// Walk the sample at one column per output pixel. For each column we read
// a chunk and reduce to min/max; vertical extent comes from (max-min).
// Bank switching is per-step: each output column may straddle banks.
const samplesPerCol = Math.max(1, (s.len / wW) | 0)
let pos = 0 // byte offset into the sample, 0..len-1
let curBank = -1
for (let col = 0; col < wW; col++) {
const start = (col * s.len / wW) | 0
const end = Math.min(s.len, (((col + 1) * s.len / wW) | 0))
if (end <= start) continue
let mn = 255, mx = 0
// Step in coarse strides for speed when samples are long.
const step = Math.max(1, ((end - start) / 8) | 0)
for (let p = start; p < end; p += step) {
const abs = s.ptr + p
const bank = (abs / TAUT_SBANK_SIZE) | 0
if (bank !== curBank) {
audio.setSampleBank(bank)
curBank = bank
}
const off = abs - bank * TAUT_SBANK_SIZE
const v = sys.peek(memBase - off) & 0xFF
if (v < mn) mn = v
if (v > mx) mx = v
// Funk-repeat overlay. The per-instrument XOR mask flips loop-region bytes by 0xFF and persists
// in the engine until stop-and-replay, so the overlay must remain even after the voice that
// funked the sample goes idle — matching ProTracker's destructive EFx, whose inverted bytes
// never revert until the song is reloaded. We therefore key the overlay off the persisted mask,
// not off a currently-active funking voice. funkLE is clamped to the snapshot mask's coverage so
// the bit lookup can never run off the (host) array.
let funkMask = null, funkLS = 0, funkLE = 0
let activeFunk = false
if (playbackMode !== PLAYMODE_NONE && s.loopEnd > s.loopStart) {
const activeInst = findFunkInstForSample(s.usedBy)
activeFunk = (activeInst > 0)
const m = funkMaskForSample(s.usedBy, activeInst)
if (m) {
funkMask = m
funkLS = s.loopStart
funkLE = Math.min(s.loopEnd, funkLS + m.length * 8)
}
}
funkWaveLast = activeFunk
const memBase = audio.getMemAddr()
const prevBank = audio.getSampleBank() || 0
let curBank = -1
// Zero line and value→y mapping (unsigned 8-bit: 255 → top, 0 → bottom).
const baseY = wy0 + (wH >>> 1)
const yOf = (v) => wy0 + (((wH * (255 - v)) / 255) | 0)
// Read sample byte p (0..len-1) applying the live funk-flip overlay; sets the
// shared `flippedAny` flag whenever a byte was inverted by the funk mask.
let flippedAny = false
const readByte = (p) => {
const abs = s.ptr + p
const bank = (abs / TAUT_SBANK_SIZE) | 0
if (bank !== curBank) { audio.setSampleBank(bank); curBank = bank }
let v = sys.peek(memBase - (abs - bank * TAUT_SBANK_SIZE)) & 0xFF
if (funkMask !== null && p >= funkLS && p < funkLE) {
const k = p - funkLS
if ((funkMask[k >>> 3] >>> (k & 7)) & 1) { v ^= 0xFF; flippedAny = true }
}
return v
}
// Zero/baseline line
graphics.plotRect(wx0, baseY, wW, 1, colSmpWaveMid)
// Per-sample bar width: how many pixels each sample spans, at least 1px.
const rectW = Math.max(1, Math.ceil(wW / s.len))
if (s.len <= wW) {
// Fewer samples than pixels: one baseline-filled bar per sample.
for (let i = 0; i < s.len; i++) {
flippedAny = false
const yv = yOf(readByte(i))
const top = Math.min(baseY, yv)
graphics.plotRect(wx0 + ((i * wW / s.len) | 0), top, rectW,
Math.max(1, Math.abs(baseY - yv)),
flippedAny ? colSmpWaveFunk : colSmpWaveLine)
}
} else {
// More samples than pixels: reduce each 1px column to its min/max and
// fill from the baseline through the envelope (a solid filled waveform).
for (let col = 0; col < wW; col++) {
const start = (col * s.len / wW) | 0
const end = Math.min(s.len, (((col + 1) * s.len / wW) | 0))
if (end <= start) continue
const step = Math.max(1, ((end - start) / 8) | 0)
let mn = 255, mx = 0
flippedAny = false
for (let p = start; p < end; p += step) {
const v = readByte(p)
if (v < mn) mn = v
if (v > mx) mx = v
}
const yTop = Math.min(baseY, yOf(mx))
const yBot = Math.max(baseY, yOf(mn))
graphics.plotRect(wx0 + col, yTop, 1, Math.max(1, yBot - yTop + 1),
flippedAny ? colSmpWaveFunk : colSmpWaveLine)
}
// unsigned 8-bit → centred around 128
const yTop = wy0 + ((wH * (255 - mx)) / 255) | 0
const yBot = wy0 + ((wH * (255 - mn)) / 255) | 0
const h = Math.max(1, yBot - yTop + 1)
graphics.plotRect(wx0 + col, yTop, 1, h, colSmpWaveLine)
}
// Restore bank 0 for playback (engine expects bank 0 as default)
audio.setSampleBank(prevBank)
}
// Per-frame driver: while a voice is funk-repeating the displayed sample, repaint the waveform
// each frame so the overlay tracks the live mask. One settling repaint fires after funk stops
// (funkWaveLast); the persisted overlay then stays until the engine clears the mask on replay.
function tickFunkWaveform() {
if (currentPanel !== VIEW_SAMPLES) { funkWaveLast = false; return }
const s = (samplesCache && samplesCache[smpListCursor]) || null
const funking = !!(s && s.len > 0 && playbackMode !== PLAYMODE_NONE &&
findFunkInstForSample(s.usedBy) > 0)
if (funking || funkWaveLast) drawSampleWaveform()
}
function drawSamplesEditButton() {
const y = SMP_BTN_Y
con.move(y, SMP_RIGHT_X)
@@ -4817,6 +4916,7 @@ function stopPlayback() {
// pass ourselves so stale blobs / hairlines don't linger on Samples / Instruments.
drawSamplesPlayBlobs()
drawInstrumentsPlayBlobs()
tickFunkWaveform() // restore the stored waveform now that funk repeat has stopped
drawSampleCursor()
drawEnvelopeCursor()
}
@@ -4834,6 +4934,7 @@ function updatePlayback() {
// playbackMode is NONE now → these paint a final blob0 / clear-cursor pass.
drawSamplesPlayBlobs()
drawInstrumentsPlayBlobs()
tickFunkWaveform() // restore the stored waveform now that playback has stopped
drawSampleCursor()
drawEnvelopeCursor()
return
@@ -4842,6 +4943,7 @@ function updatePlayback() {
drawVoiceMeters()
drawSamplesPlayBlobs()
drawInstrumentsPlayBlobs()
tickFunkWaveform() // realtime funk-repeat overlay (no-op unless funking this sample)
drawSampleCursor()
drawEnvelopeCursor()

View File

@@ -1190,13 +1190,29 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
sample_bin = bytearray(SAMPLEBIN_SIZE)
offsets = {}
pos = 0
# IT use_instruments mode points many Taud instrument slots at the same
# underlying sample object (e.g. seven "ChipBass.*" instruments all play
# "ChipBass.looped"). Write each distinct sample's PCM into the pool once and
# let every referencing slot share the offset, rather than emitting one
# identical copy per slot. `pool_order` records the distinct samples in
# ascending-offset order — the order taut.js's sample viewer expects SNam to
# follow (it dedupes instrument records by (ptr,len), sorts by ptr, and
# matches SNam[i+1] positionally — see taut.js buildSampleIndex).
written = {} # id(sample) -> pool offset already written
pool_order = [] # distinct sample objects, in pool (ascending-offset) order
for idx, s in pcm_list:
shared = written.get(id(s))
if shared is not None:
offsets[idx] = shared
continue
n = min(len(s.sample_data), SAMPLEBIN_SIZE - pos)
if n <= 0:
vprint(f" warning: sample bin full, dropping '{s.name}'")
offsets[idx] = 0; s.length = 0; continue
sample_bin[pos:pos+n] = s.sample_data[:n]
offsets[idx] = pos
written[id(s)] = pos
pool_order.append(s)
if n < len(s.sample_data):
vprint(f" warning: '{s.name}' truncated {len(s.sample_data)}{n}")
s.length = n
@@ -1384,7 +1400,7 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}")
return bytes(sample_bin) + bytes(inst_bin), offsets, ratio
return bytes(sample_bin) + bytes(inst_bin), offsets, ratio, pool_order
# ── Pattern builder ───────────────────────────────────────────────────────────
@@ -1899,7 +1915,7 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
'dct': inst.dct,
'dca': inst.dca,
}
sampleinst_raw, _, sample_ratio = build_sample_inst_bin_it(proxy, instr_data_by_slot)
sampleinst_raw, _, sample_ratio, pool_order = build_sample_inst_bin_it(proxy, instr_data_by_slot)
else:
# Samples referenced directly; proxy is samples list (0-based, slot 0 unused)
proxy = [None] + list(samples)
@@ -1908,7 +1924,7 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
for i, s in enumerate(samples)
if s is not None
}
sampleinst_raw, _, sample_ratio = build_sample_inst_bin_it(proxy)
sampleinst_raw, _, sample_ratio, pool_order = build_sample_inst_bin_it(proxy)
assert len(sampleinst_raw) == SAMPLEINST_SIZE
@@ -1961,8 +1977,14 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
if with_project_data:
inst_names = [''] + [(inst.name if inst is not None else '')
for inst in instruments[:255]]
smp_names = [''] + [(s.name if s is not None else '')
for s in samples[:255]]
# SNam mirrors the deduplicated sample pool: one entry per distinct
# sample, in pool order, named after the sample itself. taut.js dedupes
# instrument records by (ptr,len), sorts ascending by ptr, and matches
# SNam[i+1] positionally to that list, so this ordering labels every
# sample correctly and a shared sample (e.g. "ChipBass.looped") appears
# exactly once instead of once per referencing instrument slot.
smp_names = [''] + [(getattr(s, 'name', '') or '')
for s in pool_order[:255]]
proj_data = build_project_data(
project_name=h.title,
instrument_names=inst_names,

View File

@@ -138,7 +138,11 @@ def parse_instruments(data: bytes, h: S3MHeader) -> list:
continue
inst = S3MInstrument()
inst.itype = data[ptr]
inst.filename = data[ptr+1:ptr+13].rstrip(b'\x00').decode('latin-1', errors='replace')
# 12-byte DOS filename field; null-terminated with possible trailing
# garbage after the terminator (ST3 doesn't zero the tail). Truncate at
# the first null. This field carries the per-sample short name (e.g.
# 'HIT1') as distinct from the 28-byte title at 0x30.
inst.filename = data[ptr+1:ptr+13].split(b'\x00', 1)[0].decode('latin-1', errors='replace')
# memseg: 3 bytes at offsets 0x0D,0x0E,0x0F — high byte first (quirk)
memseg_hi = data[ptr + 0x0D]
memseg_lo = struct.unpack_from('<H', data, ptr + 0x0E)[0]
@@ -939,17 +943,21 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list,
cur_off += len(pat_comp) + len(cue_comp)
# ── Project Data (optional) ──────────────────────────────────────────────
# S3M instruments and samples share the same slot space, so the names go
# into both INam and SNam (1-based; slot 0 empty).
# S3M instruments and samples share the same slot space, but carry two
# distinct name fields: the 28-byte title (inst.name → INam) and the
# 12-byte DOS filename (inst.filename → SNam). e.g. WHEN.s3m instrument #1
# is titled "(c) Purple Motion / 1994" with sample name 'HIT1'.
proj_data = b''
proj_off = 0
if with_project_data:
names = [''] + [(inst.name if inst is not None else '')
for inst in instruments[:255]]
inst_names = [''] + [(inst.name if inst is not None else '')
for inst in instruments[:255]]
sample_names = [''] + [(inst.filename if inst is not None else '')
for inst in instruments[:255]]
proj_data = build_project_data(
project_name=h.title,
instrument_names=names,
sample_names=names,
instrument_names=inst_names,
sample_names=sample_names,
)
if proj_data:
proj_off = cur_off

View File

@@ -163,6 +163,24 @@ class AudioJSR223Delegate(private val vm: VM) {
return counts
}
/** Funk-repeat (S$Fx) speed currently driving the voice: 0 = off, otherwise the per-tick
* accumulator increment. A non-zero value on an active voice means the voice is live-inverting
* its instrument's loop region right now — visualisers can use this to gate the funk overlay. */
fun getVoiceFunkSpeed(playhead: Int, voice: Int): Int {
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0
if (!v.active) return 0
return v.funkSpeed
}
/** Snapshot of an instrument's funk-repeat XOR mask (one bit per loop-region byte; a set bit
* flips that byte by 0xFF during playback). Returns the mask bytes as ints (0..255), or an
* empty array when the instrument has never been funk-repeated. The render thread mutates the
* live mask, so this returns a copy — the caller gets a stable single-frame view. */
fun getInstrumentFunkMask(slot: Int): IntArray {
val mask = getFirstSnd()?.instruments?.get(slot and 0xFF)?.funkMask ?: return IntArray(0)
return IntArray(mask.size) { mask[it].toInt() and 0xFF }
}
/** Live noteVal (0..65535, 4096-TET) of the foreground voice — the value the mixer is using
* *right now* including any in-flight vibrato / arpeggio / portamento delta. Returns 0 for
* inactive voices. */