mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
Taud: 8 MB sample rom/it and xm resampling too-long samples
This commit is contained in:
@@ -10,7 +10,15 @@ const TAUD_MAGIC = [0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64] // \x1F TSV
|
||||
const TAUD_VERSION = 1
|
||||
const TAUD_HEADER_SIZE = 32 // magic(8) + version(1) + numSongs(1) + compSize(4) + projOff(4) + sig(14)
|
||||
const TAUD_SONG_ENTRY = 32 // see encodeSongEntry / decodeSongEntry below
|
||||
const SAMPLEINST_SIZE = 786432 // 737280 sample + 49152 instrument (256 × 192)
|
||||
// Sample+instrument image: 8 MB sample pool (banked, 16 × 512 K) + 64 K instrument bin = 8256 kB total.
|
||||
// (terranmon.txt:1985-1997, 2533-2564 — bank-switched via MMIO 46.)
|
||||
const SAMPLE_BANK_SIZE = 524288 // 512 K — size of the sample-bin window
|
||||
const SAMPLE_BANK_COUNT = 16 // 16 banks × 512 K = 8 MB
|
||||
const SAMPLEBIN_SIZE = SAMPLE_BANK_SIZE * SAMPLE_BANK_COUNT // 8 MB
|
||||
const INSTBIN_SIZE = 65536 // 256 inst × 256 bytes
|
||||
const SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE // 8454144 = 8256 kB
|
||||
const SAMPLEBIN_WINDOW_OFFSET = 0 // peripheral memory window for the active sample bank
|
||||
const INSTBIN_WINDOW_OFFSET = 720896 // peripheral memory offset of instrument bin
|
||||
const PATTERN_SIZE = 512 // bytes per pattern (64 rows × 8 bytes)
|
||||
const NUM_PATTERNS_MAX = 256
|
||||
const NUM_CUES = 1024
|
||||
@@ -88,18 +96,14 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
}
|
||||
|
||||
// -- 4. Decompress and upload sample+instrument bin -----------------------
|
||||
let decompPtr = sys.malloc(SAMPLEINST_SIZE)
|
||||
gzip.decompFromTo(filePtr + pos, compressedSize, decompPtr)
|
||||
// The decompressed image is 8256 kB (8 MB samples bank-major + 64 K instruments)
|
||||
// which exceeds the 8 MB user-space cap, so we route through a hardware helper
|
||||
// that decompresses straight into the adapter's native sample/instrument
|
||||
// storage instead of staging a buffer in user memory.
|
||||
audio.uploadSampleInstBlob(filePtr + pos, compressedSize)
|
||||
audio.setSampleBank(0)
|
||||
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++) {
|
||||
// TODO use sys.memcpy
|
||||
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)
|
||||
@@ -173,14 +177,19 @@ function captureTrackerDataToFile(outFile) {
|
||||
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)
|
||||
// The 8256 kB raw image (8 MB samples + 64 K instruments) cannot fit in the
|
||||
// 8 MB user space, so we hand the entire compress step to a hardware helper
|
||||
// that reads directly out of the adapter's native sample/instrument storage.
|
||||
// Realistic sample data compresses well under both gzip and zstd; we cap the
|
||||
// destination at "uncompressed size + 8 K" headroom which suffices for any
|
||||
// sane musical content.
|
||||
const COMP_BUF_CAP = 1024 * 1024 * 4 // 4 MiB cap for compressed sample+inst blob
|
||||
let compBuf = sys.malloc(COMP_BUF_CAP)
|
||||
let compressedSize = audio.captureSampleInstBlob(compBuf, COMP_BUF_CAP)
|
||||
if (compressedSize > COMP_BUF_CAP) {
|
||||
sys.free(compBuf)
|
||||
throw Error("taud: compressed sample+inst blob exceeded " + COMP_BUF_CAP + " bytes (got " + compressedSize + ")")
|
||||
}
|
||||
|
||||
// -- 2. Find last non-empty pattern in bank 0 (all-zero = uninitialized) --
|
||||
let numPatsActual = 0
|
||||
|
||||
73
it2taud.py
73
it2taud.py
@@ -41,7 +41,7 @@ import sys
|
||||
from taud_common import (
|
||||
set_verbose, vprint,
|
||||
TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
|
||||
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE,
|
||||
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLE_LEN_LIMIT,
|
||||
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
|
||||
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4,
|
||||
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I,
|
||||
@@ -51,7 +51,8 @@ from taud_common import (
|
||||
EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T,
|
||||
EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z,
|
||||
J_SEMI_TABLE,
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects_per_slot,
|
||||
encode_cue, deduplicate_patterns,
|
||||
normalise_sample, encode_song_entry, nearest_minifloat, compress_blob,
|
||||
CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len,
|
||||
)
|
||||
@@ -1090,25 +1091,60 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
|
||||
sample_detune, nna, dct, dca.
|
||||
All optional; missing keys default to neutral values.
|
||||
|
||||
Returns (bin_bytes[SAMPLEINST_SIZE], offsets_dict).
|
||||
Returns (bin_bytes[SAMPLEINST_SIZE], offsets_dict, slot_ratios) where
|
||||
slot_ratios maps Taud slot index → effective TOP_O scale (combined
|
||||
global × per-sample resample ratio).
|
||||
"""
|
||||
pcm_list = [(i, s) for i, s in enumerate(samples_or_proxy)
|
||||
if s is not None and s.sample_data]
|
||||
|
||||
def _scale_sample(s, r):
|
||||
s.sample_data = resample_linear(s.sample_data, r)
|
||||
s.length = len(s.sample_data)
|
||||
s.loop_beg = max(0, int(s.loop_beg * r))
|
||||
s.loop_end = max(0, min(int(s.loop_end * r), s.length))
|
||||
s.sus_beg = max(0, int(s.sus_beg * r))
|
||||
s.sus_end = max(0, min(int(s.sus_end * r), s.length))
|
||||
s.c5_speed = max(1, int(s.c5_speed * r))
|
||||
|
||||
# ── Pass 1: global pool-overflow resample (8 MB cap) ────────────────────
|
||||
total = sum(len(s.sample_data) for _, s in pcm_list)
|
||||
ratio = 1.0
|
||||
global_ratio = 1.0
|
||||
if total > SAMPLEBIN_SIZE:
|
||||
ratio = SAMPLEBIN_SIZE / total
|
||||
vprint(f" info: sample bin overflow ({total} bytes); resampling all by {ratio:.4f}")
|
||||
global_ratio = SAMPLEBIN_SIZE / total
|
||||
vprint(f" info: sample bin overflow ({total} bytes); resampling all by {global_ratio:.4f}")
|
||||
seen_g = set()
|
||||
for _, s in pcm_list:
|
||||
new_data = resample_linear(s.sample_data, ratio)
|
||||
s.sample_data = new_data
|
||||
s.length = len(new_data)
|
||||
s.loop_beg = max(0, int(s.loop_beg * ratio))
|
||||
s.loop_end = max(0, min(int(s.loop_end * ratio), s.length))
|
||||
s.sus_beg = max(0, int(s.sus_beg * ratio))
|
||||
s.sus_end = max(0, min(int(s.sus_end * ratio), s.length))
|
||||
s.c5_speed = max(1, int(s.c5_speed * ratio))
|
||||
if id(s) in seen_g:
|
||||
continue
|
||||
seen_g.add(id(s))
|
||||
_scale_sample(s, global_ratio)
|
||||
|
||||
# ── Pass 2: per-sample u16 cap (each sample must fit in 65535 bytes) ────
|
||||
# The Taud instrument record stores the sample length as u16, and TOP_O
|
||||
# offsets address up to 0xFF00 bytes — anything longer would silently
|
||||
# truncate at load time and over-shoot O-jumps. Resample only the
|
||||
# over-long samples and remember each one's individual ratio so the
|
||||
# caller can rescale TOP_O args per channel rather than globally.
|
||||
per_sample_ratio = {} # id(s) → per-sample ratio (after global)
|
||||
seen_p = set()
|
||||
for _, s in pcm_list:
|
||||
if id(s) in seen_p:
|
||||
continue
|
||||
seen_p.add(id(s))
|
||||
if len(s.sample_data) > SAMPLE_LEN_LIMIT:
|
||||
r = SAMPLE_LEN_LIMIT / len(s.sample_data)
|
||||
vprint(f" info: '{s.name}' exceeds {SAMPLE_LEN_LIMIT}-byte cap "
|
||||
f"({len(s.sample_data)}); resampling by {r:.4f}")
|
||||
_scale_sample(s, r)
|
||||
per_sample_ratio[id(s)] = r
|
||||
|
||||
# Effective slot → ratio for TOP_O rescaling. Slots sharing a sample
|
||||
# object (IT use_instruments mode) get the same ratio.
|
||||
slot_ratios = {}
|
||||
for slot_idx, s in pcm_list:
|
||||
slot_ratios[slot_idx] = global_ratio * per_sample_ratio.get(id(s), 1.0)
|
||||
ratio = slot_ratios
|
||||
|
||||
sample_bin = bytearray(SAMPLEBIN_SIZE)
|
||||
offsets = {}
|
||||
@@ -1719,8 +1755,13 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
||||
pat_bin += build_pattern_it(cg, ch, default_pans[vi], inst_vols,
|
||||
amiga_mode=not h.linear_slides)
|
||||
|
||||
# Rescale TOP_O sample-offset args if samples were globally downsampled.
|
||||
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
|
||||
# Rescale TOP_O sample-offset args per channel using the active slot's
|
||||
# ratio (combined global + per-sample). Walks pat_bin in cue-major /
|
||||
# channel-minor order, tracking the most recent inst byte seen on each
|
||||
# channel — must run before deduplication so the channel state stays
|
||||
# linear.
|
||||
pat_bin = rescale_offset_effects_per_slot(
|
||||
bytes(pat_bin), len(taud_cue_list), C, sample_ratio)
|
||||
|
||||
orig_count = len(taud_cue_list) * C
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
||||
|
||||
@@ -7,9 +7,9 @@ Usage:
|
||||
Limits:
|
||||
- Up to 20 MOD channels (excess disabled; hard error if pattern count
|
||||
× channel count > 4095).
|
||||
- Sample bin is 737280 bytes; if all samples together exceed this, every
|
||||
sample is globally resampled down (with c2spd adjusted) so pitch is
|
||||
preserved.
|
||||
- Sample bin is 8 MB (8388608 bytes); if all samples together exceed
|
||||
this, every sample is globally resampled down (with c2spd adjusted)
|
||||
so pitch is preserved.
|
||||
|
||||
Effect support:
|
||||
Full PT effect dispatch per TAUD_NOTE_EFFECTS.md "ProTracker to Taud
|
||||
|
||||
@@ -7,9 +7,9 @@ Usage:
|
||||
Limits:
|
||||
- Up to 20 S3M channels (excess disabled; hard error if pattern count
|
||||
× channel count > 4095).
|
||||
- Sample bin is 737280 bytes; if all samples together exceed this, every
|
||||
sample is globally resampled down (with c2spd adjusted) so pitch is
|
||||
preserved.
|
||||
- Sample bin is 8 MB (8388608 bytes); if all samples together exceed
|
||||
this, every sample is globally resampled down (with c2spd adjusted)
|
||||
so pitch is preserved.
|
||||
- AdLib instruments are skipped.
|
||||
|
||||
Effect support:
|
||||
|
||||
@@ -72,9 +72,16 @@ TAUD_VERSION = 1
|
||||
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+projOff(4)+sig(14)
|
||||
TAUD_SONG_ENTRY = 32 # full spec entry (see encode_song_entry)
|
||||
INST_RECORD_SIZE = 256 # widened 2026-05-06 (was 192). 256 inst × 256 = 64K.
|
||||
SAMPLEBIN_SIZE = 720896 # was 737280; 16K reallocated to inst bin (terranmon.txt:1985-1997)
|
||||
# Sample+instrument image (terranmon.txt:1985-1997, 2533-2564 — updated 2026-05-08).
|
||||
# Sample pool is now 8 MB, banked through MMIO 46 in 16 × 512 K windows.
|
||||
# Converters write the pool bank-major (bank 0's 512 K first, then bank 1's, ...);
|
||||
# the runtime decompresses the whole blob straight into native peripheral storage,
|
||||
# so converters just lay out an 8 MB linear array as if banking didn't exist.
|
||||
SAMPLE_BANK_SIZE = 524288 # 512 K per bank
|
||||
SAMPLE_BANK_COUNT = 16 # 16 banks × 512 K = 8 MB
|
||||
SAMPLEBIN_SIZE = SAMPLE_BANK_SIZE * SAMPLE_BANK_COUNT # 8 MB
|
||||
INSTBIN_SIZE = INST_RECORD_SIZE * 256 # 65536 = 64K
|
||||
SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE
|
||||
SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE # 8454144 = 8256 kB
|
||||
PATTERN_ROWS = 64
|
||||
PATTERN_BYTES = PATTERN_ROWS * 8 # 512
|
||||
NUM_PATTERNS_MAX = 4095
|
||||
@@ -82,6 +89,12 @@ NUM_CUES = 1024
|
||||
CUE_SIZE = 32
|
||||
NUM_VOICES = 20
|
||||
|
||||
# Per-sample length cap. Taud instrument records carry the sample length as
|
||||
# a u16 (terranmon.txt:2001+ — bytes 4..5), so any single sample must fit in
|
||||
# 65535 bytes. Converters resample over-long samples individually after the
|
||||
# global pool-overflow pass and rescale the affected channel's TOP_O args.
|
||||
SAMPLE_LEN_LIMIT = 65535
|
||||
|
||||
# Note word sentinels
|
||||
NOTE_NOP = 0xFFFF
|
||||
NOTE_KEYOFF = 0x0000
|
||||
@@ -272,6 +285,44 @@ def rescale_offset_effects(pat_bin: bytes, ratio: float) -> bytes:
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def rescale_offset_effects_per_slot(pat_bin: bytes,
|
||||
num_cues: int,
|
||||
num_channels: int,
|
||||
slot_ratios: dict) -> bytes:
|
||||
"""Scale TOP_O args using a per-slot ratio map.
|
||||
|
||||
`pat_bin` is laid out as `num_cues × num_channels` consecutive
|
||||
PATTERN_BYTES (=512) blocks, channel-minor within each cue. For each
|
||||
channel, walk the rows in cue order and track the most recently
|
||||
written slot byte (row offset 2). When a TOP_O effect appears, scale
|
||||
its arg by `slot_ratios[active_slot]`, falling back to ratio 1.0 if
|
||||
the slot is unknown (e.g. row hits an O before any inst byte has
|
||||
selected a sample for the channel).
|
||||
"""
|
||||
if not pat_bin or not slot_ratios:
|
||||
return pat_bin
|
||||
if all(r == 1.0 for r in slot_ratios.values()):
|
||||
return pat_bin
|
||||
out = bytearray(pat_bin)
|
||||
active = [0] * num_channels
|
||||
for cue in range(num_cues):
|
||||
for ch in range(num_channels):
|
||||
block = (cue * num_channels + ch) * PATTERN_BYTES
|
||||
for row in range(PATTERN_ROWS):
|
||||
rb = block + row * 8
|
||||
inst = out[rb + 2]
|
||||
if inst != 0:
|
||||
active[ch] = inst
|
||||
if out[rb + 5] == TOP_O:
|
||||
ratio = slot_ratios.get(active[ch], 1.0)
|
||||
if ratio != 1.0:
|
||||
arg = out[rb + 6] | (out[rb + 7] << 8)
|
||||
arg = max(0, min(0xFFFF, int(arg * ratio + 0.5)))
|
||||
out[rb + 6] = arg & 0xFF
|
||||
out[rb + 7] = (arg >> 8) & 0xFF
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def encode_cue(patterns12: list, instruction) -> bytearray:
|
||||
"""Encode a 32-byte cue entry for up to 20 voices with 12-bit pattern numbers.
|
||||
|
||||
|
||||
@@ -1985,18 +1985,14 @@ Synchronisation between playheads are not guaranteed. Do not play music in multi
|
||||
|
||||
Memory Space
|
||||
|
||||
0..720895 RW: Sample bin (704k)
|
||||
0..524287 RW: Sample bin window (512k)
|
||||
720896..786431 RW: Instrument bin (256 instruments, 256 bytes each; instrument 0 does nothing; 64k)
|
||||
786432..851967 RW: Play data 1 (currently exposed bank; 64k)
|
||||
851968..917503 RW: Play data 2 (currently exposed bank; 64k)
|
||||
917504..983039 RW: TAD Input Buffer (64k)
|
||||
983040..1048575 RW: TAD Decode Output (64k)
|
||||
|
||||
(Layout note 2026-05-06: sample bin shrunk by 16k and instrument bin widened
|
||||
by the same amount so all downstream dispatch ranges keep their existing
|
||||
anchors at 786432. Total memory space stays at exactly 1 MiB.)
|
||||
|
||||
Sample bin: just raw sample data thrown in there. You need to keep track of starting point for each sample
|
||||
Sample bin: just raw sample data thrown in there. You need to keep track of starting point for each sample. Actual sample memory is 8 MB and are banked. Write to MMIO address 46 to switch banks.
|
||||
|
||||
Instrument bin: Registry for 256 instruments, formatted as:
|
||||
|
||||
@@ -2293,7 +2289,6 @@ TODO:
|
||||
[x] 4THSYM.it: pitchbend is wrong, some notes keep playing (loudly!) even if new notes are emitted
|
||||
[x] `*2taud.py`: some notes are emitted with wrong volume-set command. Tested with GSLINGER.mod: on order 0x15 channel 1, mod2taud.py emits volume 8 -- also many of the effects are dropped. Suggested solution: currently all converters write default volume to the voleff when original modules (.mod/.s3m/.it) specify nothing; we should also write nothing and let the engine resolve the value just like other trackers do (also we now have "Instrument Global Volume" on instrument definition unlike the other time). This bug may affecting other formats, not just mod2taud.py, as well
|
||||
[x] nearly_there_.mod: `C#5 SD300 / ... / C-5 SD200 / A#4 / G#4 (at tickspeed 4)`: every `C-5 SD200` (there are four occurances) gets skipped
|
||||
[ ] low-number voleffs are too quiet (needs elaboration and test cases)
|
||||
[x] scale Oxxxx when samples get resampled
|
||||
[x] implement bitcrusher and overdrive (eff sym '8' and '9')
|
||||
[x] note trigger with inst and note fx set (e.g. portamento) but no volume set is not getting their default volume but getting what was before instead (SATELL.taud ptn 23) -- and simulateRowState() of taut.js always shows old volume instead of default volume, regardless of note fx's existence
|
||||
@@ -2346,12 +2341,13 @@ TODO:
|
||||
(`drawOrdersRowAt`) and per-column (`drawOrdersVoiceColumnAt`) helpers,
|
||||
replacing the full-panel redraw on every keystroke.
|
||||
[x] volume and panning policy to match note effect policy: when note is "retriggerred" (note command with instrument specified), the volume/pan must take default value; if not (note command with instrument 0) the volume/pan must stay at the old value. Make both audio engine and taut.js simulator changes.
|
||||
[ ] xm volume column commands (+x, -x, Dx, Lx, Mx, Px, Rx, Sx, Ux, Vx) are completely ignored
|
||||
[x] xm volume column commands (+x, -x, Dx, Lx, Mx, Px, Rx, Sx, Ux, Vx) are completely ignored
|
||||
[x] theday.xm order 0x28, channel 6..8 has 'note trigger with inst 1 but no volume -> key-off -> set-volume to 0x20 -> key-off -> set-volume to 0x10 -> key-off -> ...' and it sounds like gating: key-off silences the output, set-volume turns on the output again; notably, this behaviour only works when volume envelope is turned off (any fadeouts progress normally). FT2's keyOff (ft2_replayer.c:411-435) zeroes realVol/outVol when the volume envelope is disabled — IT/Schism does not, and Taud's engine follows IT semantics (no fade when fadeStep == 0). Resolved in xm2taud.py: a pre-pass tracks per-channel bound XM instrument across the order-list walk, and any key-off cell whose bound instrument has vol_env_type & XM_ENV_ON == 0 is paired with `SEL_SET vol=0` in the same row. A subsequent vol-col SET on the channel restores audibility — exactly mirroring FT2's outVol/realVol gate without diverging the engine. Engine semantics stay IT-pure.
|
||||
[ ] remove panning mode selection and replace global panning rule to 3 dB rule (not the equal energy)
|
||||
[x] FT2/MOD double effects with 00 as arg (500, 600) missing volume column -> easiest solution: fully implement `L xy00` and `K xy00` and map 5xx to L, 6xx to K (xm2taud, mod2taud), Kxy and Lxy verbatim (s3m2taud.py, it2taud.py). This is justified because the volume effects rely on memory when 00 is given, and said memory effect only get recalled when NoteFx is used. TAUD_NOTE_EFFECTS already has detailed implementation notes. Mark those two commands as implemented sorely for tracker compatibility.
|
||||
Also document then implement `Mxx` (set channel volume, not just a note: 0x00 to 0x3F) `Nxy` (channel volume slide: similar to Dxy, but applies to the current channel's volume, not just a note) `Pxy` (channel panning slide. Similar to Dxx: P0y - to the right, Px0 - to the left, PFy - fine pan right, PxF - fine pan left) effects
|
||||
[ ] 8 MB sample RAM via 512k banks
|
||||
[x] 8 MB sample RAM via 512k banks
|
||||
[ ] remove panning mode selection and replace global panning rule to 3 dB rule (not the equal energy)
|
||||
[ ] low-number voleffs are too quiet (needs elaboration and test cases)
|
||||
|
||||
|
||||
Play Data: play data are series of tracker-like instructions, visualised as:
|
||||
@@ -2407,6 +2403,7 @@ Audio Adapter MMIO
|
||||
44 RW: TAD Decoder Status
|
||||
Non-zero value indicates the decoder is busy. Different value may indicate different decoder status.
|
||||
45 RW: Select PCM Bin for playhead (writing causes side effects)
|
||||
46 RW: Select current sample bank for tracker, exposed at memory space 0..524287
|
||||
|
||||
64..2367 RW: MP2 Decoded Samples (unsigned 8-bit stereo)
|
||||
2368..4095 RW: MP2 Frame to be decoded
|
||||
@@ -2563,6 +2560,9 @@ Endianness: Little
|
||||
Uint32 Offset to Project Data. Zero if Project Data is nonexistent
|
||||
Byte[14]Tracker/Converter signature
|
||||
|
||||
## Sample and Instrument bin image
|
||||
8256 kB when decompressed. First 8 MB holds samples.
|
||||
|
||||
## Song Table
|
||||
* Rows of 32 bytes:
|
||||
Uint32 Song offset
|
||||
|
||||
@@ -185,6 +185,61 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
|
||||
fun getBaseAddr(): Int? = getFirstSnd()?.let { return it.vm.findPeriSlotNum(it)?.times(-131072)?.minus(1) }
|
||||
fun getMemAddr(): Int? = getFirstSnd()?.let { return it.vm.findPeriSlotNum(it)?.times(-1048576)?.minus(1) }
|
||||
|
||||
/** Switch the sample-bin window (peripheral memory 0..524287) to bank `bank` (0..15).
|
||||
* The 8 MB sample pool is organised as 16 × 512 K banks; only the selected bank
|
||||
* is visible through the window. (terranmon.txt:1985-1997, MMIO 46.) */
|
||||
fun setSampleBank(bank: Int) { getFirstSnd()?.mmio_write(46L, bank.toByte()) }
|
||||
fun getSampleBank(): Int? = getFirstSnd()?.sampleBank
|
||||
|
||||
/** Decompress a Taud sample+instrument blob (gzip or zstd) directly into the
|
||||
* audio adapter's 8 MB sample pool and 64 K instrument bin, bypassing the user
|
||||
* memory staging buffer. The decompressed payload must be exactly
|
||||
* `SAMPLE_BIN_TOTAL + 65536` bytes (8 MB samples followed by 64 K instruments).
|
||||
*
|
||||
* Needed because user space is capped at 8 MB and cannot hold the full 8256 kB
|
||||
* decompressed image as a contiguous buffer. */
|
||||
fun uploadSampleInstBlob(srcPtr: Int, srcLen: Int): Int {
|
||||
val snd = getFirstSnd() ?: return 0
|
||||
val inbytes = ByteArray(srcLen) { vm.peek(srcPtr.toLong() + it)!! }
|
||||
val bytes = CompressorDelegate.decomp(inbytes)
|
||||
val sampleSize = AudioAdapter.SAMPLE_BIN_TOTAL.toInt()
|
||||
val instSize = 65536
|
||||
if (bytes.size < sampleSize + instSize) return 0
|
||||
UnsafeHelper.memcpyRaw(
|
||||
bytes, UnsafeHelper.getArrayOffset(bytes),
|
||||
null, snd.sampleBin.ptr,
|
||||
sampleSize.toLong()
|
||||
)
|
||||
for (i in 0 until instSize) {
|
||||
snd.instruments[i / 256].setByte(i % 256, bytes[sampleSize + i].toInt() and 0xFF)
|
||||
}
|
||||
return bytes.size
|
||||
}
|
||||
|
||||
/** Compress the audio adapter's full 8 MB sample pool + 64 K instrument bin
|
||||
* (8256 kB total) and write the resulting gzip/zstd blob to user-memory `dstPtr`.
|
||||
* Returns the compressed size. The caller must ensure `dstMaxLen` is large
|
||||
* enough; for incompressible noise the worst case is ~8.3 MB which exceeds
|
||||
* user space — but realistic sample data compresses easily. */
|
||||
fun captureSampleInstBlob(dstPtr: Int, dstMaxLen: Int): Int {
|
||||
val snd = getFirstSnd() ?: return 0
|
||||
val sampleSize = AudioAdapter.SAMPLE_BIN_TOTAL.toInt()
|
||||
val instSize = 65536
|
||||
val raw = ByteArray(sampleSize + instSize)
|
||||
UnsafeHelper.memcpyRaw(
|
||||
null, snd.sampleBin.ptr,
|
||||
raw, UnsafeHelper.getArrayOffset(raw),
|
||||
sampleSize.toLong()
|
||||
)
|
||||
for (i in 0 until instSize) {
|
||||
raw[sampleSize + i] = snd.instruments[i / 256].getByte(i % 256)
|
||||
}
|
||||
val compressed = CompressorDelegate.comp(raw)
|
||||
val n = minOf(compressed.size, dstMaxLen)
|
||||
for (i in 0 until n) vm.poke((dstPtr + i).toLong(), compressed[i])
|
||||
return compressed.size
|
||||
}
|
||||
fun mp2Init() = getFirstSnd()?.mmio_write(40L, 16)
|
||||
fun mp2Decode() = getFirstSnd()?.mmio_write(40L, 1)
|
||||
fun mp2InitThenDecode() = getFirstSnd()?.mmio_write(40L, 17)
|
||||
|
||||
@@ -812,7 +812,9 @@ class VM(
|
||||
if (fromRel + len > 1048576) return null
|
||||
|
||||
return if (dev is AudioAdapter) {
|
||||
if (relPtrInDev(fromRel, len, 0, 114687)) dev.sampleBin.ptr + fromRel - 0
|
||||
// Sample-bin window: 0..524287 maps into the 8 MB pool through MMIO 46.
|
||||
if (relPtrInDev(fromRel, len, 0, 524287))
|
||||
dev.sampleBin.ptr + dev.sampleBank.toLong() * AudioAdapter.SAMPLE_BANK_SIZE + fromRel
|
||||
else null
|
||||
}
|
||||
else if (dev is GraphicsAdapter) {
|
||||
|
||||
@@ -62,7 +62,9 @@ class VMJSR223Delegate(private val vm: VM) {
|
||||
// System.err.println("MEMORY dev=${dev.typestring}, fromIndex=$fromIndex, fromRel=$fromRel")
|
||||
|
||||
return if (dev is AudioAdapter) {
|
||||
if (relPtrInDev(fromRel, len, 0, 114687)) dev.sampleBin.ptr + fromRel - 0
|
||||
// Sample-bin window: 0..524287 maps into the 8 MB pool through MMIO 46.
|
||||
if (relPtrInDev(fromRel, len, 0, 524287))
|
||||
dev.sampleBin.ptr + dev.sampleBank.toLong() * AudioAdapter.SAMPLE_BANK_SIZE + fromRel
|
||||
else null
|
||||
}
|
||||
else if (dev is GraphicsAdapter) {
|
||||
|
||||
@@ -144,13 +144,26 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// 8 ms at 32 kHz — long enough to bury the click, short enough not to read as fade.
|
||||
// Applied on sample end only (preserves attack transients on note start).
|
||||
const val RAMP_OUT_SAMPLES = 256
|
||||
|
||||
// Sample bin: 8 MB total, banked through a 512 K window at peripheral
|
||||
// memory 0..524287. MMIO 46 holds the currently-exposed bank index.
|
||||
const val SAMPLE_BANK_SIZE: Long = 524288L // 512 K
|
||||
const val SAMPLE_BANK_COUNT: Int = 16 // 16 × 512 K = 8 MB
|
||||
const val SAMPLE_BIN_TOTAL: Long = SAMPLE_BANK_SIZE * SAMPLE_BANK_COUNT
|
||||
const val SAMPLE_BANK_MASK: Int = SAMPLE_BANK_COUNT - 1
|
||||
}
|
||||
|
||||
// Memory map (terranmon.txt:1985-1997, updated 2026-05-06):
|
||||
// 0..720895 sample bin (704K, was 737280)
|
||||
// Memory map (terranmon.txt:1985-1997, updated 2026-05-08):
|
||||
// 0..524287 sample bin window (512K — exposes one bank of 8 MB pool)
|
||||
// 524288..720895 reserved (no-op on access)
|
||||
// 720896..786431 instrument bin (256 inst × 256 bytes = 64K)
|
||||
// 786432.. play data 1 / 2 / TAD blocks (anchors unchanged)
|
||||
internal val sampleBin = UnsafeHelper.allocate(720896L, this)
|
||||
//
|
||||
// Backing sample memory is 8 MB, banked in 16 × 512K pages. MMIO 46 holds
|
||||
// the currently-exposed bank index (0..15); reads/writes through the window
|
||||
// hit `sampleBin[sampleBank * 524288 + offset]`.
|
||||
internal val sampleBin = UnsafeHelper.allocate(SAMPLE_BIN_TOTAL, this)
|
||||
@Volatile var sampleBank: Int = 0 // 0..15, controls the 0..524287 window
|
||||
internal val instruments = Array(256) { TaudInst(it) }
|
||||
internal val playdata = Array(4096) { Array(64) { TaudPlayData(0xFFFF, 0, 0, 0, 32, 0, 0, 0) } }
|
||||
internal val playheads: Array<Playhead>
|
||||
@@ -322,7 +335,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
|
||||
override fun peek(addr: Long): Byte {
|
||||
return when (val adi = addr.toInt()) {
|
||||
in 0..720895 -> sampleBin[addr]
|
||||
in 0..524287 -> sampleBin[sampleBank * SAMPLE_BANK_SIZE + addr]
|
||||
in 524288..720895 -> 0 // reserved
|
||||
in 720896..786431 -> (adi - 720896).let { instruments[it / 256].getByte(it % 256) }
|
||||
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 + off / 512][(off % 512) / 8].getByte(off % 8) }
|
||||
@@ -336,7 +350,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val adi = addr.toInt()
|
||||
val bi = byte.toUint()
|
||||
when (adi) {
|
||||
in 0..720895 -> { sampleBin[addr] = byte }
|
||||
in 0..524287 -> { sampleBin[sampleBank * SAMPLE_BANK_SIZE + addr] = byte }
|
||||
in 524288..720895 -> { /* reserved */ }
|
||||
in 720896..786431 -> (adi - 720896).let { instruments[it / 256].setByte(it % 256, 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 + off / 512][(off % 512) / 8].setByte(off % 8, bi) }
|
||||
@@ -358,6 +373,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
43 -> tadQuality.toByte()
|
||||
44 -> tadBusy.toInt().toByte()
|
||||
45 -> selectedPcmBin.toByte()
|
||||
46 -> sampleBank.toByte()
|
||||
in 64..2367 -> mediaDecodedBin[addr - 64]
|
||||
in 2368..4095 -> mediaFrameBin[addr - 2368]
|
||||
in 4096..4097 -> 0
|
||||
@@ -393,6 +409,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
tadQuality = bi.coerceIn(0, 5)
|
||||
}
|
||||
45 -> selectedPcmBin = bi % 4
|
||||
46 -> sampleBank = bi and SAMPLE_BANK_MASK
|
||||
in 64..2367 -> { mediaDecodedBin[addr - 64] = byte }
|
||||
in 2368..4095 -> { mediaFrameBin[addr - 2368] = byte }
|
||||
in 32768..65535 -> { (adi - 32768).let {
|
||||
@@ -1619,7 +1636,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val sampleLen = inst.sampleLength.coerceAtLeast(1)
|
||||
val loopStart = inst.sampleLoopStart.toDouble()
|
||||
val loopEnd = inst.sampleLoopEnd.toDouble().coerceAtLeast(1.0)
|
||||
val binMax = 720895 // sampleBin is 720896 bytes (0..720895)
|
||||
val binMax = (SAMPLE_BIN_TOTAL - 1).toInt() // 8 MB pool, addressed via samplePtr directly (not banked)
|
||||
|
||||
val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1)
|
||||
val i1 = (i0 + 1).coerceAtMost(sampleLen - 1)
|
||||
|
||||
79
xm2taud.py
79
xm2taud.py
@@ -6,9 +6,13 @@ Usage:
|
||||
|
||||
Limits:
|
||||
- Up to 20 XM channels (excess unused).
|
||||
- Sample bin is 737280 bytes; if all samples together exceed this,
|
||||
every sample is globally resampled down (with c2spd adjusted) so
|
||||
pitch is preserved, mirroring it2taud / mod2taud.
|
||||
- Sample bin is 8 MB (8388608 bytes); if all samples together exceed
|
||||
this, every sample is globally resampled down (with c2spd adjusted)
|
||||
so pitch is preserved, mirroring it2taud / mod2taud. Any individual
|
||||
sample whose 8-bit-mono form still exceeds the u16 length cap
|
||||
(SAMPLE_LEN_LIMIT bytes) is then resampled selectively to fit, and
|
||||
TOP_O sample-offset args on the affected channel are rescaled
|
||||
per-slot.
|
||||
- Multi-sample instruments use the sample selected by the *current
|
||||
note's* keymap entry; the converter materialises one Taud
|
||||
instrument slot per (XM instrument, sample-in-instrument) pair.
|
||||
@@ -44,14 +48,15 @@ import sys
|
||||
from taud_common import (
|
||||
set_verbose, vprint,
|
||||
TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
|
||||
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE,
|
||||
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLE_LEN_LIMIT,
|
||||
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
|
||||
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4,
|
||||
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I,
|
||||
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_W, TOP_Y,
|
||||
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
|
||||
J_SEMI_TABLE,
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects_per_slot,
|
||||
encode_cue, deduplicate_patterns,
|
||||
normalise_sample, encode_song_entry, nearest_minifloat, compress_blob,
|
||||
CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len,
|
||||
)
|
||||
@@ -902,24 +907,60 @@ def _xm_sample_to_proxy(inst: XMInstrument, samp: XMSample,
|
||||
def build_sample_inst_bin_xm(proxies: list) -> tuple:
|
||||
"""proxies: list (1-indexed; slot 0 unused) of _XMSampleProxy | None.
|
||||
|
||||
Returns (sampleinst_bin, offsets_dict, ratio).
|
||||
Returns (sampleinst_bin, offsets_dict, slot_ratios) where slot_ratios
|
||||
maps Taud slot index → effective TOP_O scale (combined global ×
|
||||
per-sample resample ratio).
|
||||
"""
|
||||
pcm_list = [(i, s) for i, s in enumerate(proxies)
|
||||
if s is not None and s.sample_data]
|
||||
|
||||
def _scale_sample(s, r):
|
||||
s.sample_data = resample_linear(s.sample_data, r)
|
||||
s.length = len(s.sample_data)
|
||||
s.loop_begin = max(0, int(s.loop_begin * r))
|
||||
s.loop_end = max(0, min(int(s.loop_end * r), s.length))
|
||||
s.c2spd = max(1, int(s.c2spd * r))
|
||||
|
||||
# ── Pass 1: global pool-overflow resample (8 MB cap) ────────────────────
|
||||
total = sum(len(s.sample_data) for _, s in pcm_list)
|
||||
ratio = 1.0
|
||||
global_ratio = 1.0
|
||||
if total > SAMPLEBIN_SIZE:
|
||||
ratio = SAMPLEBIN_SIZE / total
|
||||
global_ratio = SAMPLEBIN_SIZE / total
|
||||
vprint(f" info: sample bin overflow ({total} bytes); "
|
||||
f"resampling all by {ratio:.4f}")
|
||||
f"resampling all by {global_ratio:.4f}")
|
||||
seen_g = set()
|
||||
for _, s in pcm_list:
|
||||
new_data = resample_linear(s.sample_data, ratio)
|
||||
s.sample_data = new_data
|
||||
s.length = len(new_data)
|
||||
s.loop_begin = max(0, int(s.loop_begin * ratio))
|
||||
s.loop_end = max(0, min(int(s.loop_end * ratio), s.length))
|
||||
s.c2spd = max(1, int(s.c2spd * ratio))
|
||||
if id(s) in seen_g:
|
||||
continue
|
||||
seen_g.add(id(s))
|
||||
_scale_sample(s, global_ratio)
|
||||
|
||||
# ── Pass 2: per-sample u16 cap (each sample must fit in 65535 bytes) ────
|
||||
# The Taud instrument record stores the sample length as u16, and TOP_O
|
||||
# offsets address up to 0xFF00 bytes — anything longer would silently
|
||||
# truncate at load time and over-shoot O-jumps. Resample only the
|
||||
# over-long samples and remember each one's individual ratio so the
|
||||
# caller can rescale TOP_O args per channel rather than globally.
|
||||
per_sample_ratio = {} # id(s) → per-sample ratio (after global)
|
||||
seen_p = set()
|
||||
for _, s in pcm_list:
|
||||
if id(s) in seen_p:
|
||||
continue
|
||||
seen_p.add(id(s))
|
||||
if len(s.sample_data) > SAMPLE_LEN_LIMIT:
|
||||
r = SAMPLE_LEN_LIMIT / len(s.sample_data)
|
||||
vprint(f" info: '{s.name}' exceeds {SAMPLE_LEN_LIMIT}-byte cap "
|
||||
f"({len(s.sample_data)}); resampling by {r:.4f}")
|
||||
_scale_sample(s, r)
|
||||
per_sample_ratio[id(s)] = r
|
||||
|
||||
# Effective slot → ratio for TOP_O rescaling. XM keymaps can route
|
||||
# several Taud slots to the same _XMSampleProxy (one slot per XM
|
||||
# sample-in-instrument), so they share the same per-sample ratio.
|
||||
slot_ratios = {}
|
||||
for slot_idx, s in pcm_list:
|
||||
slot_ratios[slot_idx] = global_ratio * per_sample_ratio.get(id(s), 1.0)
|
||||
ratio = slot_ratios
|
||||
|
||||
sample_bin = bytearray(SAMPLEBIN_SIZE)
|
||||
offsets = {}
|
||||
@@ -1322,7 +1363,13 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes:
|
||||
resolve_inst_slot,
|
||||
amiga_mode=not h.linear_freq,
|
||||
keyoff_zero_rows=row_marks)
|
||||
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
|
||||
# Rescale TOP_O sample-offset args per channel using the active slot's
|
||||
# ratio (combined global + per-sample). Walks pat_bin in cue-major /
|
||||
# channel-minor order, tracking the most recent inst byte seen on each
|
||||
# channel — must run before deduplication so the channel state stays
|
||||
# linear.
|
||||
pat_bin = rescale_offset_effects_per_slot(
|
||||
bytes(pat_bin), len(taud_cue_list), C, sample_ratio)
|
||||
|
||||
orig_count = len(taud_cue_list) * C
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
||||
|
||||
Reference in New Issue
Block a user