diff --git a/README.md b/README.md index 53a1ba6..422df4e 100644 --- a/README.md +++ b/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. ![demo](demo.gif) @@ -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. diff --git a/osc.nimble b/osc.nimble index e9cb806..93790cd 100644 --- a/osc.nimble +++ b/osc.nimble @@ -10,4 +10,3 @@ bin = @["osc"] # Dependencies requires "nim >= 2.2.8" -requires "illwill >= 0.4.1" diff --git a/src/osc.nim b/src/osc.nim index daafaef..e20785b 100644 --- a/src/osc.nim +++ b/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.. 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..= OffFadeMs: break - tb = newTerminalBuffer(w, h) + buf.setLen(0) + buf.add "\x1b[H\x1b[0m" + for i in 0.. 0.8: "▓" elif b > 0.6: "▒" elif b > 0.3: "░" else: "·" - for x in 0.. 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..= 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).. 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) diff --git a/src/osc/canvas/term.nim b/src/osc/canvas/term.nim new file mode 100644 index 0000000..1b411e4 --- /dev/null +++ b/src/osc/canvas/term.nim @@ -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.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.. 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() diff --git a/src/osc/phosphor.nim b/src/osc/phosphor.nim deleted file mode 100644 index c524203..0000000 --- a/src/osc/phosphor.nim +++ /dev/null @@ -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.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.. MinBright or botB > MinBright: - let tOn = topB > MinBright - let bOn = botB > MinBright - if tOn and bOn: - tb.writePhosphor(x, ty, "█", toIntensity(max(topB, botB))) - elif tOn: - tb.writePhosphor(x, ty, "▀", toIntensity(topB)) - else: - tb.writePhosphor(x, ty, "▄", toIntensity(botB)) diff --git a/src/osc/scope.nim b/src/osc/scope.nim index ecec36f..7d55f31 100644 --- a/src/osc/scope.nim +++ b/src/osc/scope.nim @@ -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..