From f295223f154a4756341a69e73496e31b2f8ca7ef Mon Sep 17 00:00:00 2001 From: minjaesong Date: Sat, 2 May 2026 02:48:24 +0900 Subject: [PATCH] IT instrument shenanigans --- TAUD_NOTE_EFFECTS.md | 10 +- it2taud.py | 83 +++++++++---- taud_common.py | 2 +- terranmon.txt | 23 ++-- .../torvald/tsvm/peripheral/AudioAdapter.kt | 115 ++++++++++++------ 5 files changed, 155 insertions(+), 78 deletions(-) diff --git a/TAUD_NOTE_EFFECTS.md b/TAUD_NOTE_EFFECTS.md index 3329270..8a1bd20 100644 --- a/TAUD_NOTE_EFFECTS.md +++ b/TAUD_NOTE_EFFECTS.md @@ -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`). diff --git a/it2taud.py b/it2taud.py index b3472c7..09f7b6e 100644 --- a/it2taud.py +++ b/it2taud.py @@ -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('> 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(' ((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, // 25 points var pfEnvelopes: Array, // 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") } }