taud: pattern ditto eff

This commit is contained in:
minjaesong
2026-05-14 01:07:40 +09:00
parent 3ecf842ac0
commit f3ece28a10
2 changed files with 176 additions and 2 deletions

View File

@@ -735,6 +735,87 @@ Peak at maximum settings: $7F × $FF >> 9 = $3F — the full panning range. Retr
--- ---
## 7 $xxyy — Pattern Ditto
**Plain.** A per-channel "fill the rest from above" marker: the engine copies the **$xx rows immediately preceding this cell on the same channel** and pastes them $yy times starting on this row. The destination block therefore covers `$xx × $yy` rows beginning at the ditto row inclusive. Any field (note, instrument, vol-column, pan-column, effect) that the composer has explicitly written into a destination row stays put and patches the corresponding field of the copied source cell — empty fields fall through to the source. The ditto opcode itself is consumed by the marker on its arming row; the rest of that row's columns are patched from the source as usual, so an empty arming row plays back identically to the first row of the source block.
For example, with `7 $1003` on row 16, rows 16..63 replay the contents of rows 0..15 three times. A `D $0400` punched onto row 22 simply overrides the effect column on that destination row; its note/vol/pan still come from the source row 6 (since (22 16) mod 16 = 6, and 0 + 6 = source row 6).
Boundary rules:
- The block stops at the end of the pattern: a ditto whose nominal span would overflow the pattern's row count clips silently at the final row.
- `$xx = $00`, `$yy = $00`, and any `$xx` greater than the row index on which the ditto sits are all treated as no-ops — there is nothing valid to copy from.
- A `7` cell appearing inside a source block is **not** recursively expanded: when that source row is pasted into a destination, its effect column is treated as empty. This keeps expansion single-pass and prevents unbounded nesting.
- Flow-control effects (B, C, S$Bx, S$Ex) that fall inside a source block still fire when their copy lands on a destination row, since the engine sees them as ordinary effect cells after expansion. Composers and converters should avoid placing S$Bx loop bounds wholly inside a ditto'd range — the loop counter is per-voice and the same destination row would be revisited twice with the same state.
**Compatibility.** Unique to Taud — no ST3/IT/PT equivalent. The effect has no memory.
**Implementation.** Per-voice state, all reset on pattern change alongside the existing pattern-loop / fine-pattern-delay clears:
- `dittoActive: bool`
- `dittoSourceStart: int` — first row of the source block (inclusive)
- `dittoLength: int` — $xx, the block size
- `dittoEndRow: int` — last destination row (inclusive)
At the very top of `applyTrackerRow`, before the per-voice reset of row-scope state, build an effective cell view for each voice:
```
raw = patternRows[V.pattern][N] # stored cell on row N for voice V
isArmer = (raw.effect == 0x7 and raw.effectArg != 0)
if isArmer:
length = (raw.effectArg >> 8) & 0xFF
repeats = raw.effectArg & 0xFF
if length > 0 and repeats > 0 and length <= N:
V.dittoSourceStart = N - length
V.dittoLength = length
V.dittoEndRow = min(N + length * repeats - 1, patternLength - 1)
V.dittoActive = true
# else: malformed argument — fall through with dittoActive unchanged
armRow = V.dittoSourceStart + V.dittoLength # always equals the row that armed this ditto
if V.dittoActive and armRow <= N <= V.dittoEndRow:
srcRow = V.dittoSourceStart + ((N - V.dittoSourceStart) mod V.dittoLength)
src = patternRows[V.pattern][srcRow]
cell.note = (raw.note != 0xFFFF) ? raw.note : src.note
cell.instrument = (raw.instrument != 0) ? raw.instrument : src.instrument
# SEL_FINE / 0 is the canonical no-op encoding for the vol- and pan-columns;
# any other (selector, value) pair is a write and patches the source.
cell.vol, cell.volEff = (raw.volEff, raw.vol) != (SEL_FINE, 0)
? (raw.vol, raw.volEff)
: (src.vol, src.volEff)
cell.pan, cell.panEff = (raw.panEff, raw.pan) != (SEL_FINE, 0)
? (raw.pan, raw.panEff)
: (src.pan, src.panEff)
# On the armer row, the 7-opcode is consumed by the marker, so for effect-column
# patching purposes the destination is treated as empty. Source 7-opcodes never
# propagate (no recursive expansion).
destOp, destArg = isArmer ? (0, 0) : (raw.effect, raw.effectArg)
if destOp != 0:
cell.effect, cell.effectArg = destOp, destArg
elif src.effect != 0x7:
cell.effect, cell.effectArg = src.effect, src.effectArg
else:
cell.effect, cell.effectArg = 0, 0
else:
cell = raw
```
The four ditto fields are not cleared at the natural end of the destination range; they simply stop matching the gating condition once `N` advances past `dittoEndRow`, and a later armer cell in the same pattern overwrites them in place. Explicit clears happen only on cue advance (B / C / natural pattern end) and full playhead reset, alongside the existing pattern-loop counters in `resetPatternLoopState` / `resetParams`.
The rest of `applyTrackerRow` then dispatches on `cell` exactly as for an undittoed row — note triggering, vol/pan column application, and effect handling are unchanged. The expansion mutates the in-memory cell view only; the stored pattern data is never rewritten.
Pattern-delay (S$Ex) re-runs `applyTrackerRow` on the same `N` — the ditto bookkeeping is idempotent across those re-entries because `dittoActive`, `dittoSourceStart`, `dittoLength`, and `dittoEndRow` already encode the destination range, and the armer guard `length <= N` makes repeated arming on the same row a no-op (the new state is identical to the old). The `armRow <= N` half of the gating condition is what protects against an S$Bx pattern-loop that jumps back to a row sitting strictly before the armer: rather than synthesising from a phantom source slot, the engine falls through to the raw cell.
Effect dispatch sees the synthesised effect, never the literal `7` opcode of the armer cell — `OP_7` therefore exists in the engine's opcode table only as an explicit no-op for the rare malformed-armer fallthrough (`length == 0`, `repeats == 0`, or `length > N`).
---
## 8 $xyzz — Bitcrusher ## 8 $xyzz — Bitcrusher
**Plain.** Applies a bitcrusher to the current voice. The crusher has two independent stages — a sample-rate reducer (`zz`, sample-and-hold) and a bit-depth quantiser (`y`) — and shares its clipping mode (`x`) with effect 9 (Overdrive). The two stages are orthogonal: enabling either is sufficient to engage the effect, and either can be active alone. **Plain.** Applies a bitcrusher to the current voice. The crusher has two independent stages — a sample-rate reducer (`zz`, sample-and-hold) and a bit-depth quantiser (`y`) — and shares its clipping mode (`x`) with effect 9 (Overdrive). The two stages are orthogonal: enabling either is sufficient to engage the effect, and either can be active alone.

