commit 448b303eba755ff4cb81ce2c230dab20b4cd3d30 Author: rolandnsharp Date: Sun Apr 5 12:43:48 2026 +1000 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df8f83d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +crt +nimcache/ +demo.gif +demo.png/ diff --git a/CRT.nimble b/CRT.nimble new file mode 100644 index 0000000..0f6bd45 --- /dev/null +++ b/CRT.nimble @@ -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" diff --git a/demo.tape b/demo.tape new file mode 100644 index 0000000..57b4f8c --- /dev/null +++ b/demo.tape @@ -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 diff --git a/src/crt.nim b/src/crt.nim new file mode 100644 index 0000000..5feefaa --- /dev/null +++ b/src/crt.nim @@ -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() diff --git a/src/crt/audio.nim b/src/crt/audio.nim new file mode 100644 index 0000000..13f89aa --- /dev/null +++ b/src/crt/audio.nim @@ -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.. 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..= 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).. 0.8: "▓" elif b > 0.6: "▒" elif b > 0.3: "░" else: "·" + for x in 0.. 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).. 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) diff --git a/src/crt/phosphor.nim b/src/crt/phosphor.nim new file mode 100644 index 0000000..d2c8b0e --- /dev/null +++ b/src/crt/phosphor.nim @@ -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.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.. 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)) diff --git a/src/crt/scope.nim b/src/crt/scope.nim new file mode 100644 index 0000000..d85830c --- /dev/null +++ b/src/crt/scope.nim @@ -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.. 0 and x < w: + for y in 0.. 0 and y < h: + for x in 0.. 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)