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 isGRow = (effop === OP_G)
const isNoteDelay = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0xD) const isNoteDelay = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0xD)
// Track whether this row reloads the channel's default volume. Engine: // Track whether this row reloads the channel's default volume. Engine:
// triggerNote() resets channelVolume to 0x3F only when the row carries an // triggerNote() (and the tone-porta-with-inst branch in advanceRow)
// instrument byte; a note-only retrigger (inst === 0) inherits the // seed channelVolume from the instrument's Default Note Volume (byte
// channel's existing volume. Tone-porta rows follow the same rule — // 196) — only when the row carries an instrument byte; a note-only
// an instrument byte on a porta row reloads default vol (matches // retrigger (inst === 0) inherits the channel's existing volume.
// schism csf_instrument_change inst_column branch). // 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 let reloadDefaultVol = false
if (note !== 0xFFFF && note !== 0xFFFE) { if (note !== 0xFFFF && note !== 0xFFFE) {
if (note === 0x0000) { if (note === 0x0000) {
@@ -1858,7 +1861,12 @@ function simulateRowState(ptnDat, uptoRow) {
// Pan: simulator does not track per-instrument default pan, so it never resets // 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" // 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) // 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 if (reloadDefaultVol) volAbs = 0x3F
// Pre-scan effect column for S$80xx (8-bit pan SET wins over volcol/pancol SET). // 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) pan_env_sus = idata.get('pan_env_sus', 0)
pf_env_loop = idata.get('pf_env_loop', 0) pf_env_loop = idata.get('pf_env_loop', 0)
pf_env_sus = idata.get('pf_env_sus', 0) pf_env_sus = idata.get('pf_env_sus', 0)
# Sample-mode default IGV: fold sample default vol (Sv) and sample GV # Sample-mode default IGV is now a pure continuous multiplier
# into Taud's IGV. Instrument-mode supplies inst_gv pre-folded. # (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: if 'inst_gv' in idata:
inst_gv = idata['inst_gv'] inst_gv = idata['inst_gv']
else: else:
smp_vol_default = min(getattr(s, 'vol', 64), 64)
smp_gv_default = min(getattr(s, 'gv', 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 # 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 # 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 # 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 dct = idata.get('dct', 0) & 0x03
dca = idata.get('dca', 0) & 0x03 dca = idata.get('dca', 0) & 0x03
inst_bin[base + 195] = (dca << 2) | dct 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}") 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 ──────────────────────────────────────────────────── # ── Volume column ────────────────────────────────────────────────────
# Priority: explicit cell vol (vol-col 0-64) > vol-col slide > main- # Priority: explicit cell vol (vol-col 0-64) > vol-col slide > main-
# effect vol override > nop. The per-instrument default volume is # effect vol override > nop. Per-trigger default volume now lives
# baked into IGV (byte 171), so the engine resolves note-trigger # in byte 196 of the instrument record (DNV); the engine seeds
# default volume itself; the converter no longer emits SEL_SET=Sv. # 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: if cell.volcol >= 0 and cell.volcol <= VC_VOL_HI:
vol_sel, vol_value = SEL_SET, min(cell.volcol, 0x3F) vol_sel, vol_value = SEL_SET, min(cell.volcol, 0x3F)
elif vs != SEL_FINE or vv != 0: elif vs != SEL_FINE or vv != 0:
@@ -1643,19 +1655,23 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
continue continue
src_smp = samples[si] src_smp = samples[si]
proxy[taud_slot] = src_smp proxy[taud_slot] = src_smp
# IT cell-trigger initial volume comes from the sample's default # IT splits per-sample volume into TWO concepts that Taud now
# volume (Sv, 0..64). It is folded into the Taud instrument's IGV # carries in two separate bytes:
# (byte 171) along with IT inst.gv (0..128) and sample gv (0..64), # * inst.gv (0..128) * sample.gv (0..64) — continuous multiplier
# so the engine applies all three as a single multiplier on every # on every output sample (matches Schism's
# fresh trigger. inst_vols is retained only for legacy callers. # `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) smp_default_vol = min(getattr(src_smp, 'vol', 64), 64)
inst_vols[taud_slot] = min(smp_default_vol, 0x3F) 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) smp_gv = min(getattr(src_smp, 'gv', 64), 64)
inst_gv_255 = min(255, round(inst.gv * smp_gv * smp_default_vol * 255 inst_gv_255 = min(255, round(inst.gv * smp_gv * 255 / (128 * 64)))
/ (128 * 64 * 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 # IT pitch-pan centre: note number 0..119 (C-5 = 60). The Taud
# representation is the absolute 4096-TET note value used in patterns # 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_loop': inst.pf_env_loop,
'pf_env_sus': inst.pf_env_sus, 'pf_env_sus': inst.pf_env_sus,
'inst_gv': inst_gv_255, 'inst_gv': inst_gv_255,
'default_note_vol': default_note_vol_255,
'fadeout': inst.fadeout, 'fadeout': inst.fadeout,
'vib_speed': vib_speed_taud, 'vib_speed': vib_speed_taud,
'vib_depth': vib_depth_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) le = min(s.loop_end, 65535)
loop_mode = 1 if (s.flags & 1) else 0 loop_mode = 1 if (s.flags & 1) else 0
flags_byte = loop_mode & 0x3 flags_byte = loop_mode & 0x3
# Envelope first point is full-scale; per-sample level is carried by # Envelope first point is full-scale; per-trigger initial level is
# IGV (byte 171) so the envelope must contribute a unit multiplier. # carried by Default Note Volume (byte 196) so the envelope must
# contribute a unit multiplier.
env_vol = 63 env_vol = 63
# MOD has no envelopes; vol LOOP word b=1 just so the engine evaluates # 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 # 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) struct.pack_into('<H', inst_bin, base + 19, 0)
inst_bin[base + 21] = env_vol inst_bin[base + 21] = env_vol
inst_bin[base + 22] = 0 inst_bin[base + 22] = 0
# Instrument Global Volume carries the MOD sample's default volume (0..64 → 0..255). # MOD has no continuous instrumentwise volume scaler — its `s.volume`
# The pattern builder no longer emits SEL_SET=Sv on note triggers; the engine # (0..64) is purely the per-trigger initial value. Byte 171 (IGV)
# multiplies by IGV instead, so the per-instrument level lives here. # stays at full and byte 196 (DNV) carries the per-instrument default.
inst_bin[base + 171] = min(0xFF, round(min(s.volume, 64) * 255 / 64)) # 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 + 177] = 0x80 # default pan = centre (unused; pan env "p" flag not set)
inst_bin[base + 182] = 0xFF # filter cutoff = off inst_bin[base + 182] = 0xFF # filter cutoff = off
inst_bin[base + 183] = 0xFF # filter resonance = off inst_bin[base + 183] = 0xFF # filter resonance = off
inst_bin[base + 186] = 1 # NNA: note cut 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} " 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'})") 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. """Build a 512-byte Taud pattern for one MOD channel.
Volume column: explicit Cxx → SEL_SET; effect-folded vol slide → vol_override; 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 otherwise SEL_FINE/0 (no-op). Per-instrument default volume lives in DNV
(byte 171) and is applied by the engine on every fresh trigger — the (byte 196) and is consulted by the engine when the trigger row has no V
converter no longer has to emit SEL_SET=Sv to scale notes. column — the converter doesn't need to emit SEL_SET=Sv on plain triggers.
""" """
out = bytearray(PATTERN_BYTES) out = bytearray(PATTERN_BYTES)
rows = grid[ch_idx] if ch_idx < len(grid) else [ModRow()] * MOD_PATTERN_ROWS 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) 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 + 21] = 63 # vol env pt 0 = full
inst_bin[base + 22] = 0 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 + 177] = 0x80 # default pan = centre
inst_bin[base + 182] = 0xFF # filter cutoff off inst_bin[base + 182] = 0xFF # filter cutoff off
inst_bin[base + 183] = 0xFF # filter resonance off inst_bin[base + 183] = 0xFF # filter resonance off
inst_bin[base + 186] = 0x01 # NNA: cut 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) 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 loop_mode = 1 if (inst.flags & 1) else 0
flags_byte = loop_mode & 0x3 # 0b 0000 00pp flags_byte = loop_mode & 0x3 # 0b 0000 00pp
# Volume envelope first point is full-scale; per-sample level is carried # Volume envelope first point is full-scale; per-trigger initial level
# by IGV (byte 171) so the envelope contributes a unit multiplier. # is carried by Default Note Volume (byte 196), so the envelope
# contributes a unit multiplier.
env_vol = 63 env_vol = 63
# Vol LOOP word: P=1 (envelope present) | b=1 (use envelope) — no actual # 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 # 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). # Volume env point 0: hold at env_vol indefinitely (offset minifloat = 0 → hold).
inst_bin[base + 21] = env_vol inst_bin[base + 21] = env_vol
inst_bin[base + 22] = 0 inst_bin[base + 22] = 0
# Instrument Global Volume carries the S3M instrument's default volume (0..64 → 0..255). # S3M has no continuous instrumentwise volume scaler — its `inst.volume`
# The pattern builder no longer emits SEL_SET=Sv on note triggers; the engine # (0..64) is purely the per-trigger initial value, equivalent to IT's
# multiplies by IGV instead, so the per-instrument level lives here. # sample.vol. So byte 171 (IGV) stays at full and byte 196 (DNV)
inst_bin[base + 171] = min(0xFF, round(min(inst.volume, 64) * 255 / 64)) # 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 + 177] = 0x80 # default pan = centre (unused; pan env "p" flag not set)
inst_bin[base + 182] = 0xFF # filter cutoff = off inst_bin[base + 182] = 0xFF # filter cutoff = off
inst_bin[base + 183] = 0xFF # filter resonance = off inst_bin[base + 183] = 0xFF # filter resonance = off
inst_bin[base + 186] = 1 # NNA: note cut 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}'") vprint(f" instrument[{base // INST_STRIDE}] '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'")
if inst.c2spd > 65535: 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 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- 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 instrument default volume lives in DNV (byte 196) and is consulted by
engine on every fresh trigger, so the converter no longer emits SEL_SET=Sv. 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; 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. 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 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. 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) 171 Uint8 Instrument Global Volume (0..255)
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int * Continuous multiplier applied on every output sample (matches IT's
- 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 `chan->instrument_volume`, see Schism player/csndfile.c:1317 and
* FastTracker2 has range of 0..64; multiply by (255/64) then round to int 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 172 Uint8 Volume Fadeout low bits
173 Bit8 Volume Fadeout high bits 173 Bit8 Volume Fadeout high bits
0b 0000 ffff 0b 0000 ffff
@@ -2267,7 +2281,27 @@ from source.
triggerNote. So when DCA flags the foreground voice, the NNA-ghost it triggerNote. So when DCA flags the foreground voice, the NNA-ghost it
spawns inherits that DCA-modified state (e.g. noteFading carries over). spawns inherits that DCA-modified state (e.g. noteFading carries over).
- The new note then triggers normally on the foreground channel. - 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 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] 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 [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: 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. * 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. * 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) { private fun triggerNote(voice: Voice, noteVal: Int, instId: Int, volOverride: Int) {
if (instId != 0) voice.instrumentId = instId if (instId != 0) voice.instrumentId = instId
val inst = instruments[voice.instrumentId] 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.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.linearFreq = -1.0 // ditto for linear-freq mode (toneMode == 2)
voice.playbackRate = computePlaybackRate(inst, noteVal) voice.playbackRate = computePlaybackRate(inst, noteVal)
// Fresh trigger resets channel volume to full ($3F) ONLY when the row carried an // Fresh trigger seeds rowVolume from the per-instrument "default note volume"
// instrument byte; a note-only retrigger (instId == 0) inherits the channel's existing // (byte 196) when the row carried an instrument byte but no explicit V column —
// volume so the user can sustain a held volume across re-triggered notes. Per-instrument // matching IT's `chan->volume = psmp->volume` rule (Schism player/effects.c:1302
// scaling lives in instGlobalVolume (byte 171), which the mixer applies as a multiplier. // and :1432). Pre-2026-05-09 .taud files left byte 196 zero and folded sample.vol
// Converters therefore no longer need to emit SEL_SET=Sv on note-trigger rows. // 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 { voice.channelVolume = when {
volOverride >= 0 -> volOverride.coerceIn(0, 0x3F) volOverride >= 0 -> volOverride.coerceIn(0, 0x3F)
instId != 0 -> 0x3F instId != 0 -> rowVolumeFromDefault(inst)
else -> voice.channelVolume else -> voice.channelVolume
} }
voice.rowVolume = 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 // so an in-progress fadeout from the prior note does not bleed
// into the porta'd note. fadeoutVolume is reset to unity so a // into the porta'd note. fadeoutVolume is reset to unity so a
// volume-column SET on this row is heard at face value rather // 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) { if (row.instrment != 0) {
voice.instrumentId = row.instrment voice.instrumentId = row.instrment
voice.channelVolume = 0x3F val seedVol = rowVolumeFromDefault(instruments[voice.instrumentId])
voice.rowVolume = 0x3F voice.channelVolume = seedVol
voice.rowVolume = seedVol
voice.keyOff = false voice.keyOff = false
voice.noteFading = false voice.noteFading = false
voice.fadeoutVolume = 1.0 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 * 193..194 u16 pitch/filter envelope SUSTAIN word
* 195 u8 duplicate-check / action (relocated from old offset 189) * 195 u8 duplicate-check / action (relocated from old offset 189)
* bits 0-1 = DCT, bits 2-3 = DCA * 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( data class TaudInst(
var index: Int, 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 volEnvSustainWord: Int, // bytes 189-190 (SUSTAIN word)
var panEnvSustainWord: Int, // bytes 191-192 var panEnvSustainWord: Int, // bytes 191-192
var pfEnvSustainWord: Int, // bytes 193-194 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( constructor(index: Int) : this(
index, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF, 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)) },
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, 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: /** 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. */ /** Duplicate Check Action — 0=note cut, 1=note off, 2=note fade. */
val duplicateCheckAction: Int get() = (dupCheckFlag ushr 2) and 0x03 val duplicateCheckAction: Int get() = (dupCheckFlag ushr 2) and 0x03
// Reserved padding at offsets 196..255 (60 bytes per instrument). // Reserved padding at offsets 197..255 (59 bytes per instrument).
private val reserved = ByteArray(60) // 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. // 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. // 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() 193 -> pfEnvSustainWord.toByte()
194 -> pfEnvSustainWord.ushr(8).toByte() 194 -> pfEnvSustainWord.ushr(8).toByte()
195 -> dupCheckFlag.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") 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 } 193 -> { pfEnvSustainWord = (pfEnvSustainWord and 0xff00) or byte }
194 -> { pfEnvSustainWord = (pfEnvSustainWord and 0x00ff) or (byte shl 8) } 194 -> { pfEnvSustainWord = (pfEnvSustainWord and 0x00ff) or (byte shl 8) }
195 -> { dupCheckFlag = byte and 0x0F } 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") 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 # Resolve envelope LOOP / SUSTAIN words from the proxy. When XM has no
# envelope, fall back to a single-point unit envelope (vol LOOP word # 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 # b=1 plus P=1 for consistency) and rely on DNV (byte 196) for the
# zero so the engine sees P=0 there and skips envelope-driven pan. # 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: if s.vol_env_pts is not None:
vol_env_loop = s.vol_env_loop_word vol_env_loop = s.vol_env_loop_word
vol_env_sus = s.vol_env_sus_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] = 0x80
inst_bin[base + 121 + k * 2 + 1] = 0x00 inst_bin[base + 121 + k * 2 + 1] = 0x00
# IGV: XM volume 0..64 → 0..255 # XM has no continuous instrumentwise volume scaler — `s.volume` (0..64)
inst_bin[base + 171] = min(0xFF, round(s.volume * 255 / 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. # Fadeout: 12-bit. Low 8 bits at +172, high 4 bits at +173.
inst_bin[base + 172] = s.fadeout & 0xFF inst_bin[base + 172] = s.fadeout & 0xFF
inst_bin[base + 173] = (s.fadeout >> 8) & 0x0F inst_bin[base + 173] = (s.fadeout >> 8) & 0x0F