Taud: equal-energy panning only

This commit is contained in:
minjaesong
2026-05-08 20:23:46 +09:00
parent 27b0f2e63f
commit ed3bbb6ffe
10 changed files with 63 additions and 103 deletions

View File

@@ -181,7 +181,7 @@ Coarse and fine modes are distinguished by the high nibble of the argument:
- **MONOTONE source** (Taud `ff = 2`):
- MONOTONE `2xx` → Taud `E $00xx` **verbatim** (Hz/tick). The engine converts the stored pitch to frequency, subtracts the argument, and converts back. MONOTONE has no fine-slide form; converters never emit `E $Fxxx` for ff=2 sources.
The mode flag therefore controls **two** decoder behaviours simultaneously: (a) which numeric scale the converter should have used when emitting coarse arguments, and (b) which arithmetic the engine performs on those arguments per tick. Converters MUST set bits 1-2 (`ff`) of the song-table flags byte to match the units they emit, and MUST NOT mix scales within one Taud song.
The mode flag therefore controls **two** decoder behaviours simultaneously: (a) which numeric scale the converter should have used when emitting coarse arguments, and (b) which arithmetic the engine performs on those arguments per tick. Converters MUST set bits 0-1 (`ff`) of the song-table flags byte to match the units they emit, and MUST NOT mix scales within one Taud song.
Because E and F share memory in Taud (narrower than ST3's broad shared memory), an ST3 song that used `E00` or `F00` to recall a D, G, or Q argument will break on import; the converter must eagerly resolve ST3 recalls into explicit Taud arguments rather than relying on memory.
@@ -1114,16 +1114,13 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
**Plain.** Sets mixer-wide behaviour flags. Available flags are:
0b 0000 0ffp
- p unset: Linear panning mode (tracker-accurate). Centre panning gets 3 dB boost. Default setting.
- p set: Equal-power panning mode. L/R amplitude is at 0.707 when centre-panned.
0b 0000 00ff
- 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).
(Bit 2 is reserved. It previously held an `m` "fadeout-zero policy" flag intended to swap between IT and FT2 semantics for `storedFadeout = 0`. That flag was removed once both trackers were verified to share identical "stored 0 ⇒ no fade" semantics — see schismtracker `player/sndmix.c:330-342` and ft2-clone `src/ft2_replayer.c:1467-1481`. Fadeout scaling now lives in the converters; see "Volume Fadeout" below.)
(Bits 2-7 are reserved. Bit 2 previously held an `m` "fadeout-zero policy" flag intended to swap between IT and FT2 semantics for `storedFadeout = 0`. That flag was removed once both trackers were verified to share identical "stored 0 ⇒ no fade" semantics — see schismtracker `player/sndmix.c:330-342` and ft2-clone `src/ft2_replayer.c:1467-1481`. Fadeout scaling now lives in the converters; see "Volume Fadeout" below.)
### Volume Fadeout
@@ -1168,10 +1165,7 @@ There is no separate "use fadeout" flag — both extremes share the same field,
- **MOD / S3M / MON**: source has no instrument-level fadeout. Converter writes Taud `0`. Notes retire on sample-end or pattern note-cut.
**Implementation.**
- Panning-linear:
- L_gain = if (pan < 0x80) 1.0 else 1.0 - (pan - 128.0) / 128.0
- R_gain = if (pan < 0x80) pan / 128.0 else 1.0
- Panning-equal-power:
- Panning (equal-energy):
- L_gain = cos(πx / 512.0)
- R_gain = sin(πx / 512.0)
- Amiga tone (both coarse and fine E/F pitch slides). The `slideArg` is a **raw tracker period-unit count** (no scaling), with sign matching linear mode (negative for E, positive for F). Coarse slides apply on every non-first tick; fine slides apply once on tick 0 — the per-step arithmetic is identical:

View File

