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:
39
README.md
39
README.md
@@ -1,6 +1,6 @@
|
||||
# terminal-oscilloscope
|
||||
|
||||
A terminal-based oscilloscope with CRT phosphor physics, written in Nim using [illwill](https://github.com/johnnovak/illwill).
|
||||
A terminal-based oscilloscope with CRT phosphor physics, written in Nim. Zero dependencies — 200KB binary, just libc.
|
||||
|
||||

|
||||
|
||||
@@ -10,7 +10,9 @@ A terminal-based oscilloscope with CRT phosphor physics, written in Nim using [i
|
||||
- **Y-T and X-Y modes** — time-domain waveform or Lissajous figures
|
||||
- **Phosphor persistence** — beam bloom, decay trails, intensity-based shading
|
||||
- **Half-block rendering** — 2x vertical resolution using Unicode `▀▄█` characters
|
||||
- **Live audio capture** — direct libav bindings via dlopen, zero dependencies
|
||||
- **Live audio capture** — direct libav bindings via dlopen, zero install
|
||||
- **Threaded audio** — 60fps rendering, audio capture on separate thread
|
||||
- **6 CRT phosphor palettes** — green, amber, cyan, blue, white, red
|
||||
|
||||
## Install
|
||||
|
||||
@@ -19,7 +21,7 @@ Requires [Nim](https://nim-lang.org/) 2.x.
|
||||
```bash
|
||||
git clone https://github.com/rolandnsharp/terminal-oscilloscope.git
|
||||
cd terminal-oscilloscope
|
||||
nimble build
|
||||
nim c -d:release --threads:on -o:osc src/osc.nim
|
||||
./osc
|
||||
```
|
||||
|
||||
@@ -32,6 +34,37 @@ nimble build
|
||||
| `]` / `[` | Zoom in / out time axis |
|
||||
| `q` | Quit (with CRT shutdown effect) |
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit the constants at the top of `src/osc.nim`:
|
||||
|
||||
```nim
|
||||
const
|
||||
# Phosphor physics
|
||||
Decay = 0.85 # persistence per frame (0.0–1.0)
|
||||
Beam = 0.4 # intensity at beam impact
|
||||
Bloom = 0.08 # horizontal glow spread
|
||||
|
||||
# Phosphor glow thresholds
|
||||
HotGlow = 0.7 # white-hot beam core
|
||||
WarmGlow = 0.4 # bright phosphor
|
||||
CoolGlow = 0.15 # dim persistence trail
|
||||
|
||||
# Palette: green, amber, cyan, blue, white, red
|
||||
Palette = "green"
|
||||
```
|
||||
|
||||
### Palettes
|
||||
|
||||
| Name | Phosphor | Look |
|
||||
|------|----------|------|
|
||||
| `green` | P31 | classic oscilloscope |
|
||||
| `amber` | P12 | warm retro terminal |
|
||||
| `cyan` | P7 | Tektronix blue-green |
|
||||
| `blue` | P11 | cool/modern |
|
||||
| `white` | P4 | TV phosphor |
|
||||
| `red` | P22-R | radar display |
|
||||
|
||||
## Audio
|
||||
|
||||
Captures system audio by opening the PulseAudio/PipeWire monitor of your default output sink directly via libavformat and libavdevice. Libraries are loaded at runtime with `dlopen` — no dev packages, no subprocess, no extra dependencies.
|
||||
|
||||
@@ -10,4 +10,3 @@ bin = @["osc"]
|
||||
# Dependencies
|
||||
|
||||
requires "nim >= 2.2.8"
|
||||
requires "illwill >= 0.4.1"
|
||||
|
||||
199
src/osc.nim
199
src/osc.nim
@@ -1,78 +1,163 @@
|
||||
## 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 2× vertical resolution
|
||||
## - Live audio via libavdevice (dlopen, zero dependencies)
|
||||
## Zero dependencies beyond Nim stdlib + libav (dlopen at runtime).
|
||||
|
||||
import illwill, os
|
||||
import osc/[effects, phosphor, scope, audio]
|
||||
import os
|
||||
import posix/termios as ptermios
|
||||
from posix import read
|
||||
import osc/canvas/[term, effects]
|
||||
import osc/[scope, audio]
|
||||
|
||||
proc exitProc() {.noconv.} =
|
||||
illwillDeinit()
|
||||
showCursor()
|
||||
quit(0)
|
||||
# ── Configuration ────────────────────────────────────────────────────
|
||||
# Edit these to tune the look.
|
||||
|
||||
const
|
||||
# Phosphor physics
|
||||
Decay = 0.85 # persistence per frame (0.0–1.0)
|
||||
Beam = 0.4 # intensity at beam impact
|
||||
Bloom = 0.08 # horizontal glow spread
|
||||
|
||||
# Phosphor glow thresholds
|
||||
HotGlow = 0.7 # white-hot beam core
|
||||
WarmGlow = 0.4 # bright phosphor
|
||||
CoolGlow = 0.15 # dim persistence trail
|
||||
|
||||
# Palette: green, amber, cyan, blue, white, red
|
||||
Palette = "green"
|
||||
|
||||
# ── Audio thread via Channel ─────────────────────────────────────────
|
||||
|
||||
type AudioFrame = object
|
||||
samples: array[4096, array[2, float]]
|
||||
count: int
|
||||
|
||||
var
|
||||
audioChan: Channel[AudioFrame]
|
||||
audioRunning: bool
|
||||
|
||||
proc audioThread(aud: ptr AudioCapture) {.thread.} =
|
||||
var scope = initScope(1, 1)
|
||||
while audioRunning:
|
||||
aud[].readSamples(scope)
|
||||
if scope.sampleCount > 0:
|
||||
var frame: AudioFrame
|
||||
frame.count = min(scope.sampleCount, 4096)
|
||||
for i in 0..<frame.count:
|
||||
frame.samples[i] = [scope.samplesL[i], scope.samplesR[i]]
|
||||
audioChan.send(frame)
|
||||
|
||||
# ── Raw terminal input ───────────────────────────────────────────────
|
||||
|
||||
var savedTermios: ptermios.Termios
|
||||
|
||||
proc setRawMode() =
|
||||
discard tcGetAttr(0.cint, addr savedTermios)
|
||||
var raw = savedTermios
|
||||
raw.c_lflag = raw.c_lflag and not (ECHO or ICANON)
|
||||
raw.c_cc[VMIN] = 0.char
|
||||
raw.c_cc[VTIME] = 0.char
|
||||
discard tcSetAttr(0.cint, TCSANOW, addr raw)
|
||||
|
||||
proc restoreMode() =
|
||||
discard tcSetAttr(0.cint, TCSANOW, addr savedTermios)
|
||||
|
||||
proc readKey(): char =
|
||||
var ch: char
|
||||
if read(0.cint, addr ch, 1) == 1: ch else: '\0'
|
||||
|
||||
# ── Phosphor ─────────────────────────────────────────────────────────
|
||||
|
||||
proc plotDot(c: var Canvas, fx, fy: float) =
|
||||
let x = int(fx); let y = int(fy)
|
||||
c.addPixel(x, y, Beam)
|
||||
c.addPixel(x - 1, y, Bloom)
|
||||
c.addPixel(x + 1, y, Bloom)
|
||||
|
||||
proc plotLine(c: var Canvas, x0, y0, x1, y1: float) =
|
||||
let steps = max(int(max(abs(x1 - x0), abs(y1 - y0))), 1)
|
||||
for i in 0..steps:
|
||||
let t = i.float / steps.float
|
||||
c.plotDot(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t)
|
||||
|
||||
proc renderTrace(c: var Canvas, scope: Scope) =
|
||||
if scope.sampleCount < 2: return
|
||||
let w = c.pixW; let h = c.pixH
|
||||
let cy = h.float / 2.0; let gain = scope.gain
|
||||
|
||||
case scope.mode
|
||||
of ModeYT:
|
||||
let visible = max(int(scope.sampleCount.float / scope.timeDiv), 2)
|
||||
var px, py: float; var first = true
|
||||
for col in 0..<w:
|
||||
let s = min((col * visible) div w, scope.sampleCount - 1)
|
||||
let x = col.float; let y = cy - scope.samplesL[s] * gain * cy * 0.5
|
||||
if first: c.plotDot(x, y) else: c.plotLine(px, py, x, y)
|
||||
first = false; px = x; py = y
|
||||
of ModeXY:
|
||||
var px, py: 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) * h.float / 2.0
|
||||
if first: c.plotDot(x, y) else: c.plotLine(px, py, x, y)
|
||||
first = false; px = x; py = y
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────
|
||||
|
||||
proc main() =
|
||||
illwillInit(fullscreen = true)
|
||||
setControlCHook(exitProc)
|
||||
hideCursor()
|
||||
|
||||
var w = terminalWidth()
|
||||
var h = terminalHeight()
|
||||
var tb = newTerminalBuffer(w, h)
|
||||
|
||||
crtTurnOn(tb, w, h)
|
||||
initTerm()
|
||||
setRawMode()
|
||||
|
||||
var w = termWidth(); var h = termHeight()
|
||||
var c = newCanvas(w, h, Palette, [HotGlow, WarmGlow, CoolGlow])
|
||||
crtTurnOn(c)
|
||||
var scope = initScope(w, h)
|
||||
var audio = startAudio()
|
||||
var aud = startAudio()
|
||||
var running = true
|
||||
|
||||
audioChan.open()
|
||||
audioRunning = true
|
||||
var aThread: Thread[ptr AudioCapture]
|
||||
createThread(aThread, audioThread, addr aud)
|
||||
|
||||
while running:
|
||||
let nw = terminalWidth()
|
||||
let nh = terminalHeight()
|
||||
let nw = termWidth(); let nh = termHeight()
|
||||
if nw != w or nh != h:
|
||||
w = nw
|
||||
h = nh
|
||||
scope.resize(w, h)
|
||||
w = nw; h = nh; c.resize(w, h); scope.resize(w, h)
|
||||
|
||||
audio.readSamples(scope)
|
||||
scope.phosphor.decay()
|
||||
scope.renderTrace()
|
||||
let got = audioChan.tryRecv()
|
||||
if got.dataAvailable:
|
||||
scope.sampleCount = got.msg.count
|
||||
for i in 0..<got.msg.count:
|
||||
scope.samplesL[i] = got.msg.samples[i][0]
|
||||
scope.samplesR[i] = got.msg.samples[i][1]
|
||||
|
||||
tb = newTerminalBuffer(w, h)
|
||||
scope.phosphor.render(tb)
|
||||
drawHUD(tb, w, h, scope)
|
||||
tb.display()
|
||||
c.decayPixels(Decay)
|
||||
c.renderTrace(scope)
|
||||
|
||||
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)
|
||||
else:
|
||||
discard
|
||||
let hud = " " & (if scope.mode == ModeYT: "Y-T" else: "X-Y") &
|
||||
" G:" & $scope.gain & " "
|
||||
let help = " m:mode +/-:gain [/]:time q:quit "
|
||||
c.flush([(1, 0, tBright, hud),
|
||||
(w - help.len - 1, h - 1, tDim, help)])
|
||||
|
||||
sleep(16)
|
||||
|
||||
audio.stop()
|
||||
crtTurnOff(tb, w, h)
|
||||
case readKey()
|
||||
of 'q', '\x1b': running = false
|
||||
of 'm': scope.mode = if scope.mode == ModeYT: ModeXY else: ModeYT
|
||||
of '+', '=': scope.gain = min(scope.gain * 1.3, 20.0)
|
||||
of '-': scope.gain = max(scope.gain / 1.3, 0.5)
|
||||
of ']': scope.timeDiv = min(scope.timeDiv * 1.5, 16.0)
|
||||
of '[': scope.timeDiv = max(scope.timeDiv / 1.5, 0.25)
|
||||
else: discard
|
||||
|
||||
tb = newTerminalBuffer(w, h)
|
||||
tb.display()
|
||||
sleep(100)
|
||||
illwillDeinit()
|
||||
showCursor()
|
||||
audioRunning = false
|
||||
joinThread(aThread)
|
||||
audioChan.close()
|
||||
aud.stop()
|
||||
crtTurnOff(c)
|
||||
restoreMode()
|
||||
deinitTerm()
|
||||
|
||||
when isMainModule:
|
||||
main()
|
||||
|
||||
@@ -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
168
src/osc/canvas/term.nim
Normal 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()
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user