Replace illwill with custom canvas — zero dependencies

- Custom terminal canvas: one buffer, one write() per frame
- Threaded audio via Nim channels (20fps → 60fps)
- 6 phosphor palettes (green, amber, cyan, blue, white, red)
- All tuning constants at top of osc.nim
- No illwill dependency, binary 352KB → 200KB
- CRT effects use same palette as trace

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
rolandnsharp
2026-04-07 17:33:45 +10:00
parent cdd3523550
commit 5dbd3ebfb6
7 changed files with 451 additions and 271 deletions

View File

@@ -1,10 +1,10 @@
## CRT turn-on and turn-off animations
## Ported from AetherTune's Rust/ratatui implementation
## CRT turn-on and turn-off animations using direct ANSI output.
## Uses the same block characters as the original illwill version.
import illwill, os, times, math, std/random
import os, times, math, std/random
import term
const
# Turn-on phase timing (ms)
OnFlashMs = 60
OnGlitchMs = 200
OnPhosphorMs = 500
@@ -12,7 +12,6 @@ const
OnBeamMs = 1100
OnTotalMs = 1200
# Turn-off phase timing (ms)
OffCollapseMs = 500
OffSqueezeMs = 800
OffDotMs = 1200
@@ -24,99 +23,139 @@ const
NoiseChars = ["", "", "", "", "", "", "", "·",
":", ";", "!", "?", "$", "#", "@", "%"]
proc elapsedMs(start: Time): int =
int((getTime() - start).inMilliseconds)
proc lcg(state: var uint64): uint64 =
state = state * 6364136223846793005'u64 + 1'u64
result = state
proc elapsedMs(start: Time): int =
int((getTime() - start).inMilliseconds)
proc goto(buf: var string, x, y: int) =
buf.add "\x1b["
buf.add $(y + 1)
buf.add ";"
buf.add $(x + 1)
buf.add "H"
proc brightColor(b: float): ForegroundColor =
if b > 0.8: fgWhite elif b > 0.4: fgGreen else: fgGreen
proc crtTurnOn*(tb: var TerminalBuffer, w, h: int) =
proc crtTurnOn*(c: Canvas) =
let start = getTime()
var rng = initRand(42)
let w = c.w
let h = c.h
var buf = newStringOfCap(w * h * 12)
while true:
let elapsed = elapsedMs(start)
if elapsed >= OnTotalMs: break
tb = newTerminalBuffer(w, h)
buf.setLen(0)
buf.add "\x1b[H" # cursor home
if elapsed < OnFlashMs:
# Phase 1: White flash — high-voltage discharge
let c = if elapsed < OnFlashMs div 2: fgWhite else: fgGreen
# Phase 1: White flash
let ansi = if elapsed < OnFlashMs div 2: c.ansiFor(tHot) else: c.ansiFor(tBright)
buf.add "\x1b[0m"
buf.add ansi
for y in 0..<h:
for x in 0..<w:
tb.write(x, y, c, styleReverse, " ")
buf.add ""
elif elapsed < OnGlitchMs:
# Phase 2: Glitch burst — scattered block characters
# Phase 2: Glitch burst
buf.add "\x1b[0m"
# Fill with spaces first
for i in 0..<w*h: buf.add " "
let count = 8 + (elapsed - OnFlashMs) div 4
for i in 0..<count:
let gx = rng.rand(w - 1)
let gy = rng.rand(h - 1)
let ch = GlitchChars[rng.rand(GlitchChars.high)]
let color = if rng.rand(2) == 0: fgGreen
elif rng.rand(2) == 1: fgGreen
else: fgWhite
tb.write(rng.rand(w - 1), rng.rand(h - 1), color, ch)
let tint = if rng.rand(2) == 0: tBright else: tHot
buf.goto(gx, gy)
buf.add "\x1b[0m"
buf.add c.ansiFor(tint)
buf.add ch
elif elapsed < OnPhosphorMs:
# Phase 3: Phosphor ramp — screen fills with brightening blocks
# Phase 3: Phosphor ramp
let p = (elapsed - OnGlitchMs).float / (OnPhosphorMs - OnGlitchMs).float
let ch = if p < 0.4: "" elif p < 0.8: "" else: ""
let color = if p < 0.4: fgGreen elif p < 0.7: fgGreen else: fgWhite
let tint = if p < 0.4: tDim elif p < 0.7: tNormal else: tBright
buf.add "\x1b[0m"
buf.add c.ansiFor(tint)
for y in 0..<h:
for x in 0..<w:
tb.write(x, y, color, ch)
buf.add ch
elif elapsed < OnStaticMs:
# Phase 4: Static noise burst
# Phase 4: Static noise
var seed = uint64(elapsed) * 7919
for y in 0..<h:
for x in 0..<w:
let r = lcg(seed)
let ch = NoiseChars[int(r shr 16) mod NoiseChars.len]
let color = [fgGreen, fgGreen, fgWhite, fgGreen][int(r shr 24) mod 4]
tb.write(x, y, color, ch)
let tint = [tNormal, tBright, tHot, tNormal][int(r shr 24) mod 4]
buf.add "\x1b[0m"
buf.add c.ansiFor(tint)
buf.add ch
elif elapsed < OnBeamMs:
# Phase 5: Beam sweep — electron beam scans top to center
# Phase 5: Beam sweep to center
buf.add "\x1b[0m"
for i in 0..<w*h: buf.add " "
let beamRow = int((elapsed - OnStaticMs).float /
(OnBeamMs - OnStaticMs).float * (h.float / 2.0))
for y in 0..<h:
let dist = abs(y - beamRow)
if dist == 0:
for x in 0..<w: tb.write(x, y, fgWhite, styleBright, "")
elif dist == 1:
for x in 0..<w: tb.write(x, y, fgGreen, "")
elif dist <= 3 and y < beamRow:
for x in 0..<w: tb.write(x, y, fgGreen, styleDim, "")
if dist <= 3:
buf.goto(0, y)
if dist == 0:
buf.add "\x1b[0m"
buf.add c.ansiFor(tHot)
for x in 0..<w: buf.add ""
elif dist == 1:
buf.add "\x1b[0m"
buf.add c.ansiFor(tBright)
for x in 0..<w: buf.add ""
elif y < beamRow:
buf.add "\x1b[0m"
buf.add c.ansiFor(tDim)
for x in 0..<w: buf.add ""
tb.display()
buf.add "\x1b[0m"
stdout.write buf
stdout.flushFile()
sleep(16)
proc crtTurnOff*(tb: var TerminalBuffer, w, h: int) =
proc crtTurnOff*(c: Canvas) =
let start = getTime()
let w = c.w
let h = c.h
let cx = w div 2
let cy = h div 2
var buf = newStringOfCap(w * h * 12)
while true:
let elapsed = elapsedMs(start)
if elapsed >= OffFadeMs: break
tb = newTerminalBuffer(w, h)
buf.setLen(0)
buf.add "\x1b[H\x1b[0m"
for i in 0..<w*h: buf.add " "
if elapsed < OffCollapseMs:
# Phase 1: Vertical collapse to center row
# Phase 1: Vertical collapse
let t = elapsed.float / OffCollapseMs.float
let halfH = int((1.0 - t) * (h.float / 2.0))
for y in max(cy - halfH, 0)..<min(cy + halfH + 1, h):
let b = 1.0 - abs(y - cy).float / max(halfH, 1).float * 0.6
let ch = if b > 0.8: "" elif b > 0.6: "" elif b > 0.3: "" else: "·"
for x in 0..<w:
tb.write(x, y, brightColor(b), ch)
let tint = if b > 0.8: tHot elif b > 0.4: tBright else: tNormal
buf.goto(0, y)
buf.add "\x1b[0m"
buf.add c.ansiFor(tint)
for x in 0..<w: buf.add ch
elif elapsed < OffSqueezeMs:
# Phase 2: Horizontal squeeze to dot
# Phase 2: Horizontal squeeze
let t = (elapsed - OffCollapseMs).float / (OffSqueezeMs - OffCollapseMs).float
let eased = 1.0 - (1.0 - t) * (1.0 - t)
let halfW = int((1.0 - eased) * (w.float / 2.0))
@@ -124,13 +163,14 @@ proc crtTurnOff*(tb: var TerminalBuffer, w, h: int) =
for y in rows:
if y < 0 or y >= h: continue
let isCentre = y == cy
buf.goto(max(cx - halfW, 0), y)
buf.add "\x1b[0m"
buf.add c.ansiFor(if isCentre: tHot else: tNormal)
for x in max(cx - halfW, 0)..<min(cx + halfW + 1, w):
let b = (if isCentre: 1.0 else: 0.55) *
(1.0 - abs(x - cx).float / max(halfW, 1).float * 0.4)
tb.write(x, y, brightColor(b), if isCentre: "" else: "")
buf.add (if isCentre: "" else: "")
elif elapsed < OffDotMs:
# Phase 3: Bright dot with phosphor glow
# Phase 3: Bright dot with glow
let t = (elapsed - OffSqueezeMs).float / (OffDotMs - OffSqueezeMs).float
let glowR = max(int(3.0 * (1.0 - t)), 1)
for dy in -glowR..glowR:
@@ -139,17 +179,28 @@ proc crtTurnOff*(tb: var TerminalBuffer, w, h: int) =
if dist > glowR.float: continue
let (px, py) = (cx + dx, cy + dy)
if px < 0 or px >= w or py < 0 or py >= h: continue
let b = (1.0 - t * 0.3) * (1.0 - dist / glowR.float)
let falloff = 1.0 - dist / glowR.float
let ch = if dx == 0 and dy == 0: ""
elif dist < glowR.float * 0.4: ""
else: "·"
tb.write(px, py, brightColor(b), ch)
let tint = if falloff > 0.7: tHot
elif falloff > 0.4: tBright
else: tNormal
buf.goto(px, py)
buf.add "\x1b[0m"
buf.add c.ansiFor(tint)
buf.add ch
else:
# Phase 4: Fade to black
let b = 1.0 - (elapsed - OffDotMs).float / (OffFadeMs - OffDotMs).float
if b > 0.05:
tb.write(cx, cy, (if b > 0.5: fgGreen else: fgGreen), "·")
let t = (elapsed - OffDotMs).float / (OffFadeMs - OffDotMs).float
if t < 0.95:
buf.goto(cx, cy)
buf.add "\x1b[0m"
buf.add c.ansiFor(tDim)
buf.add "·"
tb.display()
buf.add "\x1b[0m"
stdout.write buf
stdout.flushFile()
sleep(16)