@@ -1791,7 +1791,7 @@ function simulateRowState(ptnDat, uptoRow) {
let bpm = audio.getBPM(PLAYHEAD) // best-effort starting tempo
let speed = audio.getTickRate(PLAYHEAD)
let globalVol = 0xFF
let panLaw = 0, toneMode = 0 // toneMode: 0=linear, 1=Amiga, 2=linear-freq, 3=reserved
let toneMode = 0 // 0=linear, 1=Amiga, 2=linear-freq, 3=reserved
let memEF = 0, memG = 0
let memHU = { speed: 0, depth: 0 }
@@ -1901,9 +1901,7 @@ function simulateRowState(ptnDat, uptoRow) {
if (effop !== 0 || effarg !== 0) {
if (effop === OP_1) {
const flags = (effarg >>> 8) & 0xFF
panLaw = flags & 1
toneMode = (flags >>> 1) & 3
// bit 2 reserved (was 'm' fadeout-zero policy; removed)
toneMode = flags & 3
}
else if (effop === OP_8) {
const x = (effarg >>> 12) & 0xF
@@ -2041,7 +2039,7 @@ function simulateRowState(ptnDat, uptoRow) {
return { lastNote, lastInst, volAbs, panAbs, pitchOff,
bpm, speed, globalVol,
panLaw, toneMode,
toneMode,
bitcrushDepth, bitcrushSkip, overdriveAmp, clipMode,
glissandoOn, vibratoWave, tremoloWave, panbrelloWave,
memEF, memG, memHU, memR, memY,
@@ -2242,19 +2240,8 @@ function drawProjectContents(wo) {
for (let y = PTNVIEW_OFFSET_Y; y < SCRH; y++) fillLine(y, colBackPtn, 255)
let mixerflag = initialTrackerMixerflags
let flagStrSelected = []
let flagstr = [
['Linear pan','EquNrg pan'],
['Linear pitch','Amiga pitch', 'Linear freq', ''], // TODO MONOTONE uses linear-freq pitch
]
for (let i = 0; i < flagstr.length; i++) {
if (i != 1 && 1 != 2) {
let s = flagstr[i][(mixerflag >>> i) & 1 != 0]
flagStrSelected.push(s)
}
}
let toneMode = (mixerflag >>> 1) & 3
flagStrSelected.splice(1, 0, flagstr[1][toneMode])
let toneModeStr = ['Linear pitch','Amiga pitch','Linear freq',''][mixerflag & 3]
let flagStrSelected = [toneModeStr]
let projMeta = {

View File

@@ -1821,10 +1821,12 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
pat_comp = compress_blob(bytes(pat_bin), "pattern bin")
cue_comp = compress_blob(bytes(sheet), "cue sheet")
# flags byte: bit 1 (f) = Amiga pitch-slide mode (IT linear_slides flag inverted).
# bit 2 was the old 'm' fadeout-zero policy flag and is now reserved (always 0); fadeout
# scaling is done per-instrument in this converter — see the fadeout pass-through below.
flags_byte = 0x00 if h.linear_slides else 0x02
# flags byte: bits 0-1 (ff) = tone mode. ff=1 (Amiga period slides) when IT's
# linear_slides flag is clear; ff=0 otherwise. Pan law is fixed engine-wide to
# the equal-energy — no `p` bit any more. Bit 2 was the old 'm' fadeout-zero
# policy flag and is now reserved (always 0); fadeout scaling is done per-instrument
# in this converter — see the fadeout pass-through below.
flags_byte = 0x00 if h.linear_slides else 0x01
# IT global/mix volumes are 0..128; rescale to Taud's 0..255 (clamped).
global_vol_taud = min(0xFF, round(h.global_vol * 255 / 128))
mixing_vol_taud = min(0xFF, round(h.mix_vol * 255 / 128))

View File

@@ -767,13 +767,13 @@ def assemble_taud(mod: dict) -> bytes:
pat_comp = compress_blob(bytes(pat_bin), "pattern bin")
cue_comp = compress_blob(bytes(cue_sheet), "cue sheet")
# ProTracker is Amiga-period-based by definition, so we set the f bit so
# ProTracker is Amiga-period-based by definition, so we set ff=1 (bits 0-1) so
# the engine applies coarse pitch slides in period space (recovers PT's
# characteristic non-linear pitch character).
# bit 2 reserved (was 'm' fadeout-zero policy; removed). PT has no instrument-level
# fadeout, so every Taud instrument carries fadeout=0 ("no fade") — notes retire
# on sample-end or pattern note-cut instead, which matches PT semantics.
flags_byte = 0x02
# characteristic non-linear pitch character). Pan law is fixed to the
# equal-energy engine-wide. PT has no instrument-level fadeout, so every Taud
# instrument carries fadeout=0 ("no fade") — notes retire on sample-end or
# pattern note-cut instead, which matches PT semantics.
flags_byte = 0x01
song_table = encode_song_entry(
song_offset=song_offset,
num_voices=n_channels,

View File

@@ -52,12 +52,12 @@ MON_EFFECT_LETTERS = ['0', '1', '2', '3', '4', 'B', 'D', 'F']
MON_NOTE_C4 = 40
# Global behaviour flags byte (Taud Effect 1 / song-table byte 15):
# bit 0 (p) : pan law — leave 0 (linear) for tracker accuracy
# bits 1-2 (ff): tone mode — 2 = linear-frequency (Hz/tick)
# bits 0-1 (ff): tone mode — 2 = linear-frequency (Hz/tick)
# Selecting ff=2 makes the engine interpret 1xx/2xx/3xx slide arguments in
# audible Hz at the A4=440 Hz reference, matching Monotone's MT_PLAY.PAS
# `Frequency:=Frequency±parm1` arithmetic (see MTSRC/MT_PLAY.PAS:606-630).
GLOBAL_FLAGS_LINEAR_FREQ = 0b100
# Panning law is fixed to the equal-energy — there is no `p` bit any more.
GLOBAL_FLAGS_LINEAR_FREQ = 0b10
# ── Taud container ───────────────────────────────────────────────────────────
@@ -362,10 +362,10 @@ def assemble_taud(mon: dict) -> bytes:
# BPM 150 + ticks=mon_speed → row rate = 60/mon_speed (matches Monotone).
bpm_stored = 150 - 24
# Linear-frequency tone mode (ff=2) so 1xx/2xx/3xx Hz/tick semantics survive verbatim;
# pan law stays 0 (linear), bit 2 stays 0 (reserved). Monotone has no instrument-level
# fadeout, so every Taud instrument carries fadeout=0 ("no fade") — notes retire on
# sample-end or pattern note-cut instead.
# Linear-frequency tone mode (ff=2) so 1xx/2xx/3xx Hz/tick semantics survive verbatim.
# Pan law is fixed engine-wide to the equal-energy (no flag). Monotone has no
# instrument-level fadeout, so every Taud instrument carries fadeout=0 ("no fade") —
# notes retire on sample-end or pattern note-cut instead.
flags_byte = GLOBAL_FLAGS_LINEAR_FREQ
song_table = encode_song_entry(

View File

@@ -812,11 +812,13 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
cue_comp = compress_blob(bytes(cue_sheet), "cue sheet")
# Song table row (32 bytes; see encode_song_entry).
# flags byte: bit 1 (f) = Amiga pitch-slide mode (mirrors the S3M linear_slides flag inverted).
# bit 2 reserved (was 'm' fadeout-zero policy; removed). S3M has no instrument-level
# fadeout, so every Taud instrument carries fadeout=0 ("no fade") — notes retire on
# sample-end or pattern note-cut effects (SCx) instead, which matches ST3 semantics.
flags_byte = (0x00 if h.linear_slides else 0x02)
# flags byte: bits 0-1 (ff) = tone mode. ff=1 (Amiga period slides) when S3M's
# linear_slides flag is clear; ff=0 otherwise. Pan law is fixed engine-wide to
# the equal-energy — no `p` bit any more. Bit 2 reserved (was 'm' fadeout-zero
# policy; removed). S3M has no instrument-level fadeout, so every Taud instrument
# carries fadeout=0 ("no fade") — notes retire on sample-end or pattern note-cut
# effects (SCx) instead, which matches ST3 semantics.
flags_byte = (0x00 if h.linear_slides else 0x01)
song_table = encode_song_entry(
song_offset=song_offset,
num_voices=C,

View File

@@ -2346,7 +2346,7 @@ TODO:
[x] FT2/MOD double effects with 00 as arg (500, 600) missing volume column -> easiest solution: fully implement `L xy00` and `K xy00` and map 5xx to L, 6xx to K (xm2taud, mod2taud), Kxy and Lxy verbatim (s3m2taud.py, it2taud.py). This is justified because the volume effects rely on memory when 00 is given, and said memory effect only get recalled when NoteFx is used. TAUD_NOTE_EFFECTS already has detailed implementation notes. Mark those two commands as implemented sorely for tracker compatibility.
Also document then implement `Mxx` (set channel volume, not just a note: 0x00 to 0x3F) `Nxy` (channel volume slide: similar to Dxy, but applies to the current channel's volume, not just a note) `Pxy` (channel panning slide. Similar to Dxx: P0y - to the right, Px0 - to the left, PFy - fine pan right, PxF - fine pan left) effects
[x] 8 MB sample RAM via 512k banks
[ ] remove panning mode selection and replace global panning rule to 3 dB rule (not the equal energy)
[x] remove panning mode selection and replace global panning rule to equal energy, also move the 'ff' flags to bit 0..1
[ ] low-number voleffs are too quiet (needs elaboration and test cases)
@@ -2449,12 +2449,12 @@ Play Head Flags
Byte 2
- PCM Mode: Write non-zero value to start uploading; always 0 when read
- Tracker Mode: Global mixer flags. Maps directly to Taud effect symbol '1'
0b 0000 0ffp
p: panning mode (0: linear, 1: equal-power)
0b 0000 00ff
ff: pitchshift mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: reserved)
Tracker command may change the mixer state, but the changes WILL NOT BE REFLECTED BACK.
Starting a new song will use whatever written to this register. In other words, changes
made by songs will not persist.
Panning law is fixed to the equal-energy; there is no runtime selection.
Byte 3 (Tracker Mode)
- BPM (24 to 279. Play Data will change this register)
Byte 4 (Tracker Mode)
@@ -2573,12 +2573,8 @@ 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 0ffp
p: panning law (0: linear, 1: equal-power)
0b 0000 00ff
ff: tone mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: reserved)
(bit 2 reserved — was 'm' fadeout-zero policy, removed; fadeout
scaling now lives entirely in the converter — see byte 172/173
of the instrument record for engine semantics)
Uint8 Song global volume
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
Uint8 Song mixing volume

View File

@@ -135,10 +135,10 @@ class AudioJSR223Delegate(private val vm: VM) {
getFirstSnd()?.playheads?.get(playhead)?.let { ph ->
ph.initialGlobalFlags = flags
ph.trackerState?.let { ts ->
ts.panLaw = flags and 1
ts.toneMode = (flags ushr 1) and 3
// bit 2 reserved (was 'm' fadeout-zero policy; removed — see AudioAdapter.kt
// and TAUD_NOTE_EFFECTS.md §1 "Volume Fadeout")
ts.toneMode = flags and 3
// Bits 2-7 reserved. Bit 2 was the old 'm' fadeout-zero policy; removed.
// Pan law is fixed to the equal-energy engine-wide — no flag bit any more.
// See AudioAdapter.kt and TAUD_NOTE_EFFECTS.md §1.
}
}
}

View File

@@ -12,12 +12,12 @@ import net.torvald.tsvm.ThreeFiveMiniUfloat
import net.torvald.tsvm.VM
import net.torvald.tsvm.toInt
import java.io.ByteArrayInputStream
import kotlin.math.cos
import kotlin.math.log2
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
private class RenderRunnable(val playhead: AudioAdapter.Playhead) : Runnable {
private fun printdbg(msg: Any) {
@@ -2099,12 +2099,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
EffectOp.OP_NONE -> {}
EffectOp.OP_1 -> {
// 1 $xx00 — Global behaviour flags byte in the high byte (see TAUD_NOTE_EFFECTS.md §1).
// bit 0 (p): 0=linear pan, 1=equal-power pan
// bits 1-2 (ff): 0=linear pitch, 1=Amiga period, 2=linear frequency (Hz/tick),
// bits 0-1 (ff): 0=linear pitch, 1=Amiga period, 2=linear frequency (Hz/tick),
// 3=reserved
// Panning law is fixed to the equal-energy; no runtime selection.
val flags = rawArg ushr 8
ts.panLaw = flags and 1
ts.toneMode = (flags ushr 1) and 3
ts.toneMode = flags and 3
}
EffectOp.OP_8 -> {
// 8 $xyzz — Bitcrusher. See TAUD_NOTE_EFFECTS.md §8.
@@ -2812,18 +2811,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
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)
val lGain: Double
val rGain: Double
when (ts.panLaw) {
1 -> { // equal-power: constant loudness at centre (0.707 each)
lGain = cos(PI * pan / 512.0)
rGain = sin(PI * pan / 512.0)
}
else -> { // linear balance (tracker default): centre gives 0 dB on both channels
lGain = if (pan < 0x80) 1.0 else 1.0 - (pan - 128.0) / 128.0
rGain = if (pan < 0x80) pan / 128.0 else 1.0
}
}
// equal-energy pan law
val lGain = cos(PI * pan / 512.0)
val rGain = sin(PI * pan / 512.0)
// Sample-end ramp-out: snapshot gain, advance the ramp, deactivate at zero.
val rampGain = if (voice.rampOutSamples > 0) {
val g = voice.rampOutGain
@@ -2850,18 +2840,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
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
}
}
val lGain = cos(PI * pan / 512.0)
val rGain = sin(PI * pan / 512.0)
val rampGain = if (bg.rampOutSamples > 0) {
val g = bg.rampOutGain
bg.rampOutGain -= bg.rampOutStep
@@ -3251,8 +3231,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var firstRow = true
val voices = Array(20) { Voice() }
// Global mixer config (effect 1).
var panLaw = 0 // 0 = linear balance (default), 1 = equal-power
// Global mixer config (effect 1). Panning law is fixed to the equal-energy.
// Tone-slide mode for E / F / G effects (terranmon.txt §Song Table flags byte):
// 0 = linear pitch slides (4096-TET units, default)
// 1 = Amiga period slides (raw PT period units, applied in period space)
@@ -3370,8 +3349,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
7 -> if (isPcmMode) { pcmUpload = true } else {
initialGlobalFlags = byte
trackerState?.let { ts ->
ts.panLaw = byte and 1
ts.toneMode = (byte ushr 1) and 3
ts.toneMode = byte and 3
}
}
8 -> { bpm = byte + 24 }
@@ -3405,8 +3383,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
ts.patternDelayRemaining = 0; ts.patternDelayActive = false
ts.sexWinningChannel = -1
ts.finePatternDelayExtra = 0
ts.panLaw = initialGlobalFlags and 1
ts.toneMode = (initialGlobalFlags ushr 1) and 3
ts.toneMode = initialGlobalFlags and 3
ts.voices.forEach {
it.active = false
it.channelVolume = 0x3F

View File

@@ -1425,11 +1425,13 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes:
cue_comp = compress_blob(bytes(sheet), "cue sheet")
# Flags byte:
# bit 1 (f) = Amiga pitch-slide mode (set when XM uses Amiga period table).
# bit 2 = reserved (was 'm' fadeout-zero policy; removed). XM fadeout values are
# now scaled per-instrument above (÷32 with round-to-nearest), so the
# engine sees Taud-native units and uses its single divisor of 1024.
flags_byte = (0x00 if h.linear_freq else 0x02)
# bits 0-1 (ff) = tone mode. ff=1 (Amiga period slides) when XM uses the Amiga
# period table; ff=0 otherwise. Pan law is fixed engine-wide to
# the equal-energy — no `p` bit any more.
# bit 2 = reserved (was 'm' fadeout-zero policy; removed). XM fadeout values
# are now scaled per-instrument above (÷32 with round-to-nearest), so
# the engine sees Taud-native units and uses its single divisor of 1024.
flags_byte = (0x00 if h.linear_freq else 0x01)
song_table = encode_song_entry(
song_offset=song_offset,
num_voices=C,