Terminal oscilloscope with CRT phosphor physics

- CRT boot/shutdown animations (ported from AetherTune)
- Y-T (time-domain) and X-Y (Lissajous) display modes
- Phosphor persistence with bloom and decay
- Half-block rendering for 2x vertical resolution
- Live audio capture via ffmpeg/PulseAudio monitor
- Gain and time/div controls, grid toggle, freeze

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
rolandnsharp
2026-04-05 12:43:48 +10:00
commit 448b303eba
8 changed files with 618 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
crt
nimcache/
demo.gif
demo.png/

13
CRT.nimble Normal file
View File

@@ -0,0 +1,13 @@
# Package
version = "0.1.0"
author = "rolandnsharp"
description = "CRT TV turn on/off effects in the terminal"
license = "MIT"
srcDir = "src"
bin = @["crt"]
# Dependencies
requires "nim >= 2.2.8"
requires "illwill >= 0.4.1"

16
demo.tape Normal file
View File

@@ -0,0 +1,16 @@
Output demo.gif
Output demo.png
Set Shell "bash"
Set FontSize 14
Set Width 800
Set Height 600
Set Theme "Dracula"
Type "./crt"
Enter
Sleep 4s
Type "m"
Sleep 2.5s
Type "q"
Sleep 2.5s

86
src/crt.nim Normal file
View File

@@ -0,0 +1,86 @@
## CRT Oscilloscope — terminal-based oscilloscope with phosphor physics.
##
## Features:
## - CRT boot/shutdown animations (ported from AetherTune)
## - Y-T (time-domain) and X-Y (Lissajous) display modes
## - Phosphor persistence with bloom and decay
## - Half-block rendering for 2× vertical resolution
## - Live audio capture via ffmpeg/PulseAudio or demo signal
import illwill, os
import crt/[effects, phosphor, scope, audio]
proc exitProc() {.noconv.} =
illwillDeinit()
showCursor()
quit(0)
proc main() =
illwillInit(fullscreen = true)
setControlCHook(exitProc)
hideCursor()
let w = terminalWidth()
let h = terminalHeight()
var tb = newTerminalBuffer(w, h)
crtTurnOn(tb, w, h)
var scope = initScope(w, h)
var audio = startAudio()
var running = true
while running:
if not scope.frozen:
audio.readSamples(scope)
scope.phosphor.decay()
if not scope.frozen:
scope.renderTrace()
tb = newTerminalBuffer(w, h)
scope.phosphor.render(tb)
drawGraticule(tb, w, h, scope.grid)
drawHUD(tb, w, h, scope, audio.sourceLabel)
tb.display()
let key = getKey()
case key
of Key.Q, Key.Escape:
running = false
of Key.M:
scope.mode = if scope.mode == ModeYT: ModeXY else: ModeYT
of Key.Plus, Key.Equals:
scope.gain = min(scope.gain * 1.3, 20.0)
of Key.Minus:
scope.gain = max(scope.gain / 1.3, 0.5)
of Key.RightBracket:
scope.timeDiv = min(scope.timeDiv * 1.5, 16.0)
of Key.LeftBracket:
scope.timeDiv = max(scope.timeDiv / 1.5, 0.25)
of Key.Space:
scope.frozen = not scope.frozen
of Key.G:
scope.grid = case scope.grid
of gsGrid: gsCross
of gsCross: gsOff
of gsOff: gsGrid
of Key.D:
audio.cyclePreset()
else:
discard
sleep(16)
audio.stop()
crtTurnOff(tb, w, h)
tb = newTerminalBuffer(w, h)
tb.display()
sleep(100)
illwillDeinit()
showCursor()
when isMainModule:
main()

107
src/crt/audio.nim Normal file
View File

