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 $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.
@@ -634,7 +634,7 @@ ProTracker `E5x` maps to Taud `S $2x00` with the same index meaning.
| $6 | Square | 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`.
@@ -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.
**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`).

View File

@@ -458,7 +458,7 @@ class ITInstrument:
__slots__ = ('name', 'dfp', 'gv', 'canonical_sample', 'canonical_volume',
'vol_envelope', 'pan_envelope', 'vol_env_sustain', 'pan_env_sustain',
'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
# *_env_sustain: int (16-bit, 0b 0ut sssss pcb eeeee), 0 = no envelope
# 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)
# 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)
# nna : new note action (IT 0=cut, 1=continue, 2=note off, 3=note fade)
def parse_instruments(data: bytes, h: ITHeader) -> list:
insts = []
@@ -477,6 +478,8 @@ def parse_instruments(data: bytes, h: ITHeader) -> list:
inst = ITInstrument()
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
# 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]
@@ -871,8 +874,7 @@ def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0,
if sub == 0x8:
# IT S8x: 4-bit → nibble-repeat into 8-bit SEL_SET pan
pan8 = (val << 4) | val
pan6 = min(0x3F, round(pan8 * 63 / 255))
return (TOP_NONE, 0, None, (SEL_SET, pan6))
return (TOP_S, 0x8000 | pan8, None, None)
if sub == 0x6:
vprint(f" dropped S6{val:X} (tick delay) at ch{ch} row{row}")
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)
if cmd == EFF_X:
# IT X is full 8-bit pan (0=left, 255=right; 128=centre in OpenMPT but
# 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))
return (TOP_S, 0x8000 | (arg & 0xFF), None, None)
if cmd == EFF_Y:
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:
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,
pps, ppc_taud, pan_swing, vol_swing, ifc, ifr.
inst_gv, fadeout, vib_speed, vib_depth, vib_sweep, vib_rate, vib_wave,
default_pan, pps, ppc_taud, pan_swing, vol_swing, ifc, ifr,
sample_detune, nna.
All optional; missing keys default to neutral values.
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 + 172] = fadeout & 0xFF # low 8 bits
# Byte 173: high nybble = vibrato depth, low nybble = fadeout high bits.
fade_hi = (fadeout >> 8) & 0x03
vib_depth = idata.get('vib_depth', 0) & 0x0F
inst_bin[base + 173] = ((vib_depth & 0xF) << 4) | fade_hi
# Byte 173: low nibble = fadeout high bits (0b 0000 ffff).
inst_bin[base + 173] = (fadeout >> 8) & 0x0F
inst_bin[base + 174] = idata.get('vol_swing', 0) & 0xFF
inst_bin[base + 175] = idata.get('vib_speed', 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 + 182] = idata.get('ifc', 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}")
@@ -1549,23 +1561,48 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
continue
src_smp = samples[si]
proxy[taud_slot] = src_smp
vol64 = min(inst.canonical_volume, 64)
inst_vols[taud_slot] = min(vol64, 0x3F)
# IT global volume range is 0..128; rescale to Taud's 0..255.
inst_gv_255 = min(255, round(inst.gv * 255 / 128))
# IT cell-trigger initial volume comes from the sample's default
# volume (Dv, 0..64), not the instrument's global volume.
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.
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
# representation is the absolute 4096-TET note value used in patterns
# (anchored to TAUD_C4 at IT note 60).
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:
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:
default_pan = 0x80
# 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] = {
'vol_env': inst.vol_envelope,
'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,
'inst_gv': inst_gv_255,
'fadeout': inst.fadeout,
'vib_speed': src_smp.av_speed,
'vib_depth': src_smp.av_depth,
'vib_sweep': src_smp.av_sweep,
'vib_speed': vib_speed_taud,
'vib_depth': vib_depth_taud,
'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,
'pps': inst.pps,
'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,
'ifc': inst.ifc,
'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)
else:

View File