168
src/osc/canvas/term.nim Normal file
View File

@@ -0,0 +1,168 @@
## Minimal fast terminal canvas — one buffer, one write() per frame.
import terminal
const
Upper = ""
Lower = ""
Full = ""
MinBright* = 0.02
type
Tint* = enum
tNone, tDim, tNormal, tBright, tHot
Palette* = object
hot*: string # white-hot beam core
bright*: string # bright phosphor
normal*: string # standard glow
dim*: string # faint persistence
reset: string
Canvas* = object
w*, h*: int
pixW*, pixH*: int
pixels*: seq[float]
buf: string
pal*: Palette
thresholds*: array[3, float] # hot, warm, cool
# ── Built-in palettes ────────────────────────────────────────────────
proc makePalette*(name: string): Palette =
let reset = "\x1b[0m"
case name
of "green": # P31 — classic oscilloscope
Palette(hot: "\x1b[1;37m", bright: "\x1b[1;32m",
normal: "\x1b[32m", dim: "\x1b[2;32m", reset: reset)
of "amber": # P12 — warm terminal
Palette(hot: "\x1b[1;37m", bright: "\x1b[1;33m",
normal: "\x1b[33m", dim: "\x1b[2;33m", reset: reset)
of "cyan": # P7 — tektronix blue-green
Palette(hot: "\x1b[1;37m", bright: "\x1b[1;36m",
normal: "\x1b[36m", dim: "\x1b[2;36m", reset: reset)
of "blue": # P11 — cool/modern
Palette(hot: "\x1b[1;37m", bright: "\x1b[1;34m",
normal: "\x1b[34m", dim: "\x1b[2;34m", reset: reset)
of "white": # P4 — TV phosphor
Palette(hot: "\x1b[1;37m", bright: "\x1b[37m",
normal: "\x1b[2;37m", dim: "\x1b[2;90m", reset: reset)
of "red": # P22-R — radar display
Palette(hot: "\x1b[1;37m", bright: "\x1b[1;31m",
normal: "\x1b[31m", dim: "\x1b[2;31m", reset: reset)
else: # default to green
makePalette("green")
# ── Init / resize ───────────────────────────────────────────────────
proc newCanvas*(w, h: int, palette = "green",
thresholds = [0.7, 0.4, 0.15]): Canvas =
Canvas(
w: w, h: h, pixW: w, pixH: h * 2,
pixels: newSeq[float](w * h * 2),
buf: newStringOfCap(w * h * 16),
pal: makePalette(palette),
thresholds: thresholds
)
proc resize*(c: var Canvas, w, h: int) =
if w == c.w and h == c.h: return
let pal = c.pal
let thr = c.thresholds
c = Canvas(
w: w, h: h, pixW: w, pixH: h * 2,
pixels: newSeq[float](w * h * 2),
buf: newStringOfCap(w * h * 16),
pal: pal, thresholds: thr
)
# ── Pixel operations ─────────────────────────────────────────────────
proc pixIdx*(c: Canvas, x, y: int): int {.inline.} = y * c.pixW + x
proc addPixel*(c: var Canvas, x, y: int, intensity: float) {.inline.} =
if x >= 0 and x < c.pixW and y >= 0 and y < c.pixH:
let i = c.pixIdx(x, y)
c.pixels[i] = min(c.pixels[i] + intensity, 1.0)
proc decayPixels*(c: var Canvas, factor: float) =
for i in 0..<c.pixels.len:
c.pixels[i] *= factor
if c.pixels[i] < MinBright: c.pixels[i] = 0.0
# ── Flush entire frame ───────────────────────────────────────────────
proc tintFor*(c: Canvas, b: float): Tint {.inline.} =
if b > c.thresholds[0]: tHot
elif b > c.thresholds[1]: tBright
elif b > c.thresholds[2]: tNormal
else: tDim
proc ansiFor*(c: Canvas, t: Tint): string {.inline.} =
case t
of tHot: c.pal.hot
of tBright: c.pal.bright
of tNormal: c.pal.normal
of tDim: c.pal.dim
of tNone: c.pal.reset
proc flush*(c: var Canvas, textOverlays: openArray[(int, int, Tint, string)] = []) =
c.buf.setLen(0)
c.buf.add "\x1b[H"
var last = tNone
for ty in 0..<c.h:
let topRow = ty * 2
let botRow = topRow + 1
for x in 0..<c.w:
let topB = if topRow < c.pixH: c.pixels[c.pixIdx(x, topRow)] else: 0.0
let botB = if botRow < c.pixH: c.pixels[c.pixIdx(x, botRow)] else: 0.0
let tOn = topB > MinBright
let bOn = botB > MinBright
if tOn or bOn:
let tint = if tOn and bOn: c.tintFor(max(topB, botB))
elif tOn: c.tintFor(topB)
else: c.tintFor(botB)
if tint != last:
c.buf.add "\x1b[0m"
c.buf.add c.ansiFor(tint)
last = tint
if tOn and bOn: c.buf.add Full
elif tOn: c.buf.add Upper
else: c.buf.add Lower
else:
if last != tNone:
c.buf.add c.pal.reset
last = tNone
c.buf.add " "
c.buf.add "\x1b[0m"
for (x, y, tint, text) in textOverlays:
if y >= 0 and y < c.h and x >= 0:
c.buf.add "\x1b["
c.buf.add $(y + 1)
c.buf.add ";"
c.buf.add $(x + 1)
c.buf.add "H"
c.buf.add c.ansiFor(tint)
c.buf.add text
c.buf.add "\x1b[0m"
stdout.write c.buf
stdout.flushFile()
# ── Terminal setup/teardown ──────────────────────────────────────────
proc initTerm*() =
stdout.write "\x1b[?1049h\x1b[?25l"
stdout.flushFile()
proc deinitTerm*() =
stdout.write "\x1b[?25h\x1b[?1049l"
stdout.flushFile()
proc termWidth*(): int = terminalWidth()
proc termHeight*(): int = terminalHeight()