@@ -0,0 +1,107 @@
## Audio capture: tries ffmpeg (PulseAudio monitor) → parec → demo signal.
import osproc, streams, strutils, math
import scope
type
AudioMode* = enum
amLive ## Capturing real audio via ffmpeg/parec
amDemo ## Built-in synthesized waveforms
AudioCapture* = object
mode*: AudioMode
process: Process
stream: Stream
phase: float
demoFreqL*, demoFreqR*: float
demoPreset*: int
proc findMonitorSource(): string =
## Find the PulseAudio monitor for the default audio sink.
try:
let inspect = execProcess("wpctl",
args = ["inspect", "@DEFAULT_AUDIO_SINK@"],
options = {poUsePath, poStdErrToStdOut})
for line in inspect.splitLines():
if "node.name" in line:
let eq = line.find("=")
if eq >= 0:
return line[eq+1..^1].strip().strip(chars = {'"', ' '}) & ".monitor"
except: discard
""
proc startAudio*(): AudioCapture =
## Try real audio capture, fall back to demo.
let monitor = findMonitorSource()
if monitor.len > 0:
try:
let p = startProcess("ffmpeg",
args = ["-f", "pulse", "-i", monitor,
"-f", "s16le", "-ac", "2", "-ar", "44100",
"-flush_packets", "1", "-fflags", "nobuffer",
"-loglevel", "quiet", "pipe:1"],
options = {poUsePath})
return AudioCapture(mode: amLive, process: p, stream: p.outputStream,
demoFreqL: 440.0, demoFreqR: 330.0)
except OSError: discard
try:
let p = startProcess("parec",
args = ["--format=s16le", "--channels=2", "--rate=44100",
"--latency-msec=20"],
options = {poUsePath})
return AudioCapture(mode: amLive, process: p, stream: p.outputStream,
demoFreqL: 440.0, demoFreqR: 330.0)
except OSError: discard
AudioCapture(mode: amDemo, demoFreqL: 440.0, demoFreqR: 330.0)
proc stop*(cap: var AudioCapture) =
if cap.mode == amLive:
cap.process.terminate()
cap.process.close()
proc sourceLabel*(cap: AudioCapture): string =
if cap.mode == amLive: "LIVE" else: "DEMO"
proc cyclePreset*(cap: var AudioCapture) =
## Cycle through demo frequency ratios for interesting Lissajous patterns.
if cap.mode != amDemo: return
cap.demoPreset = (cap.demoPreset + 1) mod 4
case cap.demoPreset
of 0: cap.demoFreqL = 440.0; cap.demoFreqR = 330.0 # 4:3
of 1: cap.demoFreqL = 440.0; cap.demoFreqR = 440.0 # 1:1
of 2: cap.demoFreqL = 440.0; cap.demoFreqR = 220.0 # 2:1
of 3: cap.demoFreqL = 440.0; cap.demoFreqR = 293.3 # 3:2
else: discard
proc readSamples*(cap: var AudioCapture, scope: var Scope) =
case cap.mode
of amLive:
const frameSize = 4 # 2 channels × 16-bit
const maxFrames = 2048
var buf: array[maxFrames * frameSize, uint8]
let bytesRead = cap.stream.readData(addr buf[0], maxFrames * frameSize)
if bytesRead <= 0: return
scope.sampleCount = min(bytesRead div frameSize, scope.samplesL.len)
for i in 0..<scope.sampleCount:
let off = i * frameSize
let left = cast[int16]((buf[off + 1].uint16 shl 8) or buf[off].uint16)
let right = cast[int16]((buf[off + 3].uint16 shl 8) or buf[off + 2].uint16)
scope.samplesL[i] = left.float / 32768.0
scope.samplesR[i] = right.float / 32768.0
of amDemo:
scope.sampleCount = scope.samplesL.len
let cycles = 3.0 / scope.timeDiv
let drift = sin(cap.phase * 0.3) * 0.1
let rL = cap.demoFreqL / 440.0
let rR = cap.demoFreqR / 440.0
for i in 0..<scope.sampleCount:
let t = cap.phase + (i.float / scope.sampleCount.float) * cycles * 2.0 * PI
scope.samplesL[i] = sin(t * rL) * 0.7 +
sin(t * rL * 2.0) * 0.15 +
sin(t * rL * 3.0 + drift) * 0.08
scope.samplesR[i] = sin(t * rR + 0.5) * 0.7 +
sin(t * rR * 2.0 + 0.3) * 0.2
cap.phase += 0.05

155
src/crt/effects.nim Normal file
View File

