volume policy when unspecified: retrigger (note+inst cmd) -> default value, no retrigger (note cmd only) -> prev value

This commit is contained in:
minjaesong
2026-05-08 01:11:19 +09:00
parent a767eebc2e
commit 34b3b83d65
3 changed files with 39 additions and 22 deletions

View File

@@ -1826,8 +1826,10 @@ function simulateRowState(ptnDat, uptoRow) {
const isGRow = (effop === OP_G) const isGRow = (effop === OP_G)
const isNoteDelay = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0xD) 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 channel's default volume. Engine:
// triggerNote() resets channelVolume to 0x3F on fresh triggers, and an // triggerNote() resets channelVolume to 0x3F only when the row carries an
// instrument byte on a tone-porta row also reloads default vol (matches // instrument byte; a note-only retrigger (inst === 0) inherits the
// channel's existing volume. Tone-porta rows follow the same rule —
// an instrument byte on a porta row reloads default vol (matches
// schism csf_instrument_change inst_column branch). // schism csf_instrument_change inst_column branch).
let reloadDefaultVol = false let reloadDefaultVol = false
if (note !== 0xFFFF && note !== 0xFFFE) { if (note !== 0xFFFF && note !== 0xFFFE) {
@@ -1842,17 +1844,21 @@ function simulateRowState(ptnDat, uptoRow) {
lastNote = note lastNote = note
pitchOff = 0 pitchOff = 0
portaTarget = -1 portaTarget = -1
reloadDefaultVol = true if (inst !== 0) reloadDefaultVol = true
} else { } else {
lastNote = note lastNote = note
pitchOff = 0 pitchOff = 0
portaTarget = -1 portaTarget = -1
reloadDefaultVol = true if (inst !== 0) reloadDefaultVol = true
} }
} }
if (inst !== 0) lastInst = inst if (inst !== 0) lastInst = inst
// Default vol reset must happen before the volume column so a SET selector // Default vol reset must happen before the volume column so a SET selector
// can still override on the same row (engine order: triggerNote → applyVolColumn). // can still override on the same row (engine order: triggerNote → applyVolColumn).
// Pan: simulator does not track per-instrument default pan, so it never resets
// 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.
if (reloadDefaultVol) volAbs = 0x3F if (reloadDefaultVol) volAbs = 0x3F
// Pre-scan effect column for S$80xx (8-bit pan SET wins over volcol/pancol SET). // Pre-scan effect column for S$80xx (8-bit pan SET wins over volcol/pancol SET).

View File

@@ -2345,7 +2345,7 @@ TODO:
for LEFT/RIGHT and `shiftPatternArea` for UP/DOWN, plus per-row for LEFT/RIGHT and `shiftPatternArea` for UP/DOWN, plus per-row
(`drawOrdersRowAt`) and per-column (`drawOrdersVoiceColumnAt`) helpers, (`drawOrdersRowAt`) and per-column (`drawOrdersVoiceColumnAt`) helpers,
replacing the full-panel redraw on every keystroke. replacing the full-panel redraw on every keystroke.
[ ] volume and panning policy to match note effect policy: when note is "retriggerred" (note command with instrument specified), the volume/pan must take default value; if not (note command with instrument 0) the volume/pan must stay at the old value. Make both audio engine and taut.js simulator changes. [x] volume and panning policy to match note effect policy: when note is "retriggerred" (note command with instrument specified), the volume/pan must take default value; if not (note command with instrument 0) the volume/pan must stay at the old value. Make both audio engine and taut.js simulator changes.
Play Data: play data are series of tracker-like instructions, visualised as: Play Data: play data are series of tracker-like instructions, visualised as:

View File

