From a28fcbcefc1a5cf8235816b51b8b0652587bb8cf Mon Sep 17 00:00:00 2001 From: minjaesong Date: Mon, 11 May 2026 10:40:33 +0900 Subject: [PATCH] resolving note volume and channel volume conflaton --- TAUD_NOTE_EFFECTS.md | 82 ++++---- assets/disk0/tvdos/bin/taut.js | 24 +-- it2taud.py | 9 +- mon2taud.py | 4 +- terranmon.txt | 28 ++- .../torvald/tsvm/peripheral/AudioAdapter.kt | 179 ++++++++++++------ 6 files changed, 203 insertions(+), 123 deletions(-) diff --git a/TAUD_NOTE_EFFECTS.md b/TAUD_NOTE_EFFECTS.md index cfc0a82..dbffb65 100644 --- a/TAUD_NOTE_EFFECTS.md +++ b/TAUD_NOTE_EFFECTS.md @@ -45,6 +45,15 @@ mix = sample × note_vol × channel_vol × global_vol >> normalisation_shift with saturation applied before the 8-bit stereo output. +`note_vol` and `channel_vol` are **two independent multiplicative axes** mirroring IT's `chan->volume` and `chan->global_volume`: + +- **`note_vol`** is the per-note axis. It is reset on every note re-trigger to the instrument's Default Note Volume (instrument-record byte 196). It is the target of the volume column (selectors 0 / 1 / 2 / 3), the D / K / L volume slides, and the Q retrigger volume modifier. It survives across rows until the next re-trigger. +- **`channel_vol`** is the per-channel axis. It is **not** reset by note re-triggers — once set, it persists through any number of fresh notes on that channel. It is the target of M (set) and N (slide) only. + +The engine carries a third per-tick value, `row_vol`, which is the mixer-facing volume for the current tick. At every row boundary `row_vol` rebases to `note_vol`; per-tick modulators (tremolo R, tremor I) write `row_vol` only, so their effect dies cleanly at row end. Per-note slides (D, K, L, vol-col) write **both** `note_vol` and `row_vol` so the per-note baseline carries forward. + +Because the two axes are independent, an `M $4000` (set channel volume to full) issued after a `0.$02` (vol-col SET = 2) leaves the per-note volume untouched at 2 — the channel keeps playing quietly. Conversely, an `N` slide can fade out a channel's overall level while a vol-col SET on a fresh trigger sets the per-note baseline at full. + ## 4. Rows, ticks, patterns, cues A pattern is a rectangular grid of rows and channels; each cell holds one note event. Playback divides each row into `speed` ticks (effect A); tempo (effect T) sets the duration of one tick. At 125 BPM and speed 6, one row takes 120 ms and one tick 20 ms. Songs play patterns in a cue sequence; effects B and C navigate this sequence. @@ -57,6 +66,7 @@ A pattern is a rectangular grid of rows and channels; each cell holds one note e | Tempo byte | $64 (125 BPM; see effect T for the $19 offset) | | Global volume | $80 (mid-scale) | | Channel volume | $3F (full) | +| Note volume | $3F (full; reseeded from instrument's Default Note Volume on every re-trigger) | | Pan (all channels) | $80 (centre) | | cue index | $0000 | @@ -115,39 +125,39 @@ Opcodes are single base-36 digits (0-9, then A-Z); arguments are 16-bit hexadeci ## D $xy00 — Volume slide (multiple forms) -D's 16-bit argument encodes four mutually exclusive modes using the top nibble and the following byte. All forms operate on the channel's current volume and clip to $00..$3F after each step. +D's 16-bit argument encodes four mutually exclusive modes using the top nibble and the following byte. **All forms operate on `note_vol`** (the per-note axis described in §3, analog of IT `chan->volume`) and clip to $00..$3F after each step. The slid value persists into following rows until the next re-trigger; `channel_vol` is **not** touched by D — for the per-channel axis, use N. ### D $0y00 — Volume slide down by $y per non-first tick -**Plain.** Each tick after tick 0, volume decreases by $y. A D $0400 at speed 8 reduces volume by $1C over the row. +**Plain.** Each tick after tick 0, `note_vol` decreases by $y. A D $0400 at speed 8 reduces volume by $1C over the row. **Compatibility.** ST3 `Dx0` (volume slide down) maps to Taud `D $0x00`. The ST3 volume cap was $40; Taud's is $3F — a very high-volume sample reaching $40 in ST3 will snap to $3F in Taud. -**Implementation.** On ticks > 0, subtract the low nibble of the high byte from `channel_volume`; clamp at $00. Memory is private to D and is keyed on the full original byte (so D $0000 recalls whatever form last ran). +**Implementation.** On ticks > 0, subtract the low nibble of the high byte from `note_vol`; clamp at $00; mirror `row_vol = note_vol`. Memory is private to D and is keyed on the full original byte (so D $0000 recalls whatever form last ran). ### D $x000 — Volume slide up by $x per non-first tick -**Plain.** Each tick after tick 0, volume increases by $x. Capped at $3F. +**Plain.** Each tick after tick 0, `note_vol` increases by $x. Capped at $3F. **Compatibility.** ST3 `D0y` (volume slide up) maps to Taud `D $y000`. -**Implementation.** On ticks > 0, add the high nibble of the high byte to `channel_volume`; clamp at $3F. +**Implementation.** On ticks > 0, add the high nibble of the high byte to `note_vol`; clamp at $3F; mirror `row_vol = note_vol`. ### D $Fy00 — Fine volume slide down by $y on tick 0 -**Plain.** Applies a one-shot volume reduction of $y on tick 0 only. Independent of speed. A D $FF00 behaves as a fine slide up by $F (so a request for "down by F" is reinterpreted; see below). +**Plain.** Applies a one-shot `note_vol` reduction of $y on tick 0 only. Independent of speed. A D $FF00 behaves as a fine slide up by $F (so a request for "down by F" is reinterpreted; see below). **Compatibility.** ST3 `DFy` maps directly. The $FF edge case is preserved: ST3 treats `DFF` as fine slide up by $F rather than fine slide down by $F, and Taud follows suit. -**Implementation.** On tick 0 only, subtract the low nibble of the high byte from `channel_volume`. If the low nibble is $0, treat as fine-slide-up by $F. If the high byte is $FF, treat as fine-slide-up by $F. +**Implementation.** On tick 0 only, subtract the low nibble of the high byte from `note_vol`; mirror `row_vol = note_vol`. If the low nibble is $0, treat as fine-slide-up by $F. If the high byte is $FF, treat as fine-slide-up by $F. ### D $xF00 — Fine volume slide up by $x on tick 0 -**Plain.** One-shot volume increase of $x on tick 0 only. +**Plain.** One-shot `note_vol` increase of $x on tick 0 only. **Compatibility.** ST3 `DxF` maps directly. Volume cap is $3F, lower than ST3's $40. -**Implementation.** On tick 0 only, add the high nibble to `channel_volume`; clamp at $3F. +**Implementation.** On tick 0 only, add the high nibble to `note_vol`; clamp at $3F; mirror `row_vol = note_vol`. --- @@ -346,15 +356,17 @@ on row parse (I): on every tick: if phase == ON: - play at full channel volume + play at the unmodulated row_vol (no gating) tick_in_phase += 1 if tick_in_phase >= on_time: phase = OFF; tick_in_phase = 0 else: - force output volume to 0 (base volume preserved for later effects) + row_vol = 0 # transient gate; note_vol / channel_vol are preserved tick_in_phase += 1 if tick_in_phase >= off_time: phase = ON; tick_in_phase = 0 ``` +The OFF-phase gate writes `row_vol` only; `note_vol` and `channel_vol` are untouched, so the per-row rebase (`row_vol = note_vol` at row start) restores the audible level cleanly when tremor stops. + A zero `$xx` or `$yy` input becomes 1 tick after the `+1`, never zero. --- @@ -432,11 +444,11 @@ on every tick (including tick 0): apply vibrato update with memory_HU.speed / memory_HU.depth (see §H) on tick > 0: - channel_volume = clamp(channel_volume + slide_per_tick, 0, $3F) - row_volume = channel_volume + note_vol = clamp(note_vol + slide_per_tick, 0, $3F) + row_vol = note_vol ``` -K has its own memory slot (private). The slide always uses the per-tick form — `K $FF00` does **not** trigger a fine slide; the argument's `$F` nibbles are interpreted as `$F`-magnitude per-tick slides (down wins), matching ST3's K and IT's K semantics. +The slide writes the per-note axis (same as D); `channel_vol` is untouched. K has its own memory slot (private). The slide always uses the per-tick form — `K $FF00` does **not** trigger a fine slide; the argument's `$F` nibbles are interpreted as `$F`-magnitude per-tick slides (down wins), matching ST3's K and IT's K semantics. --- @@ -465,17 +477,17 @@ on row parse (L): on tick > 0: apply tone-portamento step using memory_G.speed (see §G) - channel_volume = clamp(channel_volume + slide_per_tick, 0, $3F) - row_volume = channel_volume + note_vol = clamp(note_vol + slide_per_tick, 0, $3F) + row_vol = note_vol ``` -L has its own memory slot (private), separate from K's and from D's. +The slide writes the per-note axis (same as D); `channel_vol` is untouched. L has its own memory slot (private), separate from K's and from D's. --- ## M $xx00 — Set channel volume to $xx -**Plain.** Sets the channel's persistent base volume to `$xx`, in the same 6-bit `$00..$3F` range as a note's default volume. Unlike a volume-column SET (which only writes the *row* volume on a re-triggering row), M overwrites the channel's stored base volume so the change persists across subsequent rows that don't carry an explicit vol-column SET. +**Plain.** Sets the per-channel volume axis (`channel_vol`, see §3) to `$xx`, in the same 6-bit `$00..$3F` range as a note's default volume. M is the analog of IT's `Mxx`, which writes `chan->global_volume` — it does **not** disturb the per-note volume (`note_vol`) set by the volume column or seeded from the instrument default. A vol-col SET of $02 on a note row followed by an `M $4000` on the next row therefore plays the channel at `2/63 × $3F/63 ≈ 3%` of full, *not* at full — exactly as IT would. **Compatibility.** IT `Mxx` maps directly: the source byte is taken **verbatim** with a clamp to `$3F` (IT's $40 cap snaps down by one). ST3 has no native M; OpenMPT/Schism's S3M-with-IT-extensions does, and the same verbatim-with-clamp rule applies on import. M has **no memory** — `M $0000` is a literal "set channel volume to silence", not a recall. Source-tracker shared-memory recalls (e.g., ST3's single-slot shared memory) MUST be eagerly resolved by the converter before emit. @@ -485,17 +497,19 @@ L has its own memory slot (private), separate from K's and from D's. on row parse (M): new_vol = (arg >> 8) & 0xFF if new_vol > 0x3F: new_vol = 0x3F - channel_volume = new_vol - row_volume = new_vol + channel_vol = new_vol + # note_vol and row_vol are NOT touched. The mixer multiplies channel_vol + # into the per-voice gain via the volume-ramp target, so the change is + # heard from this tick onwards without nuking the per-note volume. ``` -The change takes effect on tick 0 of the row. There is no slide form; for that, use N. The low byte of M's argument is reserved. +The change takes effect on tick 0 of the row (the next mixer ramp window picks it up). There is no slide form; for that, use N. The low byte of M's argument is reserved. --- ## N $xy00 — Channel volume slide -**Plain.** Slides the channel's persistent base volume by `$xy` per non-first tick (or once on tick 0 for fine forms). Encoding is identical to D (see §D), but the slide acts on `channel_volume` rather than the per-row note volume — so the change persists into following rows that don't reissue N. Range and clipping match D: `$00..$3F`. +**Plain.** Slides the per-channel volume axis (`channel_vol`, see §3 and §M) by `$xy` per non-first tick (or once on tick 0 for fine forms). Encoding is identical to D (see §D), but the slide acts on `channel_vol` — independent of `note_vol`, so vol-col SET / D-slide state on the per-note axis survives across an N. The change persists into following rows that don't reissue N. Range and clipping match D: `$00..$3F`. **Compatibility.** IT `Nxy` maps directly to Taud `N $xy00` (high byte = source argument byte, verbatim). ST3 has no native N. N's encoding sub-forms mirror D exactly: @@ -506,7 +520,7 @@ The change takes effect on tick 0 of the row. There is no slide form; for that, **Memory.** N has its own private slot, separate from D's. `N $0000` recalls the last N argument and re-applies it in its original sub-form (coarse vs fine, up vs down). -**Implementation.** Identical to D, with `channel_volume` substituted for the per-row volume target. After every step the result is clamped to `$00..$3F` and `row_volume` is forced to track `channel_volume` so subsequent ticks' mixing reflects the slid value: +**Implementation.** Identical to D, with `channel_vol` substituted for `note_vol`. After every step the result is clamped to `$00..$3F`. `note_vol` and `row_vol` are **not** touched — the mixer multiplies `channel_vol` into the per-voice gain via the volume-ramp target, so the change is heard within the row without disturbing the per-note baseline: ``` on row parse (N): @@ -514,7 +528,7 @@ on row parse (N): if raw == 0: raw = memory_N else: memory_N = raw decode raw exactly as D does (FF / F0 / Fy / xF / 0y / x0 → fine-up-F / coarse / fine forms) - schedule per-tick (or apply once) on channel_volume; row_volume = channel_volume after each step + schedule per-tick (or apply once) on channel_vol — never touch note_vol / row_vol ``` --- @@ -576,7 +590,7 @@ The mixer reads `channel_pan` (8-bit) directly through the same path as `S $80xx ProTracker `E9x` is equivalent to Taud `Q $0x00` (retrigger only, no volume change). -**Implementation.** A per-channel tick counter advances every tick, including tick 0. When it reaches `$y`, the sample retriggers (keeping current pitch), the counter resets to 0, and the volume modifier `$x` applies. The counter resets only when a row has **no** Q command; successive Q rows share and advance the counter. +**Implementation.** A per-channel tick counter advances every tick, including tick 0. When it reaches `$y`, the sample retriggers (keeping current pitch), the counter resets to 0, and the volume modifier `$x` applies to `note_vol` (the per-note axis — IT's `chan->volume`). `channel_vol` is untouched. The counter resets only when a row has **no** Q command; successive Q rows share and advance the counter. The volume modifier table, **computed with arithmetic (no LUT)**, is: @@ -613,10 +627,12 @@ on row parse (R): on every tick (including tick 0): sine = ModSinusTable[(lfo_pos >> 2) & $3F] vol_delta = (sine × memory_R.depth) >> 9 - applied_vol = clamp(base_vol + vol_delta, 0, $3F) + row_vol = clamp(note_vol + vol_delta, 0, $3F) # modulate around the per-note axis lfo_pos = (lfo_pos + memory_R.speed × 4) & $FF ``` +The LFO bias is added to `note_vol` (per-note axis, mirroring IT's tremolo on `chan->volume`) and the result lands in `row_vol`, never written back into `note_vol` itself — so the row-end rebase reseats `row_vol` cleanly and tremolo dies on the next row without leaving residue. `channel_vol` is unaffected. + Peak at maximum settings: $7F × $FF >> 9 = $3F — the full volume range. Retrigger behaviour tracks the S $4x waveform nibble bit 2: cleared means retrigger on new note, set means preserve LFO position. --- @@ -1086,16 +1102,16 @@ on sample byte read during loop playback: # Volume column effects -Each cell carries a 6-bit value field plus a 2-bit selector field for the volume column. The four selectors are: +Each cell carries a 6-bit value field plus a 2-bit selector field for the volume column. **All four selectors target `note_vol`** — the per-note volume axis (§3, analog of IT's `chan->volume`). The per-channel axis (`channel_vol`) is reachable only via the M / N effects in the main effect column. The four selectors are: -- **`0.$xx` — Set volume** to `$xx` (6-bit, $00..$3F). Equivalent to a note's default volume. -- **`1.$xx` — Volume slide up** by `$xx` per non-first tick (4-bit). Volume clamps at $3F. -- **`2.$xx` — Volume slide down** by `$xx` per non-first tick (4-bit). Volume clamps at $00. -- **`3.$Sx` — Fine volume slide** on tick 0 only. The high bit `$S` of the value selects direction (0 = down, 1 = up); the low 4 bits `$x` ($0..$F) are the magnitude. Equivalent in scale to `D $xF00` / `D $Fy00` but with a 5-bit cap. Fires once per row regardless of speed. +- **`0.$xx` — Set note_vol** to `$xx` (6-bit, $00..$3F). Equivalent in effect to seeding the note with a different default volume; persists across rows until the next re-trigger. +- **`1.$xx` — note_vol slide up** by `$xx` per non-first tick (4-bit). Clamps at $3F. The slid value persists into following rows. +- **`2.$xx` — note_vol slide down** by `$xx` per non-first tick (4-bit). Clamps at $00. The slid value persists into following rows. +- **`3.$Sx` — Fine note_vol slide** on tick 0 only. The high bit `$S` of the value selects direction (0 = down, 1 = up); the low 4 bits `$x` ($0..$F) are the magnitude. Equivalent in scale to `D $xF00` / `D $Fy00` but with a 5-bit cap. Fires once per row regardless of speed. -Volume-column effects do not consume the main effect slot; a cell can carry both (for instance, a tone portamento in the effect slot and a volume slide in the volume column). +Volume-column effects do not consume the main effect slot; a cell can carry both (for instance, a tone portamento in the effect slot and a volume slide in the volume column). Because the volume column writes the per-note axis, an `M $xx00` on the same or following row sets the per-channel axis independently — the two multiply at the mixer (see §3 / §M). -When the converter folds an ST3 K, L, M, or N effect into the volume column, the slide-up / slide-down nibbles map to selectors 1 / 2 (clamped to 6 bits — values above $3F clip). +When the converter folds an ST3 K, L, M, or N effect into the volume column, the slide-up / slide-down nibbles map to selectors 1 / 2 (clamped to 6 bits — values above $3F clip). Note that *converted* M and N still target `note_vol` here (vol-col semantics) — to preserve the original per-channel intent, emit them in the main effect column instead. NOTE: **`3.00` — is No-op** diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index 5e39e1f..8243124 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -1800,7 +1800,8 @@ function simulateRowState(ptnDat, uptoRow) { ] let lastNote = 0xFFFF, lastInst = 0 - let volAbs = 0x3F // 6-bit channel volume + let volAbs = 0x3F // 6-bit per-note volume (engine: noteVolume axis; + // M / N's per-channel axis is not modelled here) let panAbs = 0x80 // 8-bit channel pan (engine width); centre = $80 let pitchOff = 0, portaTarget = -1 let bpm = audio.getBPM(PLAYHEAD) // best-effort starting tempo @@ -1840,15 +1841,16 @@ function simulateRowState(ptnDat, uptoRow) { // Note column const isGRow = (effop === OP_G) const isNoteDelay = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0xD) - // Track whether this row reloads the channel's default volume. Engine: + // Track whether this row reloads the per-note default volume. Engine: // triggerNote() (and the tone-porta-with-inst branch in advanceRow) - // seed channelVolume from the instrument's Default Note Volume (byte - // 196) — only when the row carries an instrument byte; a note-only - // retrigger (inst === 0) inherits the channel's existing volume. - // Tone-porta rows follow the same rule (matches schism - // csf_instrument_change inst_column branch, effects.c:1302). - // The simulator approximates the seed as 0x3F (legacy fallback) — see - // the longer note below the reload block for the limitation. + // seed noteVolume from the instrument's Default Note Volume (byte 196) + // — only when the row carries an instrument byte; a note-only retrigger + // (inst === 0) inherits the channel's existing note volume. Tone-porta + // rows follow the same rule (matches schism csf_instrument_change + // inst_column branch, effects.c:1302). The per-channel axis + // (channelVolume, set by Mxx / Nxx) is NOT reset on re-trigger and is + // not tracked by this simulator. The simulator approximates the seed + // as 0x3F (legacy fallback) — see the longer note below. let reloadDefaultVol = false if (note !== 0xFFFF && note !== 0xFFFE) { if (note === 0x0000) { @@ -1877,10 +1879,10 @@ function simulateRowState(ptnDat, uptoRow) { // panAbs on trigger — this naturally matches the "stay at old value when inst === 0" // half of the policy. The engine-side default-pan reload (gated on inst !== 0) // is invisible here. Same limitation now applies to default volume: the engine - // seeds rowVolume from the instrument's byte-196 "Default Note Volume" since + // seeds noteVolume from the instrument's byte-196 "Default Note Volume" since // 2026-05-09 (terranmon §171, §196), but the simulator has no instrument-byte // access, so it falls back to 0x3F — equivalent to the legacy "DNV unset" - // path. Tracker UI displays may therefore show a slightly off row volume on + // path. Tracker UI displays may therefore show a slightly off note volume on // fresh triggers when the instrument carries a reduced DNV. if (reloadDefaultVol) volAbs = 0x3F diff --git a/it2taud.py b/it2taud.py index 8f55349..59a2bdb 100644 --- a/it2taud.py +++ b/it2taud.py @@ -1360,9 +1360,10 @@ def build_sample_inst_bin_it(samples_or_proxy: list, dct = idata.get('dct', 0) & 0x03 dca = idata.get('dca', 0) & 0x03 inst_bin[base + 195] = (dca << 2) | dct - # Byte 196: default note volume (per-trigger seed for rowVolume when - # no V column accompanies a fresh trigger). Replaces the old "fold - # sample.vol into IGV" trick — see terranmon byte 196 / TODO §2350. + # Byte 196: default note volume (per-trigger seed for the engine's + # noteVolume axis when no V column accompanies a fresh trigger). + # Replaces the old "fold sample.vol into IGV" trick — see terranmon + # byte 196 / TODO §2350. inst_bin[base + 196] = default_note_vol & 0xFF # Bytes 197..255: reserved (already zeroed). @@ -1450,7 +1451,7 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int, # Priority: explicit cell vol (vol-col 0-64) > vol-col slide > main- # effect vol override > nop. Per-trigger default volume now lives # in byte 196 of the instrument record (DNV); the engine seeds - # rowVolume from it when this row has no V column, so the converter + # noteVolume from it when this row has no V column, so the converter # still doesn't need to emit SEL_SET=Sv on plain trigger rows. if cell.volcol >= 0 and cell.volcol <= VC_VOL_HI: vol_sel, vol_value = SEL_SET, min(cell.volcol, 0x3F) diff --git a/mon2taud.py b/mon2taud.py index c9929ee..7b34932 100644 --- a/mon2taud.py +++ b/mon2taud.py @@ -220,8 +220,8 @@ def build_sample_inst_bin() -> bytes: inst_bin[base + 183] = 0xFF # filter resonance off inst_bin[base + 186] = 0x01 # NNA: cut # Monotone has no per-sample default volume concept (only one synth - # voice, no V column overrides). Set DNV to full so triggers get the - # full 0x3F rowVolume; the IGV above provides the actual attenuation. + # voice, no V column overrides). Set DNV to full so triggers seed + # noteVolume at 0x3F; the IGV above provides the actual attenuation. inst_bin[base + 196] = 0xFF # DNV: full return bytes(sample_bin) + bytes(inst_bin) diff --git a/terranmon.txt b/terranmon.txt index 9d8667e..afd431b 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -2144,8 +2144,9 @@ from source. * Continuous multiplier applied on every output sample (matches IT's `chan->instrument_volume`, see Schism player/csndfile.c:1317 and player/sndmix.c:1171). Independent of the volume column / Mxx / - Nxx — those operate on rowVolume/channelVolume, while IGV scales - the final mix unconditionally. + Nxx — the volume column writes the per-note axis (noteVolume), + Mxx/Nxx write the per-channel axis (channelVolume); IGV scales + the final mix unconditionally and is orthogonal to both. * ImpulseTracker has separate `inst.gv` (0..128) and samplewise `sample.gv` (0..64). Since Taud has no samplewise record, fold the two factors into a single 0..255 value: @@ -2282,15 +2283,17 @@ from source. spawns inherits that DCA-modified state (e.g. noteFading carries over). - The new note then triggers normally on the foreground channel. 196 Uint8 Default Note Volume (0..255) - * Per-trigger default for `channelVolume` / `rowVolume` when the row - carries a fresh note + instrument byte but no explicit volume column - (matches IT's `chan->volume = psmp->volume` on note-on, Schism + * Per-trigger default for the per-note volume axis (`noteVolume` in + the engine, analog of IT's `chan->volume`) when the row carries a + fresh note + instrument byte but no explicit volume column (matches + IT's `chan->volume = psmp->volume` on note-on, Schism player/effects.c:1302 and :1432). The 8-bit value rescales to - Taud's 0..63 row volume range: - row_default = round(default_note_volume * 63 / 255) - Any explicit V column on the trigger row OVERRIDES this — i.e. - rowVolume = vol_value, exactly mirroring IT's "V column replaces - chan->volume" rule. + Taud's 0..63 note-volume range: + note_default = round(default_note_volume * 63 / 255) + Any explicit V column SET on the trigger row OVERRIDES this — i.e. + noteVolume = vol_value, exactly mirroring IT's "V column replaces + chan->volume" rule. The per-channel axis (`channelVolume`, set by + Mxx / Nxx) is independent and is NOT reset on re-trigger. * Source-format mapping: - IT: taud_dnv = round(sample.vol * 255 / 64) # 0..64 → 0..255 - XM: taud_dnv = round(sample.volume * 255 / 64) # 0..64 → 0..255 @@ -2472,6 +2475,11 @@ Audio Adapter MMIO 2368..4095 RW: MP2 Frame to be decoded 4096..4097 RO: MP2 Frame guard bytes; always return 0 on read +4098..4353 RW: tracker per-voice fader (0: full volume, 255: full silence) for playhead #0 +4354..4609 RW: tracker per-voice fader (0: full volume, 255: full silence) for playhead #1 +4610..4865 RW: tracker per-voice fader (0: full volume, 255: full silence) for playhead #2 +4866..5121 RW: tracker per-voice fader (0: full volume, 255: full silence) for playhead #3 + Sound Hardware Info - Sampling rate: 32000 Hz - Bit depth: 8 bits/sample, unsigned diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index 667e5bc..bdcd985 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -151,11 +151,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { const val RAMP_OUT_SAMPLES = 256 // Volume-change anti-click ramp: voleff/notefx (volume column, D vol-slides, // tremor, tremolo, retrig vol-mod, fine slides etc.) mutate Voice.rowVolume - // mid-note. The mixer ramps the actual applied gain across [VOL_RAMP_SAMPLES] - // output samples to mask the discontinuity. ~2 ms at 32 kHz — short enough - // not to smear tremolo at fast speeds, long enough to bury per-tick slide - // steps. Bypassed on fresh note triggers (triggerNote snaps currentMixVolume - // to target) so attack transients pass through untouched. + // and M / N mutate Voice.channelVolume mid-note. The mixer ramps the actual + // applied gain (combining both axes) across [VOL_RAMP_SAMPLES] output samples + // to mask the discontinuity. ~2 ms at 32 kHz — short enough not to smear + // tremolo at fast speeds, long enough to bury per-tick slide steps. Bypassed + // on fresh note triggers (triggerNote snaps currentMixVolume to target) so + // attack transients pass through untouched. const val VOL_RAMP_SAMPLES = 64 // Sample bin: 8 MB total, banked through a 512 K window at peripheral @@ -1812,15 +1813,18 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { /** * Per-sample volume-ramp tick. Smooths [Voice.currentMixVolume] toward - * `rowVolume / 63.0` over [VOL_RAMP_SAMPLES] samples whenever the mixer - * detects a discrepancy. Discrepancies arise from voleff/notefx that - * mutate rowVolume mid-note (volume column SET / fine slides, D - * vol-slide tick, vol-column slide tick, tremor gating, tremolo, - * retrig vol-mod, S$80 cuts, etc.). Fresh triggers bypass this by - * snapping currentMixVolume in [triggerNote], so attacks are unsmoothed. + * `(rowVolume / 63.0) × (channelVolume / 63.0)` over [VOL_RAMP_SAMPLES] + * samples whenever the mixer detects a discrepancy. Discrepancies arise + * from voleff/notefx that mutate rowVolume mid-note (volume column SET / + * fine slides, D vol-slide tick, vol-column slide tick, tremor gating, + * tremolo, retrig vol-mod, S$80 cuts, etc.) AND from channel-volume + * changes (M / N) — both factors share one ramp so a per-channel slide + * during a per-note slide doesn't double-step. Fresh triggers bypass + * this by snapping currentMixVolume in [triggerNote], so attacks are + * unsmoothed. */ private fun advanceVolumeRamp(voice: Voice) { - val target = voice.rowVolume / 63.0 + val target = (voice.rowVolume / 63.0) * (voice.channelVolume / 63.0) // Deferred key-on snap: triggerNote arms this so the first mixer sample after a // fresh trigger re-syncs to the post-row rowVolume (already adjusted by any // V-column SET / fine slide on the same row). Bypasses the ramp entirely. @@ -1847,13 +1851,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { * Pulled out so S$Dx (note delay) can defer the same logic to a later tick. */ /** - * Trigger-time default rowVolume seed derived from the instrument's + * Trigger-time default noteVolume seed derived from the instrument's * Default Note Volume (byte 196). Pre-2026-05-09 .taud files left this * byte zero; treating 0 as "field not present" and falling back to 0x3F * keeps legacy behaviour. Used by both [triggerNote] and the tone-porta * + instrument-byte path in [advanceRow] — both must seed identically * (Schism player/effects.c:1302 writes `chan->volume = psmp->volume` - * unconditionally on inst-column rows, regardless of porta). + * unconditionally on inst-column rows, regardless of porta). Sets + * noteVolume only — channelVolume (IT chan->global_volume) survives. */ private fun rowVolumeFromDefault(inst: TaudInst): Int { val dnv = inst.defaultNoteVolume @@ -1941,21 +1946,23 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { voice.amigaPeriod = -1.0 // fresh trigger: period state must reseed from the new noteVal voice.linearFreq = -1.0 // ditto for linear-freq mode (toneMode == 2) voice.playbackRate = computePlaybackRate(inst, noteVal) - // Fresh trigger seeds rowVolume from the per-instrument "default note volume" + // Fresh trigger seeds noteVolume from the per-instrument "default note volume" // (byte 196) when the row carried an instrument byte but no explicit V column — // matching IT's `chan->volume = psmp->volume` rule (Schism player/effects.c:1302 // and :1432). Pre-2026-05-09 .taud files left byte 196 zero and folded sample.vol // into IGV instead; treating 0 as "field not present" and falling back to 0x3F // preserves legacy behaviour. A note-only retrigger (instId == 0) inherits the - // channel's existing volume so held-volume sustains keep working across retriggers. + // channel's existing note volume so held-volume sustains keep working across + // retriggers. channelVolume is deliberately NOT reset here — IT keeps + // chan->global_volume across sample changes, so M / N writes persist. // Continuous per-instrument scaling lives in instGlobalVolume (byte 171), which the // mixer applies independently of this seed. - voice.channelVolume = when { + voice.noteVolume = when { volOverride >= 0 -> volOverride.coerceIn(0, 0x3F) instId != 0 -> rowVolumeFromDefault(inst) - else -> voice.channelVolume + else -> voice.noteVolume } - voice.rowVolume = voice.channelVolume + voice.rowVolume = voice.noteVolume // Defer the anti-click ramp snap to the next mixer sample. applyVolColumn and // applyEffectRow run *after* triggerNote in applyTrackerRow and frequently // override rowVolume on the same row (e.g., a key-on row carrying a V column @@ -2070,6 +2077,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { v.samplePos = src.samplePos v.playbackRate = src.playbackRate v.forward = src.forward + v.noteVolume = src.noteVolume v.channelVolume = src.channelVolume v.rowVolume = src.rowVolume v.channelPan = src.channelPan @@ -2147,17 +2155,21 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { 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. + // SET / fine writes to noteVolume (per-note, persistent across rows until re-trigger); + // rowVolume is mirrored so the change is audible immediately for this row's mixing. + // channelVolume is left alone — vol-col is the per-note volume axis, M / N drive the + // independent per-channel axis (TAUD_NOTE_EFFECTS.md §3). when (sel) { - 0 -> { voice.channelVolume = value.coerceIn(0, 0x3F); voice.rowVolume = voice.channelVolume } + 0 -> { voice.noteVolume = value.coerceIn(0, 0x3F); voice.rowVolume = voice.noteVolume } 1 -> voice.volColSlideUp = value 2 -> voice.volColSlideDown = value 3 -> { if (value == 0) return val mag = value and 0x1F - voice.rowVolume = if ((value and 0x20) != 0) (voice.rowVolume + mag).coerceAtMost(0x3F) - else (voice.rowVolume - mag).coerceAtLeast(0) - voice.channelVolume = voice.rowVolume + voice.noteVolume = if ((value and 0x20) != 0) (voice.noteVolume + mag).coerceAtMost(0x3F) + else (voice.noteVolume - mag).coerceAtLeast(0) + voice.rowVolume = voice.noteVolume } } } @@ -2211,8 +2223,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { voice.wSlideDir = 0 voice.volColSlideUp = 0; voice.volColSlideDown = 0 voice.panColSlideRight = 0; voice.panColSlideLeft = 0 + voice.nSlideDir = 0 voice.rowEffect = row.effect voice.rowEffectArg = row.effectArg + // Per-tick modulators (tremolo R, tremor I, per-tick D/N coarse slides, etc.) write + // rowVolume directly to take effect within the row. At every row boundary rowVolume + // is rebased to the persistent noteVolume so the next row starts from the per-note + // baseline — any tremolo dip / tremor gate from the previous tick is forgotten, but + // a D-slide's per-tick mutations of noteVolume itself survive (D writes both). + voice.rowVolume = voice.noteVolume // ── Note ── // OP_L (combined porta + vol slide) also takes a tone-porta target without retriggering, @@ -2234,7 +2253,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { if (row.instrment != 0) { voice.instrumentId = row.instrment val seedVol = rowVolumeFromDefault(instruments[voice.instrumentId]) - voice.channelVolume = seedVol + voice.noteVolume = seedVol voice.rowVolume = seedVol voice.keyOff = false voice.noteFading = false @@ -2269,7 +2288,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { if (row.instrment != 0) { voice.instrumentId = row.instrment val seedVol = rowVolumeFromDefault(instruments[voice.instrumentId]) - voice.channelVolume = seedVol + voice.noteVolume = seedVol voice.rowVolume = seedVol voice.keyOff = false voice.noteFading = false @@ -2370,14 +2389,17 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { if (ts.pendingRowJump < 0) ts.pendingRowJump = rawArg.coerceIn(0, 63) } EffectOp.OP_D -> { + // D is the per-note volume slide (analog of IT D). Fine forms write noteVolume + // immediately; coarse forms arm slideMode for the per-tick handler below, which + // walks noteVolume too so the per-note volume persists into following rows. val arg = resolveArg(rawArg, voice.mem.d).also { if (rawArg != 0) voice.mem.d = it } val hi = (arg ushr 8) and 0xFF val lo = hi and 0x0F val hin = (hi ushr 4) and 0x0F when { - hi == 0xFF || hi == 0xF0 -> { voice.rowVolume = (voice.rowVolume + 0xF).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume } // $FF00 / $F000 quirk: fine up by F (TAUD_NOTE_EFFECTS.md §D) - hin == 0xF && lo != 0 -> { voice.rowVolume = (voice.rowVolume - lo).coerceAtLeast(0); voice.channelVolume = voice.rowVolume } // $Fy00 fine down by y - lo == 0xF && hin != 0 -> { voice.rowVolume = (voice.rowVolume + hin).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume } // $xF00 fine up by x + hi == 0xFF || hi == 0xF0 -> { voice.noteVolume = (voice.noteVolume + 0xF).coerceAtMost(0x3F); voice.rowVolume = voice.noteVolume } // $FF00 / $F000 quirk: fine up by F (TAUD_NOTE_EFFECTS.md §D) + hin == 0xF && lo != 0 -> { voice.noteVolume = (voice.noteVolume - lo).coerceAtLeast(0); voice.rowVolume = voice.noteVolume } // $Fy00 fine down by y + lo == 0xF && hin != 0 -> { voice.noteVolume = (voice.noteVolume + hin).coerceAtMost(0x3F); voice.rowVolume = voice.noteVolume } // $xF00 fine up by x hin == 0 && lo != 0 -> { voice.slideMode = 5; voice.slideArg = -lo } // $0y00 coarse down per non-first tick lo == 0 && hin != 0 -> { voice.slideMode = 5; voice.slideArg = hin } // $x000 coarse up per non-first tick } @@ -2478,24 +2500,27 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } EffectOp.OP_M -> { // M $xx00 — set channel volume to the high byte (literal, no recall). IT $40 is - // clamped to Taud's $3F cap. See TAUD_NOTE_EFFECTS.md §M. - val newVol = ((rawArg ushr 8) and 0xFF).coerceAtMost(0x3F) - voice.channelVolume = newVol - voice.rowVolume = newVol + // clamped to Taud's $3F cap. M writes the per-channel volume axis only and does + // NOT touch noteVolume / rowVolume — the per-note volume set by vol-col SET (or + // seeded from the instrument default on the trigger row) survives across this M. + // The mixer multiplies channelVolume into the gain via the volume-ramp target, + // so the change is heard immediately on this row. See TAUD_NOTE_EFFECTS.md §M. + voice.channelVolume = ((rawArg ushr 8) and 0xFF).coerceAtMost(0x3F) } EffectOp.OP_N -> { - // N $xy00 — channel-volume slide. Same nibble decoding as D but writes the - // persistent channelVolume so the change carries past this row. + // N $xy00 — channel-volume slide. Same nibble decoding as D but writes only the + // persistent channelVolume; noteVolume / rowVolume are untouched so per-note + // volume state (vol-col SET, D slides) survives an N. val arg = resolveArg(rawArg, voice.mem.n).also { if (rawArg != 0) voice.mem.n = it } val hi = (arg ushr 8) and 0xFF val lo = hi and 0x0F val hin = (hi ushr 4) and 0x0F when { - hi == 0xFF || hi == 0xF0 -> { voice.channelVolume = (voice.channelVolume + 0xF).coerceAtMost(0x3F); voice.rowVolume = voice.channelVolume } - hin == 0xF && lo != 0 -> { voice.channelVolume = (voice.channelVolume - lo).coerceAtLeast(0); voice.rowVolume = voice.channelVolume } - lo == 0xF && hin != 0 -> { voice.channelVolume = (voice.channelVolume + hin).coerceAtMost(0x3F); voice.rowVolume = voice.channelVolume } - hin == 0 && lo != 0 -> { voice.volColSlideDown = lo } // coarse down per non-first tick - lo == 0 && hin != 0 -> { voice.volColSlideUp = hin } // coarse up per non-first tick + hi == 0xFF || hi == 0xF0 -> voice.channelVolume = (voice.channelVolume + 0xF).coerceAtMost(0x3F) + hin == 0xF && lo != 0 -> voice.channelVolume = (voice.channelVolume - lo).coerceAtLeast(0) + lo == 0xF && hin != 0 -> voice.channelVolume = (voice.channelVolume + hin).coerceAtMost(0x3F) + hin == 0 && lo != 0 -> voice.nSlideDir = -lo // coarse down per non-first tick + lo == 0 && hin != 0 -> voice.nSlideDir = hin // coarse up per non-first tick } } EffectOp.OP_P -> { @@ -2689,9 +2714,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { if (!voice.active && voice.noteDelayTick < 0) continue val inst = instruments[voice.instrumentId] - // Note cut. + // Note cut. Zero noteVolume / rowVolume (silence this note) but leave channelVolume + // alone — IT's note cut stops the sample, it doesn't reset chan->global_volume. if (voice.cutAtTick == ts.tickInRow) { - voice.rowVolume = 0; voice.channelVolume = 0 + voice.noteVolume = 0; voice.rowVolume = 0 voice.noteWasCut = true } @@ -2754,19 +2780,24 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { } } - // Volume slides (D coarse on tick > 0). + // Volume slides (D coarse on tick > 0). D walks the per-note volume; rowVolume + // tracks it so the change is audible this tick and rebases on next row entry. if (ts.tickInRow > 0 && voice.slideMode == 5) { - voice.rowVolume = (voice.rowVolume + voice.slideArg).coerceIn(0, 0x3F) - voice.channelVolume = voice.rowVolume + voice.noteVolume = (voice.noteVolume + voice.slideArg).coerceIn(0, 0x3F) + voice.rowVolume = voice.noteVolume } - // Volume-column slides (selectors 1/2 — per non-first tick). + // Volume-column slides (selectors 1/2 — per non-first tick) and N coarse slide. + // Vol-col writes noteVolume; N writes channelVolume — they target independent axes. if (ts.tickInRow > 0) { if (voice.volColSlideUp != 0) { - voice.rowVolume = (voice.rowVolume + voice.volColSlideUp).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume + voice.noteVolume = (voice.noteVolume + voice.volColSlideUp).coerceAtMost(0x3F); voice.rowVolume = voice.noteVolume } if (voice.volColSlideDown != 0) { - voice.rowVolume = (voice.rowVolume - voice.volColSlideDown).coerceAtLeast(0); voice.channelVolume = voice.rowVolume + voice.noteVolume = (voice.noteVolume - voice.volColSlideDown).coerceAtLeast(0); voice.rowVolume = voice.noteVolume + } + if (voice.nSlideDir != 0) { + voice.channelVolume = (voice.channelVolume + voice.nSlideDir).coerceIn(0, 0x3F) } if (voice.panColSlideRight != 0) { voice.channelPan = (voice.channelPan + voice.panColSlideRight).coerceAtMost(0xFF) @@ -2801,11 +2832,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { pitchToMixer = (semis * 4096 / 12).coerceIn(1, 0xFFFD) } - // Tremolo (R) — modulates output volume around base. + // Tremolo (R) — modulates rowVolume around the per-note volume base. IT's tremolo + // operates on chan->volume (per-note), not chan->global_volume, so the LFO bias is + // added to noteVolume rather than channelVolume. The result lands in rowVolume only, + // so noteVolume itself is unaffected and tremolo dies cleanly when the row ends + // (per-row rowVolume rebase) — which is what existing IT modules expect. if (voice.tremoloActive) { val sine = lfoSample(voice.tremoloLfoPos, voice.tremoloWave) val volDelta = (sine * voice.mem.rDepth) shr 9 - voice.rowVolume = (voice.channelVolume + volDelta).coerceIn(0, 0x3F) + voice.rowVolume = (voice.noteVolume + volDelta).coerceIn(0, 0x3F) voice.tremoloLfoPos = (voice.tremoloLfoPos + voice.mem.rSpeed * 4) and 0xFF } @@ -2842,8 +2877,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { voice.autoVibPhase = 0 voice.autoVibTicksSinceTrigger = 0 voice.filterY1 = 0.0; voice.filterY2 = 0.0 - voice.rowVolume = applyRetrigVolMod(voice.rowVolume, voice.retrigVolMod) - voice.channelVolume = voice.rowVolume + voice.noteVolume = applyRetrigVolMod(voice.noteVolume, voice.retrigVolMod) + voice.rowVolume = voice.noteVolume } } @@ -3362,23 +3397,35 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { // 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). + // Two-volume model (TAUD_NOTE_EFFECTS.md §3): mix = sample × note_vol × channel_vol × … + // noteVolume — per-note volume (analog of IT chan->volume). Reset on note re-trigger + // from the instrument's Default Note Volume; written by vol-col SET / fine, + // D / K / L vol slides, vol-col slides, Q retrig vol mod. Persists across + // rows until the next re-trigger. + // channelVolume — per-channel volume (analog of IT chan->global_volume). Written by M / N. + // NOT reset by note re-trigger — the channel keeps its base volume across + // sample changes, mirroring IT's chan->global_volume semantics. + // rowVolume — per-tick mixer-facing volume. Reset to noteVolume at the start of every + // row, then modulated by tremolo / tremor / per-tick slides. + // Mixer gain ≈ (rowVolume / 63) × (channelVolume / 63). + var noteVolume = 0x3F // $00..$3F (default full) var channelVolume = 0x3F // $00..$3F (default full) - var rowVolume = 63 // $00..$3F effective output volume after slides + var rowVolume = 63 // $00..$3F effective output volume after slides var channelPan = 0x80 // 8-bit; $80 centre. Cell column packs into 6-bit, S$80xx writes the full 8-bit. var rowPan = 32 // 6-bit pan used by mixer, derived from channelPan // Anti-click volume ramp. The mixer feeds [currentMixVolume] (smoothed copy of - // rowVolume/63) into the per-voice gain stack instead of rowVolume directly so - // that voleff/notefx-driven steps (vol column, D slides, tremor, tremolo, retrig - // vol-mod, fine slides) ramp across [VOL_RAMP_SAMPLES] samples rather than - // jumping. triggerNote() arms [snapMixVolume] so the next mixer sample re-syncs - // currentMixVolume to rowVolume/63 — bypassing the ramp on key-on attacks. - // The snap is deferred (not applied inside triggerNote) because applyVolColumn - // and applyEffectRow run *after* triggerNote in applyTrackerRow and may lower - // rowVolume on the same row (e.g., a key-on with a low V column value); snapping - // immediately in triggerNote would leave currentMixVolume at 1.0 and force a - // ramp-down to the new low target, producing an audible transient spike. + // (rowVolume/63) × (channelVolume/63)) into the per-voice gain stack so that + // voleff/notefx-driven steps (vol column, D slides, tremor, tremolo, retrig + // vol-mod, fine slides) AND M / N channel-volume changes ramp across + // [VOL_RAMP_SAMPLES] samples rather than jumping. triggerNote() arms + // [snapMixVolume] so the next mixer sample re-syncs currentMixVolume to the + // post-row target — bypassing the ramp on key-on attacks. The snap is deferred + // (not applied inside triggerNote) because applyVolColumn and applyEffectRow + // run *after* triggerNote in applyTrackerRow and may lower rowVolume on the + // same row (e.g., a key-on with a low V column value); snapping immediately + // in triggerNote would leave currentMixVolume at 1.0 and force a ramp-down + // to the new low target, producing an audible transient spike. var currentMixVolume = 1.0 var volRampSamples = 0 var volRampStep = 0.0 @@ -3531,10 +3578,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { var wSlideAmount = 0 // Volume / pan column slides (selectors 1/2/3 from TAUD_NOTE_EFFECTS.md §"Volume column effects"). + // These per-tick slides modify noteVolume (the per-note axis); N has its own accumulator + // below because it modifies channelVolume (the per-channel axis) instead. var volColSlideUp = 0 var volColSlideDown = 0 var panColSlideRight = 0 var panColSlideLeft = 0 + // N coarse slide: signed delta applied to channelVolume per non-first tick. Re-armed by + // each N row, cleared at row start (along with the other slide accumulators). + var nSlideDir = 0 // Bitcrusher (effect 8) and Overdrive (effect 9) — Taud-only voice FX. // clipMode is shared between both effects: 0=clamp, 1=fold, 2=wrap. See TAUD_NOTE_EFFECTS.md §8/§9. @@ -3745,6 +3797,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { ts.amigaLEDStateL.fill(0.0); ts.amigaLEDStateR.fill(0.0) ts.voices.forEach { it.active = false + it.noteVolume = 0x3F it.channelVolume = 0x3F it.rowVolume = 0x3F it.currentMixVolume = 1.0