@@ -44,7 +44,7 @@ NUM_VOICES = 20
NOTE_NOP = 0xFFFF
NOTE_KEYOFF = 0x0000
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)
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.
Uint8 Instrument Global Volume (0..255)
* 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
Uint8 Volume Fadeout low bits (IT: 1..256; XM: 0..255)
Bit8 Fadeout and vibrato
@@ -2067,7 +2067,7 @@ Instrument bin: Registry for 256 instruments, formatted as:
Uint8 Vibrato sweep
* FastTracker2 instrument config
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)
Sint8 Pitch-pan separation (-128..127 full range)
Uint8 Pan swing (0..255 full range)
@@ -2087,15 +2087,16 @@ Instrument bin: Registry for 256 instruments, formatted as:
TODO:
* 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)
* on playback, panning changes randomly on Taud made by s3m2taud.py and mod2taud.py
* implement S6x and S7x command
* implement Wxx command (global volume slide)
* implement sample loop sustain
* Amiga mode freq shift now "underdelivers" (pitch bend not "strong" enough)
* 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
[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)
[ ] on playback, panning changes randomly on Taud made by s3m2taud.py and mod2taud.py
[ ] implement S6x and S7x command
[ ] implement Wxx command (global volume slide)
[ ] implement sample loop sustain
[ ] Amiga mode freq shift now "underdelivers" (pitch bend not "strong" enough)
[ ] 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
[ ] implement bitcrusher (eff sym '8')
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
)
// 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).
private val FINETUNE_OFFSET = intArrayOf(
-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 =
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
// 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).
*/
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
val sweep = inst.vibratoSweep
val rampDepth = if (sweep == 0) depth0
else ((depth0 * voice.autoVibTicksSinceTrigger / sweep)
.coerceAtMost(depth0))
// Two ramp-in semantics:
// FT2 vibratoSweep (byte 176): "ticks to fully ramp" — depth = depth0 * t / sweep.
// IT vibratoRate (byte 188): "ramp acceleration" — accumulator += rate per tick,
// 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++
// Wave selector lives in the high nibble of vibratoSweep is not standard;
// IT keeps a separate wave byte that we don't currently surface, so treat
// as sine. The same `lfoSample` table used for H/U effects works here
// (8-bit phase, signed -127..+127).
val sine = lfoSample(voice.autoVibPhase, 0)
// 4096-TET delta: vib depth is in IT units (≈ 1/256 semitone). One
// semitone = 4096/12 ≈ 341.33 4096-TET units; IT auto-vibrato depth 1
// is ~6.25 cents = 21 4096-TET units. (sine * rampDepth) is roughly
// -127*15 .. +127*15 = ±1905, divided by 64 → ±30 ≈ ±10 cents at depth 15.
val pitchDelta = (sine * rampDepth) shr 6
// Vibrato waveform selector lives in instrumentFlag bits 2-4.
// 0=sine, 1=ramp-down, 2=square, 3=random, 4=ramp-up (FT2 only).
// lfoSample handles 0..3; treat 4 (ramp-up) as negated ramp-down.
val wave = inst.vibratoWaveform
val rawSample = if (wave == 4) -lfoSample(voice.autoVibPhase, 1)
else lfoSample(voice.autoVibPhase, wave and 3)
// 4096-TET delta. depth0 is now 0..255 (was 0..15 in old layout); the
// shift compensates so depth ≈255 yields a similar musical excursion
// (~±9 cents) to the old depth 15.
val pitchDelta = (rawSample * rampDepth) shr 10
voice.autoVibPhase = (voice.autoVibPhase + inst.vibratoSpeed * 2) and 0xFF
return pitchDelta
}
@@ -1821,7 +1826,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
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)
// 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) {
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) {
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) {
if (voice.funkSpeed == 0 || !voice.active) continue
val inst = instruments[voice.instrumentId]
if (inst.sampleLoopEnd <= inst.sampleLoopStart) continue
voice.funkAccumulator += FUNK_TABLE[voice.funkSpeed and 0xF]
voice.funkAccumulator += voice.funkSpeed
while (voice.funkAccumulator >= 0x80) {
voice.funkAccumulator -= 0x80
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
* 171 u8 instrument global volume
* 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
* 175 u8 vibrato speed
* 176 u8 vibrato sweep
* 175 u8 vibrato speed (FT2 instrumentwise; IT Vis rescaled to 0..255)
* 176 u8 vibrato sweep (FT2-only ramp ticks; 0 for IT)
* 177 u8 default pan
* 178..179 u16 pitch-pan centre (4096-TET)
* 180 s8 pitch-pan separation
* 181 u8 pan swing
* 182 u8 default cutoff
* 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(
var index: Int,
@@ -2645,27 +2656,41 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var panEnvelopes: Array<TaudInstEnvPoint>, // 25 points
var pfEnvelopes: Array<TaudInstEnvPoint>, // 25 points (pitch/filter)
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 vibratoSpeed: Int, // byte 175
var vibratoSweep: Int, // byte 176
var vibratoSweep: Int, // byte 176 (FT2 ramp ticks)
var defaultPan: Int, // byte 177
var pitchPanCentre: Int, // bytes 178-179
var pitchPanSeparation: Int, // byte 180 (signed)
var panSwing: Int, // byte 181
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(
index, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF,
Array(25) { TaudInstEnvPoint(0x3F, 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).
private val reserved = ByteArray(8)
/** New note action — instrumentFlag bits 0-1.
* 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.
// 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()
172 -> volumeFadeoutLow.toByte()
173 -> fadeoutHighVibDepth.toByte()
173 -> fadeoutHigh.toByte()
174 -> volumeSwing.toByte()
175 -> vibratoSpeed.toByte()
176 -> vibratoSweep.toByte()
@@ -2742,7 +2767,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
181 -> panSwing.toByte()
182 -> defaultCutoff.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")
}
@@ -2781,7 +2811,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
171 -> { instGlobalVolume = 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 }
175 -> { vibratoSpeed = 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 }
182 -> { defaultCutoff = 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")
}
}