@@ -1689,19 +1689,24 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
(Math.random() * (2 * inst.volumeSwing + 1)).toInt() - inst.volumeSwing else 0 (Math.random() * (2 * inst.volumeSwing + 1)).toInt() - inst.volumeSwing else 0
voice.randomPanBias = if (inst.panSwing != 0) voice.randomPanBias = if (inst.panSwing != 0)
(Math.random() * (2 * inst.panSwing + 1)).toInt() - inst.panSwing else 0 (Math.random() * (2 * inst.panSwing + 1)).toInt() - inst.panSwing else 0
// Default pan: applied unless the pattern row has already overridden channelPan. // Default pan / pitch-pan separation: only re-applied when the row carried an instrument
// The pan envelope's 'p' flag ("use default pan") lives in the pan LOOP word at bit 7. // byte. A note-only retrigger (instId == 0) inherits the channel's existing pan, mirroring
if ((inst.panEnvLoop ushr 7) and 1 != 0) { // the volume policy below.
voice.channelPan = inst.defaultPan if (instId != 0) {
voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63) // Default pan: applied unless the pattern row has already overridden channelPan.
} // The pan envelope's 'p' flag ("use default pan") lives in the pan LOOP word at bit 7.
// Pitch-pan separation: when PPS != 0, played notes far from PPC drift in pan. if ((inst.panEnvLoop ushr 7) and 1 != 0) {
// PPS is signed (-32..+32), full-scale at one octave (4096 4096-TET units) above PPC. voice.channelPan = inst.defaultPan
if (inst.pitchPanSeparation != 0) { voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63)
val noteDelta = (noteVal - inst.pitchPanCentre).toDouble() / 4096.0 }
val panShift = (noteDelta * inst.pitchPanSeparation * 4.0).toInt() // ~×4 = 32→128 swing // Pitch-pan separation: when PPS != 0, played notes far from PPC drift in pan.
voice.channelPan = (voice.channelPan + panShift).coerceIn(0, 255) // PPS is signed (-32..+32), full-scale at one octave (4096 4096-TET units) above PPC.
voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63) if (inst.pitchPanSeparation != 0) {
val noteDelta = (noteVal - inst.pitchPanCentre).toDouble() / 4096.0
val panShift = (noteDelta * inst.pitchPanSeparation * 4.0).toInt() // ~×4 = 32→128 swing
voice.channelPan = (voice.channelPan + panShift).coerceIn(0, 255)
voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63)
}
} }
// Filter cutoff/resonance defaults — adjusted per-tick by the pf envelope when in filter mode. // Filter cutoff/resonance defaults — adjusted per-tick by the pf envelope when in filter mode.
// 255 = filter off (IT high-bit-clear); 0..254 = active range matching IT 0..127 at double resolution. // 255 = filter off (IT high-bit-clear); 0..254 = active range matching IT 0..127 at double resolution.
@@ -1715,10 +1720,16 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.amigaPeriod = -1.0 // fresh trigger: period state must reseed from the new noteVal 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.linearFreq = -1.0 // ditto for linear-freq mode (toneMode == 2)
voice.playbackRate = computePlaybackRate(inst, noteVal) voice.playbackRate = computePlaybackRate(inst, noteVal)
// Fresh trigger resets channel volume to full ($3F). Per-instrument scaling lives in // Fresh trigger resets channel volume to full ($3F) ONLY when the row carried an
// instGlobalVolume (byte 171), which the mixer applies as a multiplier. Converters // instrument byte; a note-only retrigger (instId == 0) inherits the channel's existing
// therefore no longer need to emit SEL_SET=Sv on note-trigger rows. // volume so the user can sustain a held volume across re-triggered notes. Per-instrument
voice.channelVolume = if (volOverride >= 0) volOverride.coerceIn(0, 0x3F) else 0x3F // scaling lives in instGlobalVolume (byte 171), which the mixer applies as a multiplier.
// Converters therefore no longer need to emit SEL_SET=Sv on note-trigger rows.
voice.channelVolume = when {
volOverride >= 0 -> volOverride.coerceIn(0, 0x3F)
instId != 0 -> 0x3F
else -> voice.channelVolume
}
voice.rowVolume = voice.channelVolume voice.rowVolume = voice.channelVolume
voice.noteWasCut = false voice.noteWasCut = false
voice.noteFading = false voice.noteFading = false