fixing the low volume issue, finally

This commit is contained in:
minjaesong
2026-05-09 01:57:40 +09:00
parent 6b02d73600
commit 935fbe04a6
8 changed files with 191 additions and 67 deletions

View File

@@ -1826,11 +1826,14 @@ function simulateRowState(ptnDat, uptoRow) {
const isGRow = (effop === OP_G)
const isNoteDelay = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0xD)
// Track whether this row reloads the channel's default volume. Engine:
// triggerNote() resets channelVolume to 0x3F only when the row carries an
// instrument byte; a note-only retrigger (inst === 0) inherits the
// channel's existing volume. Tone-porta rows follow the same rule —
// an instrument byte on a porta row reloads default vol (matches
// schism csf_instrument_change inst_column branch).
// triggerNote() (and the tone-porta-with-inst branch in advanceRow)
// seed channelVolume from the instrument's Default Note Volume (byte
// 196) — only when the row carries an instrument byte; a note-only
// retrigger (inst === 0) inherits the channel's existing volume.
// Tone-porta rows follow the same rule (matches schism
// csf_instrument_change inst_column branch, effects.c:1302).
// The simulator approximates the seed as 0x3F (legacy fallback) — see
// the longer note below the reload block for the limitation.
let reloadDefaultVol = false
if (note !== 0xFFFF && note !== 0xFFFE) {
if (note === 0x0000) {
@@ -1858,7 +1861,12 @@ function simulateRowState(ptnDat, uptoRow) {
// Pan: simulator does not track per-instrument default pan, so it never resets
// panAbs on trigger — this naturally matches the "stay at old value when inst === 0"
// half of the policy. The engine-side default-pan reload (gated on inst !== 0)
// is invisible here.
// is invisible here. Same limitation now applies to default volume: the engine
// seeds rowVolume from the instrument's byte-196 "Default Note Volume" since
// 2026-05-09 (terranmon §171, §196), but the simulator has no instrument-byte
// access, so it falls back to 0x3F — equivalent to the legacy "DNV unset"
// path. Tracker UI displays may therefore show a slightly off row volume on
// fresh triggers when the instrument carries a reduced DNV.
if (reloadDefaultVol) volAbs = 0x3F
// Pre-scan effect column for S$80xx (8-bit pan SET wins over volcol/pancol SET).

View File

@@ -1241,14 +1241,21 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
pan_env_sus = idata.get('pan_env_sus', 0)
pf_env_loop = idata.get('pf_env_loop', 0)
pf_env_sus = idata.get('pf_env_sus', 0)
# Sample-mode default IGV: fold sample default vol (Sv) and sample GV
# into Taud's IGV. Instrument-mode supplies inst_gv pre-folded.
# Sample-mode default IGV is now a pure continuous multiplier
# (sample.gv only — there is no inst.gv in IT sample mode). The
# samplewise default vol (Sv) is carried separately by byte 196.
# Instrument-mode supplies both inst_gv and default_note_vol pre-
# computed in the upstream proxy walk.
if 'inst_gv' in idata:
inst_gv = idata['inst_gv']
else:
smp_vol_default = min(getattr(s, 'vol', 64), 64)
smp_gv_default = min(getattr(s, 'gv', 64), 64)
inst_gv = min(255, round(smp_vol_default * smp_gv_default * 255 / (64 * 64)))
inst_gv = min(255, round(smp_gv_default * 255 / 64))
if 'default_note_vol' in idata:
default_note_vol = idata['default_note_vol']
else:
smp_vol_default = min(getattr(s, 'vol', 64), 64)
default_note_vol = min(255, round(smp_vol_default * 255 / 64))
# IT fadeout (file-stored 0..1024 per ITTECH; some loaders accept up to 2048) maps
# verbatim to Taud's 12-bit fadeStep. Schism's per-tick decrement is stored / 1024 of
# unit volume (sndmix.c:331-339, effects.c:1261: accumulator 65536, decrement
@@ -1328,7 +1335,11 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
dct = idata.get('dct', 0) & 0x03
dca = idata.get('dca', 0) & 0x03
inst_bin[base + 195] = (dca << 2) | dct
# Bytes 196..255: reserved (already zeroed).
# Byte 196: default note volume (per-trigger seed for rowVolume when
# no V column accompanies a fresh trigger). Replaces the old "fold
# sample.vol into IGV" trick — see terranmon byte 196 / TODO §2350.
inst_bin[base + 196] = default_note_vol & 0xFF
# Bytes 197..255: reserved (already zeroed).
vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}")
@@ -1412,9 +1423,10 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int,
# ── Volume column ────────────────────────────────────────────────────
# Priority: explicit cell vol (vol-col 0-64) > vol-col slide > main-
# effect vol override > nop. The per-instrument default volume is
# baked into IGV (byte 171), so the engine resolves note-trigger
# default volume itself; the converter no longer emits SEL_SET=Sv.
# effect vol override > nop. Per-trigger default volume now lives
# in byte 196 of the instrument record (DNV); the engine seeds
# rowVolume from it when this row has no V column, so the converter
# still doesn't need to emit SEL_SET=Sv on plain trigger rows.
if cell.volcol >= 0 and cell.volcol <= VC_VOL_HI:
vol_sel, vol_value = SEL_SET, min(cell.volcol, 0x3F)
elif vs != SEL_FINE or vv != 0:
@@ -1643,19 +1655,23 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
continue
src_smp = samples[si]
proxy[taud_slot] = src_smp
# IT cell-trigger initial volume comes from the sample's default
# volume (Sv, 0..64). It is folded into the Taud instrument's IGV
# (byte 171) along with IT inst.gv (0..128) and sample gv (0..64),
# so the engine applies all three as a single multiplier on every
# fresh trigger. inst_vols is retained only for legacy callers.
# IT splits per-sample volume into TWO concepts that Taud now
# carries in two separate bytes:
# * inst.gv (0..128) * sample.gv (0..64) — continuous multiplier
# on every output sample (matches Schism's
# `chan->instrument_volume = (psmp->global_volume * penv->global_volume) >> 7`,
# csndfile.c:1317). Goes to byte 171 (IGV).
# * sample.vol (Sv, 0..64) — per-trigger seed for chan->volume,
# replaceable by an explicit V column on the same row (Schism
# effects.c:1302, :1432, :1819). Goes to byte 196 (DNV).
# Folding sample.vol into IGV (the pre-2026-05-09 layout) caused
# any V-column override on a sample with default vol < 64 to be
# attenuated a second time — see terranmon §2350.
smp_default_vol = min(getattr(src_smp, 'vol', 64), 64)
inst_vols[taud_slot] = min(smp_default_vol, 0x3F)
# IT inst.gv (0..128) * sample.gv (0..64) * sample.vol (0..64)
# collapse into Taud's single instrumentwise IGV (0..255).
smp_gv = min(getattr(src_smp, 'gv', 64), 64)
inst_gv_255 = min(255, round(inst.gv * smp_gv * smp_default_vol * 255
/ (128 * 64 * 64)))
inst_gv_255 = min(255, round(inst.gv * smp_gv * 255 / (128 * 64)))
default_note_vol_255 = min(255, round(smp_default_vol * 255 / 64))
# IT pitch-pan centre: note number 0..119 (C-5 = 60). The Taud
# representation is the absolute 4096-TET note value used in patterns
@@ -1699,6 +1715,7 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
'pf_env_loop': inst.pf_env_loop,
'pf_env_sus': inst.pf_env_sus,
'inst_gv': inst_gv_255,
'default_note_vol': default_note_vol_255,
'fadeout': inst.fadeout,
'vib_speed': vib_speed_taud,
'vib_depth': vib_depth_taud,

View File

@@ -522,8 +522,9 @@ def build_sample_inst_bin(samples: list) -> tuple:
le = min(s.loop_end, 65535)
loop_mode = 1 if (s.flags & 1) else 0
flags_byte = loop_mode & 0x3
# Envelope first point is full-scale; per-sample level is carried by
# IGV (byte 171) so the envelope must contribute a unit multiplier.
# Envelope first point is full-scale; per-trigger initial level is
# carried by Default Note Volume (byte 196) so the envelope must
# contribute a unit multiplier.
env_vol = 63
# MOD has no envelopes; vol LOOP word b=1 just so the engine evaluates
# the unit envelope, plus P=1 (envelope present) for consistency with
@@ -545,14 +546,16 @@ def build_sample_inst_bin(samples: list) -> tuple:
struct.pack_into('<H', inst_bin, base + 19, 0)
inst_bin[base + 21] = env_vol
inst_bin[base + 22] = 0
# Instrument Global Volume carries the MOD sample's default volume (0..64 → 0..255).
# The pattern builder no longer emits SEL_SET=Sv on note triggers; the engine
# multiplies by IGV instead, so the per-instrument level lives here.
inst_bin[base + 171] = min(0xFF, round(min(s.volume, 64) * 255 / 64))
# MOD has no continuous instrumentwise volume scaler — its `s.volume`
# (0..64) is purely the per-trigger initial value. Byte 171 (IGV)
# stays at full and byte 196 (DNV) carries the per-instrument default.
# Pre-2026-05-09 layout folded s.volume into IGV — see terranmon §2350.
inst_bin[base + 171] = 0xFF # IGV: continuous unity
inst_bin[base + 177] = 0x80 # default pan = centre (unused; pan env "p" flag not set)
inst_bin[base + 182] = 0xFF # filter cutoff = off
inst_bin[base + 183] = 0xFF # filter resonance = off
inst_bin[base + 186] = 1 # NNA: note cut
inst_bin[base + 196] = min(0xFF, round(min(s.volume, 64) * 255 / 64)) # DNV
vprint(f" instrument[{taud_idx}] '{s.name}' ptr={ptr} c2spd={s.c2spd} "
f"vol={s.volume} loop=({ls},{le},{'on' if loop_mode else 'off'})")
@@ -573,9 +576,9 @@ def build_pattern(grid: list, ch_idx: int, default_pan: int,
"""Build a 512-byte Taud pattern for one MOD channel.
Volume column: explicit Cxx → SEL_SET; effect-folded vol slide → vol_override;
otherwise SEL_FINE/0 (no-op). Per-instrument default volume lives in IGV
(byte 171) and is applied by the engine on every fresh trigger — the
converter no longer has to emit SEL_SET=Sv to scale notes.
otherwise SEL_FINE/0 (no-op). Per-instrument default volume lives in DNV
(byte 196) and is consulted by the engine when the trigger row has no V
column — the converter doesn't need to emit SEL_SET=Sv on plain triggers.
"""
out = bytearray(PATTERN_BYTES)
rows = grid[ch_idx] if ch_idx < len(grid) else [ModRow()] * MOD_PATTERN_ROWS

View File

@@ -212,11 +212,15 @@ def build_sample_inst_bin() -> bytes:
struct.pack_into('<H', inst_bin, base + 19, 0) # pitch-env flags (P=0 → mixer skips)
inst_bin[base + 21] = 63 # vol env pt 0 = full
inst_bin[base + 22] = 0
inst_bin[base + 171] = 0xA0 # IGV
inst_bin[base + 171] = 0xA0 # IGV (square-wave headroom)
inst_bin[base + 177] = 0x80 # default pan = centre
inst_bin[base + 182] = 0xFF # filter cutoff off
inst_bin[base + 183] = 0xFF # filter resonance off
inst_bin[base + 186] = 0x01 # NNA: cut
# Monotone has no per-sample default volume concept (only one synth
# voice, no V column overrides). Set DNV to full so triggers get the
# full 0x3F rowVolume; the IGV above provides the actual attenuation.
inst_bin[base + 196] = 0xFF # DNV: full
return bytes(sample_bin) + bytes(inst_bin)

View File

@@ -499,8 +499,9 @@ def build_sample_inst_bin(instruments: list) -> tuple:
loop_mode = 1 if (inst.flags & 1) else 0
flags_byte = loop_mode & 0x3 # 0b 0000 00pp
# Volume envelope first point is full-scale; per-sample level is carried
# by IGV (byte 171) so the envelope contributes a unit multiplier.
# Volume envelope first point is full-scale; per-trigger initial level
# is carried by Default Note Volume (byte 196), so the envelope
# contributes a unit multiplier.
env_vol = 63
# Vol LOOP word: P=1 (envelope present) | b=1 (use envelope) — no actual
# loop / sustain. P added 2026-05-06 alongside the pan/pf gate spec
@@ -523,14 +524,17 @@ def build_sample_inst_bin(instruments: list) -> tuple:
# Volume env point 0: hold at env_vol indefinitely (offset minifloat = 0 → hold).
inst_bin[base + 21] = env_vol
inst_bin[base + 22] = 0
# Instrument Global Volume carries the S3M instrument's default volume (0..64 → 0..255).
# The pattern builder no longer emits SEL_SET=Sv on note triggers; the engine
# multiplies by IGV instead, so the per-instrument level lives here.
inst_bin[base + 171] = min(0xFF, round(min(inst.volume, 64) * 255 / 64))
# S3M has no continuous instrumentwise volume scaler — its `inst.volume`
# (0..64) is purely the per-trigger initial value, equivalent to IT's
# sample.vol. So byte 171 (IGV) stays at full and byte 196 (DNV)
# carries the per-instrument default. Pre-2026-05-09 layout folded
# inst.volume into IGV — see terranmon §2350.
inst_bin[base + 171] = 0xFF # IGV: continuous unity
inst_bin[base + 177] = 0x80 # default pan = centre (unused; pan env "p" flag not set)
inst_bin[base + 182] = 0xFF # filter cutoff = off
inst_bin[base + 183] = 0xFF # filter resonance = off
inst_bin[base + 186] = 1 # NNA: note cut
inst_bin[base + 196] = min(0xFF, round(min(inst.volume, 64) * 255 / 64)) # DNV
vprint(f" instrument[{base // INST_STRIDE}] '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'")
if inst.c2spd > 65535:
@@ -558,8 +562,9 @@ def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int,
Volume column: explicit S3M cell vol -> SEL_SET; M/N/K/L vol slides folded
by encode_effect -> vol_override; otherwise SEL_FINE/0 (no-op). Per-
instrument default volume lives in IGV (byte 171) and is applied by the
engine on every fresh trigger, so the converter no longer emits SEL_SET=Sv.
instrument default volume lives in DNV (byte 196) and is consulted by
the engine when the trigger row has no V column, so the converter
doesn't need to emit SEL_SET=Sv on plain trigger rows.
Pan column: row 0 emits SEL_SET = default_pan to position the channel;
other rows default to SEL_FINE/0 unless an X/P/etc effect overrides.
"""

View File

@@ -2141,9 +2141,23 @@ from source.
Byte 1: Value (00..FF)
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat, biased; range 0..15.75 s, smallest non-zero step 1/256 s ≈ 3.91 ms — chosen so single tracker ticks resolve at every supported BPM). 0 = hold at this point indefinitely.
171 Uint8 Instrument Global Volume (0..255)
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
- ImpulseTracker also has samplewise default volume (0..64) and samplewise global volume (0..64), and they must be taken into account because Taud has no samplewise config, following the ImpulseTracker spec
* FastTracker2 has range of 0..64; multiply by (255/64) then round to int
* Continuous multiplier applied on every output sample (matches IT's
`chan->instrument_volume`, see Schism player/csndfile.c:1317 and
player/sndmix.c:1171). Independent of the volume column / Mxx /
Nxx — those operate on rowVolume/channelVolume, while IGV scales
the final mix unconditionally.
* ImpulseTracker has separate `inst.gv` (0..128) and samplewise
`sample.gv` (0..64). Since Taud has no samplewise record, fold
the two factors into a single 0..255 value:
taud_igv = round(inst.gv * sample.gv * 255 / (128 * 64))
The samplewise `sample.vol` (0..64) is NOT folded here — it is the
per-trigger default chan_volume in IT (replaceable by V column),
and Taud carries it in byte 196 ("Default Note Volume"). Folding
it here was the cause of the "low-number voleffs are too quiet"
regression (TODO §2350, fixed 2026-05-09).
* FastTracker2 has range of 0..64 with no instrumentwise multiplier
beyond it; multiply by (255/64) and round. The XM samplewise
volume goes into byte 196.
172 Uint8 Volume Fadeout low bits
173 Bit8 Volume Fadeout high bits
0b 0000 ffff
@@ -2267,7 +2281,27 @@ from source.
triggerNote. So when DCA flags the foreground voice, the NNA-ghost it
spawns inherits that DCA-modified state (e.g. noteFading carries over).
- The new note then triggers normally on the foreground channel.
196..255 Reserved (60 bytes free for future per-instrument fields)
196 Uint8 Default Note Volume (0..255)
* Per-trigger default for `channelVolume` / `rowVolume` when the row
carries a fresh note + instrument byte but no explicit volume column
(matches IT's `chan->volume = psmp->volume` on note-on, Schism
player/effects.c:1302 and :1432). The 8-bit value rescales to
Taud's 0..63 row volume range:
row_default = round(default_note_volume * 63 / 255)
Any explicit V column on the trigger row OVERRIDES this — i.e.
rowVolume = vol_value, exactly mirroring IT's "V column replaces
chan->volume" rule.
* Source-format mapping:
- IT: taud_dnv = round(sample.vol * 255 / 64) # 0..64 → 0..255
- XM: taud_dnv = round(sample.volume * 255 / 64) # 0..64 → 0..255
- S3M: taud_dnv = round(min(inst.volume, 64) * 255 / 64)
- MOD: taud_dnv = round(min(sample.volume, 64) * 255 / 64)
* .taud files written before 2026-05-09 stored sample.vol folded into
byte 171 (IGV) and left this byte zero. Engines reading those older
files SHOULD treat default_note_volume == 0 as "field not present"
and fall back to row_default = 63 — preserving the pre-fix behaviour
for legacy files where IGV already carries sample.vol.
197..255 Reserved (59 bytes free for future per-instrument fields)
@@ -2347,7 +2381,19 @@ TODO:
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
[x] 8 MB sample RAM via 512k banks
[x] remove panning mode selection and replace global panning rule to equal energy, also move the 'ff' flags to bit 0..1
[ ] low-number voleffs are too quiet (needs elaboration and test cases)
[x] low-number voleffs are too quiet (resolved 2026-05-09).
Root cause: the converters folded IT `sample.vol` into IGV (byte 171),
and the engine multiplied by IGV continuously — so any V-column override
on a sample with default vol < 64 was attenuated a second time, while
IT/Schism's V column replaces `chan->volume` outright (sample.vol does
not feature in the continuous `instrument_volume` factor — see
player/csndfile.c:1317 and player/sndmix.c:1171).
Fix: split the two concepts apart. Byte 171 (IGV) is now pure
`inst.gv * sample.gv` continuous multiplier; new byte 196 ("Default
Note Volume") carries `sample.vol` and is consulted by triggerNote
when no V column is present. Engine + all four `*2taud` converters
updated; legacy `.taud` files (byte 196 == 0) fall back to the
previous "row volume default = 63" behaviour.
Play Data: play data are series of tracker-like instructions, visualised as:

View File

@@ -1701,6 +1701,20 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
* Trigger a fresh note on [voice]: load the instrument, reset sample position, kick off the envelope.
* Pulled out so S$Dx (note delay) can defer the same logic to a later tick.
*/
/**
* Trigger-time default rowVolume seed derived from the instrument's
* Default Note Volume (byte 196). Pre-2026-05-09 .taud files left this
* byte zero; treating 0 as "field not present" and falling back to 0x3F
* keeps legacy behaviour. Used by both [triggerNote] and the tone-porta
* + instrument-byte path in [advanceRow] — both must seed identically
* (Schism player/effects.c:1302 writes `chan->volume = psmp->volume`
* unconditionally on inst-column rows, regardless of porta).
*/
private fun rowVolumeFromDefault(inst: TaudInst): Int {
val dnv = inst.defaultNoteVolume
return if (dnv == 0) 0x3F else (dnv * 63 + 127) / 255
}
private fun triggerNote(voice: Voice, noteVal: Int, instId: Int, volOverride: Int) {
if (instId != 0) voice.instrumentId = instId
val inst = instruments[voice.instrumentId]
@@ -1773,14 +1787,18 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.amigaPeriod = -1.0 // fresh trigger: period state must reseed from the new noteVal
voice.linearFreq = -1.0 // ditto for linear-freq mode (toneMode == 2)
voice.playbackRate = computePlaybackRate(inst, noteVal)
// Fresh trigger resets channel volume to full ($3F) ONLY when the row carried an
// instrument byte; a note-only retrigger (instId == 0) inherits the channel's existing
// volume so the user can sustain a held volume across re-triggered notes. Per-instrument
// scaling lives in instGlobalVolume (byte 171), which the mixer applies as a multiplier.
// Converters therefore no longer need to emit SEL_SET=Sv on note-trigger rows.
// Fresh trigger seeds rowVolume from the per-instrument "default note volume"
// (byte 196) when the row carried an instrument byte but no explicit V column —
// matching IT's `chan->volume = psmp->volume` rule (Schism player/effects.c:1302
// and :1432). Pre-2026-05-09 .taud files left byte 196 zero and folded sample.vol
// into IGV instead; treating 0 as "field not present" and falling back to 0x3F
// preserves legacy behaviour. A note-only retrigger (instId == 0) inherits the
// channel's existing volume so held-volume sustains keep working across retriggers.
// Continuous per-instrument scaling lives in instGlobalVolume (byte 171), which the
// mixer applies independently of this seed.
voice.channelVolume = when {
volOverride >= 0 -> volOverride.coerceIn(0, 0x3F)
instId != 0 -> 0x3F
instId != 0 -> rowVolumeFromDefault(inst)
else -> voice.channelVolume
}
voice.rowVolume = voice.channelVolume
@@ -2054,11 +2072,17 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// so an in-progress fadeout from the prior note does not bleed
// into the porta'd note. fadeoutVolume is reset to unity so a
// volume-column SET on this row is heard at face value rather
// than scaled by the decayed tail.
// than scaled by the decayed tail. The seed must use the new
// instrument's Default Note Volume (byte 196) — hard-coding
// 0x3F here would push samples with a reduced default vol up
// to full level on every porta-with-inst row (e.g.
// nearly_there_.mod ord 0x1B ch 4 r49 jumped from ~35 to 63
// and the bump persisted through the following vibrato rows).
if (row.instrment != 0) {
voice.instrumentId = row.instrment
voice.channelVolume = 0x3F
voice.rowVolume = 0x3F
val seedVol = rowVolumeFromDefault(instruments[voice.instrumentId])
voice.channelVolume = seedVol
voice.rowVolume = seedVol
voice.keyOff = false
voice.noteFading = false
voice.fadeoutVolume = 1.0
@@ -3512,7 +3536,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
* 193..194 u16 pitch/filter envelope SUSTAIN word
* 195 u8 duplicate-check / action (relocated from old offset 189)
* bits 0-1 = DCT, bits 2-3 = DCA
* 196..255 reserved (60 bytes)
* 196 u8 default note volume (0..255 → 0..63 on read).
* Per-trigger seed for rowVolume when the row carries
* a fresh note + instrument byte but no V column. 0
* means "legacy file, fall back to 0x3F" (pre-2026-05-09
* files folded sample.vol into IGV instead).
* 197..255 reserved (59 bytes)
*/
data class TaudInst(
var index: Int,
@@ -3549,7 +3578,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var volEnvSustainWord: Int, // bytes 189-190 (SUSTAIN word)
var panEnvSustainWord: Int, // bytes 191-192
var pfEnvSustainWord: Int, // bytes 193-194
var dupCheckFlag: Int // byte 195 (relocated from 189)
var dupCheckFlag: Int, // byte 195 (relocated from 189)
var defaultNoteVolume: Int // byte 196 — per-trigger rowVolume default
) {
constructor(index: Int) : this(
index, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF,
@@ -3557,7 +3587,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
Array(25) { TaudInstEnvPoint(0x80, ThreeFiveMiniUfloat(0)) },
Array(25) { TaudInstEnvPoint(0x80, ThreeFiveMiniUfloat(0)) },
0, 0, 0, 0, 0, 0x80, 0x5000, 0, 0, 0xFF, 0,
0, 0, 0, 0, 0, 0, 0, 0
0, 0, 0, 0, 0, 0, 0, 0,
0
)
/** Sample-flag byte 14 bit 2 — when set, the sample loop is a sustain loop:
@@ -3576,8 +3607,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
/** Duplicate Check Action — 0=note cut, 1=note off, 2=note fade. */
val duplicateCheckAction: Int get() = (dupCheckFlag ushr 2) and 0x03
// Reserved padding at offsets 196..255 (60 bytes per instrument).
private val reserved = ByteArray(60)
// Reserved padding at offsets 197..255 (59 bytes per instrument).
// Byte 196 is the new "default note volume" field — see triggerNote.
private val reserved = ByteArray(59)
// Funk repeat (S$Fx00) bit-mask — non-destructive XOR overlay across the loop region.
// Lazily allocated; a 1-bit flips the byte, a 0-bit leaves it intact.
@@ -3666,7 +3698,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
193 -> pfEnvSustainWord.toByte()
194 -> pfEnvSustainWord.ushr(8).toByte()
195 -> dupCheckFlag.toByte()
in 196..255 -> reserved[offset - 196]
196 -> defaultNoteVolume.toByte()
in 197..255 -> reserved[offset - 197]
else -> throw InternalError("Bad offset $offset")
}
@@ -3728,7 +3761,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
193 -> { pfEnvSustainWord = (pfEnvSustainWord and 0xff00) or byte }
194 -> { pfEnvSustainWord = (pfEnvSustainWord and 0x00ff) or (byte shl 8) }
195 -> { dupCheckFlag = byte and 0x0F }
in 196..255 -> { reserved[offset - 196] = byte.toByte() }
196 -> { defaultNoteVolume = byte and 0xFF }
in 197..255 -> { reserved[offset - 197] = byte.toByte() }
else -> throw InternalError("Bad offset $offset")
}
}

View File

@@ -1013,8 +1013,9 @@ def build_sample_inst_bin_xm(proxies: list) -> tuple:
# Resolve envelope LOOP / SUSTAIN words from the proxy. When XM has no
# envelope, fall back to a single-point unit envelope (vol LOOP word
# b=1 plus P=1 for consistency) and rely on IGV for level. Pan stays
# zero so the engine sees P=0 there and skips envelope-driven pan.
# b=1 plus P=1 for consistency) and rely on DNV (byte 196) for the
# per-trigger initial level. Pan stays zero so the engine sees P=0
# there and skips envelope-driven pan.
if s.vol_env_pts is not None:
vol_env_loop = s.vol_env_loop_word
vol_env_sus = s.vol_env_sus_word
@@ -1063,8 +1064,14 @@ def build_sample_inst_bin_xm(proxies: list) -> tuple:
inst_bin[base + 121 + k * 2] = 0x80
inst_bin[base + 121 + k * 2 + 1] = 0x00
# IGV: XM volume 0..64 → 0..255
inst_bin[base + 171] = min(0xFF, round(s.volume * 255 / 64))
# XM has no continuous instrumentwise volume scaler — `s.volume` (0..64)
# is purely the per-trigger initial value (FT2 ft2_replayer.c handles
# this exactly the same as IT does with sample.vol). So byte 171 (IGV)
# stays at full unity and byte 196 (DNV) carries the per-instrument
# default. Pre-2026-05-09 layout folded s.volume into IGV — see
# terranmon §2350.
inst_bin[base + 171] = 0xFF # IGV: continuous unity
inst_bin[base + 196] = min(0xFF, round(s.volume * 255 / 64)) # DNV
# Fadeout: 12-bit. Low 8 bits at +172, high 4 bits at +173.
inst_bin[base + 172] = s.fadeout & 0xFF
inst_bin[base + 173] = (s.fadeout >> 8) & 0x0F