mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
fixing the low volume issue, finally
This commit is contained in:
@@ -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).
|
||||||
|
|||||||
53
it2taud.py
53
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)
|
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,
|
||||||
|
|||||||
21
mod2taud.py
21
mod2taud.py
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
21
s3m2taud.py
21
s3m2taud.py
@@ -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.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
xm2taud.py
15
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
|
# 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
|
||||||
|
|||||||
Reference in New Issue
Block a user