diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index ea580b8..63c2345 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -1826,8 +1826,10 @@ function simulateRowState(ptnDat, uptoRow) { 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: - // triggerNote() resets channelVolume to 0x3F on fresh triggers, and an - // instrument byte on a tone-porta row also reloads default vol (matches + // triggerNote() resets channelVolume to 0x3F 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 — + // an instrument byte on a porta row reloads default vol (matches // schism csf_instrument_change inst_column branch). let reloadDefaultVol = false if (note !== 0xFFFF && note !== 0xFFFE) { @@ -1842,17 +1844,21 @@ function simulateRowState(ptnDat, uptoRow) { lastNote = note pitchOff = 0 portaTarget = -1 - reloadDefaultVol = true + if (inst !== 0) reloadDefaultVol = true } else { lastNote = note pitchOff = 0 portaTarget = -1 - reloadDefaultVol = true + if (inst !== 0) reloadDefaultVol = true } } if (inst !== 0) lastInst = inst // Default vol reset must happen before the volume column so a SET selector // 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 // Pre-scan effect column for S$80xx (8-bit pan SET wins over volcol/pancol SET). diff --git a/terranmon.txt b/terranmon.txt index af464d6..3677bd2 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -2345,7 +2345,7 @@ TODO: for LEFT/RIGHT and `shiftPatternArea` for UP/DOWN, plus per-row (`drawOrdersRowAt`) and per-column (`drawOrdersVoiceColumnAt`) helpers, 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: diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index 0f2d3b1..3131287 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -1689,19 +1689,24 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { (Math.random() * (2 * inst.volumeSwing + 1)).toInt() - inst.volumeSwing else 0 voice.randomPanBias = if (inst.panSwing != 0) (Math.random() * (2 * inst.panSwing + 1)).toInt() - inst.panSwing else 0 - // 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. - if ((inst.panEnvLoop ushr 7) and 1 != 0) { - voice.channelPan = inst.defaultPan - voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63) - } - // Pitch-pan separation: when PPS != 0, played notes far from PPC drift in pan. - // PPS is signed (-32..+32), full-scale at one octave (4096 4096-TET units) above PPC. - 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) + // Default pan / pitch-pan separation: only re-applied when the row carried an instrument + // byte. A note-only retrigger (instId == 0) inherits the channel's existing pan, mirroring + // the volume policy below. + if (instId != 0) { + // 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. + if ((inst.panEnvLoop ushr 7) and 1 != 0) { + voice.channelPan = inst.defaultPan + voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63) + } + // Pitch-pan separation: when PPS != 0, played notes far from PPC drift in pan. + // PPS is signed (-32..+32), full-scale at one octave (4096 4096-TET units) above PPC. + 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. // 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.linearFreq = -1.0 // ditto for linear-freq mode (toneMode == 2) voice.playbackRate = computePlaybackRate(inst, noteVal) - // Fresh trigger resets channel volume to full ($3F). Per-instrument 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 = if (volOverride >= 0) volOverride.coerceIn(0, 0x3F) else 0x3F + // Fresh trigger resets channel volume to full ($3F) ONLY when the row carried an + // instrument byte; a note-only retrigger (instId == 0) inherits the channel's existing + // volume so the user can sustain a held volume across re-triggered notes. Per-instrument + // 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.noteWasCut = false voice.noteFading = false