From 935fbe04a6cf66a1c9c7b3f930bea1ff038b42b6 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Sat, 9 May 2026 01:57:40 +0900 Subject: [PATCH] fixing the low volume issue, finally --- assets/disk0/tvdos/bin/taut.js | 20 ++++-- it2taud.py | 53 ++++++++++----- mod2taud.py | 21 +++--- mon2taud.py | 6 +- s3m2taud.py | 21 +++--- terranmon.txt | 56 ++++++++++++++-- .../torvald/tsvm/peripheral/AudioAdapter.kt | 66 ++++++++++++++----- xm2taud.py | 15 +++-- 8 files changed, 191 insertions(+), 67 deletions(-) diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index 9f5db96..dcbfa5e 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -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). diff --git a/it2taud.py b/it2taud.py index d40c421..15e4ed2 100644 --- a/it2taud.py +++ b/it2taud.py @@ -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, diff --git a/mod2taud.py b/mod2taud.py index 937c024..4ae0ba1 100644 --- a/mod2taud.py +++ b/mod2taud.py @@ -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(' bytes: struct.pack_into(' 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. """ diff --git a/terranmon.txt b/terranmon.txt index 013decb..20ca5a5 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -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: diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index 745abd7..ad37cb6 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -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") } } diff --git a/xm2taud.py b/xm2taud.py index 1e06503..ffdc9e8 100644 --- a/xm2taud.py +++ b/xm2taud.py @@ -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