mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-17 18:04:04 +09:00
Compare commits
5 Commits
94e3ce55ce
...
89d3c5d776
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89d3c5d776 | ||
|
|
517d0ad9a7 | ||
|
|
9524bf36e0 | ||
|
|
8e17256224 | ||
|
|
ac409bf961 |
@@ -561,32 +561,106 @@ Peak at maximum settings: $7F × $FF >> 9 = $3F — the full panning range. Retr
|
|||||||
|
|
||||||
## 8 $xyzz — Bitcrusher
|
## 8 $xyzz — Bitcrusher
|
||||||
|
|
||||||
**Plain.** Applies Bitcrusher to the current voice.
|
**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.
|
||||||
|
|
||||||
- x: clipping mode. 0: clamp, 1: fold, 2: wrap
|
- **x — clipping mode** (shared with effect 9): `0` clamp (hard limit at ±1.0), `1` fold (ping-pong around ±1.0; values outside the range mirror back symmetrically), `2` wrap (saw-tooth wrap mod 2; ±1 are fixed points so no DC step at the boundary). Values 3..F are reserved and treated as clamp.
|
||||||
- y: bit depth (1..15). 8..15 has no effect on TSVM audio adapter (already operates on 8 bits)
|
- **y — bit depth**, range $1..$F. `0` disables the quantiser stage. `1` reduces the voice to a 1-bit (sign-only) signal. `8..F` are accepted but produce no audible quantisation, since TSVM's mix bus is already 8-bit; they are reserved for future hardware revisions.
|
||||||
- z: sample skip (0..255). 0: no skip, 1: use every 2nd samples, 2: use every 3rd samples, ..., 255: use every 256th samples
|
- **zz — sample skip**, range $00..$FF. `0` disables skip; non-zero N holds the post-quantiser output for N additional output samples (i.e. emit one fresh sample every N+1). The held value is the bitcrusher's *output*, so the sample-and-hold is downstream of the quantiser and the shared clipper.
|
||||||
- `8 0000` will disable the bitcrusher
|
- `8 $0000` disables both stages and resets the shared clipping mode to clamp.
|
||||||
- `8 x000` will modify the clipping mode shared effect symbol '9'
|
- `8 $x000` updates only the shared clipping mode and leaves the active depth/skip undisturbed — useful for switching between clamp/fold/wrap mid-pattern without retyping the whole argument. The same form on effect 9 has identical semantics.
|
||||||
|
|
||||||
**Compatibility.** Unique to Taud. No compatible equivalent exists.
|
**Compatibility.** Unique to Taud — no ST3/IT/PT equivalent. The effect has no memory: every cell that names effect 8 must spell out its full argument (apart from the `$x000` shorthand described above). `8 $1100` ⇒ 1-bit, no skip, fold-clipped — a useful sanity check pattern.
|
||||||
|
|
||||||
**Implementation.** TODO
|
**Implementation.** Per-voice state: `bitcrusherDepth` (0..15; 0 = quantiser off), `bitcrusherSkip` (0..255), `bitcrusherCounter` (mod skip+1), `bitcrusherHeld` (last emitted sample), and `clipMode` (0..2, shared with effect 9). On row parse:
|
||||||
|
|
||||||
|
```
|
||||||
|
on row parse (8 $xyzz):
|
||||||
|
voice.clipMode = x & 3
|
||||||
|
if arg == $0000:
|
||||||
|
voice.bitcrusherDepth = 0
|
||||||
|
voice.bitcrusherSkip = 0
|
||||||
|
voice.bitcrusherCounter = 0
|
||||||
|
else if y == 0 and zz == 0:
|
||||||
|
# x000 — clip-mode-only update; preserve depth/skip/counter
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
voice.bitcrusherDepth = y
|
||||||
|
voice.bitcrusherSkip = zz
|
||||||
|
voice.bitcrusherCounter = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
On every output sample, after `applyVoiceFilter` and *after* the overdrive stage of effect 9:
|
||||||
|
|
||||||
|
```
|
||||||
|
on output sample (per voice):
|
||||||
|
if voice.bitcrusherCounter == 0:
|
||||||
|
s' = sample # post-overdrive input
|
||||||
|
if 1 ≤ voice.bitcrusherDepth ≤ 7:
|
||||||
|
s' = clip(s', voice.clipMode) # ensure in-range before quantising
|
||||||
|
levels = (1 << voice.bitcrusherDepth) - 1
|
||||||
|
q = round((s' + 1) × 0.5 × levels) # nearest integer; clamp to [0, levels]
|
||||||
|
s' = (q / levels) × 2 - 1
|
||||||
|
voice.bitcrusherHeld = s'
|
||||||
|
out = s'
|
||||||
|
else:
|
||||||
|
out = voice.bitcrusherHeld
|
||||||
|
if voice.bitcrusherSkip > 0:
|
||||||
|
voice.bitcrusherCounter = (voice.bitcrusherCounter + 1) mod (voice.bitcrusherSkip + 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
The clipper is shared between effects 8 and 9 and is implemented as a single helper:
|
||||||
|
|
||||||
|
```
|
||||||
|
clip(x, mode):
|
||||||
|
if mode == 1: # fold (triangle)
|
||||||
|
while x > +1: x = 2 - x
|
||||||
|
while x < -1: x = -2 - x
|
||||||
|
return x
|
||||||
|
if mode == 2: # wrap (saw, period 2)
|
||||||
|
v = ((x + 1) mod 2 + 2) mod 2
|
||||||
|
return v - 1
|
||||||
|
return clamp(x, -1, +1) # mode 0 (and reserved values)
|
||||||
|
```
|
||||||
|
|
||||||
|
The voice-FX state is preserved verbatim by the NNA-ghost copier, so the post-NNA tail of a note keeps the same timbre as the foreground voice that spawned it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9 $x0zz — Overdrive
|
## 9 $x0zz — Overdrive
|
||||||
|
|
||||||
**Plain.** Amplify the volume.
|
**Plain.** Amplifies the voice's post-filter signal and routes it through the shared clipper. With `x = 0` (clamp) the effect is a hard-knee soft-clipping distortion; with `x = 1` (fold) it becomes a wave-folder; with `x = 2` (wrap) it produces aggressive aliased fuzz with sawtooth-style discontinuities at the rails. Volume is *not* re-normalised after clipping — `9 $00FF` clamp-clipped plays at roughly the same loudness as the dry voice once everything saturates. The middle nibble is reserved and must be zero.
|
||||||
|
|
||||||
- x: clipping mode. 0: clamp, 1: fold, 2: wrap
|
- **x — clipping mode** (shared with effect 8): `0` clamp, `1` fold, `2` wrap (see effect 8 for the precise transfer functions). Values 3..F are reserved and treated as clamp.
|
||||||
- z: amplification. $00: 1x amplification (no extra volume), $01: 17/16 amplification, $02: 18/16 amplification, $10: 2x amplification (+ 6 dBFS), $F0: 16x amplification, $FF: 16.9375x amplification
|
- **zz — amplification index**, range $00..$FF. The applied gain is `(16 + zz) / 16`, so `$00` is 1.0× (effect inactive), `$10` is 2.0× (+6 dBFS), `$F0` is 16.0× (+24 dBFS), and `$FF` is 16.9375× (≈ +24.55 dBFS).
|
||||||
- `9 0000` will reset the overdrive
|
- `9 $0000` resets the overdrive (gain returns to unity, the stage stops processing) **and** resets the shared clipping mode to clamp.
|
||||||
- `9 x000` will modify the clipping mode shared with effect symbol '9'
|
- `9 $x000` updates only the shared clipping mode and leaves the active amplification undisturbed — symmetric with `8 $x000`.
|
||||||
|
|
||||||
**Compatibility.** Unique to Taud. No compatible equivalent exists.
|
**Compatibility.** Unique to Taud — no ST3/IT/PT equivalent. The effect has no memory.
|
||||||
|
|
||||||
**Implementation.** TODO
|
**Implementation.** Per-voice state: `overdriveAmp` (0..255; 0 = effect off) and `clipMode` (shared with effect 8). On row parse:
|
||||||
|
|
||||||
|
```
|
||||||
|
on row parse (9 $x0zz):
|
||||||
|
voice.clipMode = x & 3
|
||||||
|
if arg == $0000:
|
||||||
|
voice.overdriveAmp = 0
|
||||||
|
else if zz == 0:
|
||||||
|
# x000 — clip-mode-only update; preserve amp
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
voice.overdriveAmp = zz
|
||||||
|
```
|
||||||
|
|
||||||
|
On every output sample, after `applyVoiceFilter` and *before* the bitcrusher stage of effect 8:
|
||||||
|
|
||||||
|
```
|
||||||
|
on output sample (per voice):
|
||||||
|
if voice.overdriveAmp > 0:
|
||||||
|
sample = sample × (16 + voice.overdriveAmp) / 16
|
||||||
|
sample = clip(sample, voice.clipMode)
|
||||||
|
```
|
||||||
|
|
||||||
|
When both effects 8 and 9 are active on the same voice the chain is **filter → overdrive (×gain → clip) → bitcrusher (bit-depth quantise → sample-skip hold)**. Because the clipper is shared, changing `clipMode` from either effect propagates to the other on the next sample — there is one mode per voice, not one per stage.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ const fxNames = {
|
|||||||
'6':"UNIMPLEMENTED",
|
'6':"UNIMPLEMENTED",
|
||||||
'7':"UNIMPLEMENTED",
|
'7':"UNIMPLEMENTED",
|
||||||
'8':"Bitcrusher ",
|
'8':"Bitcrusher ",
|
||||||
'9':"UNIMPLEMENTED",
|
'9':"Overdrive ",
|
||||||
A:"Tick speed ",
|
A:"Tick speed ",
|
||||||
B:"Jump to order",
|
B:"Jump to order",
|
||||||
C:"Break pattern",
|
C:"Break pattern",
|
||||||
@@ -116,17 +116,17 @@ P:"UNIMPLEMENTED", // IT: panning slide. Use PanEff instead
|
|||||||
Q:"Retrigger ",
|
Q:"Retrigger ",
|
||||||
R:"Tremolo ",
|
R:"Tremolo ",
|
||||||
S:"Special ",
|
S:"Special ",
|
||||||
S0:"UNIMPLEMENTED", // PT: Set audio filter.
|
S0:"UNIMPLEMENTED", // PT: Set audio filter
|
||||||
S1:"Gliss. ctrl ",
|
S1:"Gliss. ctrl ",
|
||||||
S2:"Sample tune ",
|
S2:"Sample tune ",
|
||||||
S3:"Vibrato LFO ",
|
S3:"Vibrato LFO ",
|
||||||
S4:"Tremolo LFO ",
|
S4:"Tremolo LFO ",
|
||||||
S5:"Panbrello LFO",
|
S5:"Panbrello LFO",
|
||||||
S6:"UNIMPLEMENTED", // IT: Fine pattern delay.
|
S6:"Fine delay ",
|
||||||
S7:"UNIMPLEMENTED", // IT: misc. functions
|
S7:"Note action ",
|
||||||
S8:"Channel pan ", // Taud: 8-bit channel panning.
|
S8:"Channel pan ", // Taud: 8-bit channel panning
|
||||||
S9:"UNIMPLEMENTED", // IT: Sound control.
|
S9:"UNIMPLEMENTED", // IT: Sound control
|
||||||
SA:"UNIMPLEMENTED", // SC3: Stereo control. IT: Sample offset high twobyte.
|
SA:"UNIMPLEMENTED", // SC3: Stereo control. IT: Sample offset high twobyte (not applicable because Taud has 64k limit)
|
||||||
SB:"Pattern loop ",
|
SB:"Pattern loop ",
|
||||||
SC:"Note cut ",
|
SC:"Note cut ",
|
||||||
SD:"Note delay ",
|
SD:"Note delay ",
|
||||||
@@ -135,10 +135,10 @@ SF:"Funk it ",
|
|||||||
T:"Tempo ",
|
T:"Tempo ",
|
||||||
U:"Fine vibrato ",
|
U:"Fine vibrato ",
|
||||||
V:"Global volume",
|
V:"Global volume",
|
||||||
W:"UNIMPLEMENTED", // IT: Global volume slide.
|
W:"G.Vol Slide ",
|
||||||
X:"UNIMPLEMENTED", // IT: 8-bit channel panning. Use PanEff or S80xx instead
|
X:"UNIMPLEMENTED", // IT: 8-bit channel panning. Use S80xx instead
|
||||||
Y:"Panbrello ",
|
Y:"Panbrello ",
|
||||||
Z:"UNIMPLEMENTED", // IT: MIDI macro.
|
Z:"UNIMPLEMENTED", // IT: MIDI macro
|
||||||
}
|
}
|
||||||
const panFxNames = {
|
const panFxNames = {
|
||||||
0:"Set to",
|
0:"Set to",
|
||||||
@@ -1052,54 +1052,111 @@ function drawVoiceDetail(isVerticalLayout = false, ptn = null, activeRow = -1, c
|
|||||||
if (voleff == 0xC0) { voleffop1 = 999; voleffarg1 = '' }
|
if (voleff == 0xC0) { voleffop1 = 999; voleffarg1 = '' }
|
||||||
if (paneff == 0xC0) { paneffop1 = 999; paneffarg1 = '' }
|
if (paneff == 0xC0) { paneffop1 = 999; paneffarg1 = '' }
|
||||||
|
|
||||||
const lines = []
|
// Two-column, two-section layout. Upper section: this row's cell fields,
|
||||||
lines.push({ label: 'Note ', value: `${noteToStr(note)} ($${note.hex04()})`, fg: colNote })
|
// split L (Note/Inst/Vx/Px) / R (Fx/FxOp/FxArg). Lower section: cumulative
|
||||||
lines.push({ label: 'Inst ', value: inst === 0 ? '---' : ('$'+inst.hex02()), fg: colInst })
|
// engine state, packed in column-major order across both columns.
|
||||||
lines.push({ label: 'Vx ', value: `${volFxNames[voleffop1]} ${voleffarg1}`, fg: colVol })
|
const colW = Math.floor(detailW / 2)
|
||||||
lines.push({ label: 'Px ', value: `${panFxNames[paneffop1]} ${paneffarg1}`, fg: colPan })
|
const col1X = dx
|
||||||
lines.push({ label: 'Fx ', value: fxName.trimEnd(), fg: colEffOp })
|
const col2X = dx + colW
|
||||||
lines.push({ label: 'FxOp ', value: fx, fg: colEffOp })
|
const labelW = 6
|
||||||
lines.push({ label: 'FxArg', value: `$${effarg.hex04()}`, fg: colEffArg })
|
const valW1 = colW - labelW - 2
|
||||||
|
const valW2 = (detailW - colW) - labelW - 2
|
||||||
|
|
||||||
if (cumState !== null) {
|
const drawLine = (y, x, line, valWidth) => {
|
||||||
lines.push({ label: '------', value: '', fg: colSep })
|
con.move(y, x)
|
||||||
lines.push({ label: 'L.Note', value: noteToStr(cumState.lastNote), fg: colNote })
|
con.color_pair(colStatus, 255)
|
||||||
lines.push({ label: 'L.Inst', value: cumState.lastInst === 0 ? '---' : ('$'+cumState.lastInst.hex02()), fg: colInst })
|
print((line.label + ' ').substring(0, labelW) + ' ')
|
||||||
lines.push({ label: 'Vol ', value: `$${cumState.volAbs.hex02()}`, fg: colVol })
|
con.color_pair(line.fg, 255)
|
||||||
lines.push({ label: 'Pan ', value: `$${cumState.panAbs.hex02()}`, fg: colPan })
|
const v = (line.value + ' '.repeat(valWidth + 1))
|
||||||
|
print(v.substring(0, valWidth + 1))
|
||||||
|
}
|
||||||
|
const blankLine = (y, x, width) => {
|
||||||
|
con.move(y, x)
|
||||||
|
con.color_pair(colBackPtn, 255)
|
||||||
|
print(' '.repeat(width))
|
||||||
|
}
|
||||||
|
|
||||||
|
const upperLeft = [
|
||||||
|
{ label: 'Note ', value: `${noteToStr(note)} ($${note.hex04()})`, fg: colNote },
|
||||||
|
{ label: 'Inst ', value: inst === 0 ? '---' : ('$'+inst.hex02()), fg: colInst },
|
||||||
|
{ label: 'Vx ', value: `${volFxNames[voleffop1]} ${voleffarg1}`, fg: colVol },
|
||||||
|
{ label: 'Px ', value: `${panFxNames[paneffop1]} ${paneffarg1}`, fg: colPan },
|
||||||
|
]
|
||||||
|
const upperRight = [
|
||||||
|
{ label: 'Fx ', value: fxName.trimEnd(), fg: colEffOp },
|
||||||
|
{ label: 'FxOp ', value: fx, fg: colEffOp },
|
||||||
|
{ label: 'FxArg', value: `$${effarg.hex04()}`, fg: colEffArg },
|
||||||
|
]
|
||||||
|
const upperHeight = Math.max(upperLeft.length, upperRight.length)
|
||||||
|
|
||||||
|
for (let i = 0; i < upperHeight; i++) {
|
||||||
|
const y = PTNVIEW_OFFSET_Y + i
|
||||||
|
if (i < upperLeft.length) drawLine(y, col1X, upperLeft[i], valW1)
|
||||||
|
else blankLine(y, col1X, colW)
|
||||||
|
if (i < upperRight.length) drawLine(y, col2X, upperRight[i], valW2)
|
||||||
|
else blankLine(y, col2X, detailW - colW)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section divider
|
||||||
|
const sepY = PTNVIEW_OFFSET_Y + upperHeight
|
||||||
|
con.move(sepY, dx)
|
||||||
|
con.color_pair(colSep, 255)
|
||||||
|
print('\u00C4'.repeat(detailW))
|
||||||
|
|
||||||
|
// Lower section: cumulative state.
|
||||||
|
const lowerY0 = sepY + 1
|
||||||
|
const lowerH = PTNVIEW_HEIGHT - upperHeight - 1
|
||||||
|
let cumLines = []
|
||||||
|
if (cumState !== null && lowerH > 0) {
|
||||||
const _apo = Math.abs(cumState.pitchOff)
|
const _apo = Math.abs(cumState.pitchOff)
|
||||||
const _psgn = cumState.pitchOff > 0 ? '+' : cumState.pitchOff < 0 ? '-' : '='
|
const _psgn = cumState.pitchOff > 0 ? '+' : cumState.pitchOff < 0 ? '-' : '='
|
||||||
const _absN = (cumState.lastNote !== 0xFFFF && cumState.pitchOff !== 0)
|
const _absN = (cumState.lastNote !== 0xFFFF && cumState.pitchOff !== 0)
|
||||||
? noteToStr(Math.max(0, Math.min(0xFFFE, cumState.lastNote + cumState.pitchOff))) + ' '
|
? noteToStr(Math.max(0, Math.min(0xFFFE, cumState.lastNote + cumState.pitchOff))) + ' '
|
||||||
: ''
|
: ''
|
||||||
lines.push({ label: 'Pitch ', value: `${_absN}(${_psgn}$${_apo.hex04()})`, fg: colNote })
|
const _clipNm = ['clamp','fold','wrap','wrap'][cumState.clipMode]
|
||||||
lines.push({ label: `E${MIDDOT}F `, value: `$${cumState.memEF.hex04()}`, fg: colEffArg })
|
const _bcStr = (cumState.bitcrushDepth === 0 && cumState.bitcrushSkip === 0)
|
||||||
lines.push({ label: 'G ', value: `$${cumState.memG.hex04()}`, fg: colEffArg })
|
? 'off'
|
||||||
lines.push({ label: `H${MIDDOT}U `, value: `$${cumState.memHU.speed.hex02()}/$${cumState.memHU.depth.hex02()}`, fg: colEffArg })
|
: `d${cumState.bitcrushDepth.toString(16).toUpperCase()}/s$${cumState.bitcrushSkip.hex02()}`
|
||||||
lines.push({ label: 'R ', value: `$${cumState.memR.speed.hex02()}/$${cumState.memR.depth.hex02()}`, fg: colEffArg })
|
const _odStr = (cumState.overdriveAmp === 0) ? 'off' : `$${cumState.overdriveAmp.hex02()}`
|
||||||
lines.push({ label: 'Y ', value: `$${cumState.memY.speed.hex02()}/$${cumState.memY.depth.hex02()}`, fg: colEffArg })
|
|
||||||
lines.push({ label: 'D ', value: `$${cumState.memD.hex04()}`, fg: colEffArg })
|
cumLines = [
|
||||||
lines.push({ label: 'I ', value: `$${cumState.memI.hex04()}`, fg: colEffArg })
|
{ label: 'L.Note', value: noteToStr(cumState.lastNote), fg: colNote },
|
||||||
lines.push({ label: 'J ', value: `$${cumState.memJ.hex04()}`, fg: colEffArg })
|
{ label: 'L.Inst', value: cumState.lastInst === 0 ? '---' : ('$'+cumState.lastInst.hex02()), fg: colInst },
|
||||||
lines.push({ label: 'O ', value: `$${cumState.memO.hex04()}`, fg: colEffArg })
|
{ label: 'Vol ', value: `$${cumState.volAbs.hex02()}`, fg: colVol },
|
||||||
lines.push({ label: 'Q ', value: `$${cumState.memQ.hex04()}`, fg: colEffArg })
|
{ label: 'Pan ', value: `$${cumState.panAbs.hex02()}`, fg: colPan },
|
||||||
lines.push({ label: 'Tslid ', value: `$${cumState.memTSlide.hex02()}`, fg: colEffArg })
|
{ label: 'Pitch ', value: `${_absN}(${_psgn}$${_apo.hex04()})`, fg: colNote },
|
||||||
|
{ label: 'BPM ', value: `${cumState.bpm}`, fg: colStatus },
|
||||||
|
{ label: 'Spd ', value: `${cumState.speed}`, fg: colStatus },
|
||||||
|
{ label: 'GVol ', value: `$${cumState.globalVol.hex02()}`, fg: colStatus },
|
||||||
|
{ label: `E${MIDDOT}F `, value: `$${cumState.memEF.hex04()}`, fg: colEffArg },
|
||||||
|
{ label: 'G ', value: `$${cumState.memG.hex04()}`, fg: colEffArg },
|
||||||
|
{ label: `H${MIDDOT}U `, value: `$${cumState.memHU.speed.hex02()}/$${cumState.memHU.depth.hex02()}`, fg: colEffArg },
|
||||||
|
{ label: 'R ', value: `$${cumState.memR.speed.hex02()}/$${cumState.memR.depth.hex02()}`, fg: colEffArg },
|
||||||
|
{ label: 'Y ', value: `$${cumState.memY.speed.hex02()}/$${cumState.memY.depth.hex02()}`, fg: colEffArg },
|
||||||
|
{ label: 'D ', value: `$${cumState.memD.hex04()}`, fg: colEffArg },
|
||||||
|
{ label: 'I ', value: `$${cumState.memI.hex04()}`, fg: colEffArg },
|
||||||
|
{ label: 'J ', value: `$${cumState.memJ.hex04()}`, fg: colEffArg },
|
||||||
|
{ label: 'O ', value: `$${cumState.memO.hex04()}`, fg: colEffArg },
|
||||||
|
{ label: 'Q ', value: `$${cumState.memQ.hex04()}`, fg: colEffArg },
|
||||||
|
{ label: 'Tslid ', value: `$${cumState.memTSlide.hex02()}`, fg: colEffArg },
|
||||||
|
{ label: 'W ', value: `$${cumState.memW.hex04()}`, fg: colEffArg },
|
||||||
|
{ label: 'BCrsh ', value: _bcStr, fg: colEffArg },
|
||||||
|
{ label: 'OvDrv ', value: _odStr, fg: colEffArg },
|
||||||
|
{ label: 'Clip ', value: _clipNm, fg: colEffArg },
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const showCount = Math.min(lines.length, PTNVIEW_HEIGHT)
|
// Column-major fill: cap per-column height to lowerH, drop overflow.
|
||||||
for (let i = 0; i < showCount; i++) {
|
const perCol = Math.min(lowerH, Math.ceil(cumLines.length / 2))
|
||||||
const y = PTNVIEW_OFFSET_Y + i
|
const totShow = Math.min(cumLines.length, perCol * 2)
|
||||||
const line = lines[i]
|
for (let i = 0; i < perCol; i++) {
|
||||||
con.move(y, dx)
|
const yL = lowerY0 + i
|
||||||
con.color_pair(colStatus, 255)
|
const idxL = i
|
||||||
print((line.label + ' ').substring(0, 6) + ' ')
|
const idxR = perCol + i
|
||||||
con.color_pair(line.fg, 255)
|
if (idxL < totShow) drawLine(yL, col1X, cumLines[idxL], valW1)
|
||||||
print((line.value + ' '.repeat(detailW)).substring(0, detailW - 8))
|
else blankLine(yL, col1X, colW)
|
||||||
}
|
if (idxR < totShow) drawLine(yL, col2X, cumLines[idxR], valW2)
|
||||||
for (let i = showCount; i < PTNVIEW_HEIGHT; i++) {
|
else blankLine(yL, col2X, detailW - colW)
|
||||||
con.move(PTNVIEW_OFFSET_Y + i, dx)
|
|
||||||
con.color_pair(colBackPtn, 255)
|
|
||||||
print(' '.repeat(detailW))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1574,24 +1631,50 @@ function getActiveRowForDetail() {
|
|||||||
return (playbackMode !== PLAYMODE_NONE) ? pbRow : patternGridRow
|
return (playbackMode !== PLAYMODE_NONE) ? pbRow : patternGridRow
|
||||||
}
|
}
|
||||||
|
|
||||||
// Walk pattern rows 0..uptoRow and accumulate effect-memory cohort state
|
// Walk pattern rows 0..uptoRow and accumulate engine-visible cohort state.
|
||||||
|
// Mirrors AudioAdapter.kt applyTrackerRow / applyEffectRow / applySEffect for the
|
||||||
|
// state surfaced in the voice-detail panel. Out of scope: B/C control flow,
|
||||||
|
// SEx pattern delay, SBx pattern loop, NNA / past-note actions, envelope toggles.
|
||||||
function simulateRowState(ptnDat, uptoRow) {
|
function simulateRowState(ptnDat, uptoRow) {
|
||||||
const OP_A = 10
|
const OP_1 = 1, OP_8 = 8, OP_9 = 9, OP_A = 10
|
||||||
const OP_D = 13, OP_E = 14, OP_F = 15, OP_G = 16
|
const OP_D = 13, OP_E = 14, OP_F = 15, OP_G = 16
|
||||||
const OP_H = 17, OP_I = 18, OP_J = 19, OP_O = 24
|
const OP_H = 17, OP_I = 18, OP_J = 19, OP_O = 24
|
||||||
const OP_Q = 26, OP_R = 27, OP_T = 29, OP_U = 30, OP_Y = 34
|
const OP_Q = 26, OP_R = 27, OP_S = 28, OP_T = 29
|
||||||
|
const OP_U = 30, OP_V = 31, OP_W = 32, OP_Y = 34
|
||||||
|
|
||||||
|
// ST3-style finetune offsets, mirrors AudioAdapter.kt FINETUNE_OFFSET
|
||||||
|
const FINETUNE_OFFSET = [
|
||||||
|
-0x0154, -0x0132, -0x0111, -0x00E4, -0x00B8, -0x008B, -0x005D, -0x003B,
|
||||||
|
0x0000, 0x0023, 0x0046, 0x0074, 0x0098, 0x00C8, 0x00F9, 0x0110
|
||||||
|
]
|
||||||
|
|
||||||
let lastNote = 0xFFFF, lastInst = 0
|
let lastNote = 0xFFFF, lastInst = 0
|
||||||
let volAbs = 0x3F, panAbs = 0x20
|
let volAbs = 0x3F // 6-bit channel volume
|
||||||
|
let panAbs = 0x80 // 8-bit channel pan (engine width); centre = $80
|
||||||
let pitchOff = 0, portaTarget = -1
|
let pitchOff = 0, portaTarget = -1
|
||||||
let speed = audio.getTickRate(PLAYHEAD) // not always going to be correct but it should be mostly
|
let bpm = audio.getBPM(PLAYHEAD) // best-effort starting tempo
|
||||||
|
let speed = audio.getTickRate(PLAYHEAD)
|
||||||
|
let globalVol = 0xFF
|
||||||
|
let panLaw = 0, amigaMode = false, fadeoutCutOnZero = false
|
||||||
|
|
||||||
let memEF = 0, memG = 0
|
let memEF = 0, memG = 0
|
||||||
let memHU = { speed: 0, depth: 0 }
|
let memHU = { speed: 0, depth: 0 }
|
||||||
let memR = { speed: 0, depth: 0 }
|
let memR = { speed: 0, depth: 0 }
|
||||||
let memY = { speed: 0, depth: 0 }
|
let memY = { speed: 0, depth: 0 }
|
||||||
let memD = 0, memI = 0, memJ = 0, memO = 0, memQ = 0, memTSlide = 0
|
let memD = 0, memI = 0, memJ = 0, memO = 0, memQ = 0, memTSlide = 0, memW = 0
|
||||||
|
|
||||||
|
// Bitcrusher / overdrive (clipMode shared between OP_8 and OP_9)
|
||||||
|
let bitcrushDepth = 0, bitcrushSkip = 0
|
||||||
|
let overdriveAmp = 0
|
||||||
|
let clipMode = 0
|
||||||
|
|
||||||
|
// S-effect state
|
||||||
|
let glissandoOn = false
|
||||||
|
let vibratoWave = 0, tremoloWave = 0, panbrelloWave = 0
|
||||||
|
|
||||||
const clampV = v => Math.max(0, Math.min(0x3F, v | 0))
|
const clampV = v => Math.max(0, Math.min(0x3F, v | 0))
|
||||||
|
const clampP = v => Math.max(0, Math.min(0xFF, v | 0))
|
||||||
|
const clampG = v => Math.max(0, Math.min(0xFF, v | 0))
|
||||||
|
|
||||||
const limit = Math.min(uptoRow, ROWS_PER_PAT - 1)
|
const limit = Math.min(uptoRow, ROWS_PER_PAT - 1)
|
||||||
for (let row = 0; row <= limit; row++) {
|
for (let row = 0; row <= limit; row++) {
|
||||||
@@ -1603,53 +1686,88 @@ function simulateRowState(ptnDat, uptoRow) {
|
|||||||
const effop = ptnDat[off+5]
|
const effop = ptnDat[off+5]
|
||||||
const effarg = ptnDat[off+6] | (ptnDat[off+7] << 8)
|
const effarg = ptnDat[off+6] | (ptnDat[off+7] << 8)
|
||||||
|
|
||||||
// Notes on a portamento row (G) become the slide target; they don't retrigger
|
// Note column
|
||||||
const isGRow = (effop === OP_G)
|
const isGRow = (effop === OP_G)
|
||||||
|
const isNoteDelay = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0xD)
|
||||||
if (note !== 0xFFFF && note !== 0xFFFE) {
|
if (note !== 0xFFFF && note !== 0xFFFE) {
|
||||||
if (!isGRow) {
|
if (note === 0x0000) {
|
||||||
|
// key-off; sample stays referenced
|
||||||
|
} else if (isGRow) {
|
||||||
|
portaTarget = note
|
||||||
|
} else if (isNoteDelay) {
|
||||||
|
// Delayed trigger: latched but doesn't fire on this row's first tick.
|
||||||
|
// For "state at end of row" treat as if it triggered.
|
||||||
lastNote = note
|
lastNote = note
|
||||||
pitchOff = 0
|
pitchOff = 0
|
||||||
portaTarget = -1
|
portaTarget = -1
|
||||||
} else {
|
} else {
|
||||||
portaTarget = note
|
lastNote = note
|
||||||
|
pitchOff = 0
|
||||||
|
portaTarget = -1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (inst !== 0) lastInst = inst
|
if (inst !== 0) lastInst = inst
|
||||||
|
|
||||||
// Volume column: set OR slide (0xC0 = 3.00 nop is the empty sentinel, not 0x00)
|
// Pre-scan effect column for S$80xx (8-bit pan SET wins over volcol/pancol SET).
|
||||||
const volop = (voleff >>> 6) & 3
|
const rowHasS80 = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0x8)
|
||||||
const volefarg = voleff & 63
|
|
||||||
|
// Volume column. voleff = (sel<<6) | value6. $C0 = sel 3 / value 0 = empty nop.
|
||||||
|
const volSel = (voleff >>> 6) & 3
|
||||||
|
const volVal = voleff & 63
|
||||||
if (voleff !== 0xC0) {
|
if (voleff !== 0xC0) {
|
||||||
if (volop === 0) {
|
if (volSel === 0) {
|
||||||
volAbs = volefarg
|
volAbs = volVal
|
||||||
} else if (volop === 1) {
|
} else if (volSel === 1) {
|
||||||
volAbs = clampV(volAbs + (volefarg & 15) * (speed - 1))
|
volAbs = clampV(volAbs + volVal * (speed - 1)) // engine: per non-first tick
|
||||||
} else if (volop === 2) {
|
} else if (volSel === 2) {
|
||||||
volAbs = clampV(volAbs - (volefarg & 15) * (speed - 1))
|
volAbs = clampV(volAbs - volVal * (speed - 1))
|
||||||
} else if (volop === 3 && volefarg !== 0) {
|
} else if (volSel === 3 && volVal !== 0) {
|
||||||
if (volefarg >= 32) volAbs = clampV(volAbs + (volefarg & 15)) // fine slide up
|
const mag = volVal & 0x1F
|
||||||
else volAbs = clampV(volAbs - (volefarg & 15)) // fine slide down
|
if ((volVal & 0x20) !== 0) volAbs = clampV(volAbs + mag) // fine up
|
||||||
|
else volAbs = clampV(volAbs - mag) // fine down
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pan column: set OR slide (0xC0 = 3.00 nop is the empty sentinel, not 0x00)
|
// Pan column. Same encoding as volume. Engine pan is 8-bit; SET expands 6→8 by replicating bits.
|
||||||
const panop = (paneff >>> 6) & 3
|
const panSel = (paneff >>> 6) & 3
|
||||||
const panefarg = paneff & 63
|
const panVal = paneff & 63
|
||||||
if (paneff !== 0xC0) {
|
if (paneff !== 0xC0) {
|
||||||
if (panop === 0) {
|
if (panSel === 0) {
|
||||||
panAbs = panefarg
|
if (!rowHasS80) panAbs = ((panVal << 2) | (panVal >>> 4)) & 0xFF
|
||||||
} else if (panop === 1) {
|
} else if (panSel === 1) {
|
||||||
panAbs = clampV(panAbs + (panefarg & 15) * (speed - 1))
|
panAbs = clampP(panAbs + panVal * (speed - 1))
|
||||||
} else if (panop === 2) {
|
} else if (panSel === 2) {
|
||||||
panAbs = clampV(panAbs - (panefarg & 15) * (speed - 1))
|
panAbs = clampP(panAbs - panVal * (speed - 1))
|
||||||
} else if (panop === 3 && panefarg !== 0) {
|
} else if (panSel === 3 && panVal !== 0) {
|
||||||
if (panefarg >= 32) panAbs = clampV(panAbs + (panefarg & 15))
|
const mag = panVal & 0x1F
|
||||||
else panAbs = clampV(panAbs - (panefarg & 15))
|
if ((panVal & 0x20) !== 0) panAbs = clampP(panAbs + mag)
|
||||||
|
else panAbs = clampP(panAbs - mag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (effop !== 0 || effarg !== 0) {
|
if (effop !== 0 || effarg !== 0) {
|
||||||
if (effop === OP_A) {
|
if (effop === OP_1) {
|
||||||
|
const flags = (effarg >>> 8) & 0xFF
|
||||||
|
panLaw = flags & 1
|
||||||
|
amigaMode = (flags & 2) !== 0
|
||||||
|
fadeoutCutOnZero = (flags & 4) !== 0
|
||||||
|
}
|
||||||
|
else if (effop === OP_8) {
|
||||||
|
const x = (effarg >>> 12) & 0xF
|
||||||
|
const y = (effarg >>> 8) & 0xF
|
||||||
|
const z = effarg & 0xFF
|
||||||
|
clipMode = x & 3
|
||||||
|
if (effarg === 0) { bitcrushDepth = 0; bitcrushSkip = 0 }
|
||||||
|
else if (y !== 0 || z !== 0) { bitcrushDepth = y; bitcrushSkip = z }
|
||||||
|
}
|
||||||
|
else if (effop === OP_9) {
|
||||||
|
const x = (effarg >>> 12) & 0xF
|
||||||
|
const z = effarg & 0xFF
|
||||||
|
clipMode = x & 3
|
||||||
|
if (effarg === 0) overdriveAmp = 0
|
||||||
|
else if (z !== 0) overdriveAmp = z
|
||||||
|
}
|
||||||
|
else if (effop === OP_A) {
|
||||||
if ((effarg >>> 8) !== 0) speed = (effarg >>> 8)
|
if ((effarg >>> 8) !== 0) speed = (effarg >>> 8)
|
||||||
}
|
}
|
||||||
else if (effop === OP_D) {
|
else if (effop === OP_D) {
|
||||||
@@ -1658,16 +1776,16 @@ function simulateRowState(ptnDat, uptoRow) {
|
|||||||
const hb = (raw >>> 8) & 0xFF
|
const hb = (raw >>> 8) & 0xFF
|
||||||
const hiNib = (hb >>> 4) & 0xF
|
const hiNib = (hb >>> 4) & 0xF
|
||||||
const loNib = hb & 0xF
|
const loNib = hb & 0xF
|
||||||
if (hiNib === 0xF) {
|
if (hb === 0xFF || hb === 0xF0) {
|
||||||
// $Fy00 fine slide down, but $F000/$FF00 → fine slide up by $F
|
volAbs = clampV(volAbs + 0xF) // $FF00 / $F000 quirk
|
||||||
if (hb === 0xFF || loNib === 0) volAbs = clampV(volAbs + 0xF)
|
} else if (hiNib === 0xF && loNib !== 0) {
|
||||||
else volAbs = clampV(volAbs - loNib)
|
volAbs = clampV(volAbs - loNib) // $Fy00 fine down
|
||||||
} else if (loNib === 0xF) {
|
} else if (loNib === 0xF && hiNib !== 0) {
|
||||||
volAbs = clampV(volAbs + hiNib) // $xF00 fine slide up
|
volAbs = clampV(volAbs + hiNib) // $xF00 fine up
|
||||||
} else if (hiNib === 0 && loNib !== 0) {
|
} else if (hiNib === 0 && loNib !== 0) {
|
||||||
volAbs = clampV(volAbs - loNib * (speed - 1)) // $0y00 coarse down
|
volAbs = clampV(volAbs - loNib * (speed - 1)) // $0y00 coarse down
|
||||||
} else if (hiNib !== 0 && loNib === 0) {
|
} else if (hiNib !== 0 && loNib === 0) {
|
||||||
volAbs = clampV(volAbs + hiNib * (speed - 1)) // $x000 coarse up
|
volAbs = clampV(volAbs + hiNib * (speed - 1)) // $x000 coarse up
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1698,27 +1816,83 @@ function simulateRowState(ptnDat, uptoRow) {
|
|||||||
}
|
}
|
||||||
else if (effop === OP_H || effop === OP_U) {
|
else if (effop === OP_H || effop === OP_U) {
|
||||||
const spd = (effarg >>> 8) & 0xFF; const dep = effarg & 0xFF
|
const spd = (effarg >>> 8) & 0xFF; const dep = effarg & 0xFF
|
||||||
if (spd !== 0) memHU.speed = spd; if (dep !== 0) memHU.depth = dep
|
if (spd !== 0) memHU.speed = spd
|
||||||
|
if (dep !== 0) memHU.depth = dep
|
||||||
}
|
}
|
||||||
else if (effop === OP_R) {
|
else if (effop === OP_R) {
|
||||||
const spd = (effarg >>> 8) & 0xFF; const dep = effarg & 0xFF
|
const spd = (effarg >>> 8) & 0xFF; const dep = effarg & 0xFF
|
||||||
if (spd !== 0) memR.speed = spd; if (dep !== 0) memR.depth = dep
|
if (spd !== 0) memR.speed = spd
|
||||||
|
if (dep !== 0) memR.depth = dep
|
||||||
}
|
}
|
||||||
else if (effop === OP_Y) {
|
else if (effop === OP_Y) {
|
||||||
const spd = (effarg >>> 8) & 0xFF; const dep = effarg & 0xFF
|
const spd = (effarg >>> 8) & 0xFF; const dep = effarg & 0xFF
|
||||||
if (spd !== 0) memY.speed = spd; if (dep !== 0) memY.depth = dep
|
if (spd !== 0) memY.speed = spd
|
||||||
|
if (dep !== 0) memY.depth = dep
|
||||||
}
|
}
|
||||||
else if (effop === OP_I) { if (effarg !== 0) memI = effarg }
|
else if (effop === OP_I) { if (effarg !== 0) memI = effarg }
|
||||||
else if (effop === OP_J) { if (effarg !== 0) memJ = effarg }
|
else if (effop === OP_J) { if (effarg !== 0) memJ = effarg }
|
||||||
else if (effop === OP_O) { if (effarg !== 0) memO = effarg }
|
else if (effop === OP_O) { if (effarg !== 0) memO = effarg }
|
||||||
else if (effop === OP_Q) { if (effarg !== 0) memQ = effarg }
|
else if (effop === OP_Q) { if (effarg !== 0) memQ = effarg }
|
||||||
else if (effop === OP_T) { if ((effarg >>> 8) === 0 && effarg !== 0) memTSlide = effarg }
|
else if (effop === OP_S) {
|
||||||
|
const sub = (effarg >>> 12) & 0xF
|
||||||
|
const x = (effarg >>> 8) & 0xF
|
||||||
|
if (sub === 0x1) {
|
||||||
|
glissandoOn = (x !== 0)
|
||||||
|
} else if (sub === 0x2) {
|
||||||
|
pitchOff += FINETUNE_OFFSET[x]
|
||||||
|
} else if (sub === 0x3) {
|
||||||
|
vibratoWave = x & 3
|
||||||
|
} else if (sub === 0x4) {
|
||||||
|
tremoloWave = x & 3
|
||||||
|
} else if (sub === 0x5) {
|
||||||
|
panbrelloWave = x & 3
|
||||||
|
} else if (sub === 0x8) {
|
||||||
|
panAbs = effarg & 0xFF // S$80xx full 8-bit pan SET
|
||||||
|
}
|
||||||
|
// 0x6/0x7/0xB/0xC/0xD/0xE/0xF — out of scope (control flow / per-tick / NNA).
|
||||||
|
}
|
||||||
|
else if (effop === OP_T) {
|
||||||
|
const hi = (effarg >>> 8) & 0xFF
|
||||||
|
if (hi !== 0) {
|
||||||
|
bpm = Math.max(24, Math.min(280, hi + 0x18))
|
||||||
|
} else {
|
||||||
|
const low = effarg & 0xFF
|
||||||
|
if ((low & 0xF0) === 0x00 || (low & 0xF0) === 0x10) memTSlide = low
|
||||||
|
// bpm slide accumulates per-tick in the engine; not modelled at row granularity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (effop === OP_V) {
|
||||||
|
globalVol = (effarg >>> 8) & 0xFF
|
||||||
|
}
|
||||||
|
else if (effop === OP_W) {
|
||||||
|
const raw = (effarg !== 0) ? (memW = effarg) : memW
|
||||||
|
if (raw !== 0) {
|
||||||
|
const hb = (raw >>> 8) & 0xFF
|
||||||
|
const hiNib = (hb >>> 4) & 0xF
|
||||||
|
const loNib = hb & 0xF
|
||||||
|
if (hb === 0xFF || hb === 0xF0) {
|
||||||
|
globalVol = clampG(globalVol + 0xF)
|
||||||
|
} else if (hiNib === 0xF && loNib !== 0) {
|
||||||
|
globalVol = clampG(globalVol - loNib)
|
||||||
|
} else if (loNib === 0xF && hiNib !== 0) {
|
||||||
|
globalVol = clampG(globalVol + hiNib)
|
||||||
|
} else if (hiNib === 0 && loNib !== 0) {
|
||||||
|
globalVol = clampG(globalVol - loNib * (speed - 1))
|
||||||
|
} else if (hiNib !== 0 && loNib === 0) {
|
||||||
|
globalVol = clampG(globalVol + hiNib * (speed - 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { lastNote, lastInst, volAbs, panAbs, pitchOff,
|
return { lastNote, lastInst, volAbs, panAbs, pitchOff,
|
||||||
|
bpm, speed, globalVol,
|
||||||
|
panLaw, amigaMode, fadeoutCutOnZero,
|
||||||
|
bitcrushDepth, bitcrushSkip, overdriveAmp, clipMode,
|
||||||
|
glissandoOn, vibratoWave, tremoloWave, panbrelloWave,
|
||||||
memEF, memG, memHU, memR, memY,
|
memEF, memG, memHU, memR, memY,
|
||||||
memD, memI, memJ, memO, memQ, memTSlide }
|
memD, memI, memJ, memO, memQ, memTSlide, memW }
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawPatternListColumn() {
|
function drawPatternListColumn() {
|
||||||
|
|||||||
13
it2taud.py
13
it2taud.py
@@ -52,7 +52,7 @@ from taud_common import (
|
|||||||
EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T,
|
EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T,
|
||||||
EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z,
|
EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z,
|
||||||
J_SEMI_TABLE,
|
J_SEMI_TABLE,
|
||||||
d_arg_to_col, resample_linear, encode_cue, deduplicate_patterns,
|
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||||
normalise_sample, encode_song_entry,
|
normalise_sample, encode_song_entry,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1314,7 +1314,7 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
|
|||||||
|
|
||||||
vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}")
|
vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}")
|
||||||
|
|
||||||
return bytes(sample_bin) + bytes(inst_bin), offsets
|
return bytes(sample_bin) + bytes(inst_bin), offsets, ratio
|
||||||
|
|
||||||
|
|
||||||
# ── Pattern builder ───────────────────────────────────────────────────────────
|
# ── Pattern builder ───────────────────────────────────────────────────────────
|
||||||
@@ -1682,7 +1682,7 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
|||||||
'dct': inst.dct,
|
'dct': inst.dct,
|
||||||
'dca': inst.dca,
|
'dca': inst.dca,
|
||||||
}
|
}
|
||||||
sampleinst_raw, _ = build_sample_inst_bin_it(proxy, instr_data_by_slot)
|
sampleinst_raw, _, sample_ratio = build_sample_inst_bin_it(proxy, instr_data_by_slot)
|
||||||
else:
|
else:
|
||||||
# Samples referenced directly; proxy is samples list (0-based, slot 0 unused)
|
# Samples referenced directly; proxy is samples list (0-based, slot 0 unused)
|
||||||
proxy = [None] + list(samples)
|
proxy = [None] + list(samples)
|
||||||
@@ -1691,7 +1691,7 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
|||||||
for i, s in enumerate(samples)
|
for i, s in enumerate(samples)
|
||||||
if s is not None
|
if s is not None
|
||||||
}
|
}
|
||||||
sampleinst_raw, _ = build_sample_inst_bin_it(proxy)
|
sampleinst_raw, _, sample_ratio = build_sample_inst_bin_it(proxy)
|
||||||
|
|
||||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||||
|
|
||||||
@@ -1723,8 +1723,11 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
|||||||
pat_bin += build_pattern_it(cg, ch, default_pans[vi], inst_vols,
|
pat_bin += build_pattern_it(cg, ch, default_pans[vi], inst_vols,
|
||||||
amiga_mode=not h.linear_slides)
|
amiga_mode=not h.linear_slides)
|
||||||
|
|
||||||
|
# Rescale TOP_O sample-offset args if samples were globally downsampled.
|
||||||
|
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
|
||||||
|
|
||||||
orig_count = len(taud_cue_list) * C
|
orig_count = len(taud_cue_list) * C
|
||||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count)
|
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
||||||
vprint(f" patterns: {orig_count} → {num_taud_pats} unique "
|
vprint(f" patterns: {orig_count} → {num_taud_pats} unique "
|
||||||
f"({orig_count - num_taud_pats} deduplicated)")
|
f"({orig_count - num_taud_pats} deduplicated)")
|
||||||
|
|
||||||
|
|||||||
23
mod2taud.py
23
mod2taud.py
@@ -39,7 +39,7 @@ from taud_common import (
|
|||||||
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_Y,
|
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_Y,
|
||||||
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
|
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
|
||||||
J_SEMI_TABLE,
|
J_SEMI_TABLE,
|
||||||
d_arg_to_col, resample_linear, encode_cue, deduplicate_patterns,
|
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||||
encode_song_entry,
|
encode_song_entry,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -287,7 +287,17 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
|
|||||||
return (TOP_O, (arg & 0xFF) << 8, None, None)
|
return (TOP_O, (arg & 0xFF) << 8, None, None)
|
||||||
|
|
||||||
if cmd == 0xA:
|
if cmd == 0xA:
|
||||||
return (TOP_NONE, 0, d_arg_to_col(arg), None)
|
# Route Axy via Taud's effect-column D so it can coexist with a Cxx
|
||||||
|
# SET on the same row. (Vol-column slide selectors share the cell with
|
||||||
|
# the SET selector — when both Cxx and Axy land on a trigger row the
|
||||||
|
# vol-col slot can only encode one, and the slide gets dropped, losing
|
||||||
|
# 5 ticks of slide per row.) Resolution-time A00 is already collapsed
|
||||||
|
# to a concrete arg in resolve_pt_recalls; a remaining 0 means truly
|
||||||
|
# no-op (memory was empty), so emit nothing rather than D 00 (which
|
||||||
|
# would recall TSVM's D memory).
|
||||||
|
if arg == 0:
|
||||||
|
return (TOP_NONE, 0, None, None)
|
||||||
|
return (TOP_D, (arg & 0xFF) << 8, None, None)
|
||||||
|
|
||||||
if cmd == 0xB:
|
if cmd == 0xB:
|
||||||
return (TOP_B, arg & 0xFF, None, None)
|
return (TOP_B, arg & 0xFF, None, None)
|
||||||
@@ -536,7 +546,7 @@ def build_sample_inst_bin(samples: list) -> tuple:
|
|||||||
vprint(f" instrument[{taud_idx}] '{s.name}' ptr={ptr} c2spd={s.c2spd} "
|
vprint(f" instrument[{taud_idx}] '{s.name}' ptr={ptr} c2spd={s.c2spd} "
|
||||||
f"vol={s.volume} loop=({ls},{le},{'on' if loop_mode else 'off'})")
|
f"vol={s.volume} loop=({ls},{le},{'on' if loop_mode else 'off'})")
|
||||||
|
|
||||||
return bytes(sample_bin) + bytes(inst_bin), offsets
|
return bytes(sample_bin) + bytes(inst_bin), offsets, ratio
|
||||||
|
|
||||||
|
|
||||||
# ── Pattern build ────────────────────────────────────────────────────────────
|
# ── Pattern build ────────────────────────────────────────────────────────────
|
||||||
@@ -694,7 +704,7 @@ def assemble_taud(mod: dict) -> bytes:
|
|||||||
relocate_late_note_delays(patterns, order_list, n_channels, init_speed)
|
relocate_late_note_delays(patterns, order_list, n_channels, init_speed)
|
||||||
|
|
||||||
vprint(" building sample/instrument bin…")
|
vprint(" building sample/instrument bin…")
|
||||||
sampleinst_raw, _offsets = build_sample_inst_bin(samples)
|
sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(samples)
|
||||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||||
|
|
||||||
compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0)
|
compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0)
|
||||||
@@ -732,9 +742,12 @@ def assemble_taud(mod: dict) -> bytes:
|
|||||||
pat_bin += build_pattern(grid, ch, default_pan, inst_vols)
|
pat_bin += build_pattern(grid, ch, default_pan, inst_vols)
|
||||||
assert len(pat_bin) == n_patterns * n_channels * PATTERN_BYTES
|
assert len(pat_bin) == n_patterns * n_channels * PATTERN_BYTES
|
||||||
|
|
||||||
|
# Rescale TOP_O sample-offset args if samples were globally downsampled.
|
||||||
|
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
|
||||||
|
|
||||||
vprint(" deduplicating patterns…")
|
vprint(" deduplicating patterns…")
|
||||||
orig_count = n_patterns * n_channels
|
orig_count = n_patterns * n_channels
|
||||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count)
|
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
||||||
vprint(f" patterns: {orig_count} → {num_taud_pats} unique "
|
vprint(f" patterns: {orig_count} → {num_taud_pats} unique "
|
||||||
f"({orig_count - num_taud_pats} deduplicated)")
|
f"({orig_count - num_taud_pats} deduplicated)")
|
||||||
|
|
||||||
|
|||||||
11
s3m2taud.py
11
s3m2taud.py
@@ -43,7 +43,7 @@ from taud_common import (
|
|||||||
EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T,
|
EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T,
|
||||||
EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z,
|
EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z,
|
||||||
J_SEMI_TABLE,
|
J_SEMI_TABLE,
|
||||||
d_arg_to_col, resample_linear, encode_cue, deduplicate_patterns,
|
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||||
normalise_sample, encode_song_entry,
|
normalise_sample, encode_song_entry,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -528,7 +528,7 @@ def build_sample_inst_bin(instruments: list) -> tuple:
|
|||||||
if inst.c2spd > 65535:
|
if inst.c2spd > 65535:
|
||||||
vprint(f" warning: sampling rate of '{inst.name}' exceeds 65535 (got '{inst.c2spd}')")
|
vprint(f" warning: sampling rate of '{inst.name}' exceeds 65535 (got '{inst.c2spd}')")
|
||||||
|
|
||||||
return bytes(sample_bin) + bytes(inst_bin), offsets
|
return bytes(sample_bin) + bytes(inst_bin), offsets, ratio
|
||||||
|
|
||||||
|
|
||||||
def _default_channel_pan(ch_setting: int) -> int:
|
def _default_channel_pan(ch_setting: int) -> int:
|
||||||
@@ -740,7 +740,7 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
|
|||||||
|
|
||||||
# Build sample+instrument bin
|
# Build sample+instrument bin
|
||||||
vprint(" building sample/instrument bin…")
|
vprint(" building sample/instrument bin…")
|
||||||
sampleinst_raw, _offsets = build_sample_inst_bin(instruments)
|
sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(instruments)
|
||||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||||
|
|
||||||
# Compress
|
# Compress
|
||||||
@@ -787,10 +787,13 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
|
|||||||
inst_vols, amiga_mode=not h.linear_slides)
|
inst_vols, amiga_mode=not h.linear_slides)
|
||||||
assert len(pat_bin) == num_taud_pats * PATTERN_BYTES
|
assert len(pat_bin) == num_taud_pats * PATTERN_BYTES
|
||||||
|
|
||||||
|
# Rescale TOP_O sample-offset args if samples were globally downsampled.
|
||||||
|
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
|
||||||
|
|
||||||
# Deduplicate identical patterns
|
# Deduplicate identical patterns
|
||||||
vprint(" deduplicating patterns…")
|
vprint(" deduplicating patterns…")
|
||||||
orig_count = num_taud_pats
|
orig_count = num_taud_pats
|
||||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count)
|
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
||||||
vprint(f" patterns: {orig_count} → {num_taud_pats} unique ({orig_count - num_taud_pats} deduplicated)")
|
vprint(f" patterns: {orig_count} → {num_taud_pats} unique ({orig_count - num_taud_pats} deduplicated)")
|
||||||
|
|
||||||
# Cue sheet (using remapped pattern indices)
|
# Cue sheet (using remapped pattern indices)
|
||||||
|
|||||||
@@ -131,6 +131,27 @@ def resample_linear(data: bytes, ratio: float) -> bytes:
|
|||||||
return bytes(out)
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def rescale_offset_effects(pat_bin: bytes, ratio: float) -> bytes:
|
||||||
|
"""Scale TOP_O sample-offset args in raw pattern bytes by `ratio`.
|
||||||
|
|
||||||
|
Each row is 8 bytes; byte 5 is the effect opcode, bytes 6-7 are the
|
||||||
|
little-endian 16-bit arg (= byte offset into the sample). When the
|
||||||
|
sample bin overflows and every sample is downsampled globally, the
|
||||||
|
offset commands must shrink the same amount or O-jumps land past
|
||||||
|
the new end of sample.
|
||||||
|
"""
|
||||||
|
if ratio == 1.0 or not pat_bin:
|
||||||
|
return pat_bin
|
||||||
|
out = bytearray(pat_bin)
|
||||||
|
for i in range(0, len(out) - 7, 8):
|
||||||
|
if out[i + 5] == TOP_O:
|
||||||
|
arg = out[i + 6] | (out[i + 7] << 8)
|
||||||
|
arg = max(0, min(0xFFFF, int(arg * ratio + 0.5)))
|
||||||
|
out[i + 6] = arg & 0xFF
|
||||||
|
out[i + 7] = (arg >> 8) & 0xFF
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
def encode_cue(patterns12: list, instruction: int) -> bytearray:
|
def encode_cue(patterns12: list, instruction: int) -> bytearray:
|
||||||
"""Encode a 32-byte cue entry for up to 20 voices with 12-bit pattern numbers."""
|
"""Encode a 32-byte cue entry for up to 20 voices with 12-bit pattern numbers."""
|
||||||
pats = list(patterns12) + [0xFFF] * NUM_VOICES
|
pats = list(patterns12) + [0xFFF] * NUM_VOICES
|
||||||
|
|||||||
@@ -2112,8 +2112,9 @@ TODO:
|
|||||||
[x] 4THSYM.it: pitchbend is wrong, some notes keep playing (loudly!) even if new notes are emitted
|
[x] 4THSYM.it: pitchbend is wrong, some notes keep playing (loudly!) even if new notes are emitted
|
||||||
[x] `*2taud.py`: some notes are emitted with wrong volume-set command. Tested with GSLINGER.mod: on order 0x15 channel 1, mod2taud.py emits volume 8 -- also many of the effects are dropped. Suggested solution: currently all converters write default volume to the voleff when original modules (.mod/.s3m/.it) specify nothing; we should also write nothing and let the engine resolve the value just like other trackers do (also we now have "Instrument Global Volume" on instrument definition unlike the other time). This bug may affecting other formats, not just mod2taud.py, as well
|
[x] `*2taud.py`: some notes are emitted with wrong volume-set command. Tested with GSLINGER.mod: on order 0x15 channel 1, mod2taud.py emits volume 8 -- also many of the effects are dropped. Suggested solution: currently all converters write default volume to the voleff when original modules (.mod/.s3m/.it) specify nothing; we should also write nothing and let the engine resolve the value just like other trackers do (also we now have "Instrument Global Volume" on instrument definition unlike the other time). This bug may affecting other formats, not just mod2taud.py, as well
|
||||||
[x] nearly_there_.mod: `C#5 SD300 / ... / C-5 SD200 / A#4 / G#4 (at tickspeed 4)`: every `C-5 SD200` (there are four occurances) gets skipped
|
[x] nearly_there_.mod: `C#5 SD300 / ... / C-5 SD200 / A#4 / G#4 (at tickspeed 4)`: every `C-5 SD200` (there are four occurances) gets skipped
|
||||||
[ ] scale Oxxxx when samples get resampled
|
[ ] low-number voleffs are too quiet (needs elaboration and test cases)
|
||||||
[ ] implement bitcrusher and overdrive (eff sym '8' and '9')
|
[x] scale Oxxxx when samples get resampled
|
||||||
|
[x] implement bitcrusher and overdrive (eff sym '8' and '9')
|
||||||
|
|
||||||
|
|
||||||
Play Data: play data are series of tracker-like instructions, visualised as:
|
Play Data: play data are series of tracker-like instructions, visualised as:
|
||||||
|
|||||||
@@ -1093,6 +1093,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
//
|
//
|
||||||
// Effect opcodes follow base-36 digit values (see TAUD_NOTE_EFFECTS.md):
|
// Effect opcodes follow base-36 digit values (see TAUD_NOTE_EFFECTS.md):
|
||||||
// 0x00 : no effect
|
// 0x00 : no effect
|
||||||
|
// 0x08, 0x09 : Taud-only voice FX (8 = bitcrusher, 9 = overdrive; see §8/§9).
|
||||||
// 0x0A..0x23 : letters A..Z (A=0x0A speed, B=0x0B order jump,
|
// 0x0A..0x23 : letters A..Z (A=0x0A speed, B=0x0B order jump,
|
||||||
// C=0x0C pattern break, D=0x0D vol slide, E=0x0E pitch
|
// C=0x0C pattern break, D=0x0D vol slide, E=0x0E pitch
|
||||||
// down, F=0x0F pitch up, G=0x10 tone porta,
|
// down, F=0x0F pitch up, G=0x10 tone porta,
|
||||||
@@ -1141,6 +1142,8 @@ 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_8 = 0x08
|
||||||
|
const val OP_9 = 0x09
|
||||||
const val OP_A = 0x0A
|
const val OP_A = 0x0A
|
||||||
const val OP_B = 0x0B
|
const val OP_B = 0x0B
|
||||||
const val OP_C = 0x0C
|
const val OP_C = 0x0C
|
||||||
@@ -1408,6 +1411,71 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
return y0
|
return y0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply Taud's voice-level overdrive (effect 9) and bitcrusher (effect 8) to a
|
||||||
|
* post-filter sample in [-1, 1]. Call once per output sample, per active voice.
|
||||||
|
*
|
||||||
|
* Order is overdrive → shared clipper → bitcrusher (sample-rate reduce → bit depth quantise).
|
||||||
|
* If neither effect is engaged the input is returned unchanged. See TAUD_NOTE_EFFECTS.md §8/§9.
|
||||||
|
*/
|
||||||
|
private fun applyTaudVoiceFx(voice: Voice, sample: Double): Double {
|
||||||
|
var s = sample
|
||||||
|
val overdriveOn = voice.overdriveAmp > 0
|
||||||
|
// 8..15 collapses to a no-op on TSVM's 8-bit mixdown, but we still allow the bit field to
|
||||||
|
// ride alongside an active sample-skip — only depth in 1..7 actually quantises.
|
||||||
|
val depthQuantises = voice.bitcrusherDepth in 1..7
|
||||||
|
val skipActive = voice.bitcrusherSkip > 0
|
||||||
|
val crushActive = depthQuantises || skipActive
|
||||||
|
|
||||||
|
if (overdriveOn) {
|
||||||
|
s *= (16 + voice.overdriveAmp) / 16.0
|
||||||
|
s = clipSample(s, voice.clipMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (crushActive) {
|
||||||
|
if (voice.bitcrusherCounter == 0) {
|
||||||
|
if (depthQuantises) {
|
||||||
|
val levels = (1 shl voice.bitcrusherDepth) - 1
|
||||||
|
val clipped = clipSample(s, voice.clipMode).coerceIn(-1.0, 1.0)
|
||||||
|
val q = kotlin.math.floor((clipped + 1.0) * 0.5 * levels + 0.5)
|
||||||
|
.coerceIn(0.0, levels.toDouble())
|
||||||
|
s = (q / levels) * 2.0 - 1.0
|
||||||
|
}
|
||||||
|
voice.bitcrusherHeld = s
|
||||||
|
} else {
|
||||||
|
s = voice.bitcrusherHeld
|
||||||
|
}
|
||||||
|
if (skipActive) {
|
||||||
|
voice.bitcrusherCounter = (voice.bitcrusherCounter + 1) % (voice.bitcrusherSkip + 1)
|
||||||
|
} else {
|
||||||
|
voice.bitcrusherCounter = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared clipper for effects 8 and 9. Modes: 0 clamp, 1 fold (triangle), 2 wrap (sawtooth).
|
||||||
|
* Inputs outside [-1, 1] are folded/wrapped back into range; well-behaved samples pass through.
|
||||||
|
*/
|
||||||
|
private fun clipSample(x: Double, mode: Int): Double = when (mode and 3) {
|
||||||
|
1 -> {
|
||||||
|
// Ping-pong fold around ±1. Loops handle arbitrary overdrive ratios up to 16.94×
|
||||||
|
// without runaway: each iteration shrinks |v| by 2, so worst-case ~5 passes.
|
||||||
|
var v = x
|
||||||
|
while (v > 1.0) v = 2.0 - v
|
||||||
|
while (v < -1.0) v = -2.0 - v
|
||||||
|
v
|
||||||
|
}
|
||||||
|
2 -> {
|
||||||
|
// Period-2 wrap, mapped so that x = ±1 land on themselves (no DC step at boundary).
|
||||||
|
var v = ((x + 1.0) % 2.0)
|
||||||
|
if (v < 0.0) v += 2.0
|
||||||
|
v - 1.0
|
||||||
|
}
|
||||||
|
else -> x.coerceIn(-1.0, 1.0) // mode 0 (and any reserved value) — clamp
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IT-style auto-vibrato: returns a 4096-TET pitch delta to add to the
|
* IT-style auto-vibrato: returns a 4096-TET pitch delta to add to the
|
||||||
* playback note for the current tick, and advances the LFO phase.
|
* playback note for the current tick, and advances the LFO phase.
|
||||||
@@ -1701,6 +1769,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
v.panEnvOn = src.panEnvOn
|
v.panEnvOn = src.panEnvOn
|
||||||
v.pfEnvOn = src.pfEnvOn
|
v.pfEnvOn = src.pfEnvOn
|
||||||
v.noteFading = src.noteFading
|
v.noteFading = src.noteFading
|
||||||
|
// Voice-FX state (effects 8/9): preserve so the NNA-ghosted tail keeps the same timbre.
|
||||||
|
v.clipMode = src.clipMode
|
||||||
|
v.bitcrusherDepth = src.bitcrusherDepth
|
||||||
|
v.bitcrusherSkip = src.bitcrusherSkip
|
||||||
|
v.bitcrusherCounter = src.bitcrusherCounter
|
||||||
|
v.bitcrusherHeld = src.bitcrusherHeld
|
||||||
|
v.overdriveAmp = src.overdriveAmp
|
||||||
v.sourceChannel = channel
|
v.sourceChannel = channel
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
@@ -1849,6 +1924,46 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
ts.amigaMode = (flags and 2) != 0
|
ts.amigaMode = (flags and 2) != 0
|
||||||
ts.fadeoutCutOnZero = (flags and 4) != 0
|
ts.fadeoutCutOnZero = (flags and 4) != 0
|
||||||
}
|
}
|
||||||
|
EffectOp.OP_8 -> {
|
||||||
|
// 8 $xyzz — Bitcrusher. See TAUD_NOTE_EFFECTS.md §8.
|
||||||
|
// x = clipping mode (shared with effect 9): 0 clamp, 1 fold, 2 wrap.
|
||||||
|
// y = bit depth 1..15 (0 disables quantiser; 8..15 no-op on TSVM 8-bit output).
|
||||||
|
// zz = sample-skip count 0..255.
|
||||||
|
// 8 $0000 disables the bitcrusher entirely.
|
||||||
|
// 8 $x000 only updates the shared clipping mode (does not disturb depth/skip).
|
||||||
|
val x = (rawArg ushr 12) and 0xF
|
||||||
|
val y = (rawArg ushr 8) and 0xF
|
||||||
|
val z = rawArg and 0xFF
|
||||||
|
voice.clipMode = x and 3
|
||||||
|
if (rawArg == 0) {
|
||||||
|
voice.bitcrusherDepth = 0
|
||||||
|
voice.bitcrusherSkip = 0
|
||||||
|
voice.bitcrusherCounter = 0
|
||||||
|
} else if (y == 0 && z == 0) {
|
||||||
|
// x000 — clip mode only, leave bitcrusher state alone.
|
||||||
|
} else {
|
||||||
|
voice.bitcrusherDepth = y
|
||||||
|
voice.bitcrusherSkip = z
|
||||||
|
voice.bitcrusherCounter = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EffectOp.OP_9 -> {
|
||||||
|
// 9 $x0zz — Overdrive. See TAUD_NOTE_EFFECTS.md §9.
|
||||||
|
// x = clipping mode (shared with effect 8): 0 clamp, 1 fold, 2 wrap.
|
||||||
|
// zz = amplification index 0..255; gain = (16 + zz) / 16 ⇒ $00=1×, $10=2×, $FF≈16.94×.
|
||||||
|
// 9 $0000 disables the overdrive entirely.
|
||||||
|
// 9 $x000 only updates the shared clipping mode.
|
||||||
|
val x = (rawArg ushr 12) and 0xF
|
||||||
|
val z = rawArg and 0xFF
|
||||||
|
voice.clipMode = x and 3
|
||||||
|
if (rawArg == 0) {
|
||||||
|
voice.overdriveAmp = 0
|
||||||
|
} else if (z == 0) {
|
||||||
|
// x000 — clip mode only.
|
||||||
|
} else {
|
||||||
|
voice.overdriveAmp = z
|
||||||
|
}
|
||||||
|
}
|
||||||
EffectOp.OP_A -> {
|
EffectOp.OP_A -> {
|
||||||
val tr = (rawArg ushr 8) and 0xFF
|
val tr = (rawArg ushr 8) and 0xFF
|
||||||
if (tr != 0) playhead.tickRate = tr
|
if (tr != 0) playhead.tickRate = tr
|
||||||
@@ -1866,11 +1981,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
val lo = hi and 0x0F
|
val lo = hi and 0x0F
|
||||||
val hin = (hi ushr 4) and 0x0F
|
val hin = (hi ushr 4) and 0x0F
|
||||||
when {
|
when {
|
||||||
hi == 0xFF -> { voice.rowVolume = (voice.rowVolume + 0xF).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume } // DFF quirk: fine up by F
|
hi == 0xFF || hi == 0xF0 -> { voice.rowVolume = (voice.rowVolume + 0xF).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume } // $FF00 / $F000 quirk: fine up by F (TAUD_NOTE_EFFECTS.md §D)
|
||||||
hin == 0xF && lo != 0 -> { voice.rowVolume = (voice.rowVolume - lo).coerceAtLeast(0); voice.channelVolume = voice.rowVolume }
|
hin == 0xF && lo != 0 -> { voice.rowVolume = (voice.rowVolume - lo).coerceAtLeast(0); voice.channelVolume = voice.rowVolume } // $Fy00 fine down by y
|
||||||
lo == 0xF && hin != 0 -> { voice.rowVolume = (voice.rowVolume + hin).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume }
|
lo == 0xF && hin != 0 -> { voice.rowVolume = (voice.rowVolume + hin).coerceAtMost(0x3F); voice.channelVolume = voice.rowVolume } // $xF00 fine up by x
|
||||||
hin == 0 && lo != 0 -> { voice.slideMode = 5; voice.slideArg = -lo } // slide down per non-first tick
|
hin == 0 && lo != 0 -> { voice.slideMode = 5; voice.slideArg = -lo } // $0y00 coarse down per non-first tick
|
||||||
lo == 0 && hin != 0 -> { voice.slideMode = 5; voice.slideArg = hin } // slide up per non-first tick
|
lo == 0 && hin != 0 -> { voice.slideMode = 5; voice.slideArg = hin } // $x000 coarse up per non-first tick
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EffectOp.OP_E -> {
|
EffectOp.OP_E -> {
|
||||||
@@ -2371,8 +2486,6 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Double.sqrt() = Math.sqrt(this)
|
|
||||||
|
|
||||||
internal fun generateTrackerAudio(playhead: Playhead): ByteArray? {
|
internal fun generateTrackerAudio(playhead: Playhead): ByteArray? {
|
||||||
val ts = playhead.trackerState ?: return null
|
val ts = playhead.trackerState ?: return null
|
||||||
|
|
||||||
@@ -2404,13 +2517,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
for (voice in ts.voices) {
|
for (voice in ts.voices) {
|
||||||
if (!voice.active || voice.muted) continue
|
if (!voice.active || voice.muted) continue
|
||||||
val voiceInst = instruments[voice.instrumentId]
|
val voiceInst = instruments[voice.instrumentId]
|
||||||
val s = applyVoiceFilter(voice, fetchTrackerSample(voice, voiceInst))
|
val s = applyTaudVoiceFx(voice, applyVoiceFilter(voice, fetchTrackerSample(voice, voiceInst)))
|
||||||
val instGv = voiceInst.instGlobalVolume / 255.0
|
val instGv = voiceInst.instGlobalVolume / 255.0
|
||||||
// Volume swing bias (random per-trigger, ±randomVolBias of 0..255 units folded into the 0..63 row volume).
|
// Volume swing bias (random per-trigger, ±randomVolBias of 0..255 units folded into the 0..63 row volume).
|
||||||
val swingScale = 1.0 + voice.randomVolBias / 255.0
|
val swingScale = 1.0 + voice.randomVolBias / 255.0
|
||||||
// Volume envelope is bypassed (treated as unity) when S $77 has disabled it.
|
// Volume envelope is bypassed (treated as unity) when S $77 has disabled it.
|
||||||
val effEnvVol = if (voice.volEnvOn) voice.envVolume else 1.0
|
val effEnvVol = if (voice.volEnvOn) voice.envVolume else 1.0
|
||||||
val vol = effEnvVol.sqrt() * voice.fadeoutVolume * (voice.rowVolume / 63.0).sqrt() *
|
val vol = effEnvVol * voice.fadeoutVolume * (voice.rowVolume / 63.0) *
|
||||||
swingScale * gvol * mvol * instGv * playhead.masterVolume / 255.0
|
swingScale * gvol * mvol * instGv * playhead.masterVolume / 255.0
|
||||||
val pan = if (voice.hasPanEnv && voice.panEnvOn) {
|
val pan = if (voice.hasPanEnv && voice.panEnvOn) {
|
||||||
val envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255)
|
val envPanRaw = (voice.envPan * 255.0).roundToInt().coerceIn(0, 255)
|
||||||
@@ -2436,11 +2549,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
for (bg in ts.backgroundVoices) {
|
for (bg in ts.backgroundVoices) {
|
||||||
if (!bg.active || bg.muted) continue
|
if (!bg.active || bg.muted) continue
|
||||||
val bgInst = instruments[bg.instrumentId]
|
val bgInst = instruments[bg.instrumentId]
|
||||||
val s = applyVoiceFilter(bg, fetchTrackerSample(bg, bgInst))
|
val s = applyTaudVoiceFx(bg, applyVoiceFilter(bg, fetchTrackerSample(bg, bgInst)))
|
||||||
val instGv = bgInst.instGlobalVolume / 255.0
|
val instGv = bgInst.instGlobalVolume / 255.0
|
||||||
val swingScale = 1.0 + bg.randomVolBias / 255.0
|
val swingScale = 1.0 + bg.randomVolBias / 255.0
|
||||||
val effEnvVol = if (bg.volEnvOn) bg.envVolume else 1.0
|
val effEnvVol = if (bg.volEnvOn) bg.envVolume else 1.0
|
||||||
val vol = effEnvVol.sqrt() * bg.fadeoutVolume * (bg.rowVolume / 63.0).sqrt() *
|
val vol = effEnvVol * bg.fadeoutVolume * (bg.rowVolume / 63.0) *
|
||||||
swingScale * gvol * mvol * instGv * playhead.masterVolume / 255.0
|
swingScale * gvol * mvol * instGv * playhead.masterVolume / 255.0
|
||||||
val pan = if (bg.hasPanEnv && bg.panEnvOn) {
|
val pan = if (bg.hasPanEnv && bg.panEnvOn) {
|
||||||
val envPanRaw = (bg.envPan * 255.0).roundToInt().coerceIn(0, 255)
|
val envPanRaw = (bg.envPan * 255.0).roundToInt().coerceIn(0, 255)
|
||||||
@@ -2772,6 +2885,18 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
var panColSlideRight = 0
|
var panColSlideRight = 0
|
||||||
var panColSlideLeft = 0
|
var panColSlideLeft = 0
|
||||||
|
|
||||||
|
// Bitcrusher (effect 8) and Overdrive (effect 9) — Taud-only voice FX.
|
||||||
|
// clipMode is shared between both effects: 0=clamp, 1=fold, 2=wrap. See TAUD_NOTE_EFFECTS.md §8/§9.
|
||||||
|
var clipMode = 0
|
||||||
|
// Bitcrusher: depth in 1..15 (0 = quantiser disabled; 8..15 are no-op for TSVM 8-bit output).
|
||||||
|
var bitcrusherDepth = 0
|
||||||
|
// Bitcrusher: sample-skip count. 0 = no skip, N = hold post-FX output for N additional samples.
|
||||||
|
var bitcrusherSkip = 0
|
||||||
|
var bitcrusherCounter = 0 // sample-rate-reduction counter, mod (skip + 1)
|
||||||
|
var bitcrusherHeld = 0.0 // last emitted post-quantisation value, held when skipping
|
||||||
|
// Overdrive: 0 = disabled. Otherwise gain = (16 + amp) / 16, range 17/16..271/16 (≈16.94×).
|
||||||
|
var overdriveAmp = 0
|
||||||
|
|
||||||
// Effect-recall memory for this voice.
|
// Effect-recall memory for this voice.
|
||||||
val mem = MemorySlots()
|
val mem = MemorySlots()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user