mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
Compare commits
3 Commits
2cdd731c3b
...
2177ddbd6b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2177ddbd6b | ||
|
|
aa32c70d8a | ||
|
|
72761c0552 |
@@ -54,7 +54,7 @@ A pattern is a rectangular grid of rows and channels; each cell holds one note e
|
||||
| Parameter | Value |
|
||||
|---|---|
|
||||
| Speed | $06 (6 ticks/row) |
|
||||
| Tempo byte | $65 (125 BPM; see effect T for the $18 offset) |
|
||||
| Tempo byte | $64 (125 BPM; see effect T for the $19 offset) |
|
||||
| Global volume | $80 (mid-scale) |
|
||||
| Channel volume | $3F (full) |
|
||||
| Pan (all channels) | $80 (centre) |
|
||||
@@ -241,7 +241,7 @@ Glissando control (S $1x) snaps the output pitch to the nearest semitone after e
|
||||
|
||||
**Plain.** Slides the channel's current pitch toward the note specified in the same row, at $xxxx units per tick (after tick 0), stopping when the target is reached. A row with G and a note does **not** re-trigger the sample — the note's pitch becomes the portamento target and the already-sounding sample continues at its current pitch.
|
||||
|
||||
The unit of `$xxxx` depends on the song-table tone mode (effect `1`, bits 1-2):
|
||||
The unit of `$xxxx` depends on the song-table tone mode (effect `1`, bits 0-1):
|
||||
|
||||
- `ff = 0` (linear) and `ff = 1` (Amiga): 4096-TET pitch units per tick. Amiga sources should be converted to linear units on G, since the original PT G slide already operated semi-linearly within a small range and the shared-memory pitfall of E/F does not apply here.
|
||||
- `ff = 2` (linear-frequency): Hz/tick. The engine walks the channel's *frequency* toward the target note's frequency by `±$xxxx` Hz each non-first tick. This is MONOTONE's `3xx` behaviour verbatim (MTSRC/MT_PLAY.PAS:620-630).
|
||||
@@ -627,13 +627,13 @@ Taud splits T by which byte carries the value:
|
||||
|
||||
### T $xx00 (high byte non-zero) — Set tempo
|
||||
|
||||
**Plain.** Sets the Taud tempo byte to `$xx`. The resulting BPM is `$xx + $18`: Taud byte $00 → 24 BPM, $65 → 125 BPM (default), $FF → 279 BPM.
|
||||
**Plain.** Sets the Taud tempo byte to `$xx`. The resulting BPM is `$xx + $19`: Taud byte $00 → 25 BPM, $64 → 125 BPM (default), $FF → 280 BPM.
|
||||
|
||||
**Compatibility.** ST3 `Txx` (where `xx ∈ $20..$FF`) stores BPM directly; convert with `taud_byte = xx − $18`. Taud byte $08 corresponds to ST3's minimum BPM of 32; Taud bytes below $08 are inexpressible in ST3 and should round up to $08 (BPM 32) when exporting. OpenMPT's extended tempo slides (`T $0x` down, `T $1x` up) in S3M files map to Taud T $00xx — see below.
|
||||
**Compatibility.** ST3 `Txx` (where `xx ∈ $20..$FF`) stores BPM directly; convert with `taud_byte = xx − $18`. Taud byte $07 corresponds to ST3's minimum BPM of 32; Taud bytes below $07 are inexpressible in ST3 and should round up to $07 (BPM 32) when exporting. OpenMPT's extended tempo slides (`T $0x` down, `T $1x` up) in S3M files map to Taud T $00xx — see below.
|
||||
|
||||
ProTracker `Fxx` with `xx ≥ $20` maps to Taud `T $(xx − $18)00`; `Fxx` with `xx < $20` maps to A (speed) instead.
|
||||
ProTracker `Fxx` with `xx ≥ $20` maps to Taud `T $(xx − $19)00`; `Fxx` with `xx < $20` maps to A (speed) instead.
|
||||
|
||||
**Implementation.** If the high byte is non-zero, set `tempo_byte = arg >> 8`; derive `BPM = tempo_byte + $18`; compute tick duration as `samples_per_tick = 32000 × 5 / (BPM × 2) = 80000 / BPM` (integer truncated) at the fixed 32000 Hz output rate. Example: BPM 125 → 640 samples per tick; BPM 24 → 3333 samples per tick; BPM 279 → 286 samples per tick. There is no memory for set-tempo.
|
||||
**Implementation.** If the high byte is non-zero, set `tempo_byte = arg >> 8`; derive `BPM = tempo_byte + $19`; compute tick duration as `samples_per_tick = 32000 × 5 / (BPM × 2) = 80000 / BPM` (integer truncated) at the fixed 32000 Hz output rate. Example: BPM 125 → 640 samples per tick; BPM 24 → 3200 samples per tick; BPM 280 → 286 samples per tick. There is no memory for set-tempo.
|
||||
|
||||
### T $00xy (high byte zero) — Tempo slide
|
||||
|
||||
@@ -1122,16 +1122,18 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
|
||||
|
||||
**Plain.** Sets mixer-wide behaviour flags. Available flags are:
|
||||
|
||||
0b 0000 rr ff
|
||||
0b 000 rrr ff
|
||||
|
||||
- ff = 0: Linear tone mode. Pitch shift will behave like MIDI/ImpulseTracker/ScreamTracker linear mode. **Coarse and fine E/F arguments are stored as 4096-TET pitch units** and subtracted/added directly from the stored pitch.
|
||||
- ff = 1: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker default mode. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode.
|
||||
- ff = 2: Linear-frequency tone mode (MONOTONE compat). **E, F, and G arguments are stored as Hz/tick** (a signed change in audible frequency per song tick), and the engine converts the channel's stored 4096-TET pitch back to a frequency, adds/subtracts the argument, then converts back to 4096-TET. Reference is fixed at 12-TET A4 = 440 Hz / C4 ≈ 261.6256 Hz, which matches MONOTONE's MT_PLAY.PAS `notesHz` table (A0 = 27.5 Hz, equal-temperament). Unlike Amiga mode, *all three* slide effects use the new arithmetic — Monotone's `1xx`, `2xx`, and `3xx` are all in Hz/tick (see MTSRC/MT_PLAY.PAS:606-630).
|
||||
|
||||
- rr = 0: Yes interpolation. Actual interpolation algorithm is implementation-dependent, but recommended to use either Fast Sinc or Linear.
|
||||
- rr = 1: No interpolation.
|
||||
- rr = 2: Amiga 500 interpolation.
|
||||
- rr = 3: Amiga 1200 interpolation.
|
||||
- rrr = 0: Yes interpolation. Actual interpolation algorithm is implementation-dependent, but recommended to use either Fast Sinc or Linear.
|
||||
- rrr = 1: No interpolation.
|
||||
- rrr = 2: Amiga 500 interpolation.
|
||||
- rrr = 3: Amiga 1200 interpolation.
|
||||
- rrr = 4: SNES 4-tap Gaussian
|
||||
- rrr = 5: Preserve delta modulation (linear intp.)
|
||||
|
||||
### Volume Fadeout
|
||||
|
||||
@@ -1247,7 +1249,7 @@ These quirks of ST3 are worth preserving or flagging when importing S3M files in
|
||||
|
||||
**Cxx BCD encoding.** ST3 stores pattern-break row numbers as BCD on disk (`$10` means decimal 10). Taud uses binary. Decode on import; encode on export. Out-of-range BCD bytes (decimal 64 or higher) clamp to row 0.
|
||||
|
||||
**Tempo range.** ST3 accepts tempos $20..$FF (BPM 32..255); Taud accepts bytes $00..$FF (BPM 24..279). Imported ST3 tempos must be shifted down by $18; Taud tempos below $08 and above $E7 cannot be represented in ST3 and should clamp on export.
|
||||
**Tempo range.** ST3 accepts tempos $20..$FF (BPM 32..255); Taud accepts bytes $00..$FF (BPM 25..280). Imported ST3 tempos must be shifted down by $19; Taud tempos below $07 and above $E6 cannot be represented in ST3 and should clamp on export.
|
||||
|
||||
**SBx + SEx interaction.** ST3 miscounts loop iterations when pattern delay is active inside a pattern loop; Taud fixes this. Songs that depended on the bug for their intended playback will loop fewer times in Taud. Flag such songs on import.
|
||||
|
||||
@@ -1265,7 +1267,7 @@ These quirks of ST3 are worth preserving or flagging when importing S3M files in
|
||||
- **ST3 Amiga mode** (`linear_slides` clear): both coarse (Exx/Fxx) and fine/extra-fine (EFx/EEx/FFx/FEx) are stored **verbatim** as raw ST3 period units — coarse as `E/F $00xx`, fine as `E/F $F00x` — with no scaling. Taud `f` flag is **set**; the engine applies both forms in Amiga period space at playback, exactly recovering the source's period-step count and the non-linear pitch character.
|
||||
- G (tone portamento) is always converted with `round(× 64/3)` and treated as linear, regardless of mode.
|
||||
|
||||
**Default tempo byte.** Taud's default $65 equals 125 BPM under the $18 offset; this is not the same as ST3's `$7D` default, which maps to Taud `$65` after subtracting $18. Converters must remap on both import and export.
|
||||
**Default tempo byte.** Taud's default $64 equals 125 BPM under the $19 offset; this is not the same as ST3's `$7D` default, which maps to Taud `$64` after subtracting $19. Converters must remap on both import and export.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* TSVM Audio Device Tracker
|
||||
* Microtone. formerly known as TSVM Audio Device Tracker. (taut)
|
||||
*
|
||||
* Created by minjaesong on 2026-04-20
|
||||
*/
|
||||
@@ -19,6 +19,7 @@ const BULLET = "\u00847u"
|
||||
const VERT = "\u00B3"
|
||||
const TWOVERT = "\u00BA"
|
||||
|
||||
// global var for the app
|
||||
if (!_G.TAUT) _G.TAUT = {};
|
||||
if (!_G.TAUT.UI) _G.TAUT.UI = {};
|
||||
if (!_G.TAUT.UI.NEXTPANEL) _G.TAUT.UI.NEXTPANEL = undefined;
|
||||
|
||||
@@ -2634,8 +2634,9 @@ Endianness: Little
|
||||
Uint16 Current Tuning base note (1..65533). A4 (western default) is 0x5C00. C9 (tracker default) is 0xA000. If zero, assume the tracker default value
|
||||
Float32 Frequency at the base note. Tracker default is 8363.0. If zero, assume the tracker default
|
||||
Uint8 Flags for Global Behaviour (effect symbol '1')
|
||||
0b 0000 00ff
|
||||
0b 000 rrr ff
|
||||
ff: tone mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: reserved)
|
||||
rrr: interpolation mode (0: default, 1: none, 2: Amiga 500, 3: Amiga 1200, 4: SNES 4-tap Gaussian, 5: preserve delta modulation)
|
||||
Uint8 Song global volume
|
||||
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
|
||||
Uint8 Song mixing volume
|
||||
|
||||
@@ -126,9 +126,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
const val SAMPLING_RATE = 32000
|
||||
const val TRACKER_CHUNK = 512
|
||||
// Per-voice soundscope ring-buffer length. Power of two so wrap-around is a single AND.
|
||||
// Sized at 2× the soundscope width so the AudioMenu waveform view always has spare
|
||||
// Sized at 4× the soundscope width so the AudioMenu waveform view always has spare
|
||||
// samples on either side of the centre to search for a stable trigger point.
|
||||
const val SCOPE_BUFFER_SIZE = 1024
|
||||
const val SCOPE_BUFFER_SIZE = 2048
|
||||
// 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
|
||||
@@ -149,6 +149,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// 8 ms at 32 kHz — long enough to bury the click, short enough not to read as fade.
|
||||
// Applied on sample end only (preserves attack transients on note start).
|
||||
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.
|
||||
const val VOL_RAMP_SAMPLES = 64
|
||||
|
||||
// Sample bin: 8 MB total, banked through a 512 K window at peripheral
|
||||
// memory 0..524287. MMIO 46 holds the currently-exposed bank index.
|
||||
@@ -167,11 +175,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
const val INTERP_A500 = 2
|
||||
const val INTERP_A1200 = 3
|
||||
|
||||
// Fast Sinc — 16-tap windowed sinc with 1024 sub-sample positions.
|
||||
// Fast Sinc — 6-tap windowed sinc with 1024 sub-sample positions.
|
||||
// Mirrors MilkyTracker's MIXER_SINCTABLE (ResamplerSinc.h: WIDTH=8, 1024-step table,
|
||||
// window = 0.5 + 0.5·cos(πi / WIDTH·step)). Coefficients are symmetric so we only
|
||||
// store half the kernel; the second half is index-mirrored at lookup time.
|
||||
const val SINC_WIDTH = 8
|
||||
const val SINC_WIDTH = 3
|
||||
const val SINC_PRECISION_SHIFT = 10
|
||||
const val SINC_PRECISION = 1 shl SINC_PRECISION_SHIFT // 1024
|
||||
private val SINC_TABLE: DoubleArray = run {
|
||||
@@ -1421,7 +1429,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// applies only in fall-through (no active sustain or loop wrap) since Schism
|
||||
// suppresses fade_flag inside both wrap branches. Without this rule, instruments
|
||||
// with fadeout=0 + envelope ending at 0 would silently hold their voices forever.
|
||||
if (vEnd == 0 && !wrapping) voice.active = false
|
||||
// Use startRampOut instead of bare active=false so the trailing sample value
|
||||
// fades to zero over RAMP_OUT_SAMPLES (~8 ms); a hard deactivation here would
|
||||
// click because envVolMix still has not fully reached 0 by the time this tick
|
||||
// fires.
|
||||
if (vEnd == 0 && !wrapping) startRampOut(voice)
|
||||
} else {
|
||||
val vOffset = inst.volEnvelopes[voice.envIndex].offset.toDouble()
|
||||
val vCurValue = inst.volEnvelopes[voice.envIndex].value
|
||||
@@ -1429,7 +1441,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// Reached a terminator point — envelope holds here.
|
||||
voice.envVolume = (vCurValue / 63.0).coerceIn(0.0, 1.0)
|
||||
// Same Schism cut rule as above: only when in fall-through.
|
||||
if (vCurValue == 0 && !wrapping) voice.active = false
|
||||
if (vCurValue == 0 && !wrapping) startRampOut(voice)
|
||||
} else {
|
||||
voice.envTimeSec += tickSec
|
||||
if (voice.envTimeSec >= vOffset) {
|
||||
@@ -1741,8 +1753,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val sample: Double = when (interpMode) {
|
||||
INTERP_DEFAULT -> {
|
||||
var acc = 0.0
|
||||
// Taps span [i0 - WIDTH + 1, i0 + WIDTH], with the kernel centred on i0+frac.
|
||||
for (j in -SINC_WIDTH + 1 .. SINC_WIDTH) {
|
||||
// Taps span [i0 - WIDTH, i0 + WIDTH], with the kernel centred on i0+frac.
|
||||
for (j in -SINC_WIDTH .. SINC_WIDTH) {
|
||||
val coeff = sincTap(frac, j)
|
||||
if (coeff != 0.0) acc += readSamplePoint(inst, i0 + j, sampleLen, binMax) * coeff
|
||||
}
|
||||
@@ -1798,6 +1810,38 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
voice.rampOutStep = 1.0 / RAMP_OUT_SAMPLES
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private fun advanceVolumeRamp(voice: Voice) {
|
||||
val target = voice.rowVolume / 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.
|
||||
if (voice.snapMixVolume) {
|
||||
voice.currentMixVolume = target
|
||||
voice.volRampSamples = 0
|
||||
voice.volRampStep = 0.0
|
||||
voice.snapMixVolume = false
|
||||
return
|
||||
}
|
||||
if (voice.volRampSamples > 0) {
|
||||
voice.currentMixVolume += voice.volRampStep
|
||||
voice.volRampSamples--
|
||||
if (voice.volRampSamples == 0) voice.currentMixVolume = target
|
||||
} else if (voice.currentMixVolume != target) {
|
||||
voice.volRampStep = (target - voice.currentMixVolume) / VOL_RAMP_SAMPLES
|
||||
voice.volRampSamples = VOL_RAMP_SAMPLES - 1
|
||||
voice.currentMixVolume += voice.volRampStep
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a fresh note on [voice]: load the instrument, reset sample position, kick off the envelope.
|
||||
* Pulled out so S$Dx (note delay) can defer the same logic to a later tick.
|
||||
@@ -1827,6 +1871,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
voice.envIndex = 0
|
||||
voice.envTimeSec = 0.0
|
||||
voice.envVolume = (inst.volEnvelopes[0].value / 63.0).coerceIn(0.0, 1.0)
|
||||
// Snap the per-sample-smoothed envelope to the fresh starting value so attack
|
||||
// transients land at the envelope's node-0 value immediately. Per-tick step is
|
||||
// recomputed by applyTrackerTick on the next tick boundary.
|
||||
voice.envVolMix = voice.envVolume
|
||||
voice.envVolStep = 0.0
|
||||
voice.envPanIndex = 0
|
||||
voice.envPanTimeSec = 0.0
|
||||
voice.envPan = inst.panEnvelopes[0].value / 255.0
|
||||
@@ -1907,6 +1956,16 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
else -> voice.channelVolume
|
||||
}
|
||||
voice.rowVolume = voice.channelVolume
|
||||
// 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
|
||||
// value of 30). Snapping currentMixVolume here would set it to 1.0, then the
|
||||
// mixer would detect the lowered post-volColumn target and ramp DOWN from
|
||||
// 1.0 — an audible transient spike at every soft-attack note. The deferred
|
||||
// snap reads rowVolume after the row has fully resolved.
|
||||
voice.snapMixVolume = true
|
||||
voice.volRampSamples = 0
|
||||
voice.volRampStep = 0.0
|
||||
voice.noteWasCut = false
|
||||
voice.noteFading = false
|
||||
// S $73..$7C state resets on each fresh trigger so per-note overrides don't leak.
|
||||
@@ -2015,10 +2074,17 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
v.rowVolume = src.rowVolume
|
||||
v.channelPan = src.channelPan
|
||||
v.rowPan = src.rowPan
|
||||
// Inherit the smoothed gain so the ghost picks up where the foreground left off
|
||||
// without a click. Ramp state (counter/step) intentionally not copied — the ghost
|
||||
// doesn't take new voleff/notefx events, so any in-flight ramp can complete via
|
||||
// the snap-to-target on first mix iteration.
|
||||
v.currentMixVolume = src.currentMixVolume
|
||||
v.keyOff = src.keyOff
|
||||
v.envIndex = src.envIndex
|
||||
v.envTimeSec = src.envTimeSec
|
||||
v.envVolume = src.envVolume
|
||||
v.envVolMix = src.envVolMix
|
||||
v.envVolStep = src.envVolStep
|
||||
v.envPanIndex = src.envPanIndex
|
||||
v.envPanTimeSec = src.envPanTimeSec
|
||||
v.envPan = src.envPan
|
||||
@@ -2614,6 +2680,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
|
||||
private fun applyTrackerTick(ts: TrackerState, playhead: Playhead) {
|
||||
val tickSec = 2.5 / playhead.bpm
|
||||
// Samples-per-tick at the current BPM — used to spread the per-tick envVolume
|
||||
// jump across the upcoming tick interval so the mixer hears a continuous slope
|
||||
// instead of a stair-step. Recomputed every tick because BPM can change mid-row.
|
||||
val spt = SAMPLING_RATE * tickSec
|
||||
for (vi in 0 until ts.voices.size) {
|
||||
val voice = ts.voices[vi]
|
||||
if (!voice.active && voice.noteDelayTick < 0) continue
|
||||
@@ -2634,7 +2704,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
voice.noteDelayTick = -1
|
||||
}
|
||||
|
||||
if (!voice.active) { advanceEnvelope(voice, inst, tickSec); continue }
|
||||
if (!voice.active) {
|
||||
advanceEnvelope(voice, inst, tickSec)
|
||||
voice.envVolStep = if (spt > 0.0) (voice.envVolume - voice.envVolMix) / spt else 0.0
|
||||
continue
|
||||
}
|
||||
|
||||
// Pitch slides (E/F coarse on tick > 0).
|
||||
if (ts.tickInRow > 0 && (voice.slideMode == 1 || voice.slideMode == 2)) {
|
||||
@@ -2822,6 +2896,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
}
|
||||
|
||||
advanceEnvelope(voice, inst, tickSec)
|
||||
// Compute per-sample slope so envVolMix walks smoothly to the new envVolume
|
||||
// across the next tick interval; this turns the mixer's view of the envelope
|
||||
// from a stair-step into a continuous ramp and removes the per-tick clicks
|
||||
// that are otherwise audible on steep envelope slopes (e.g., XM volume
|
||||
// envelopes with fast attack/decay nodes — the slumberjack.xm symptom).
|
||||
voice.envVolStep = if (spt > 0.0) (voice.envVolume - voice.envVolMix) / spt else 0.0
|
||||
advancePfEnvelope(voice, inst, tickSec)
|
||||
}
|
||||
|
||||
@@ -2868,6 +2948,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
if (!bg.active) { bgIt.remove(); continue }
|
||||
val inst = instruments[bg.instrumentId]
|
||||
advanceEnvelope(bg, inst, tickSec)
|
||||
bg.envVolStep = if (spt > 0.0) (bg.envVolume - bg.envVolMix) / spt else 0.0
|
||||
advancePfEnvelope(bg, inst, tickSec)
|
||||
if (bg.keyOff || bg.noteFading) {
|
||||
val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8)
|
||||
@@ -2968,11 +3049,19 @@ 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
|
||||
// Per-sample envelope smoothing: walk envVolMix toward the tick-set
|
||||
// envVolume so the mixer sees a continuous slope instead of the per-tick
|
||||
// stair-step that produces clicks on steep envelope segments.
|
||||
voice.envVolMix += voice.envVolStep
|
||||
// Volume envelope is bypassed (treated as unity) when S $77 has disabled it.
|
||||
val effEnvVol = if (voice.volEnvOn) voice.envVolume else 1.0
|
||||
val effEnvVol = if (voice.volEnvOn) voice.envVolMix else 1.0
|
||||
// Anti-click ramp: smooths voleff/notefx-driven rowVolume steps. Key-on
|
||||
// triggers snap currentMixVolume to target (in triggerNote) so attacks
|
||||
// are passed through unramped.
|
||||
advanceVolumeRamp(voice)
|
||||
// Split the gain stack so the soundscope can see the voice amplitude independently
|
||||
// of the playhead-wide faders (master / mixing / global volume).
|
||||
val perVoiceGain = effEnvVol * voice.fadeoutVolume * (voice.rowVolume / 63.0) *
|
||||
val perVoiceGain = effEnvVol * voice.fadeoutVolume * voice.currentMixVolume *
|
||||
swingScale * instGv
|
||||
val globalGain = gvol * mvol * playhead.masterVolume / 255.0
|
||||
val vol = perVoiceGain * globalGain
|
||||
@@ -3008,8 +3097,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val s = applyTaudVoiceFx(bg, applyVoiceFilter(bg, fetchTrackerSample(bg, bgInst, ts.interpolationMode)))
|
||||
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) *
|
||||
bg.envVolMix += bg.envVolStep
|
||||
val effEnvVol = if (bg.volEnvOn) bg.envVolMix else 1.0
|
||||
// Background voices don't receive new voleff/notefx events, but ghosting
|
||||
// can leave currentMixVolume mid-ramp from the foreground's last change —
|
||||
// keep advancing so the inherited ramp completes cleanly.
|
||||
advanceVolumeRamp(bg)
|
||||
val vol = effEnvVol * bg.fadeoutVolume * bg.currentMixVolume *
|
||||
swingScale * gvol * mvol * instGv * playhead.masterVolume / 255.0
|
||||
val pan = if (bg.hasPanEnv && bg.panEnvOn) {
|
||||
val envPanRaw = (bg.envPan * 255.0).roundToInt().coerceIn(0, 255)
|
||||
@@ -3274,10 +3368,36 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
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.
|
||||
var currentMixVolume = 1.0
|
||||
var volRampSamples = 0
|
||||
var volRampStep = 0.0
|
||||
var snapMixVolume = false
|
||||
|
||||
var keyOff = false
|
||||
var envIndex = 0
|
||||
var envTimeSec = 0.0
|
||||
var envVolume = 1.0
|
||||
// Per-sample smoothed copy of envVolume. advanceEnvelope() runs once per tick
|
||||
// (~640 samples at 125 BPM, 32 kHz) and overwrites envVolume with a stair-step
|
||||
// approximation of the linear interpolation between envelope nodes — between
|
||||
// ticks envVolume is held constant, so steep envelope slopes click audibly at
|
||||
// every tick boundary. The mixer feeds envVolMix to the gain stack instead;
|
||||
// applyTrackerTick computes envVolStep so envVolMix ramps linearly across the
|
||||
// upcoming tick interval and lands on the new envVolume by the next tick.
|
||||
// triggerNote snaps envVolMix to the fresh envVolume so attacks aren't smeared.
|
||||
var envVolMix = 1.0
|
||||
var envVolStep = 0.0
|
||||
var envPanIndex = 0
|
||||
var envPanTimeSec = 0.0
|
||||
var envPan = 0.5 // 0.0=full-left, 1.0=full-right, 0.5=centre
|
||||
@@ -3627,6 +3747,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
it.active = false
|
||||
it.channelVolume = 0x3F
|
||||
it.rowVolume = 0x3F
|
||||
it.currentMixVolume = 1.0
|
||||
it.volRampSamples = 0
|
||||
it.volRampStep = 0.0
|
||||
it.snapMixVolume = false
|
||||
it.envVolMix = 1.0
|
||||
it.envVolStep = 0.0
|
||||
it.channelPan = 0x80
|
||||
it.rowPan = 32
|
||||
it.glissandoOn = false
|
||||
@@ -3639,6 +3765,36 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
it.nnaOverride = -1
|
||||
it.volEnvOn = true; it.panEnvOn = true; it.pfEnvOn = true
|
||||
it.noteFading = false
|
||||
// "What's playing" state — must be cleared alongside the volume reset
|
||||
// above, otherwise a voice can carry a stale instrumentId from a prior
|
||||
// session into a freshly-reset volume slot. Concretely: end of session
|
||||
// leaves voice.instrumentId = N from the last retrigger; resetParams
|
||||
// (run on session exit and re-entry) clears channelVolume back to 0x3F
|
||||
// but used to leave instrumentId = N. The next session's first play of
|
||||
// a row carrying note + porta + no instrument byte then triggers
|
||||
// instruments[N] (a real sample) at the porta-target pitch with
|
||||
// channelVolume = 0x3F — the unreeeal_superhero_3.taud cue-0 ch7/ch8
|
||||
// "loud wrong note" symptom. triggerNote already reseeds these on a
|
||||
// row carrying an instrument byte, so the asymmetry was only audible
|
||||
// for the run of porta-only rows preceding the first inst-byte row.
|
||||
it.instrumentId = 0
|
||||
it.samplePos = 0.0
|
||||
it.playbackRate = 1.0
|
||||
it.forward = true
|
||||
it.keyOff = false
|
||||
it.envIndex = 0; it.envTimeSec = 0.0; it.envVolume = 1.0
|
||||
it.envPanIndex = 0; it.envPanTimeSec = 0.0; it.envPan = 0.5
|
||||
it.hasPanEnv = false
|
||||
it.envPfIndex = 0; it.envPfTimeSec = 0.0; it.envPfValue = 0.5
|
||||
it.hasPfEnv = false; it.envPfIsFilter = false
|
||||
it.fadeoutVolume = 1.0
|
||||
it.rampOutSamples = 0; it.rampOutGain = 0.0; it.rampOutStep = 0.0
|
||||
it.noteVal = 0xFFFF; it.basePitch = 0x4000
|
||||
it.amigaPeriod = -1.0; it.linearFreq = -1.0
|
||||
it.tonePortaTarget = -1; it.tonePortaSpeed = 0
|
||||
it.filterY1 = 0.0; it.filterY2 = 0.0
|
||||
it.filterCutoffCached = -1; it.filterResonanceCached = -1
|
||||
it.currentCutoff = 0xFF; it.currentResonance = 0xFF
|
||||
}
|
||||
ts.backgroundVoices.clear()
|
||||
// Funk repeat (S$Fx): drop every per-instrument inversion mask so that
|
||||
|
||||
Reference in New Issue
Block a user