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