View File

@@ -1,89 +0,0 @@
## Phosphor buffer with CRT physics: persistence decay, beam bloom,
## and half-block rendering with intensity-based shading.
import illwill, math
const
PhosphorDecay* = 0.60 # per-frame persistence (P31 green phosphor)
BeamIntensity* = 0.7 # brightness at beam impact
BloomH* = 0.15 # horizontal glow spread
MinBright* = 0.02 # below this, phosphor is considered off
type
Intensity* = enum
iHot ## beam core — white-hot
iBright ## fluoro green phosphor
iMedium ## green glow
iDim ## faint persistence trail
PhosphorBuffer* = object
w*, h*: int # terminal columns/rows
pixH*: int # pixel height (2× rows via half-blocks)
data*: seq[float] # brightness per pixel [w × pixH]
proc initPhosphor*(w, h: int): PhosphorBuffer =
let pixH = h * 2
PhosphorBuffer(w: w, h: h, pixH: pixH, data: newSeq[float](w * pixH))
proc idx(pb: PhosphorBuffer, x, y: int): int {.inline.} =
y * pb.w + x
proc add(pb: var PhosphorBuffer, x, y: int, intensity: float) {.inline.} =
if x >= 0 and x < pb.w and y >= 0 and y < pb.pixH:
pb.data[pb.idx(x, y)] = min(pb.data[pb.idx(x, y)] + intensity, 1.0)
proc decay*(pb: var PhosphorBuffer) =
for i in 0..<pb.data.len:
pb.data[i] *= PhosphorDecay
if pb.data[i] < MinBright:
pb.data[i] = 0.0
proc plotDot*(pb: var PhosphorBuffer, fx, fy: float) =
## Deposit a phosphor dot with physics-based bloom.
let x = int(fx)
let y = int(fy)
if x < 0 or x >= pb.w or y < 0 or y >= pb.pixH: return
# Beam impact — no vertical bloom, keeps traces thin
pb.add(x, y, BeamIntensity)
pb.add(x - 1, y, BloomH)
pb.add(x + 1, y, BloomH)
proc plotLine*(pb: var PhosphorBuffer, x0, y0, x1, y1: float) =
## Interpolated line of phosphor dots between two points.
let steps = max(int(max(abs(x1 - x0), abs(y1 - y0))), 1)
for i in 0..steps:
let t = i.float / steps.float
pb.plotDot(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t)
# ── Half-block rendering ────────────────────────────────────────────
proc toIntensity*(b: float): Intensity =
if b > 0.7: iHot elif b > 0.4: iBright elif b > 0.15: iMedium else: iDim
proc writePhosphor*(tb: var TerminalBuffer, x, y: int, ch: string,
intensity: Intensity) =
case intensity
of iHot: tb.write(x, y, fgWhite, styleBright, ch)
of iBright: tb.write(x, y, fgGreen, styleBright, ch)
of iMedium: tb.write(x, y, fgGreen, ch)
of iDim: tb.write(x, y, fgGreen, styleDim, ch)
proc render*(pb: PhosphorBuffer, tb: var TerminalBuffer) =
## Blit the phosphor buffer to the terminal using half-block characters.
for ty in 0..<pb.h:
let topRow = ty * 2
let botRow = ty * 2 + 1
for x in 0..<pb.w:
let topB = if topRow < pb.pixH: pb.data[pb.idx(x, topRow)] else: 0.0
let botB = if botRow < pb.pixH: pb.data[pb.idx(x, botRow)] else: 0.0
if topB > MinBright or botB > MinBright:
let tOn = topB > MinBright
let bOn = botB > MinBright
if tOn and bOn:
tb.writePhosphor(x, ty, "", toIntensity(max(topB, botB)))
elif tOn:
tb.writePhosphor(x, ty, "", toIntensity(topB))
else:
tb.writePhosphor(x, ty, "", toIntensity(botB))

