IT instrument shenanigans

This commit is contained in:
minjaesong
2026-05-02 02:48:24 +09:00
parent 6de9476c4f
commit f295223f15
5 changed files with 155 additions and 78 deletions

View File

@@ -578,11 +578,11 @@ Peak at maximum settings: $7F × $FF >> 9 = $3F — the full panning range. Retr
S is a multiplexing opcode; the **high nibble of the high byte** selects the sub-effect, and the remainder is the sub-argument. S is a multiplexing opcode; the **high nibble of the high byte** selects the sub-effect, and the remainder is the sub-argument.
## S $1x00 — ST3/IT Glissando control ## S $1x00 — PT/ST3/IT Glissando control
**Plain.** `$1000` turns glissando off; `$1100` turns it on. When on, tone portamento (G) output is quantised to the nearest semitone ($0155 approximation) before being sent to the mixer. The internal G pitch counter still advances smoothly; only the audible pitch steps. **This command is implemented sorely for ST3/IT compatibility.** and therefore only works in 12-TET context. **Plain.** `$1000` turns glissando off; `$1100` turns it on. When on, tone portamento (G) output is quantised to the nearest semitone ($0155 approximation) before being sent to the mixer. The internal G pitch counter still advances smoothly; only the audible pitch steps. **This command is implemented sorely for ST3/IT compatibility** and therefore only works in 12-TET context.
**Compatibility.** ST3 `S10`/`S11` maps directly. In Taud, "nearest semitone" uses the best integer approximation: round `pitch / $155` to the nearest integer, multiply by $155; equivalently, `snapped = (pitch + $AB) / $155 × $155`. Because $155 is an approximation of 4096/12, accumulated rounding across many octaves will drift by up to a few cents; this is documented behaviour and intentional given the microtonal grid. **Compatibility.** ST3/IT `S10`/`S11` and PT `E30`/`E31` maps directly. In Taud, "nearest semitone" uses the best integer approximation: round `pitch / $155` to the nearest integer, multiply by $155; equivalently, `snapped = (pitch + $AB) / $155 × $155`. Because $155 is an approximation of 4096/12, accumulated rounding across many octaves will drift by up to a few cents; this is documented behaviour and intentional given the microtonal grid.
**Implementation.** Maintain a per-channel boolean `glissando_on`. When G updates `pitch`, if `glissando_on` is set, compute `display_pitch = round(pitch × 12 / 4096) × 4096 / 12` (using integer division with rounding) and send `display_pitch` to the mixer; otherwise send `pitch` directly. **Implementation.** Maintain a per-channel boolean `glissando_on`. When G updates `pitch`, if `glissando_on` is set, compute `display_pitch = round(pitch × 12 / 4096) × 4096 / 12` (using integer division with rounding) and send `display_pitch` to the mixer; otherwise send `pitch` directly.
@@ -634,7 +634,7 @@ ProTracker `E5x` maps to Taud `S $2x00` with the same index meaning.
| $6 | Square | No | | $6 | Square | No |
| $7 | Random | No | | $7 | Random | No |
**Compatibility.** ST3 `S3x` maps directly. **Compatibility.** ST3 `S3x` and ProTracker `E4x` maps directly.
**Implementation.** Store `vibrato_waveform = $x & $3` and `vibrato_retrigger = (($x & $4) == 0)` for the channel. The ramp-down shape is `$7F ((pos & $3F) << 2)` across one logical cycle; the square shape is `sign(sine(pos)) × $7F`; random draws a fresh `rand() & $FF $80` every tick. On a new note, if `vibrato_retrigger` is true, reset `lfo_pos = 0`. **Implementation.** Store `vibrato_waveform = $x & $3` and `vibrato_retrigger = (($x & $4) == 0)` for the channel. The ramp-down shape is `$7F ((pos & $3F) << 2)` across one logical cycle; the square shape is `sign(sine(pos)) × $7F`; random draws a fresh `rand() & $FF $80` every tick. On a new note, if `vibrato_retrigger` is true, reset `lfo_pos = 0`.
@@ -644,7 +644,7 @@ ProTracker `E5x` maps to Taud `S $2x00` with the same index meaning.
**Plain.** Selects the shape of the tremolo (R) oscillator; value encoding is identical to S $3x. **Plain.** Selects the shape of the tremolo (R) oscillator; value encoding is identical to S $3x.
**Compatibility.** ST3 `S4x` maps directly. ProTracker `E7x` maps to Taud `S $4x00`. **Compatibility.** ST3 `S4x` and ProTracker `E7x` maps directly.
**Implementation.** As for S $3x, but applied to R's separate state (`tremolo_waveform`, `tremolo_retrigger`, and tremolo `lfo_pos`). **Implementation.** As for S $3x, but applied to R's separate state (`tremolo_waveform`, `tremolo_retrigger`, and tremolo `lfo_pos`).

