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:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
crt
|
||||||
|
nimcache/
|
||||||
|
demo.gif
|
||||||
|
demo.png/
|
||||||
13
CRT.nimble
Normal file
13
CRT.nimble
Normal 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
16
demo.tape
Normal 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
86
src/crt.nim
Normal 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
107
src/crt/audio.nim
Normal 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
155
src/crt/effects.nim
Normal 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
100
src/crt/phosphor.nim
Normal 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
137
src/crt/scope.nim
Normal 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)
|
||||||
Reference in New Issue
Block a user