fix: no-param note handling divergence

This commit is contained in:
minjaesong
2026-05-03 15:10:36 +09:00
parent 5d968fecf5
commit c7e7ee650d
5 changed files with 80 additions and 96 deletions

View File

@@ -1232,10 +1232,20 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
vol_sus = idata.get('vol_sus', USE_ENV_BIT)
pan_sus = idata.get('pan_sus', 0)
pf_sus = idata.get('pf_sus', 0)
inst_gv = idata.get('inst_gv', 0xFF)
# IT fadeout (0..1024) is in half-units of Taud's per-tick scale; double to align with
# FT2 / native Taud (12-bit, engine subtracts fadeout/65536 per tick). Clamp defensively.
fadeout = min(0xFFF, (idata.get('fadeout', 0) & 0xFFFF) * 2)
# Sample-mode default IGV: fold sample default vol (Sv) and sample GV
# into Taud's IGV. Instrument-mode supplies inst_gv pre-folded.
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)))
# IT fadeout (file-stored 0..2048; ITTECH practical max ≈ 1024) maps verbatim to
# the Taud 12-bit fadeStep. The player picks divisor 1024 in IT mode (vs 65536
# in FT2 mode) so that one fadeStep unit per tick matches Schism's
# `chan->fadeout_volume -= (stored<<5)<<1` semantics (sndmix.c:331-339,
# effects.c:1261). Clamp defensively to 4095.
fadeout = min(0xFFF, idata.get('fadeout', 0) & 0xFFFF)
struct.pack_into('<H', inst_bin, base + 15, vol_sus & 0xFFFF)
struct.pack_into('<H', inst_bin, base + 17, pan_sus & 0xFFFF)
@@ -1322,7 +1332,6 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int,
rows = chunk_grid[ch_idx] if ch_idx < len(chunk_grid) else [ITRow()] * PATTERN_ROWS
last_inst = 0
last_note_it = -1
last_vol = None
for r, cell in enumerate(rows[:PATTERN_ROWS]):
# ── Resolve vol-col into overrides ──────────────────────────────────
@@ -1356,30 +1365,21 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int,
note_triggers = (0 <= (cell.note if cell.note >= 0 else -1) <= 119)
# ── Volume column ────────────────────────────────────────────────────
# Priority: explicit cell vol (from vol-col 0-64) > note-trigger default
# > retrigger recall > vol-col slide > main-effect vol override > nop
# 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.
if cell.volcol >= 0 and cell.volcol <= VC_VOL_HI:
vol_sel, vol_value = SEL_SET, min(cell.volcol, 0x3F)
elif note_triggers and cell.inst > 0:
vol_sel = SEL_SET
vol_value = inst_vols.get(last_inst, 0x3F)
elif note_triggers and last_vol is not None:
vol_sel, vol_value = SEL_SET, last_vol
elif (cell.inst > 0 and cell.note < 0
and last_note_it >= 0 and last_vol is not None):
# Instrument-only retrigger: restate last volume
vol_sel, vol_value = SEL_SET, last_vol
elif vol_override is not None:
vol_sel, vol_value = vol_override
elif vs != SEL_FINE or vv != 0:
vol_sel, vol_value = vs, vv
elif vol_override is not None:
vol_sel, vol_value = vol_override
else:
vol_sel, vol_value = SEL_FINE, 0
if cell.note is not None and 0 <= (cell.note if cell.note >= 0 else -1) <= 119:
last_note_it = cell.note
if vol_sel == SEL_SET:
last_vol = vol_value
# ── Pan column ───────────────────────────────────────────────────────
if cell.pan_set is not None:
@@ -1611,15 +1611,18 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
src_smp = samples[si]
proxy[taud_slot] = src_smp
# IT cell-trigger initial volume comes from the sample's default
# volume (Dv, 0..64), not the instrument's global volume.
# 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.
smp_default_vol = min(getattr(src_smp, 'vol', 64), 64)
inst_vols[taud_slot] = min(smp_default_vol, 0x3F)
# IT instrument GV (0..128) and sample GV (0..64) collapse into
# Taud's single instrumentwise GV (0..255). Sample default volume
# is handled separately by inst_vols above.
# 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 * 255 / (128 * 64)))
inst_gv_255 = min(255, round(inst.gv * smp_gv * smp_default_vol * 255
/ (128 * 64 * 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

View File

@@ -522,7 +522,10 @@ 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
inst_bin[base + 171] = 0xFF # instrument global volume
# 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))
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
@@ -546,15 +549,15 @@ def build_pattern(grid: list, ch_idx: int, default_pan: int,
inst_vols: dict) -> bytes:
"""Build a 512-byte Taud pattern for one MOD channel.
Volume column rules (mirrors s3m2taud):
explicit Cxx vol > note-trigger inst default > instrument-only retrigger
recall > vol_override from effect > no-op.
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.
"""
out = bytearray(PATTERN_BYTES)
rows = grid[ch_idx] if ch_idx < len(grid) else [ModRow()] * MOD_PATTERN_ROWS
last_inst = 0
last_period = 0
last_vol = None
for r, row in enumerate(rows[:MOD_PATTERN_ROWS]):
note_taud = period_to_taud_note(row.period)
note_triggers = (row.period > 0)
@@ -562,10 +565,6 @@ def build_pattern(grid: list, ch_idx: int, default_pan: int,
if row.inst > 0:
last_inst = row.inst
retrigger = (row.inst > 0
and row.period == 0
and last_period > 0)
op, arg, vol_override, pan_override = encode_effect(
row.effect, row.effect_arg, ch_idx, r)
@@ -575,13 +574,6 @@ def build_pattern(grid: list, ch_idx: int, default_pan: int,
if vol_override is not None and vol_override[0] != SEL_SET:
vprint(f" ch{ch_idx} row{r}: dropped vol slide "
f"(cell already carries explicit Cxx volume)")
elif note_triggers and row.inst > 0:
vol_sel = SEL_SET
vol_value = inst_vols.get(last_inst, 0x3F)
elif note_triggers and last_vol is not None:
vol_sel, vol_value = SEL_SET, last_vol
elif retrigger and last_vol is not None:
vol_sel, vol_value = SEL_SET, last_vol
elif vol_override is not None:
vol_sel, vol_value = vol_override
else:
@@ -589,8 +581,6 @@ def build_pattern(grid: list, ch_idx: int, default_pan: int,
if note_triggers:
last_period = row.period
if vol_sel == SEL_SET:
last_vol = vol_value
# ── Pan column ──
if pan_override is not None:

View File

@@ -514,7 +514,10 @@ 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
inst_bin[base + 171] = 0xFF # instrument global volume
# 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))
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
@@ -544,11 +547,10 @@ def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int,
amiga_mode: bool = False) -> bytes:
"""Build a 512-byte Taud pattern for one S3M channel.
Volume column: explicit S3M cell vol SEL_SET; when a note triggers
with no explicit vol, emit SEL_SET using the instrument's default volume
(looked up from inst_vols, a 1-based inst index → 0..63 volume dict).
M/N/K/L overrides apply only when the cell has no explicit vol and no
note trigger. Otherwise SEL_FINE/0 (no-op).
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.
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.
"""
@@ -557,55 +559,27 @@ def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int,
out = bytearray(PATTERN_BYTES)
rows = s3m_grid[ch_idx] if ch_idx < len(s3m_grid) else [S3MRow()] * PATTERN_ROWS
last_inst = 0 # 1-based; tracks which instrument is loaded on this channel
last_note = S3M_NOTE_EMPTY # last raw S3M note byte that was a real pitch
last_vol = None # last SEL_SET volume value (0-63), for retrigger recall
for r, row in enumerate(rows[:PATTERN_ROWS]):
note = encode_note(row.note)
inst = row.inst # S3M 1-based Taud 1-based
inst = row.inst # S3M 1-based -> Taud 1-based
if row.inst > 0:
last_inst = row.inst
# ── Instrument-only retrigger ──
# Instrument-only row: recall the last volume without touching the note.
retrigger = (row.inst > 0
and row.note == S3M_NOTE_EMPTY
and last_note not in (S3M_NOTE_EMPTY, S3M_NOTE_OFF))
op, arg, vol_override, pan_override = encode_effect(
row.effect, row.effect_arg, ch_idx, r, amiga_mode=amiga_mode)
# ── Volume column ──
note_triggers = (row.note not in (S3M_NOTE_EMPTY, S3M_NOTE_OFF))
# -- Volume column --
if row.vol >= 0:
vol_sel, vol_value = SEL_SET, min(row.vol, 0x3F)
if vol_override is not None and vol_override[0] != SEL_SET:
vprint(f" ch{ch_idx} row{r}: dropped vol slide "
f"(cell already carries explicit volume)")
elif note_triggers and row.inst > 0:
# Note trigger with a fresh instrument: use that instrument's
# default volume.
vol_sel = SEL_SET
vol_value = inst_vols.get(last_inst, 0x3F)
elif note_triggers and last_vol is not None:
# Note trigger without instrument: keep the channel's current
# volume rather than resetting to the instrument default.
vol_sel, vol_value = SEL_SET, last_vol
elif retrigger and last_vol is not None:
# Instrument-only row: re-emit the last known volume so the sample
# restarts at the correct level without an explicit note trigger.
vol_sel, vol_value = SEL_SET, last_vol
elif vol_override is not None:
vol_sel, vol_value = vol_override
else:
vol_sel, vol_value = SEL_FINE, 0 # no-op fine slide
# Track note and volume for future retrigger lookups.
if row.note not in (S3M_NOTE_EMPTY, S3M_NOTE_OFF):
last_note = row.note
if vol_sel == SEL_SET:
last_vol = vol_value
# ── Pan column ──
if pan_override is not None:
pan_sel, pan_value = pan_override

View File

@@ -2109,9 +2109,10 @@ TODO:
[x] figure out how IT (0..256) and FT2 (0..FFF + cut) handles volume fadeout numbers, and come up with a compatible Taud spec, then implement
[x] Pitchbend on Amiga frequency mode sometimes works right, sometimes works wrong. (effect underdelivers) Affects every song with Amiga picth mode, AND ON THE fresh taut.js session only
[x] Fix 4THSYM.it filters
[ ] 4THSYM.it: pitchbend is wrong, some notes keep playing (loudly!) even if new notes are emitted
[ ] some notes are emitted with wrong volset (tested with .mod, may affect others as well)
[ ] nearly_there_.mod: `C#5 SD300 / ... / C-5 SD200 / A#4 / G#4`: every C-5 SD200 (there are four occurances) gets skipped
[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
[ ] 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
[ ] scale Oxxxx when samples get resampled
[ ] implement bitcrusher and overdrive (eff sym '8' and '9')

View File

@@ -1553,9 +1553,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.basePitch = noteVal
voice.amigaPeriod = -1.0 // fresh trigger: period state must reseed from the new noteVal
voice.playbackRate = computePlaybackRate(inst, noteVal)
if (volOverride >= 0) {
voice.channelVolume = volOverride.coerceIn(0, 0x3F)
}
// Fresh trigger resets channel volume to full ($3F). 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.
voice.channelVolume = if (volOverride >= 0) volOverride.coerceIn(0, 0x3F) else 0x3F
voice.rowVolume = voice.channelVolume
voice.noteWasCut = false
voice.noteFading = false
@@ -1793,7 +1794,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// ── Note ──
val toneG = (row.effect == EffectOp.OP_G)
when (row.note) {
0xFFFF -> {} // no-op
// No note but an instrument byte is present: latch the instrument so
// the *next* note-only trigger picks up the right sample. Trackers
// call this an "instrument-only retrigger"; in MOD/S3M/IT the sample
// keeps playing, but the channel's instrument reference advances.
0xFFFF -> { if (row.instrment != 0) voice.instrumentId = row.instrment }
0x0000 -> { voice.keyOff = true; voice.active = false } // key-off; breaks sustain loop
0xFFFE -> voice.active = false // note cut
else -> {
@@ -1806,7 +1811,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.noteDelayTick = (row.effectArg ushr 8) and 0xF
voice.delayedNote = row.note
voice.delayedInst = row.instrment
voice.delayedVol = if (row.volume >= 0) row.volume else -1
// Only treat the vol cell as an override when it carries SEL_SET;
// SEL_FINE/0 (no-op) and slide selectors must not collapse into
// a SET=0 on the deferred trigger.
voice.delayedVol = if (row.volumeEff == 0) row.volume else -1
} else {
applyDuplicateCheck(ts, vi, row.instrment, row.note)
maybeSpawnBackgroundForNNA(ts, voice, vi)
@@ -2211,10 +2219,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Auto-vibrato (instrument-supplied sample LFO) — added on top of pitchToMixer.
val autoVibDelta = advanceAutoVibrato(voice, inst)
// Pitch envelope contribution: env value 0..1, 0.5 = unity. -32..+32
// semitone range maps to ±32 × 4096/12 ≈ ±10923 4096-TET units.
// Pitch envelope contribution: env value 0..1, 0.5 = unity.
// IT pitch envelope max is ±16 semitones (Schism sndmix.c:455-462 indexes
// linear_slide_up_table[abs(envpitch)] where envpitch ∈ [-256,+256] and
// table[255] = 65536·2^(255/192) ≈ 2.504, i.e. 15.94 semitones).
val pitchEnvDelta = if (voice.hasPfEnv && voice.pfEnvOn && !voice.envPfIsFilter)
((voice.envPfValue - 0.5) * 2.0 * 32.0 * 4096.0 / 12.0).toInt()
((voice.envPfValue - 0.5) * 2.0 * 16.0 * 4096.0 / 12.0).toInt()
else 0
val finalPitch = (pitchToMixer + autoVibDelta + pitchEnvDelta).coerceIn(0, 0xFFFE)
@@ -2236,13 +2246,18 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Refresh biquad filter coefficients once per tick (only recomputes when changed).
refreshVoiceFilter(voice)
// Volume fadeout: after key-off OR Note-Fade NNA, decrement by inst.volumeFadeout / 65536 per tick.
// The 12-bit fadeout value is split across volumeFadeoutLow + low nibble of fadeoutHigh.
// Stored 0: with fadeoutCutOnZero (FT2 mode) the voice is cut on key-off; otherwise no fadeout (IT mode).
// Volume fadeout: after key-off OR Note-Fade NNA, decrement per tick.
// The 12-bit fadeStep is split across volumeFadeoutLow + low nibble of fadeoutHigh.
// Divisor selects per-tracker semantics:
// FT2 mode (fadeoutCutOnZero=true): fadeStep / 65536 per tick — matches FT2 .XM (16-bit accumulator, decrement = stored).
// IT mode (fadeoutCutOnZero=false): fadeStep / 1024 per tick — matches Schism (sndmix.c:331-339 + effects.c:1261:
// accumulator 65536, decrement = (stored<<5)<<1 = stored·64).
// Stored 0: FT2 mode cuts on key-off; IT mode leaves voice playing (no fade).
if (voice.keyOff || voice.noteFading) {
val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8)
if (fadeStep > 0) {
voice.fadeoutVolume = (voice.fadeoutVolume - fadeStep / 65536.0).coerceAtLeast(0.0)
val divisor = if (ts.fadeoutCutOnZero) 65536.0 else 1024.0
voice.fadeoutVolume = (voice.fadeoutVolume - fadeStep / divisor).coerceAtLeast(0.0)
if (voice.fadeoutVolume <= 0.0) voice.active = false
} else if (ts.fadeoutCutOnZero) {
voice.active = false
@@ -2297,7 +2312,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (bg.keyOff || bg.noteFading) {
val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8)
if (fadeStep > 0) {
bg.fadeoutVolume = (bg.fadeoutVolume - fadeStep / 65536.0).coerceAtLeast(0.0)
val divisor = if (ts.fadeoutCutOnZero) 65536.0 else 1024.0
bg.fadeoutVolume = (bg.fadeoutVolume - fadeStep / divisor).coerceAtLeast(0.0)
} else if (ts.fadeoutCutOnZero) {
bg.active = false
bgIt.remove()
@@ -2307,7 +2323,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Auto-vibrato keeps running on backgrounds — it's an instrument-intrinsic LFO.
val autoVibDelta = advanceAutoVibrato(bg, inst)
val pitchEnvDelta = if (bg.hasPfEnv && bg.pfEnvOn && !bg.envPfIsFilter)
((bg.envPfValue - 0.5) * 2.0 * 32.0 * 4096.0 / 12.0).toInt()
((bg.envPfValue - 0.5) * 2.0 * 16.0 * 4096.0 / 12.0).toInt()
else 0
val finalPitch = (bg.noteVal + autoVibDelta + pitchEnvDelta).coerceIn(0, 0xFFFE)
bg.playbackRate = computePlaybackRate(inst, finalPitch)