View File

@@ -458,7 +458,7 @@ class ITInstrument:
__slots__ = ('name', 'dfp', 'gv', 'canonical_sample', 'canonical_volume', __slots__ = ('name', 'dfp', 'gv', 'canonical_sample', 'canonical_volume',
'vol_envelope', 'pan_envelope', 'vol_env_sustain', 'pan_env_sustain', 'vol_envelope', 'pan_envelope', 'vol_env_sustain', 'pan_env_sustain',
'pf_envelope', 'pf_env_sustain', 'pf_is_filter', 'pf_envelope', 'pf_env_sustain', 'pf_is_filter',
'ifc', 'ifr', 'fadeout', 'pps', 'ppc', 'rv', 'rp') 'ifc', 'ifr', 'fadeout', 'pps', 'ppc', 'rv', 'rp', 'nna')
# vol_envelope / pan_envelope / pf_envelope: list of 25 (value, minifloat_idx) tuples, or None # vol_envelope / pan_envelope / pf_envelope: list of 25 (value, minifloat_idx) tuples, or None
# *_env_sustain: int (16-bit, 0b 0ut sssss pcb eeeee), 0 = no envelope # *_env_sustain: int (16-bit, 0b 0ut sssss pcb eeeee), 0 = no envelope
# pf_is_filter: bool — pf envelope mode (False = pitch, True = filter) # pf_is_filter: bool — pf envelope mode (False = pitch, True = filter)
@@ -466,6 +466,7 @@ class ITInstrument:
# fadeout : 0..1024 (IT FadeOut field, applied per tick after key-off) # fadeout : 0..1024 (IT FadeOut field, applied per tick after key-off)
# pps / ppc : pitch-pan separation (signed -32..+32) and centre note (0..119) # pps / ppc : pitch-pan separation (signed -32..+32) and centre note (0..119)
# rv / rp : random volume swing (0..100) / random pan swing (0..64) # rv / rp : random volume swing (0..100) / random pan swing (0..64)
# nna : new note action (IT 0=cut, 1=continue, 2=note off, 3=note fade)
def parse_instruments(data: bytes, h: ITHeader) -> list: def parse_instruments(data: bytes, h: ITHeader) -> list:
insts = [] insts = []
@@ -477,6 +478,8 @@ def parse_instruments(data: bytes, h: ITHeader) -> list:
inst = ITInstrument() inst = ITInstrument()
inst.name = data[ptr+0x20:ptr+0x3A].rstrip(b'\x00').decode('latin-1', errors='replace') inst.name = data[ptr+0x20:ptr+0x3A].rstrip(b'\x00').decode('latin-1', errors='replace')
# NNA at IMPI+0x11 (new format). 0=cut, 1=continue, 2=note off, 3=note fade.
inst.nna = data[ptr + 0x11] & 0x03
inst.fadeout = struct.unpack_from('<H', data, ptr + 0x14)[0] # 0..1024 inst.fadeout = struct.unpack_from('<H', data, ptr + 0x14)[0] # 0..1024
# PPS is signed -32..+32; PPC is the centre note (IT note number 0..119, C-5=60). # PPS is signed -32..+32; PPC is the centre note (IT note number 0..119, C-5=60).
inst.pps = struct.unpack_from('b', data, ptr + 0x16)[0] inst.pps = struct.unpack_from('b', data, ptr + 0x16)[0]
@@ -871,8 +874,7 @@ def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0,
if sub == 0x8: if sub == 0x8:
# IT S8x: 4-bit → nibble-repeat into 8-bit SEL_SET pan # IT S8x: 4-bit → nibble-repeat into 8-bit SEL_SET pan
pan8 = (val << 4) | val pan8 = (val << 4) | val
pan6 = min(0x3F, round(pan8 * 63 / 255)) return (TOP_S, 0x8000 | pan8, None, None)
return (TOP_NONE, 0, None, (SEL_SET, pan6))
if sub == 0x6: if sub == 0x6:
vprint(f" dropped S6{val:X} (tick delay) at ch{ch} row{row}") vprint(f" dropped S6{val:X} (tick delay) at ch{ch} row{row}")
return (TOP_NONE, 0, None, None) return (TOP_NONE, 0, None, None)
@@ -903,10 +905,7 @@ def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0,
return (TOP_NONE, 0, None, None) return (TOP_NONE, 0, None, None)
if cmd == EFF_X: if cmd == EFF_X:
# IT X is full 8-bit pan (0=left, 255=right; 128=centre in OpenMPT but return (TOP_S, 0x8000 | (arg & 0xFF), None, None)
# IT spec says 0-255 maps to full left-right)
pan6 = min(0x3F, round(arg * 63 / 255))
return (TOP_NONE, 0, None, (SEL_SET, pan6))
if cmd == EFF_Y: if cmd == EFF_Y:
hi = (arg >> 4) & 0xF hi = (arg >> 4) & 0xF
@@ -1104,8 +1103,9 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
instr_data_by_slot: optional dict mapping taud_slot → dict with keys: instr_data_by_slot: optional dict mapping taud_slot → dict with keys:
vol_env, vol_sus, pan_env, pan_sus, pf_env, pf_sus, pf_is_filter, vol_env, vol_sus, pan_env, pan_sus, pf_env, pf_sus, pf_is_filter,
inst_gv, fadeout, vib_speed, vib_depth, vib_sweep, default_pan, inst_gv, fadeout, vib_speed, vib_depth, vib_sweep, vib_rate, vib_wave,
pps, ppc_taud, pan_swing, vol_swing, ifc, ifr. default_pan, pps, ppc_taud, pan_swing, vol_swing, ifc, ifr,
sample_detune, nna.
All optional; missing keys default to neutral values. All optional; missing keys default to neutral values.
Returns (bin_bytes[SAMPLEINST_SIZE], offsets_dict). Returns (bin_bytes[SAMPLEINST_SIZE], offsets_dict).
@@ -1222,10 +1222,8 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
inst_bin[base + 171] = inst_gv & 0xFF inst_bin[base + 171] = inst_gv & 0xFF
inst_bin[base + 172] = fadeout & 0xFF # low 8 bits inst_bin[base + 172] = fadeout & 0xFF # low 8 bits
# Byte 173: high nybble = vibrato depth, low nybble = fadeout high bits. # Byte 173: low nibble = fadeout high bits (0b 0000 ffff).
fade_hi = (fadeout >> 8) & 0x03 inst_bin[base + 173] = (fadeout >> 8) & 0x0F
vib_depth = idata.get('vib_depth', 0) & 0x0F
inst_bin[base + 173] = ((vib_depth & 0xF) << 4) | fade_hi
inst_bin[base + 174] = idata.get('vol_swing', 0) & 0xFF inst_bin[base + 174] = idata.get('vol_swing', 0) & 0xFF
inst_bin[base + 175] = idata.get('vib_speed', 0) & 0xFF inst_bin[base + 175] = idata.get('vib_speed', 0) & 0xFF
inst_bin[base + 176] = idata.get('vib_sweep', 0) & 0xFF inst_bin[base + 176] = idata.get('vib_sweep', 0) & 0xFF
@@ -1238,6 +1236,20 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
inst_bin[base + 181] = idata.get('pan_swing', 0) & 0xFF inst_bin[base + 181] = idata.get('pan_swing', 0) & 0xFF
inst_bin[base + 182] = idata.get('ifc', 255) & 0xFF inst_bin[base + 182] = idata.get('ifc', 255) & 0xFF
inst_bin[base + 183] = idata.get('ifr', 255) & 0xFF inst_bin[base + 183] = idata.get('ifr', 255) & 0xFF
# Bytes 184-185: sample detune (4096-TET, signed stored as u16).
struct.pack_into('<H', inst_bin, base + 184,
idata.get('sample_detune', 0) & 0xFFFF)
# Byte 186: instrument flag — 0b 000 www nn
# nn = NNA (Taud encoding: 00=note off, 01=cut, 10=continue, 11=fade)
# www = vibrato waveform (0=sine, 1=ramp-down, 2=square, 3=random, 4=ramp-up FT2)
nna = idata.get('nna', 0) & 0x03
vib_wave = idata.get('vib_wave', 0) & 0x07
inst_bin[base + 186] = (vib_wave << 2) | nna
# Byte 187: vibrato depth (0..255 full range).
inst_bin[base + 187] = idata.get('vib_depth', 0) & 0xFF
# Byte 188: vibrato rate (0..255 full range, IT samplewise Vir).
inst_bin[base + 188] = idata.get('vib_rate', 0) & 0xFF
# Bytes 189-191: 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}")
@@ -1549,23 +1561,48 @@ 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
vol64 = min(inst.canonical_volume, 64) # IT cell-trigger initial volume comes from the sample's default
inst_vols[taud_slot] = min(vol64, 0x3F) # volume (Dv, 0..64), not the instrument's global volume.
# IT global volume range is 0..128; rescale to Taud's 0..255. smp_default_vol = min(getattr(src_smp, 'vol', 64), 64)
inst_gv_255 = min(255, round(inst.gv * 255 / 128)) 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.
smp_gv = min(getattr(src_smp, 'gv', 64), 64)
inst_gv_255 = min(255, round(inst.gv * smp_gv * 255 / (128 * 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
# (anchored to TAUD_C4 at IT note 60). # (anchored to TAUD_C4 at IT note 60).
ppc_taud = TAUD_C4 + (max(0, min(119, inst.ppc)) - 60) * 4096 // 12 ppc_taud = TAUD_C4 + (max(0, min(119, inst.ppc)) - 60) * 4096 // 12
# IT default pan: 0..64 (centre = 32). Taud uses 0..255. # IT default pan: instrumentwise (IMPI+0x19) takes precedence when
# its "use" bit is set; otherwise samplewise (IMPS+0x2F) wins when
# its "use" bit is set; otherwise centre (0x80). Both fields encode
# 0..64 → rescale to Taud's 0..255 range.
smp_dfp_raw = getattr(src_smp, 'dfp', 0)
if inst.dfp is not None: if inst.dfp is not None:
default_pan = min(255, max(0, round(inst.dfp * 255 / 64))) default_pan = min(255, max(0, round(inst.dfp * 255 / 64)))
elif smp_dfp_raw & 0x80:
default_pan = min(255, max(0, round((smp_dfp_raw & 0x7F) * 255 / 64)))
else: else:
default_pan = 0x80 default_pan = 0x80
# Auto-vibrato lives on the canonical sample (not the IT instrument). # Auto-vibrato lives on the canonical sample (not the IT instrument).
# IT samplewise auto-vibrato: Vis (speed 0..64), Vid (depth 0..32),
# Vir (rate 0..255 — IT-style ramp-in), Vit (waveform 0..3).
# Taud byte 175 (Vibrato Speed) follows FT2 0..255 scale: rescale Vis.
# Taud byte 187 (Vibrato Depth) is full 0..255: rescale Vid 0..32 → 0..255.
# Taud byte 188 (Vibrato Rate) is IT Vir verbatim.
# Taud byte 176 (Vibrato Sweep) is FT2-only — leave 0 for IT.
vib_speed_taud = min(255, round(src_smp.av_speed * 255 / 64))
vib_depth_taud = min(255, round(src_smp.av_depth * 255 / 32))
# IT NNA (0=cut, 1=continue, 2=note off, 3=note fade) →
# Taud NNA (00=note off, 01=cut, 10=continue, 11=fade).
it_to_taud_nna = (0b01, 0b10, 0b00, 0b11)
nna_taud = it_to_taud_nna[inst.nna & 0x03]
instr_data_by_slot[taud_slot] = { instr_data_by_slot[taud_slot] = {
'vol_env': inst.vol_envelope, 'vol_env': inst.vol_envelope,
'vol_sus': inst.vol_env_sustain, 'vol_sus': inst.vol_env_sustain,
@@ -1575,9 +1612,11 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
'pf_sus': inst.pf_env_sustain, 'pf_sus': inst.pf_env_sustain,
'inst_gv': inst_gv_255, 'inst_gv': inst_gv_255,
'fadeout': inst.fadeout, 'fadeout': inst.fadeout,
'vib_speed': src_smp.av_speed, 'vib_speed': vib_speed_taud,
'vib_depth': src_smp.av_depth, 'vib_depth': vib_depth_taud,
'vib_sweep': src_smp.av_sweep, 'vib_sweep': 0, # FT2-only; IT uses vib_rate
'vib_rate': src_smp.av_sweep & 0xFF, # IT Vir (samplewise sweep)
'vib_wave': src_smp.av_wave & 0x07, # IT vib type (0..3)
'default_pan': default_pan, 'default_pan': default_pan,
'pps': inst.pps, 'pps': inst.pps,
'ppc_taud': ppc_taud & 0xFFFF, 'ppc_taud': ppc_taud & 0xFFFF,
@@ -1585,6 +1624,8 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
'vol_swing': min(255, round(inst.rv * 255 / 100)) if inst.rv else 0, 'vol_swing': min(255, round(inst.rv * 255 / 100)) if inst.rv else 0,
'ifc': inst.ifc, 'ifc': inst.ifc,
'ifr': inst.ifr, 'ifr': inst.ifr,
'sample_detune': 0, # IT samples have no finetune
'nna': nna_taud,
} }
sampleinst_raw, _ = build_sample_inst_bin_it(proxy, instr_data_by_slot) sampleinst_raw, _ = build_sample_inst_bin_it(proxy, instr_data_by_slot)
else: else:

View File

@@ -44,7 +44,7 @@ NUM_VOICES = 20
NOTE_NOP = 0xFFFF NOTE_NOP = 0xFFFF
NOTE_KEYOFF = 0x0000 NOTE_KEYOFF = 0x0000
NOTE_CUT = 0xFFFE NOTE_CUT = 0xFFFE
TAUD_C4 = 0x5000 # reference C for instrument sampling rate (was TAUD_C3 = 0x4000) TAUD_C4 = 0x5000 # The audio engine's Middle C
# Taud effect opcodes (base-36: 0..9 → 0x00..0x09, A..Z → 0x0A..0x23) # Taud effect opcodes (base-36: 0..9 → 0x00..0x09, A..Z → 0x0A..0x23)
TOP_NONE = 0x00 TOP_NONE = 0x00

View File

@@ -2053,7 +2053,7 @@ Instrument bin: Registry for 256 instruments, formatted as:
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely. Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
Uint8 Instrument Global Volume (0..255) Uint8 Instrument Global Volume (0..255)
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int * ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
- ImpulseTracker 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 - 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 * FastTracker2 has range of 0..64; multiply by (255/64) then round to int
Uint8 Volume Fadeout low bits (IT: 1..256; XM: 0..255) Uint8 Volume Fadeout low bits (IT: 1..256; XM: 0..255)
Bit8 Fadeout and vibrato Bit8 Fadeout and vibrato
@@ -2067,7 +2067,7 @@ Instrument bin: Registry for 256 instruments, formatted as:
Uint8 Vibrato sweep Uint8 Vibrato sweep
* FastTracker2 instrument config * FastTracker2 instrument config
Uint8 Default pan value (0..255 full range, see offset 17 for the enable flag) Uint8 Default pan value (0..255 full range, see offset 17 for the enable flag)
* ImpulseTracker has samplewise default volume and samplewise global volume, and they must be taken into account because Taud has no samplewise config * ImpulseTracker has samplewise default pan and instrumentwise default pan, and they must be taken into account because Taud has no samplewise config
Uint16 Pitch-pan centre (4096-TET note value) Uint16 Pitch-pan centre (4096-TET note value)
Sint8 Pitch-pan separation (-128..127 full range) Sint8 Pitch-pan separation (-128..127 full range)
Uint8 Pan swing (0..255 full range) Uint8 Pan swing (0..255 full range)
@@ -2087,15 +2087,16 @@ Instrument bin: Registry for 256 instruments, formatted as:
TODO: TODO:
* implement Instrument Flag, Vibrato Depth, Vibrato Rate, other samplewise/instrumentwise changes to it2taud and audio engine [x] implement Instrument Flag, Vibrato Depth, Vibrato Rate, other samplewise/instrumentwise changes to it2taud and audio engine
* implement new note action on the audio engine (IT uses "background channels", maybe we can do the same but make "background channels" mixer-private) [ ] implement new note action on the audio engine (IT uses "background channels", maybe we can do the same but make "background channels" mixer-private)
* on playback, panning changes randomly on Taud made by s3m2taud.py and mod2taud.py [ ] on playback, panning changes randomly on Taud made by s3m2taud.py and mod2taud.py
* implement S6x and S7x command [ ] implement S6x and S7x command
* implement Wxx command (global volume slide) [ ] implement Wxx command (global volume slide)
* implement sample loop sustain [ ] implement sample loop sustain
* Amiga mode freq shift now "underdelivers" (pitch bend not "strong" enough) [ ] Amiga mode freq shift now "underdelivers" (pitch bend not "strong" enough)
* cue and pattern compression of the Taud format (taud_common.py, taud.mjs) [ ] cue and pattern compression of the Taud format (taud_common.py, taud.mjs)
* figure out how IT (8 bits) and FT2 (12 bits) handles volume fadeout numbers, and come up with a compatible Taud spec, then implement [ ] figure out how IT (8 bits) and FT2 (12 bits) handles volume fadeout numbers, and come up with a compatible Taud spec, then implement
[ ] implement bitcrusher (eff sym '8')
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

@@ -1115,11 +1115,6 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
-0x5A, -0x51, -0x47, -0x3C, -0x31, -0x25, -0x19, -0x0C -0x5A, -0x51, -0x47, -0x3C, -0x31, -0x25, -0x19, -0x0C
) )
// Funk repeat advance table (S $Fx00). See TAUD_NOTE_EFFECTS.md §S$Fx.
private val FUNK_TABLE = intArrayOf(
0, 5, 6, 7, 8, 0xA, 0xB, 0xD, 0x10, 0x13, 0x16, 0x1A, 0x20, 0x2B, 0x40, 0x80
)
// ST3-style fine-tune Hz reference offsets in 4096-TET units (S $2x00). // ST3-style fine-tune Hz reference offsets in 4096-TET units (S $2x00).
private val FINETUNE_OFFSET = intArrayOf( private val FINETUNE_OFFSET = intArrayOf(
-0x0154, -0x0132, -0x0111, -0x00E4, -0x00B8, -0x008B, -0x005D, -0x003B, -0x0154, -0x0132, -0x0111, -0x00E4, -0x00B8, -0x008B, -0x005D, -0x003B,
@@ -1169,7 +1164,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
private fun computePlaybackRate(inst: TaudInst, noteVal: Int): Double = private fun computePlaybackRate(inst: TaudInst, noteVal: Int): Double =
inst.samplingRate.toDouble() / SAMPLING_RATE * 2.0.pow((noteVal - MIDDLE_C) / 4096.0) inst.samplingRate.toDouble() / SAMPLING_RATE *
2.0.pow((noteVal - MIDDLE_C + inst.sampleDetuneSigned) / 4096.0)
// Applies one tick of Amiga-mode pitch slide. When the song is in Amiga tone mode, E/F coarse // Applies one tick of Amiga-mode pitch slide. When the song is in Amiga tone mode, E/F coarse
// slide arguments are stored as raw tracker period units (the original ProTracker/ST3 byte), // slide arguments are stored as raw tracker period units (the original ProTracker/ST3 byte),
@@ -1396,25 +1392,34 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
* 0 means full depth immediately). * 0 means full depth immediately).
*/ */
private fun advanceAutoVibrato(voice: Voice, inst: TaudInst): Int { private fun advanceAutoVibrato(voice: Voice, inst: TaudInst): Int {
val depth0 = (inst.fadeoutHighVibDepth ushr 4) and 0xF // Depth from byte 187 (full 0..255). Speed from byte 175 (FT2 0..255 scale).
val depth0 = inst.vibratoDepth
if (depth0 == 0 || inst.vibratoSpeed == 0) return 0 if (depth0 == 0 || inst.vibratoSpeed == 0) return 0
val sweep = inst.vibratoSweep // Two ramp-in semantics:
val rampDepth = if (sweep == 0) depth0 // FT2 vibratoSweep (byte 176): "ticks to fully ramp" — depth = depth0 * t / sweep.
else ((depth0 * voice.autoVibTicksSinceTrigger / sweep) // IT vibratoRate (byte 188): "ramp acceleration" — accumulator += rate per tick,
.coerceAtMost(depth0)) // capped at depth0 * 256, then divided by 256.
val ftSweep = inst.vibratoSweep
val itRate = inst.vibratoRate
val t = voice.autoVibTicksSinceTrigger
val rampDepth = when {
ftSweep != 0 -> ((depth0 * t / ftSweep).coerceAtMost(depth0))
itRate != 0 -> ((t * itRate) ushr 8).coerceAtMost(depth0)
else -> depth0
}
voice.autoVibTicksSinceTrigger++ voice.autoVibTicksSinceTrigger++
// Wave selector lives in the high nibble of vibratoSweep is not standard; // Vibrato waveform selector lives in instrumentFlag bits 2-4.
// IT keeps a separate wave byte that we don't currently surface, so treat // 0=sine, 1=ramp-down, 2=square, 3=random, 4=ramp-up (FT2 only).
// as sine. The same `lfoSample` table used for H/U effects works here // lfoSample handles 0..3; treat 4 (ramp-up) as negated ramp-down.
// (8-bit phase, signed -127..+127). val wave = inst.vibratoWaveform
val sine = lfoSample(voice.autoVibPhase, 0) val rawSample = if (wave == 4) -lfoSample(voice.autoVibPhase, 1)
// 4096-TET delta: vib depth is in IT units (≈ 1/256 semitone). One else lfoSample(voice.autoVibPhase, wave and 3)
// semitone = 4096/12 ≈ 341.33 4096-TET units; IT auto-vibrato depth 1 // 4096-TET delta. depth0 is now 0..255 (was 0..15 in old layout); the
// is ~6.25 cents = 21 4096-TET units. (sine * rampDepth) is roughly // shift compensates so depth ≈255 yields a similar musical excursion
// -127*15 .. +127*15 = ±1905, divided by 64 → ±30 ≈ ±10 cents at depth 15. // (~±9 cents) to the old depth 15.
val pitchDelta = (sine * rampDepth) shr 6 val pitchDelta = (rawSample * rampDepth) shr 10
voice.autoVibPhase = (voice.autoVibPhase + inst.vibratoSpeed * 2) and 0xFF voice.autoVibPhase = (voice.autoVibPhase + inst.vibratoSpeed * 2) and 0xFF
return pitchDelta return pitchDelta
} }
@@ -1821,7 +1826,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
ts.patternDelayRemaining = x ts.patternDelayRemaining = x
} }
} }
0xF -> { voice.funkSpeed = x; if (x == 0) voice.funkAccumulator = 0 } 0xF -> { voice.funkSpeed = arg and 0xFF; if (x == 0) voice.funkAccumulator = 0 }
} }
} }
@@ -1983,9 +1988,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
refreshVoiceFilter(voice) refreshVoiceFilter(voice)
// Volume fadeout: after key-off, decrement by inst.volumeFadeout / 1024 per tick. // Volume fadeout: after key-off, decrement by inst.volumeFadeout / 1024 per tick.
// The 10-bit fadeout value is split across volumeFadeoutLow + low nibble of fadeoutHighVibDepth. // The 10-bit fadeout value is split across volumeFadeoutLow + low nibble of fadeoutHigh.
if (voice.keyOff) { if (voice.keyOff) {
val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHighVibDepth and 0x0F) shl 8) val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8)
if (fadeStep > 0) { if (fadeStep > 0) {
voice.fadeoutVolume = (voice.fadeoutVolume - fadeStep / 1024.0).coerceAtLeast(0.0) voice.fadeoutVolume = (voice.fadeoutVolume - fadeStep / 1024.0).coerceAtLeast(0.0)
} }
@@ -2003,12 +2008,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
} }
// Funk repeat (S$Fx) — advance bit-mask per tick on instruments with active funkSpeed. // Funk repeat (S$Fxxxx) — advance bit-mask per tick on instruments with active funkSpeed.
for (voice in ts.voices) { for (voice in ts.voices) {
if (voice.funkSpeed == 0 || !voice.active) continue if (voice.funkSpeed == 0 || !voice.active) continue
val inst = instruments[voice.instrumentId] val inst = instruments[voice.instrumentId]
if (inst.sampleLoopEnd <= inst.sampleLoopStart) continue if (inst.sampleLoopEnd <= inst.sampleLoopStart) continue
voice.funkAccumulator += FUNK_TABLE[voice.funkSpeed and 0xF] voice.funkAccumulator += voice.funkSpeed
while (voice.funkAccumulator >= 0x80) { while (voice.funkAccumulator >= 0x80) {
voice.funkAccumulator -= 0x80 voice.funkAccumulator -= 0x80
val loopLen = (inst.sampleLoopEnd - inst.sampleLoopStart).coerceAtLeast(1) val loopLen = (inst.sampleLoopEnd - inst.sampleLoopStart).coerceAtLeast(1)
@@ -2615,17 +2620,23 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
* 121..170 Bit16×25 pitch/filter envelope points * 121..170 Bit16×25 pitch/filter envelope points
* 171 u8 instrument global volume * 171 u8 instrument global volume
* 172 u8 volume fadeout low bits * 172 u8 volume fadeout low bits
* 173 u8 fadeout high (low nibble) + vibrato depth (high nibble) * 173 u8 fadeout high bits (low nibble; 0b 0000 ffff)
* 174 u8 volume swing * 174 u8 volume swing
* 175 u8 vibrato speed * 175 u8 vibrato speed (FT2 instrumentwise; IT Vis rescaled to 0..255)
* 176 u8 vibrato sweep * 176 u8 vibrato sweep (FT2-only ramp ticks; 0 for IT)
* 177 u8 default pan * 177 u8 default pan
* 178..179 u16 pitch-pan centre (4096-TET) * 178..179 u16 pitch-pan centre (4096-TET)
* 180 s8 pitch-pan separation * 180 s8 pitch-pan separation
* 181 u8 pan swing * 181 u8 pan swing
* 182 u8 default cutoff * 182 u8 default cutoff
* 183 u8 default resonance * 183 u8 default resonance
* 184..191 byte[8] reserved * 184..185 u16 sample detune (4096-TET, signed stored as u16)
* 186 u8 instrument flag (0b 000 www nn — NNA bits 0-1, vib waveform bits 2-4)
* NNA: 00=note off, 01=note cut, 10=continue, 11=note fade
* waveform: 0=sine, 1=ramp-down, 2=square, 3=random, 4=ramp-up (FT2)
* 187 u8 vibrato depth (0..255 full range)
* 188 u8 vibrato rate (0..255 full range — IT samplewise Vir)
* 189..191 byte[3] reserved
*/ */
data class TaudInst( data class TaudInst(
var index: Int, var index: Int,
@@ -2645,27 +2656,41 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var panEnvelopes: Array<TaudInstEnvPoint>, // 25 points var panEnvelopes: Array<TaudInstEnvPoint>, // 25 points
var pfEnvelopes: Array<TaudInstEnvPoint>, // 25 points (pitch/filter) var pfEnvelopes: Array<TaudInstEnvPoint>, // 25 points (pitch/filter)
var volumeFadeoutLow: Int, // byte 172 var volumeFadeoutLow: Int, // byte 172
var fadeoutHighVibDepth: Int, // byte 173 var fadeoutHigh: Int, // byte 173 (low nibble — 0b 0000 ffff)
var volumeSwing: Int, // byte 174 var volumeSwing: Int, // byte 174
var vibratoSpeed: Int, // byte 175 var vibratoSpeed: Int, // byte 175
var vibratoSweep: Int, // byte 176 var vibratoSweep: Int, // byte 176 (FT2 ramp ticks)
var defaultPan: Int, // byte 177 var defaultPan: Int, // byte 177
var pitchPanCentre: Int, // bytes 178-179 var pitchPanCentre: Int, // bytes 178-179
var pitchPanSeparation: Int, // byte 180 (signed) var pitchPanSeparation: Int, // byte 180 (signed)
var panSwing: Int, // byte 181 var panSwing: Int, // byte 181
var defaultCutoff: Int, // byte 182 var defaultCutoff: Int, // byte 182
var defaultResonance: Int // byte 183 var defaultResonance: Int, // byte 183
var sampleDetune: Int, // bytes 184-185 (signed 4096-TET stored as u16)
var instrumentFlag: Int, // byte 186 (NNA + vibrato waveform)
var vibratoDepth: Int, // byte 187 (0..255 full range)
var vibratoRate: Int // byte 188 (IT samplewise Vir)
) { ) {
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,
Array(25) { TaudInstEnvPoint(0x3F, ThreeFiveMiniUfloat(0)) }, Array(25) { TaudInstEnvPoint(0x3F, ThreeFiveMiniUfloat(0)) },
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
) )
// Reserved padding at offsets 184..191 (8 bytes per instrument). /** New note action — instrumentFlag bits 0-1.
private val reserved = ByteArray(8) * 0=note off, 1=note cut, 2=continue, 3=note fade. */
val newNoteAction: Int get() = instrumentFlag and 0x03
/** Auto-vibrato waveform — instrumentFlag bits 2-4.
* 0=sine, 1=ramp-down, 2=square, 3=random, 4=ramp-up (FT2). */
val vibratoWaveform: Int get() = (instrumentFlag ushr 2) and 0x07
/** Sample detune as a signed 4096-TET delta. */
val sampleDetuneSigned: Int get() = sampleDetune.toShort().toInt()
// Reserved padding at offsets 189..191 (3 bytes per instrument).
private val reserved = ByteArray(3)
// 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.
@@ -2731,7 +2756,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
171 -> instGlobalVolume.toByte() 171 -> instGlobalVolume.toByte()
172 -> volumeFadeoutLow.toByte() 172 -> volumeFadeoutLow.toByte()
173 -> fadeoutHighVibDepth.toByte() 173 -> fadeoutHigh.toByte()
174 -> volumeSwing.toByte() 174 -> volumeSwing.toByte()
175 -> vibratoSpeed.toByte() 175 -> vibratoSpeed.toByte()
176 -> vibratoSweep.toByte() 176 -> vibratoSweep.toByte()
@@ -2742,7 +2767,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
181 -> panSwing.toByte() 181 -> panSwing.toByte()
182 -> defaultCutoff.toByte() 182 -> defaultCutoff.toByte()
183 -> defaultResonance.toByte() 183 -> defaultResonance.toByte()
in 184..191 -> reserved[offset - 184] 184 -> sampleDetune.toByte()
185 -> sampleDetune.ushr(8).toByte()
186 -> instrumentFlag.toByte()
187 -> vibratoDepth.toByte()
188 -> vibratoRate.toByte()
in 189..191 -> reserved[offset - 189]
else -> throw InternalError("Bad offset $offset") else -> throw InternalError("Bad offset $offset")
} }
@@ -2781,7 +2811,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
171 -> { instGlobalVolume = byte and 0xFF } 171 -> { instGlobalVolume = byte and 0xFF }
172 -> { volumeFadeoutLow = byte and 0xFF } 172 -> { volumeFadeoutLow = byte and 0xFF }
173 -> { fadeoutHighVibDepth = byte and 0xFF } 173 -> { fadeoutHigh = byte and 0x0F } // low nibble only (0b 0000 ffff)
174 -> { volumeSwing = byte and 0xFF } 174 -> { volumeSwing = byte and 0xFF }
175 -> { vibratoSpeed = byte and 0xFF } 175 -> { vibratoSpeed = byte and 0xFF }
176 -> { vibratoSweep = byte and 0xFF } 176 -> { vibratoSweep = byte and 0xFF }
@@ -2792,7 +2822,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
181 -> { panSwing = byte and 0xFF } 181 -> { panSwing = byte and 0xFF }
182 -> { defaultCutoff = byte and 0xFF } 182 -> { defaultCutoff = byte and 0xFF }
183 -> { defaultResonance = byte and 0xFF } 183 -> { defaultResonance = byte and 0xFF }
in 184..191 -> { reserved[offset - 184] = byte.toByte() } 184 -> { sampleDetune = (sampleDetune and 0xff00) or byte }
185 -> { sampleDetune = (sampleDetune and 0x00ff) or (byte shl 8) }
186 -> { instrumentFlag = byte and 0xFF }
187 -> { vibratoDepth = byte and 0xFF }
188 -> { vibratoRate = byte and 0xFF }
in 189..191 -> { reserved[offset - 189] = byte.toByte() }
else -> throw InternalError("Bad offset $offset") else -> throw InternalError("Bad offset $offset")
} }
} }