View File

@@ -1279,6 +1279,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
private object EffectOp { private object EffectOp {
const val OP_NONE = 0x00 const val OP_NONE = 0x00
const val OP_1 = 0x01 const val OP_1 = 0x01
const val OP_7 = 0x07
const val OP_8 = 0x08 const val OP_8 = 0x08
const val OP_9 = 0x09 const val OP_9 = 0x09
const val OP_A = 0x0A const val OP_A = 0x0A
@@ -2224,9 +2225,69 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val patNum = cue.patterns[vi] val patNum = cue.patterns[vi]
if (patNum == 0xFFF) continue if (patNum == 0xFFF) continue
val patIdx = patNum.coerceIn(0, 4095) val patIdx = patNum.coerceIn(0, 4095)
val row = playdata[patIdx][ts.rowIndex] val rawRow = playdata[patIdx][ts.rowIndex]
val voice = ts.voices[vi] val voice = ts.voices[vi]
// ── Pattern Ditto (effect 7) row-time expansion ──
// See TAUD_NOTE_EFFECTS.md §7. Arm the destination range when this row
// carries a 7-opcode with a valid argument; then, if the current row
// sits inside an active destination block, synthesise an effective cell
// that combines the source-block cell with any explicit fields the
// composer punched into the destination row.
val n = ts.rowIndex
val isArmer = (rawRow.effect == EffectOp.OP_7 && rawRow.effectArg != 0)
if (isArmer) {
val length = (rawRow.effectArg ushr 8) and 0xFF
val repeats = rawRow.effectArg and 0xFF
if (length > 0 && repeats > 0 && length <= n) {
val patLen = (cue.instruction as? PlayInstPatLen)?.rows ?: 64
voice.dittoSourceStart = n - length
voice.dittoLength = length
voice.dittoEndRow = minOf(n + length * repeats - 1, patLen - 1)
voice.dittoActive = true
}
// else: malformed — leave any previously-armed ditto state alone.
}
val dittoArmRow = voice.dittoSourceStart + voice.dittoLength
val row: TaudPlayData =
if (voice.dittoActive && n in dittoArmRow..voice.dittoEndRow) {
val rel = (n - voice.dittoSourceStart) % voice.dittoLength
val srcRow = voice.dittoSourceStart + rel
val src = playdata[patIdx][srcRow]
// Vol- / pan-column "no-op" sentinel is SEL_FINE (3) with value 0.
val volIsSet = !(rawRow.volumeEff == 3 && rawRow.volume == 0)
val panIsSet = !(rawRow.panEff == 3 && rawRow.pan == 0)
// On the armer row, the 7-opcode is consumed by the marker, so
// for effect-column patching purposes the destination is treated
// as empty. Source 7-opcodes never propagate (no recursive
// expansion).
val destOp = if (isArmer) 0 else rawRow.effect
val destArg = if (isArmer) 0 else rawRow.effectArg
val effOp: Int
val effArg: Int
when {
destOp != 0 -> { effOp = destOp; effArg = destArg }
src.effect != EffectOp.OP_7 -> { effOp = src.effect; effArg = src.effectArg }
else -> { effOp = 0; effArg = 0 }
}
TaudPlayData(
note = if (rawRow.note != 0xFFFF) rawRow.note else src.note,
instrment = if (rawRow.instrment != 0) rawRow.instrment else src.instrment,
volume = if (volIsSet) rawRow.volume else src.volume,
volumeEff = if (volIsSet) rawRow.volumeEff else src.volumeEff,
pan = if (panIsSet) rawRow.pan else src.pan,
panEff = if (panIsSet) rawRow.panEff else src.panEff,
effect = effOp,
effectArg = effArg,
)
} else {
rawRow
}
// Reset per-row transient state. // Reset per-row transient state.
voice.cutAtTick = -1 voice.cutAtTick = -1
voice.noteDelayTick = -1 voice.noteDelayTick = -1
@@ -2347,6 +2408,16 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
private fun applyEffectRow(ts: TrackerState, playhead: Playhead, voice: Voice, vi: Int, op: Int, rawArg: Int) { private fun applyEffectRow(ts: TrackerState, playhead: Playhead, voice: Voice, vi: Int, op: Int, rawArg: Int) {
when (op) { when (op) {
EffectOp.OP_NONE -> {} EffectOp.OP_NONE -> {}
EffectOp.OP_7 -> {
// 7 $xxyy — Pattern Ditto. See TAUD_NOTE_EFFECTS.md §7.
// The opcode is a marker only; the row-time expansion in
// [applyTrackerRow] consumes the armer cell and substitutes the
// effective row from the source block, so by the time dispatch
// reaches here either (a) the cell was an armer and we already
// overwrote the synthesised row's effect to 0 / source effect,
// or (b) we hit a malformed 7-cell (length == 0 or repeats == 0
// or length > N) — both cases are no-ops at dispatch time.
}
EffectOp.OP_1 -> { EffectOp.OP_1 -> {
// 1 $xx00 — Global behaviour flags byte in the high byte (see TAUD_NOTE_EFFECTS.md §1). // 1 $xx00 — Global behaviour flags byte in the high byte (see TAUD_NOTE_EFFECTS.md §1).
// bits 0-1 (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),
@@ -3054,11 +3125,18 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
playhead.position = ts.cuePos playhead.position = ts.cuePos
} }
// Per TAUD_NOTE_EFFECTS.md §S$Bx00: on pattern change reset loop_start_row and loop_count. // Per-pattern voice state reset, called on every cue advance (B / C / natural end).
// - S$Bx pattern-loop counters (TAUD_NOTE_EFFECTS.md §S$Bx00).
// - Pattern-ditto (effect 7) destination range — the source block lives in the
// pattern we are leaving and must not bleed into the next one (§7).
private fun resetPatternLoopState(ts: TrackerState) { private fun resetPatternLoopState(ts: TrackerState) {
for (voice in ts.voices) { for (voice in ts.voices) {
voice.loopStartRow = 0 voice.loopStartRow = 0
voice.loopCount = 0 voice.loopCount = 0
voice.dittoActive = false
voice.dittoSourceStart = 0
voice.dittoLength = 0
voice.dittoEndRow = 0
} }
} }
@@ -3599,6 +3677,17 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var loopStartRow = 0 var loopStartRow = 0
var loopCount = 0 var loopCount = 0
// Pattern ditto (effect 7) — per-channel state. See TAUD_NOTE_EFFECTS.md §7.
// dittoActive is the master gate; while true, rows in
// [dittoSourceStart + dittoLength .. dittoEndRow] are expanded by copying
// the cells from the source block (dittoSourceStart .. dittoSourceStart +
// dittoLength 1) and patching in any non-empty fields from the raw
// destination cell. All four reset on cue advance (B / C / natural end).
var dittoActive = false
var dittoSourceStart = 0
var dittoLength = 0
var dittoEndRow = 0
// Tempo slide (T $00xy) — per-channel because T is a per-channel effect, but we apply globally via playhead. // Tempo slide (T $00xy) — per-channel because T is a per-channel effect, but we apply globally via playhead.
var tempoSlideDir = 0 // 0 = none, -1 = down, +1 = up var tempoSlideDir = 0 // 0 = none, -1 = down, +1 = up
var tempoSlideAmount = 0 var tempoSlideAmount = 0
@@ -3841,6 +3930,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
it.glissandoOn = false it.glissandoOn = false
it.loopStartRow = 0 it.loopStartRow = 0
it.loopCount = 0 it.loopCount = 0
it.dittoActive = false
it.dittoSourceStart = 0
it.dittoLength = 0
it.dittoEndRow = 0
it.funkSpeed = 0 it.funkSpeed = 0
it.funkAccumulator = 0 it.funkAccumulator = 0
it.funkWritePos = 0 it.funkWritePos = 0