@@ -0,0 +1,155 @@
## CRT turn-on and turn-off animations
## Ported from AetherTune's Rust/ratatui implementation
import illwill, os, times, math, std/random
const
# Turn-on phase timing (ms)
OnFlashMs = 60
OnGlitchMs = 200
OnPhosphorMs = 500
OnStaticMs = 650
OnBeamMs = 1100
OnTotalMs = 1200
# Turn-off phase timing (ms)
OffCollapseMs = 500
OffSqueezeMs = 800
OffDotMs = 1200
OffFadeMs = 1600
GlitchChars = ["", "", "", "", "", "", "", "",
"", "", "", "", "", "·", ":", "!",
"@", "#", "$", "%", "^", "&", "*"]
NoiseChars = ["", "", "", "", "", "", "", "·",
":", ";", "!", "?", "$", "#", "@", "%"]
proc lcg(state: var uint64): uint64 =
state = state * 6364136223846793005'u64 + 1'u64
result = state
proc elapsedMs(start: Time): int =
int((getTime() - start).inMilliseconds)
proc brightColor(b: float): ForegroundColor =
if b > 0.7: fgWhite elif b > 0.4: fgCyan else: fgGreen
proc crtTurnOn*(tb: var TerminalBuffer, w, h: int) =
let start = getTime()
var rng = initRand(42)
while true:
let elapsed = elapsedMs(start)
if elapsed >= OnTotalMs: break
tb = newTerminalBuffer(w, h)
if elapsed < OnFlashMs:
# Phase 1: White flash — high-voltage discharge
let c = if elapsed < OnFlashMs div 2: fgWhite else: fgCyan
for y in 0..<h:
for x in 0..<w:
tb.write(x, y, c, styleReverse, " ")
elif elapsed < OnGlitchMs:
# Phase 2: Glitch burst — scattered block characters
let count = 8 + (elapsed - OnFlashMs) div 4
for i in 0..<count:
let ch = GlitchChars[rng.rand(GlitchChars.high)]
let color = if rng.rand(2) == 0: fgCyan
elif rng.rand(2) == 1: fgGreen
else: fgWhite
tb.write(rng.rand(w - 1), rng.rand(h - 1), color, ch)
elif elapsed < OnPhosphorMs:
# Phase 3: Phosphor ramp — screen fills with brightening blocks
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: fgCyan else: fgWhite
for y in 0..<h:
for x in 0..<w:
tb.write(x, y, color, ch)
elif elapsed < OnStaticMs:
# Phase 4: Static noise burst
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, fgCyan, fgWhite, fgCyan][int(r shr 24) mod 4]
tb.write(x, y, color, ch)
elif elapsed < OnBeamMs:
# Phase 5: Beam sweep — electron beam scans top to bottom
let beamRow = int((elapsed - OnStaticMs).float /
(OnBeamMs - OnStaticMs).float * h.float)
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, fgCyan, "")
elif dist <= 3 and y < beamRow:
for x in 0..<w: tb.write(x, y, fgCyan, styleDim, "")
tb.display()
sleep(16)
proc crtTurnOff*(tb: var TerminalBuffer, w, h: int) =
let start = getTime()
let cx = w div 2
let cy = h div 2
while true:
let elapsed = elapsedMs(start)
if elapsed >= OffFadeMs: break
tb = newTerminalBuffer(w, h)
if elapsed < OffCollapseMs:
# Phase 1: Vertical collapse to center row
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)
elif elapsed < OffSqueezeMs:
# Phase 2: Horizontal squeeze to dot
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))
let rows = if halfW > 2: @[cy - 1, cy, cy + 1] else: @[cy]
for y in rows:
if y < 0 or y >= h: continue
let isCentre = y == cy
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: "")
elif elapsed < OffDotMs:
# Phase 3: Bright dot with phosphor glow
let t = (elapsed - OffSqueezeMs).float / (OffDotMs - OffSqueezeMs).float
let glowR = max(int(3.0 * (1.0 - t)), 1)
for dy in -glowR..glowR:
for dx in (-glowR * 2)..(glowR * 2):
let dist = sqrt((dx.float / 2.0) * (dx.float / 2.0) + dy.float * dy.float)
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 ch = if dx == 0 and dy == 0: ""
elif dist < glowR.float * 0.4: ""
else: "·"
tb.write(px, py, brightColor(b), 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: fgCyan else: fgGreen), "·")
tb.display()
sleep(16)

