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
|
# 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
|
- **Y-T and X-Y modes** — time-domain waveform or Lissajous figures
|
||||||
- **Phosphor persistence** — beam bloom, decay trails, intensity-based shading
|
- **Phosphor persistence** — beam bloom, decay trails, intensity-based shading
|
||||||
- **Half-block rendering** — 2x vertical resolution using Unicode `▀▄█` characters
|
- **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
|
## Install
|
||||||
|
|
||||||
@@ -19,7 +21,7 @@ Requires [Nim](https://nim-lang.org/) 2.x.
|
|||||||
```bash
|
```bash
|
||||||
git clone https://github.com/rolandnsharp/terminal-oscilloscope.git
|
git clone https://github.com/rolandnsharp/terminal-oscilloscope.git
|
||||||
cd terminal-oscilloscope
|
cd terminal-oscilloscope
|
||||||
nimble build
|
nim c -d:release --threads:on -o:osc src/osc.nim
|
||||||
./osc
|
./osc
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -32,6 +34,37 @@ nimble build
|
|||||||
| `]` / `[` | Zoom in / out time axis |
|
| `]` / `[` | Zoom in / out time axis |
|
||||||
| `q` | Quit (with CRT shutdown effect) |
|
| `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
|
## 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.
|
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
|
# Dependencies
|
||||||
|
|
||||||
requires "nim >= 2.2.8"
|
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.
|
## Terminal oscilloscope with CRT phosphor physics.
|
||||||
##
|
## Zero dependencies beyond Nim stdlib + libav (dlopen at runtime).
|
||||||
## - 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)
|
|
||||||
|
|
||||||
import illwill, os
|
import os
|
||||||
import osc/[effects, phosphor, scope, audio]
|
import posix/termios as ptermios
|
||||||
|
from posix import read
|
||||||
|
import osc/canvas/[term, effects]
|
||||||
|
import osc/[scope, audio]
|
||||||
|
|
||||||
proc exitProc() {.noconv.} =
|
# ── Configuration ────────────────────────────────────────────────────
|
||||||
illwillDeinit()
|
# Edit these to tune the look.
|
||||||
showCursor()
|
|
||||||
quit(0)
|
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() =
|
proc main() =
|
||||||
illwillInit(fullscreen = true)
|
initTerm()
|
||||||
setControlCHook(exitProc)
|
setRawMode()
|
||||||
hideCursor()
|
|
||||||
|
|
||||||
var w = terminalWidth()
|
|
||||||
var h = terminalHeight()
|
|
||||||
var tb = newTerminalBuffer(w, h)
|
|
||||||
|
|
||||||
crtTurnOn(tb, w, h)
|
|
||||||
|
|
||||||
|
var w = termWidth(); var h = termHeight()
|
||||||
|
var c = newCanvas(w, h, Palette, [HotGlow, WarmGlow, CoolGlow])
|
||||||
|
crtTurnOn(c)
|
||||||
var scope = initScope(w, h)
|
var scope = initScope(w, h)
|
||||||
var audio = startAudio()
|
var aud = startAudio()
|
||||||
var running = true
|
var running = true
|
||||||
|
|
||||||
|
audioChan.open()
|
||||||
|
audioRunning = true
|
||||||
|
var aThread: Thread[ptr AudioCapture]
|
||||||
|
createThread(aThread, audioThread, addr aud)
|
||||||
|
|
||||||
while running:
|
while running:
|
||||||
let nw = terminalWidth()
|
let nw = termWidth(); let nh = termHeight()
|
||||||
let nh = terminalHeight()
|
|
||||||
if nw != w or nh != h:
|
if nw != w or nh != h:
|
||||||
w = nw
|
w = nw; h = nh; c.resize(w, h); scope.resize(w, h)
|
||||||
h = nh
|
|
||||||
scope.resize(w, h)
|
|
||||||
|
|
||||||
audio.readSamples(scope)
|
let got = audioChan.tryRecv()
|
||||||
scope.phosphor.decay()
|
if got.dataAvailable:
|
||||||
scope.renderTrace()
|
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)
|
c.decayPixels(Decay)
|
||||||
scope.phosphor.render(tb)
|
c.renderTrace(scope)
|
||||||
drawHUD(tb, w, h, scope)
|
|
||||||
tb.display()
|
|
||||||
|
|
||||||
let key = getKey()
|
let hud = " " & (if scope.mode == ModeYT: "Y-T" else: "X-Y") &
|
||||||
case key
|
" G:" & $scope.gain & " "
|
||||||
of Key.Q, Key.Escape:
|
let help = " m:mode +/-:gain [/]:time q:quit "
|
||||||
running = false
|
c.flush([(1, 0, tBright, hud),
|
||||||
of Key.M:
|
(w - help.len - 1, h - 1, tDim, help)])
|
||||||
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
|
|
||||||
|
|
||||||
sleep(16)
|
sleep(16)
|
||||||
|
|
||||||
audio.stop()
|
case readKey()
|
||||||
crtTurnOff(tb, w, h)
|
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)
|
audioRunning = false
|
||||||
tb.display()
|
joinThread(aThread)
|
||||||
sleep(100)
|
audioChan.close()
|
||||||
illwillDeinit()
|
aud.stop()
|
||||||
showCursor()
|
crtTurnOff(c)
|
||||||
|
restoreMode()
|
||||||
|
deinitTerm()
|
||||||
|
|
||||||
when isMainModule:
|
when isMainModule:
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
## CRT turn-on and turn-off animations
|
## CRT turn-on and turn-off animations using direct ANSI output.
|
||||||
## Ported from AetherTune's Rust/ratatui implementation
|
## 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
|
const
|
||||||
# Turn-on phase timing (ms)
|
|
||||||
OnFlashMs = 60
|
OnFlashMs = 60
|
||||||
OnGlitchMs = 200
|
OnGlitchMs = 200
|
||||||
OnPhosphorMs = 500
|
OnPhosphorMs = 500
|
||||||
@@ -12,7 +12,6 @@ const
|
|||||||
OnBeamMs = 1100
|
OnBeamMs = 1100
|
||||||
OnTotalMs = 1200
|
OnTotalMs = 1200
|
||||||
|
|
||||||
# Turn-off phase timing (ms)
|
|
||||||
OffCollapseMs = 500
|
OffCollapseMs = 500
|
||||||
OffSqueezeMs = 800
|
OffSqueezeMs = 800
|
||||||
OffDotMs = 1200
|
OffDotMs = 1200
|
||||||
@@ -24,99 +23,139 @@ const
|
|||||||
NoiseChars = ["░", "▒", "▓", "│", "─", "┼", "╬", "·",
|
NoiseChars = ["░", "▒", "▓", "│", "─", "┼", "╬", "·",
|
||||||
":", ";", "!", "?", "$", "#", "@", "%"]
|
":", ";", "!", "?", "$", "#", "@", "%"]
|
||||||
|
|
||||||
|
proc elapsedMs(start: Time): int =
|
||||||
|
int((getTime() - start).inMilliseconds)
|
||||||
|
|
||||||
proc lcg(state: var uint64): uint64 =
|
proc lcg(state: var uint64): uint64 =
|
||||||
state = state * 6364136223846793005'u64 + 1'u64
|
state = state * 6364136223846793005'u64 + 1'u64
|
||||||
result = state
|
result = state
|
||||||
|
|
||||||
proc elapsedMs(start: Time): int =
|
proc goto(buf: var string, x, y: int) =
|
||||||
int((getTime() - start).inMilliseconds)
|
buf.add "\x1b["
|
||||||
|
buf.add $(y + 1)
|
||||||
|
buf.add ";"
|
||||||
|
buf.add $(x + 1)
|
||||||
|
buf.add "H"
|
||||||
|
|
||||||
proc brightColor(b: float): ForegroundColor =
|
proc crtTurnOn*(c: Canvas) =
|
||||||
if b > 0.8: fgWhite elif b > 0.4: fgGreen else: fgGreen
|
|
||||||
|
|
||||||
proc crtTurnOn*(tb: var TerminalBuffer, w, h: int) =
|
|
||||||
let start = getTime()
|
let start = getTime()
|
||||||
var rng = initRand(42)
|
var rng = initRand(42)
|
||||||
|
let w = c.w
|
||||||
|
let h = c.h
|
||||||
|
var buf = newStringOfCap(w * h * 12)
|
||||||
|
|
||||||
while true:
|
while true:
|
||||||
let elapsed = elapsedMs(start)
|
let elapsed = elapsedMs(start)
|
||||||
if elapsed >= OnTotalMs: break
|
if elapsed >= OnTotalMs: break
|
||||||
tb = newTerminalBuffer(w, h)
|
buf.setLen(0)
|
||||||
|
buf.add "\x1b[H" # cursor home
|
||||||
|
|
||||||
if elapsed < OnFlashMs:
|
if elapsed < OnFlashMs:
|
||||||
# Phase 1: White flash — high-voltage discharge
|
# Phase 1: White flash
|
||||||
let c = if elapsed < OnFlashMs div 2: fgWhite else: fgGreen
|
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 y in 0..<h:
|
||||||
for x in 0..<w:
|
for x in 0..<w:
|
||||||
tb.write(x, y, c, styleReverse, " ")
|
buf.add "█"
|
||||||
|
|
||||||
elif elapsed < OnGlitchMs:
|
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
|
let count = 8 + (elapsed - OnFlashMs) div 4
|
||||||
for i in 0..<count:
|
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 ch = GlitchChars[rng.rand(GlitchChars.high)]
|
||||||
let color = if rng.rand(2) == 0: fgGreen
|
let tint = if rng.rand(2) == 0: tBright else: tHot
|
||||||
elif rng.rand(2) == 1: fgGreen
|
buf.goto(gx, gy)
|
||||||
else: fgWhite
|
buf.add "\x1b[0m"
|
||||||
tb.write(rng.rand(w - 1), rng.rand(h - 1), color, ch)
|
buf.add c.ansiFor(tint)
|
||||||
|
buf.add ch
|
||||||
|
|
||||||
elif elapsed < OnPhosphorMs:
|
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 p = (elapsed - OnGlitchMs).float / (OnPhosphorMs - OnGlitchMs).float
|
||||||
let ch = if p < 0.4: "░" elif p < 0.8: "▒" else: "▓"
|
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 y in 0..<h:
|
||||||
for x in 0..<w:
|
for x in 0..<w:
|
||||||
tb.write(x, y, color, ch)
|
buf.add ch
|
||||||
|
|
||||||
elif elapsed < OnStaticMs:
|
elif elapsed < OnStaticMs:
|
||||||
# Phase 4: Static noise burst
|
# Phase 4: Static noise
|
||||||
var seed = uint64(elapsed) * 7919
|
var seed = uint64(elapsed) * 7919
|
||||||
for y in 0..<h:
|
for y in 0..<h:
|
||||||
for x in 0..<w:
|
for x in 0..<w:
|
||||||
let r = lcg(seed)
|
let r = lcg(seed)
|
||||||
let ch = NoiseChars[int(r shr 16) mod NoiseChars.len]
|
let ch = NoiseChars[int(r shr 16) mod NoiseChars.len]
|
||||||
let color = [fgGreen, fgGreen, fgWhite, fgGreen][int(r shr 24) mod 4]
|
let tint = [tNormal, tBright, tHot, tNormal][int(r shr 24) mod 4]
|
||||||
tb.write(x, y, color, ch)
|
buf.add "\x1b[0m"
|
||||||
|
buf.add c.ansiFor(tint)
|
||||||
|
buf.add ch
|
||||||
|
|
||||||
elif elapsed < OnBeamMs:
|
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 /
|
let beamRow = int((elapsed - OnStaticMs).float /
|
||||||
(OnBeamMs - OnStaticMs).float * (h.float / 2.0))
|
(OnBeamMs - OnStaticMs).float * (h.float / 2.0))
|
||||||
for y in 0..<h:
|
for y in 0..<h:
|
||||||
let dist = abs(y - beamRow)
|
let dist = abs(y - beamRow)
|
||||||
if dist == 0:
|
if dist <= 3:
|
||||||
for x in 0..<w: tb.write(x, y, fgWhite, styleBright, "━")
|
buf.goto(0, y)
|
||||||
elif dist == 1:
|
if dist == 0:
|
||||||
for x in 0..<w: tb.write(x, y, fgGreen, "─")
|
buf.add "\x1b[0m"
|
||||||
elif dist <= 3 and y < beamRow:
|
buf.add c.ansiFor(tHot)
|
||||||
for x in 0..<w: tb.write(x, y, fgGreen, styleDim, "─")
|
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)
|
sleep(16)
|
||||||
|
|
||||||
proc crtTurnOff*(tb: var TerminalBuffer, w, h: int) =
|
proc crtTurnOff*(c: Canvas) =
|
||||||
let start = getTime()
|
let start = getTime()
|
||||||
|
let w = c.w
|
||||||
|
let h = c.h
|
||||||
let cx = w div 2
|
let cx = w div 2
|
||||||
let cy = h div 2
|
let cy = h div 2
|
||||||
|
var buf = newStringOfCap(w * h * 12)
|
||||||
|
|
||||||
while true:
|
while true:
|
||||||
let elapsed = elapsedMs(start)
|
let elapsed = elapsedMs(start)
|
||||||
if elapsed >= OffFadeMs: break
|
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:
|
if elapsed < OffCollapseMs:
|
||||||
# Phase 1: Vertical collapse to center row
|
# Phase 1: Vertical collapse
|
||||||
let t = elapsed.float / OffCollapseMs.float
|
let t = elapsed.float / OffCollapseMs.float
|
||||||
let halfH = int((1.0 - t) * (h.float / 2.0))
|
let halfH = int((1.0 - t) * (h.float / 2.0))
|
||||||
for y in max(cy - halfH, 0)..<min(cy + halfH + 1, h):
|
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 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: "·"
|
let ch = if b > 0.8: "▓" elif b > 0.6: "▒" elif b > 0.3: "░" else: "·"
|
||||||
for x in 0..<w:
|
let tint = if b > 0.8: tHot elif b > 0.4: tBright else: tNormal
|
||||||
tb.write(x, y, brightColor(b), ch)
|
buf.goto(0, y)
|
||||||
|
buf.add "\x1b[0m"
|
||||||
|
buf.add c.ansiFor(tint)
|
||||||
|
for x in 0..<w: buf.add ch
|
||||||
|
|
||||||
elif elapsed < OffSqueezeMs:
|
elif elapsed < OffSqueezeMs:
|
||||||
# Phase 2: Horizontal squeeze to dot
|
# Phase 2: Horizontal squeeze
|
||||||
let t = (elapsed - OffCollapseMs).float / (OffSqueezeMs - OffCollapseMs).float
|
let t = (elapsed - OffCollapseMs).float / (OffSqueezeMs - OffCollapseMs).float
|
||||||
let eased = 1.0 - (1.0 - t) * (1.0 - t)
|
let eased = 1.0 - (1.0 - t) * (1.0 - t)
|
||||||
let halfW = int((1.0 - eased) * (w.float / 2.0))
|
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:
|
for y in rows:
|
||||||
if y < 0 or y >= h: continue
|
if y < 0 or y >= h: continue
|
||||||
let isCentre = y == cy
|
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):
|
for x in max(cx - halfW, 0)..<min(cx + halfW + 1, w):
|
||||||
let b = (if isCentre: 1.0 else: 0.55) *
|
buf.add (if isCentre: "━" else: "─")
|
||||||
(1.0 - abs(x - cx).float / max(halfW, 1).float * 0.4)
|
|
||||||
tb.write(x, y, brightColor(b), if isCentre: "━" else: "─")
|
|
||||||
|
|
||||||
elif elapsed < OffDotMs:
|
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 t = (elapsed - OffSqueezeMs).float / (OffDotMs - OffSqueezeMs).float
|
||||||
let glowR = max(int(3.0 * (1.0 - t)), 1)
|
let glowR = max(int(3.0 * (1.0 - t)), 1)
|
||||||
for dy in -glowR..glowR:
|
for dy in -glowR..glowR:
|
||||||
@@ -139,17 +179,28 @@ proc crtTurnOff*(tb: var TerminalBuffer, w, h: int) =
|
|||||||
if dist > glowR.float: continue
|
if dist > glowR.float: continue
|
||||||
let (px, py) = (cx + dx, cy + dy)
|
let (px, py) = (cx + dx, cy + dy)
|
||||||
if px < 0 or px >= w or py < 0 or py >= h: continue
|
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: "●"
|
let ch = if dx == 0 and dy == 0: "●"
|
||||||
elif dist < glowR.float * 0.4: "░"
|
elif dist < glowR.float * 0.4: "░"
|
||||||
else: "·"
|
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:
|
else:
|
||||||
# Phase 4: Fade to black
|
# Phase 4: Fade to black
|
||||||
let b = 1.0 - (elapsed - OffDotMs).float / (OffFadeMs - OffDotMs).float
|
let t = (elapsed - OffDotMs).float / (OffFadeMs - OffDotMs).float
|
||||||
if b > 0.05:
|
if t < 0.95:
|
||||||
tb.write(cx, cy, (if b > 0.5: fgGreen else: fgGreen), "·")
|
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)
|
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.
|
## Oscilloscope state — display mode and sample buffers.
|
||||||
|
|
||||||
import illwill, strutils
|
|
||||||
import phosphor
|
|
||||||
|
|
||||||
type
|
type
|
||||||
DisplayMode* = enum
|
DisplayMode* = enum
|
||||||
@@ -9,16 +6,14 @@ type
|
|||||||
ModeXY ## Lissajous: x=left, y=right
|
ModeXY ## Lissajous: x=left, y=right
|
||||||
|
|
||||||
Scope* = object
|
Scope* = object
|
||||||
phosphor*: PhosphorBuffer
|
|
||||||
mode*: DisplayMode
|
mode*: DisplayMode
|
||||||
samplesL*, samplesR*: seq[float]
|
samplesL*, samplesR*: seq[float]
|
||||||
sampleCount*: int
|
sampleCount*: int
|
||||||
gain*: float # amplitude scaling (volts/div)
|
gain*: float
|
||||||
timeDiv*: float # horizontal zoom (time/div)
|
timeDiv*: float
|
||||||
|
|
||||||
proc initScope*(w, h: int): Scope =
|
proc initScope*(w, h: int): Scope =
|
||||||
Scope(
|
Scope(
|
||||||
phosphor: initPhosphor(w, h),
|
|
||||||
mode: ModeYT,
|
mode: ModeYT,
|
||||||
samplesL: newSeq[float](4096),
|
samplesL: newSeq[float](4096),
|
||||||
samplesR: newSeq[float](4096),
|
samplesR: newSeq[float](4096),
|
||||||
@@ -27,67 +22,5 @@ proc initScope*(w, h: int): Scope =
|
|||||||
timeDiv: 3.4
|
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) =
|
proc resize*(scope: var Scope, w, h: int) =
|
||||||
if w == scope.w and h == scope.h: return
|
discard # scope doesn't depend on terminal size
|
||||||
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)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user