mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
taud: pattern ditto eff
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user