100
src/crt/phosphor.nim Normal file
View File

@@ -0,0 +1,100 @@
## 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.9 # brightness at beam impact
BloomInner* = 0.25 # glow spread to adjacent pixels
BloomOuter* = 0.08 # faint halo from electron scatter
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
pb.add(x, y, BeamIntensity)
# Inner bloom — phosphor scatter
pb.add(x, y - 1, BloomInner)
pb.add(x, y + 1, BloomInner)
pb.add(x - 1, y, BloomInner * 0.5)
pb.add(x + 1, y, BloomInner * 0.5)
# Outer bloom — electron scatter
pb.add(x, y - 2, BloomOuter)
pb.add(x, y + 2, BloomOuter)
pb.add(x - 1, y - 1, BloomOuter)
pb.add(x + 1, y - 1, BloomOuter)
pb.add(x - 1, y + 1, BloomOuter)
pb.add(x + 1, y + 1, BloomOuter)
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))

137
src/crt/scope.nim Normal file
View File

@@ -0,0 +1,137 @@
## Oscilloscope: trace rendering, graticule grid, and HUD overlay.
import illwill, strutils
import phosphor
type
DisplayMode* = enum
ModeYT ## Time-domain: x=time, y=amplitude
ModeXY ## Lissajous: x=left, y=right
GridStyle* = enum
gsGrid ## Full graticule
gsCross ## Center crosshair only
gsOff ## No grid
Scope* = object
phosphor*: PhosphorBuffer
mode*: DisplayMode
samplesL*, samplesR*: seq[float]
sampleCount*: int
gain*: float # amplitude scaling (volts/div)
timeDiv*: float # horizontal zoom (time/div)
frozen*: bool
grid*: GridStyle
proc initScope*(w, h: int): Scope =
Scope(
phosphor: initPhosphor(w, h),
mode: ModeYT,
samplesL: newSeq[float](4096),
samplesR: newSeq[float](4096),
sampleCount: 0,
gain: 3.0,
timeDiv: 1.0,
frozen: false,
grid: gsGrid
)
proc w*(s: Scope): int = s.phosphor.w
proc h*(s: Scope): int = s.phosphor.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
# ── Graticule ────────────────────────────────────────────────────────
proc drawGraticule*(tb: var TerminalBuffer, w, h: int, grid: GridStyle) =
if grid == gsOff: return
let cx = w div 2
let cy = h div 2
if grid == gsGrid:
# Division lines
for d in 1..<10:
let x = d * w div 10
if x > 0 and x < w:
for y in 0..<h:
tb.write(x, y, fgGreen, styleDim, "")
for d in 1..<8:
let y = d * h div 8
if y > 0 and y < h:
for x in 0..<w:
tb.write(x, y, fgGreen, styleDim, "")
# Center crosshair (shown for both gsGrid and gsCross)
for x in 0..<w: tb.write(x, cy, fgGreen, styleDim, "")
for y in 0..<h: tb.write(cx, y, fgGreen, styleDim, "")
tb.write(cx, cy, fgGreen, "")
if grid == gsGrid:
# Intersections
for dx in 1..<10:
let x = dx * w div 10
if x > 0 and x < w:
for dy in 1..<8:
let y = dy * h div 8
if y > 0 and y < h:
tb.write(x, y, fgGreen, styleDim, "")
# ── HUD ──────────────────────────────────────────────────────────────
proc drawHUD*(tb: var TerminalBuffer, w, h: int, scope: Scope,
source: string) =
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: ""
let freezeStr = if scope.frozen: " ▌▌" else: ""
tb.write(1, 0, fgGreen, styleBright,
" " & modeStr & gainStr & tdStr & freezeStr & " ")
tb.write(w - source.len - 2, 0, fgGreen, styleDim, source)
let help = " m:mode +/-:gain [/]:time g:grid spc:freeze q:quit "
tb.write(w - help.len - 1, h - 1, fgGreen, styleDim, help)