diff --git a/TAUD_NOTE_EFFECTS.md b/TAUD_NOTE_EFFECTS.md index 8a1bd20..fc461be 100644 --- a/TAUD_NOTE_EFFECTS.md +++ b/TAUD_NOTE_EFFECTS.md @@ -692,13 +692,28 @@ ProTracker `E5x` maps to Taud `S $2x00` with the same index meaning. **Compatibility.** IT `S7x` maps directly. -**Implementation.** TODO +**Implementation.** Engines maintain a *mixer-private* background-voice pool per playhead, separate from the addressable foreground voices. When a fresh note retriggers a still-active foreground voice, the engine reads the effective NNA — the per-voice override set by `S $73..$76` if present, otherwise the instrument's default NNA (instrument record byte 186, low two bits) — and acts on the displaced voice as follows: + +- **Note Cut (1):** discard the foreground state in place; no ghost is created. +- **Note Off (0):** clone the foreground voice into the background pool and set its key-off flag, releasing any sustain loop. The clone's volume envelope plays out and fadeout decays from full. +- **Continue (2):** clone the foreground voice into the background pool unchanged; envelopes and sample position continue from where they were. +- **Note Fade (3):** clone the foreground voice into the background pool and immediately begin fadeout decay without releasing sustain. The volume envelope keeps looping its sustain region while fadeoutVolume drains to zero. + +Note Fade and Note Off are distinct: Note Fade does **not** set key-off, so the volume envelope's sustain loop continues to cycle; Note Off does set key-off, breaking sustain. Both share the same fadeout slope (`volumeFadeoutLow + (fadeoutHigh & 0x0F << 8)` units per tick out of 1024). + +The background pool is reaped when a ghost's `fadeoutVolume` drops to zero or its sample finishes (non-looping). Pool size is implementation-defined; the reference engine caps it at 64 ghosts per playhead and evicts the oldest on overflow. Background voices receive only passive per-tick maintenance (envelope advance, fadeout decay, auto-vibrato, filter coefficient refresh) — no row-driven effects (vibrato/tremolo/arpeggio/Q-retrigger/cut/delay) ever target them, since they are not addressable from the pattern. + +`S $70..$72` (Past Note Cut/Off/Fade) operate on every ghost whose `sourceChannel` matches the issuing channel: $70 drops them outright, $71 sets key-off on each, $72 begins fadeout on each. + +`S $73..$76` write the per-voice NNA override on the **currently active foreground voice** so that *its* next NNA event uses the overridden action. The override is cleared on every fresh trigger. + +`S $77..$7C` toggle the volume / panning / pitch-or-filter envelope on the currently active voice. While disabled, the envelope is frozen (no advancement) and the mixer treats its contribution as unity (envVolume / envPan / envPfValue all replaced by the neutral 1.0 / 0.5 / 0.5). --- ## S $80xx — Set channel pan position -**Plain.** Sets the channel pan to `$xx`, with $00 being full left and $FF being full right. $80 is centre. +**Plain.** Sets the channel pan to `$xx`, with $00 being full left and $FF being full right. $80 is centre. When this command and panning column's Set Pan are both present, this command takes precedence. **Compatibility.** IT `Xxx` maps directly. ST3 `S8x` uses a 4-bit value. 1. convert by nibble-repeat: ST3 `S83` → Taud `S $8033`. Panning column command `0.$xx` has the same semantics and is the preferred form when a pan column is available in the pattern. ProTracker `8xx` (fine pan) and `E8x` (coarse pan) both map into Taud's 8-bit pan — the ProTracker 8-bit form maps directly; the 4-bit form nibble-repeats. @@ -829,7 +844,7 @@ The panning column uses the same 6-bit value + 2-bit selector layout: - **`2.$xx` — Pan slide left** by `$xx` per non-first tick (4-bit). - **`3.$Sx` — Fine pan slide** on tick 0 only, same direction-bit encoding as the volume column's selector 3. -NOTE: **`3.00` — is No-op** +NOTE: **`3.00` — is No-op**. When Set Pan and S $80xx are both present, S-command takes precedence. --- diff --git a/it2taud.py b/it2taud.py index 09f7b6e..df2c5db 100644 --- a/it2taud.py +++ b/it2taud.py @@ -25,8 +25,9 @@ Effect support: A-Z dispatch per TAUD_NOTE_EFFECTS.md. IT-specific: Cxx is binary (not BCD like ST3). V scales by ×2 (IT 0-128 → Taud 0-255). X is the full 8-bit IT pan. Y panbrello nibble-repeats. Z (MIDI macro) - dropped. S6x tick-delay dropped. SAx high-offset dropped. S7x NNA - toggles dropped. Vol-column pitch-slide / tone-porta / vibrato sub- + dropped. S6x tick-delay dropped. SAx high-offset dropped. S7x NNA / + past-note / envelope toggles forwarded directly (IT sub-codes match + Taud one-to-one). Vol-column pitch-slide / tone-porta / vibrato sub- commands forwarded to main effect slot when empty; dropped otherwise. Per-effect private memory cohorts resolved eagerly (D/K/L share; E/F optionally linked with G per flag bit 5). @@ -879,7 +880,9 @@ def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0, vprint(f" dropped S6{val:X} (tick delay) at ch{ch} row{row}") return (TOP_NONE, 0, None, None) if sub == 0x7: - return (TOP_NONE, 0, None, None) # NNA/envelope — drop silently + # NNA / past-note / envelope on-off — IT S7x maps directly to Taud S $7x00 + # (same sub-code table). No payload to translate. + return (TOP_S, 0x7000 | (val << 8), None, None) if sub == 0x9: return (TOP_NONE, 0, None, None) # sound control — drop silently if sub == 0xA: diff --git a/terranmon.txt b/terranmon.txt index efdc6de..988d5a9 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -2088,12 +2088,14 @@ Instrument bin: Registry for 256 instruments, formatted as: TODO: [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 + [x] implement new note action on the audio engine (IT uses "background channels", maybe we can do the same but make "background channels" mixer-private) + [x] (same context as above) implement S7x command + [ ] on playback, panning changes randomly on Taud made by s3m2taud.py and mod2taud.py, but not by it2taud.py (maybe something's off with the instrument exports?) + [ ] implement S6x command + [ ] `S B000` and `S B100` not working as intended -- on first playback it jumps to the next cue same row, on subsequent playbacks the commands are completely ignored [ ] implement Wxx command (global volume slide) [ ] 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) -- appear to be fixed (2nd_pm.taud is the only one behaves incorrectly) [ ] 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') diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index ada839e..824d1df 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -124,6 +124,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { internal val DBGPRN = false const val SAMPLING_RATE = 32000 const val TRACKER_CHUNK = 512 + // Mixer-private background-voice pool size per playhead. NNA "Continue/Note Off/Note Fade" + // ghosts displaced foreground voices into this pool; oldest is evicted on overflow. + const val MAX_BG_VOICES = 64 const val MIDDLE_C = 0x5000 // reference C for instrument samplingRate (terranmon.txt:2000) // Amiga period at MIDDLE_C for a standard 8363 Hz instrument (NTSC clock 3579545 Hz). // PT "C-2" period 428 ↔ TSVM MIDDLE_C ↔ 8363 Hz; mod2taud uses the same convention. @@ -1193,7 +1196,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { // Volume envelope val vSus = inst.volEnvSustain val vUseEnv = (vSus ushr 5) and 1 != 0 - if (vUseEnv) { + if (vUseEnv && voice.volEnvOn) { val vEnabled = (vSus ushr 14) and 1 != 0 val vIsSustain = (vSus ushr 13) and 1 != 0 val vSusOn = vEnabled && (!vIsSustain || !voice.keyOff) @@ -1230,7 +1233,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } // Pan envelope (only when active for this instrument) - if (!voice.hasPanEnv) return + if (!voice.hasPanEnv || !voice.panEnvOn) return val pSus = inst.panEnvSustain val pUseEnv = (pSus ushr 5) and 1 != 0 if (!pUseEnv) return @@ -1274,7 +1277,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { * as advanceEnvelope. Result is stored in `voice.envPfValue` (0.0..1.0; 0.5 = unity). */ private fun advancePfEnvelope(voice: Voice, inst: TaudInst, tickSec: Double) { - if (!voice.hasPfEnv) return + if (!voice.hasPfEnv || !voice.pfEnvOn) return val maxIdx = 24 val pSus = inst.pfEnvSustain val pUseEnv = (pSus ushr 5) and 1 != 0 @@ -1529,12 +1532,110 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } voice.rowVolume = voice.channelVolume voice.noteWasCut = false + voice.noteFading = false + // S $73..$7C state resets on each fresh trigger so per-note overrides don't leak. + voice.nnaOverride = -1 + voice.volEnvOn = true + voice.panEnvOn = true + voice.pfEnvOn = true // Vibrato/tremolo/panbrello retrigger: reset LFO position when waveform requests it. if (voice.vibratoRetrig) voice.vibratoLfoPos = 0 if (voice.tremoloRetrig) voice.tremoloLfoPos = 0 if (voice.panbrelloRetrig) voice.panbrelloLfoPos = 0 } + /** + * On a fresh foreground trigger, optionally migrate the existing voice into the + * mixer-private background pool per the New Note Action setting (instrument default + * unless overridden by S $73..$76). Note Cut: no ghost, foreground retriggers in place. + * Note Off: ghost gets keyOff (sustain release + fadeout). Continue: ghost as-is. + * Note Fade: ghost begins fadeout immediately without releasing sustain. + */ + private fun maybeSpawnBackgroundForNNA(ts: TrackerState, voice: Voice, channel: Int) { + if (!voice.active) return + val nna = if (voice.nnaOverride >= 0) voice.nnaOverride + else instruments[voice.instrumentId].newNoteAction + if (nna == 1) return // Note Cut — foreground sample is replaced; no background needed. + + val bg = ghostVoice(voice, channel) + when (nna) { + 0 -> bg.keyOff = true // Note Off — release sustain; fadeout starts naturally. + 3 -> bg.noteFading = true // Note Fade — fadeout immediately, sustain still loops. + // 2 (Continue) — ghost continues unchanged. + } + ts.backgroundVoices.addLast(bg) + while (ts.backgroundVoices.size > MAX_BG_VOICES) { + ts.backgroundVoices.removeFirst() + } + } + + /** Snapshot the playback-relevant state of [src] into a fresh Voice tagged for [channel]. */ + private fun ghostVoice(src: Voice, channel: Int): Voice { + val v = Voice() + v.active = true + v.muted = src.muted + v.instrumentId = src.instrumentId + v.samplePos = src.samplePos + v.playbackRate = src.playbackRate + v.forward = src.forward + v.channelVolume = src.channelVolume + v.rowVolume = src.rowVolume + v.channelPan = src.channelPan + v.rowPan = src.rowPan + v.keyOff = src.keyOff + v.envIndex = src.envIndex + v.envTimeSec = src.envTimeSec + v.envVolume = src.envVolume + v.envPanIndex = src.envPanIndex + v.envPanTimeSec = src.envPanTimeSec + v.envPan = src.envPan + v.hasPanEnv = src.hasPanEnv + v.hasPfEnv = src.hasPfEnv + v.envPfIndex = src.envPfIndex + v.envPfTimeSec = src.envPfTimeSec + v.envPfValue = src.envPfValue + v.envPfIsFilter = src.envPfIsFilter + v.fadeoutVolume = src.fadeoutVolume + v.autoVibPhase = src.autoVibPhase + v.autoVibTicksSinceTrigger = src.autoVibTicksSinceTrigger + v.currentCutoff = src.currentCutoff + v.currentResonance = src.currentResonance + v.filterActive = src.filterActive + v.filterA0 = src.filterA0 + v.filterB0 = src.filterB0 + v.filterB1 = src.filterB1 + v.filterY1 = src.filterY1 + v.filterY2 = src.filterY2 + v.filterCutoffCached = src.filterCutoffCached + v.filterResonanceCached = src.filterResonanceCached + v.randomVolBias = src.randomVolBias + v.randomPanBias = src.randomPanBias + v.noteVal = src.noteVal + v.basePitch = src.basePitch + v.volEnvOn = src.volEnvOn + v.panEnvOn = src.panEnvOn + v.pfEnvOn = src.pfEnvOn + v.noteFading = src.noteFading + v.sourceChannel = channel + return v + } + + /** Past-note action (S $70..$72): apply [action] to all background voices spawned by [channel]. */ + private fun applyPastNoteAction(ts: TrackerState, channel: Int, action: Int) { + when (action) { + 0 -> { // Past Note Cut — drop them. + val iter = ts.backgroundVoices.iterator() + while (iter.hasNext()) if (iter.next().sourceChannel == channel) iter.remove() + } + 1 -> ts.backgroundVoices.forEach { bg -> // Past Note Off — sustain release. + if (bg.sourceChannel == channel) bg.keyOff = true + } + 2 -> ts.backgroundVoices.forEach { bg -> // Past Note Fade — start fadeout. + if (bg.sourceChannel == channel) bg.noteFading = true + } + } + } + private fun applyVolColumn(voice: Voice, value: Int, sel: Int) { // value is the 6-bit cell field; sel is the 2-bit selector. See TAUD_NOTE_EFFECTS.md // §"Volume column effects" for the multi-selector encoding. @@ -1554,8 +1655,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } private fun applyPanColumn(voice: Voice, value: Int, sel: Int) { + // S $80xx (8-bit pan SET in the effect column) wins over PanEff SET (6-bit) on the same + // row — skip the SET branch here so the effect column's higher-precision write is final. + // Slide selectors (1/2/3) still apply, since their per-tick behaviour is independent. + val rowHasS80 = voice.rowEffect == EffectOp.OP_S && + ((voice.rowEffectArg ushr 12) and 0xF) == 0x8 when (sel) { - 0 -> { voice.channelPan = (value shl 2) or (value ushr 4); voice.rowPan = (voice.channelPan shr 2).coerceIn(0, 63) } + 0 -> if (!rowHasS80) { voice.channelPan = (value shl 2) or (value ushr 4); voice.rowPan = (voice.channelPan shr 2).coerceIn(0, 63) } 1 -> voice.panColSlideRight = value 2 -> voice.panColSlideLeft = value 3 -> { @@ -1609,12 +1715,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { // Tone porta: target the note, do not retrigger sample. voice.tonePortaTarget = row.note } else if ((row.effect == EffectOp.OP_S) && ((row.effectArg ushr 12) and 0xF) == 0xD) { - // Note delay: defer trigger to the requested tick. + // Note delay: defer trigger to the requested tick. NNA fires when the + // deferred trigger actually executes, not now. voice.noteDelayTick = (row.effectArg ushr 8) and 0xF voice.delayedNote = row.note voice.delayedInst = row.instrment voice.delayedVol = if (row.volume >= 0) row.volume else -1 } else { + maybeSpawnBackgroundForNNA(ts, voice, vi) triggerNote(voice, row.note, row.instrment, -1) } } @@ -1800,6 +1908,25 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { 0x3 -> { voice.vibratoWave = x and 3; voice.vibratoRetrig = (x and 4) == 0 } 0x4 -> { voice.tremoloWave = x and 3; voice.tremoloRetrig = (x and 4) == 0 } 0x5 -> { voice.panbrelloWave = x and 3; voice.panbrelloRetrig = (x and 4) == 0 } + 0x7 -> when (x) { + // Past-note actions on the channel's background ghosts. + 0x0 -> applyPastNoteAction(ts, vi, 0) // Past Note Cut + 0x1 -> applyPastNoteAction(ts, vi, 1) // Past Note Off + 0x2 -> applyPastNoteAction(ts, vi, 2) // Past Note Fade + // NNA override for the live note (used at next NNA event on this voice). + // Codes follow the per-voice nnaOverride convention (0=Off, 1=Cut, 2=Continue, 3=Fade). + 0x3 -> voice.nnaOverride = 1 // NNA Note Cut + 0x4 -> voice.nnaOverride = 2 // NNA Note Continue + 0x5 -> voice.nnaOverride = 0 // NNA Note Off + 0x6 -> voice.nnaOverride = 3 // NNA Note Fade + // Envelope on/off — mixer ignores and per-tick freezes the disabled envelope. + 0x7 -> voice.volEnvOn = false + 0x8 -> voice.volEnvOn = true + 0x9 -> voice.panEnvOn = false + 0xA -> voice.panEnvOn = true + 0xB -> voice.pfEnvOn = false + 0xC -> voice.pfEnvOn = true + } 0x8 -> { // S$80xx — full 8-bit pan; arg low byte is the value. voice.channelPan = arg and 0xFF @@ -1832,7 +1959,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { private fun applyTrackerTick(ts: TrackerState, playhead: Playhead) { val tickSec = 2.5 / playhead.bpm - for (voice in ts.voices) { + for (vi in 0 until ts.voices.size) { + val voice = ts.voices[vi] if (!voice.active && voice.noteDelayTick < 0) continue val inst = instruments[voice.instrumentId] @@ -1843,7 +1971,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } // Note delay — fire deferred trigger when the requested tick arrives. + // NNA fires now (not at row parse) so that delayed retriggers ghost correctly. if (voice.noteDelayTick == ts.tickInRow) { + maybeSpawnBackgroundForNNA(ts, voice, vi) triggerNote(voice, voice.delayedNote, voice.delayedInst, voice.delayedVol) voice.noteDelayTick = -1 } @@ -1969,7 +2099,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { // Pitch envelope contribution: env value 0..1, 0.5 = unity. -32..+32 // semitone range maps to ±32 × 4096/12 ≈ ±10923 4096-TET units. - val pitchEnvDelta = if (voice.hasPfEnv && !voice.envPfIsFilter) + val pitchEnvDelta = if (voice.hasPfEnv && voice.pfEnvOn && !voice.envPfIsFilter) ((voice.envPfValue - 0.5) * 2.0 * 32.0 * 4096.0 / 12.0).toInt() else 0 @@ -1979,7 +2109,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { // Filter envelope (filter mode): scale current cutoff by env value (0..1, 0.5 = unity). // If the instrument has no initial cutoff (255 = off), the envelope drives the filter // from the maximum active value (254) so the filter can become audible during the note. - if (voice.hasPfEnv && voice.envPfIsFilter) { + if (voice.hasPfEnv && voice.pfEnvOn && voice.envPfIsFilter) { val baseCut = if (inst.defaultCutoff < 255) inst.defaultCutoff else 254 voice.currentCutoff = (baseCut * (voice.envPfValue * 2.0)).toInt().coerceIn(0, 254) } @@ -1987,9 +2117,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { // Refresh biquad filter coefficients once per tick (only recomputes when changed). refreshVoiceFilter(voice) - // Volume fadeout: after key-off, decrement by inst.volumeFadeout / 1024 per tick. + // Volume fadeout: after key-off OR Note-Fade NNA, decrement by inst.volumeFadeout / 1024 per tick. // The 10-bit fadeout value is split across volumeFadeoutLow + low nibble of fadeoutHigh. - if (voice.keyOff) { + if (voice.keyOff || voice.noteFading) { val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8) if (fadeStep > 0) { voice.fadeoutVolume = (voice.fadeoutVolume - fadeStep / 1024.0).coerceAtLeast(0.0) @@ -2021,6 +2151,42 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { voice.funkWritePos = (voice.funkWritePos + 1) % loopLen } } + + // Background (NNA-ghost) voices: passive maintenance only — envelopes, fadeout, filter, + // and pitch recompute. No row-driven effects (vibrato/tremolo/arp/Q/etc.) ever target + // background voices; they continue from the moment of ghosting until they fade or end. + val bgIt = ts.backgroundVoices.iterator() + while (bgIt.hasNext()) { + val bg = bgIt.next() + if (!bg.active) { bgIt.remove(); continue } + val inst = instruments[bg.instrumentId] + advanceEnvelope(bg, inst, tickSec) + advancePfEnvelope(bg, inst, tickSec) + if (bg.keyOff || bg.noteFading) { + val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8) + if (fadeStep > 0) { + bg.fadeoutVolume = (bg.fadeoutVolume - fadeStep / 1024.0).coerceAtLeast(0.0) + } + } + // Auto-vibrato keeps running on backgrounds — it's an instrument-intrinsic LFO. + val autoVibDelta = advanceAutoVibrato(bg, inst) + val pitchEnvDelta = if (bg.hasPfEnv && bg.pfEnvOn && !bg.envPfIsFilter) + ((bg.envPfValue - 0.5) * 2.0 * 32.0 * 4096.0 / 12.0).toInt() + else 0 + val finalPitch = (bg.noteVal + autoVibDelta + pitchEnvDelta).coerceIn(0, 0xFFFE) + bg.playbackRate = computePlaybackRate(inst, finalPitch) + // Filter-mode pf envelope: same scaling rule as foreground. + if (bg.hasPfEnv && bg.pfEnvOn && bg.envPfIsFilter) { + val baseCut = if (inst.defaultCutoff < 255) inst.defaultCutoff else 254 + bg.currentCutoff = (baseCut * (bg.envPfValue * 2.0)).toInt().coerceIn(0, 254) + } + refreshVoiceFilter(bg) + // Reap fully-faded ghosts so the pool stays drained. + if ((bg.keyOff || bg.noteFading) && bg.fadeoutVolume <= 0.0) { + bg.active = false + bgIt.remove() + } + } } private fun applyRetrigVolMod(vol: Int, x: Int): Int = when (x and 0xF) { @@ -2079,9 +2245,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { val instGv = voiceInst.instGlobalVolume / 255.0 // Volume swing bias (random per-trigger, ±randomVolBias of 0..255 units folded into the 0..63 row volume). val swingScale = 1.0 + voice.randomVolBias / 255.0 - val vol = voice.envVolume * voice.fadeoutVolume * voice.rowVolume / 63.0 * + // Volume envelope is bypassed (treated as unity) when S $77 has disabled it. + val effEnvVol = if (voice.volEnvOn) voice.envVolume else 1.0 + val vol = effEnvVol * voice.fadeoutVolume * voice.rowVolume / 63.0 * swingScale * gvol * instGv * playhead.masterVolume / 255.0 - val pan = if (voice.hasPanEnv) { + val pan = if (voice.hasPanEnv && voice.panEnvOn) { val envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255) (voice.channelPan + envPanRaw - 128 + voice.randomPanBias).coerceIn(0, 255) } else (voice.channelPan + voice.randomPanBias).coerceIn(0, 255) @@ -2100,6 +2268,33 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { mixL += s * vol * lGain mixR += s * vol * rGain } + // Background (NNA-ghost) voices — same per-sample mixing path as foreground, but + // they live in a mixer-private pool that no row event can address. + for (bg in ts.backgroundVoices) { + if (!bg.active || bg.muted) continue + val bgInst = instruments[bg.instrumentId] + val s = applyVoiceFilter(bg, fetchTrackerSample(bg, bgInst)) + val instGv = bgInst.instGlobalVolume / 255.0 + val swingScale = 1.0 + bg.randomVolBias / 255.0 + val effEnvVol = if (bg.volEnvOn) bg.envVolume else 1.0 + val vol = effEnvVol * bg.fadeoutVolume * bg.rowVolume / 63.0 * + swingScale * gvol * instGv * playhead.masterVolume / 255.0 + val pan = if (bg.hasPanEnv && bg.panEnvOn) { + val envPanRaw = (bg.envPan * 255.0).roundToInt().coerceIn(0, 255) + (bg.channelPan + envPanRaw - 128 + bg.randomPanBias).coerceIn(0, 255) + } else (bg.channelPan + bg.randomPanBias).coerceIn(0, 255) + val lGain: Double + val rGain: Double + when (ts.panLaw) { + 1 -> { lGain = cos(PI * pan / 512.0); rGain = sin(PI * pan / 512.0) } + else -> { + lGain = if (pan < 0x80) 1.0 else 1.0 - (pan - 128.0) / 128.0 + rGain = if (pan < 0x80) pan / 128.0 else 1.0 + } + } + mixL += s * vol * lGain + mixR += s * vol * rGain + } ts.mixLeft[n] = mixL.toFloat().coerceIn(-1.0f, 1.0f) ts.mixRight[n] = mixR.toFloat().coerceIn(-1.0f, 1.0f) @@ -2252,6 +2447,21 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { var playbackRate = 1.0 var forward = true + // -1 for live foreground voices held by TrackerState.voices[]; 0..19 for background + // (mixer-private) ghosts spawned by NNA on the matching channel index. + var sourceChannel = -1 + // -1 = use instrument-default NNA; otherwise overrides the next NNA event on this voice + // (see S $73..$76). Cleared on every fresh trigger. + var nnaOverride = -1 + // Per-voice envelope gates (S $77..$7C). When false the corresponding envelope is frozen + // *and* its value is treated as unity by the mixer / pitch path. + var volEnvOn = true + var panEnvOn = true + var pfEnvOn = true + // Note-Fade NNA flag — triggers volume fadeout without sustain release (vs keyOff which + // also breaks the volume envelope's sustain loop). Both paths feed the same fade decay. + var noteFading = false + // Volumes: channel volume is the persistent base; rowVolume tracks per-tick output (set per row from channel volume + volume column). var channelVolume = 0x3F // $00..$3F (default full) var rowVolume = 63 // $00..$3F effective output volume after slides @@ -2411,6 +2621,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { // Pre-allocated mix buffers for dither path (reused each audio chunk). val mixLeft = FloatArray(TRACKER_CHUNK) val mixRight = FloatArray(TRACKER_CHUNK) + + // Mixer-private background voices: NNA-ghosted copies of displaced foreground voices. + // Not addressable from row events; only S $70..$72 and the mixer/per-tick maintenance + // touch them. ArrayDeque so we can evict oldest (head) when the pool is full. + val backgroundVoices = ArrayDeque() } class Playhead( @@ -2540,7 +2755,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { it.funkAccumulator = 0 it.funkWritePos = 0 it.muted = false + it.nnaOverride = -1 + it.volEnvOn = true; it.panEnvOn = true; it.pfEnvOn = true + it.noteFading = false } + ts.backgroundVoices.clear() } }