View File

@@ -1,7 +1,4 @@
## Oscilloscope: trace rendering, graticule grid, and HUD overlay.
import illwill, strutils
import phosphor
## Oscilloscope state — display mode and sample buffers.
type
DisplayMode* = enum
@@ -9,16 +6,14 @@ type
ModeXY ## Lissajous: x=left, y=right
Scope* = object
phosphor*: PhosphorBuffer
mode*: DisplayMode
samplesL*, samplesR*: seq[float]
sampleCount*: int
gain*: float # amplitude scaling (volts/div)
timeDiv*: float # horizontal zoom (time/div)
gain*: float
timeDiv*: float
proc initScope*(w, h: int): Scope =
Scope(
phosphor: initPhosphor(w, h),
mode: ModeYT,
samplesL: newSeq[float](4096),
samplesR: newSeq[float](4096),
@@ -27,67 +22,5 @@ proc initScope*(w, h: int): Scope =
timeDiv: 3.4
)
proc w*(s: Scope): int = s.phosphor.w
proc h*(s: Scope): int = s.phosphor.h
proc resize*(scope: var Scope, w, h: int) =
if w == scope.w and h == scope.h: return
scope.phosphor = initPhosphor(w, h)
# ── Trace rendering ──────────────────────────────────────────────────
proc renderTrace*(scope: var Scope) =
if scope.sampleCount < 2: return
let w = scope.w
let pixH = scope.phosphor.pixH
let cy = pixH.float / 2.0
let gain = scope.gain
case scope.mode
of ModeYT:
let visible = max(int(scope.sampleCount.float / scope.timeDiv), 2)
var prevX, prevY: float
var first = true
for col in 0..<w:
let sIdx = min((col * visible) div w, scope.sampleCount - 1)
let y = cy - scope.samplesL[sIdx] * gain * cy * 0.5
let x = col.float
if first:
scope.phosphor.plotDot(x, y)
else:
scope.phosphor.plotLine(prevX, prevY, x, y)
first = false
prevX = x
prevY = y
of ModeXY:
var prevX, prevY: float
var first = true
let step = max(scope.sampleCount div 1024, 1)
for i in countup(0, scope.sampleCount - 1, step):
let x = (1.0 + scope.samplesL[i] * gain * 0.5) * w.float / 2.0
let y = (1.0 - scope.samplesR[i] * gain * 0.5) * pixH.float / 2.0
if first:
scope.phosphor.plotDot(x, y)
else:
scope.phosphor.plotLine(prevX, prevY, x, y)
first = false
prevX = x
prevY = y
# ── HUD ──────────────────────────────────────────────────────────────
proc drawHUD*(tb: var TerminalBuffer, w, h: int, scope: Scope) =
let modeStr = case scope.mode
of ModeYT: "Y-T"
of ModeXY: "X-Y"
let gainStr = " G:" & formatFloat(scope.gain, ffDecimal, 1)
let tdStr = if scope.mode == ModeYT:
" T:" & formatFloat(scope.timeDiv, ffDecimal, 1)
else: ""
tb.write(1, 0, fgGreen, styleBright,
" " & modeStr & gainStr & tdStr & " ")
let help = " m:mode +/-:gain [/]:time q:quit "
tb.write(w - help.len - 1, h - 1, fgGreen, styleDim, help)
discard # scope doesn't depend on terminal size