diff --git a/.gitignore b/.gitignore index 48c3702..a0f9dda 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /osc +/osc_braille +/osc_fast nimcache/ demo.png/ diag.png/ diff --git a/README.md b/README.md index 422df4e..9f997e8 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,20 @@ A terminal-based oscilloscope with CRT phosphor physics, written in Nim. Zero dependencies — 200KB binary, just libc. +## Half-block renderer + ![demo](demo.gif) +## Braille renderer (amber palette) + +![braille demo](braille_demo.gif) + ## Features - **CRT boot/shutdown animations** — phosphor ramp, beam sweep, vertical collapse, dot fade - **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 +- **Two renderers** — half-block (`▀▄█`) or braille dots for 4× resolution - **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 @@ -21,10 +27,20 @@ Requires [Nim](https://nim-lang.org/) 2.x. ```bash git clone https://github.com/rolandnsharp/terminal-oscilloscope.git cd terminal-oscilloscope +``` + +**Half-block version** (chunky CRT look): +```bash nim c -d:release --threads:on -o:osc src/osc.nim ./osc ``` +**Braille version** (high-resolution dots): +```bash +nim c -d:release --threads:on -o:osc_braille src/osc_braille.nim +./osc_braille +``` + ## Controls | Key | Action | @@ -36,7 +52,7 @@ nim c -d:release --threads:on -o:osc src/osc.nim ## Configuration -Edit the constants at the top of `src/osc.nim`: +Edit the constants at the top of `src/osc.nim` or `src/osc_braille.nim`: ```nim const diff --git a/braille_demo.gif b/braille_demo.gif new file mode 100644 index 0000000..6efd487 Binary files /dev/null and b/braille_demo.gif differ diff --git a/src/osc.nim b/src/osc.nim index e20785b..f42345c 100644 --- a/src/osc.nim +++ b/src/osc.nim @@ -137,7 +137,7 @@ proc main() = 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), + c.flush([(1, 0, tNormal, hud), (w - help.len - 1, h - 1, tDim, help)]) sleep(16) diff --git a/src/osc/canvas/braille.nim b/src/osc/canvas/braille.nim new file mode 100644 index 0000000..1e8d2b4 --- /dev/null +++ b/src/osc/canvas/braille.nim @@ -0,0 +1,155 @@ +## Braille-dot terminal canvas — 2×4 dots per character cell. +## Each cell maps to a Unicode braille character (U+2800–U+28FF). +## +## Dot layout per cell: +## ┌───┐ +## │ 0 3 │ bit 0 = top-left, bit 3 = top-right +## │ 1 4 │ bit 1 = mid-left, bit 4 = mid-right +## │ 2 5 │ bit 2 = bottom-left, bit 5 = bottom-right +## │ 6 7 │ bit 6 = low-left, bit 7 = low-right +## └───┘ +## +## Resolution: terminal columns × 2, terminal rows × 4 + +import terminal +import term except Canvas, newCanvas, resize, pixIdx, addPixel, decayPixels, flush + +const MinBright* = 0.02 + +type + BrailleCanvas* = object + w*, h*: int # terminal columns/rows + pixW*, pixH*: int # pixel dimensions (w*2, h*4) + pixels*: seq[float] # brightness per pixel [pixW * pixH] + buf: string + pal*: Palette + thresholds*: array[3, float] + +# ── Init / resize ─────────────────────────────────────────────────── + +proc newBrailleCanvas*(w, h: int, palette = "green", + thresholds = [0.7, 0.4, 0.15]): BrailleCanvas = + BrailleCanvas( + w: w, h: h, pixW: w * 2, pixH: h * 4, + pixels: newSeq[float](w * 2 * h * 4), + buf: newStringOfCap(w * h * 16), + pal: makePalette(palette), + thresholds: thresholds + ) + +proc resize*(c: var BrailleCanvas, w, h: int) = + if w == c.w and h == c.h: return + let pal = c.pal + let thr = c.thresholds + c = BrailleCanvas( + w: w, h: h, pixW: w * 2, pixH: h * 4, + pixels: newSeq[float](w * 2 * h * 4), + buf: newStringOfCap(w * h * 16), + pal: pal, thresholds: thr + ) + +# ── Pixel operations ───────────────────────────────────────────────── + +proc pixIdx*(c: BrailleCanvas, x, y: int): int {.inline.} = y * c.pixW + x + +proc addPixel*(c: var BrailleCanvas, 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 BrailleCanvas, factor: float) = + for i in 0.. c.thresholds[0]: tHot + elif b > c.thresholds[1]: tWarm + elif b > c.thresholds[2]: tNormal + else: tDim + +proc ansiFor*(c: BrailleCanvas, t: Tint): string {.inline.} = + case t + of tHot: c.pal.hot + of tWarm: c.pal.bright + of tNormal: c.pal.normal + of tDim: c.pal.dim + of tNone: c.pal.reset + +proc flush*(c: var BrailleCanvas, + textOverlays: openArray[(int, int, Tint, string)] = []) = + c.buf.setLen(0) + c.buf.add "\x1b[H" + + var last = tNone + + for ty in 0.. MinBright: + pattern = pattern or (1u8 shl DotBits[col][row]) + if b > maxB: maxB = b + anyLit = true + + if anyLit: + let tint = c.tintFor(maxB) + if tint != last: + c.buf.add "\x1b[0m" + c.buf.add c.ansiFor(tint) + last = tint + c.buf.add brailleChar(pattern) + 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 and termWidth/termHeight — use from term.nim diff --git a/src/osc/canvas/effects.nim b/src/osc/canvas/effects.nim index f3058e4..82e94ab 100644 --- a/src/osc/canvas/effects.nim +++ b/src/osc/canvas/effects.nim @@ -52,7 +52,7 @@ proc crtTurnOn*(c: Canvas) = if elapsed < OnFlashMs: # Phase 1: White flash - let ansi = if elapsed < OnFlashMs div 2: c.ansiFor(tHot) else: c.ansiFor(tBright) + let ansi = if elapsed < OnFlashMs div 2: c.ansiFor(tHot) else: c.ansiFor(tWarm) buf.add "\x1b[0m" buf.add ansi for y in 0.. 0.8: "▓" elif b > 0.6: "▒" elif b > 0.3: "░" else: "·" - let tint = if b > 0.8: tHot elif b > 0.4: tBright else: tNormal + let tint = if b > 0.8: tHot elif b > 0.4: tWarm else: tNormal buf.goto(0, y) buf.add "\x1b[0m" buf.add c.ansiFor(tint) @@ -184,7 +184,7 @@ proc crtTurnOff*(c: Canvas) = elif dist < glowR.float * 0.4: "░" else: "·" let tint = if falloff > 0.7: tHot - elif falloff > 0.4: tBright + elif falloff > 0.4: tWarm else: tNormal buf.goto(px, py) buf.add "\x1b[0m" diff --git a/src/osc/canvas/term.nim b/src/osc/canvas/term.nim index 1b411e4..64ad973 100644 --- a/src/osc/canvas/term.nim +++ b/src/osc/canvas/term.nim @@ -10,14 +10,14 @@ const type Tint* = enum - tNone, tDim, tNormal, tBright, tHot + tNone, tDim, tNormal, tWarm, tHot Palette* = object hot*: string # white-hot beam core bright*: string # bright phosphor normal*: string # standard glow dim*: string # faint persistence - reset: string + reset*: string Canvas* = object w*, h*: int @@ -94,14 +94,14 @@ proc decayPixels*(c: var Canvas, factor: float) = proc tintFor*(c: Canvas, b: float): Tint {.inline.} = if b > c.thresholds[0]: tHot - elif b > c.thresholds[1]: tBright + elif b > c.thresholds[1]: tWarm 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 tWarm: c.pal.bright of tNormal: c.pal.normal of tDim: c.pal.dim of tNone: c.pal.reset diff --git a/src/osc_braille.nim b/src/osc_braille.nim new file mode 100644 index 0000000..20fa193 --- /dev/null +++ b/src/osc_braille.nim @@ -0,0 +1,157 @@ +## Terminal oscilloscope — braille dot rendering (4× resolution). + +import os +import posix/termios as ptermios +from posix import read +import osc/canvas/[braille, term, effects] +import osc/[scope, audio] + +# ── Configuration ──────────────────────────────────────────────────── + +const + Decay = 0.85 + Beam = 0.4 + Bloom = 0.08 + HotGlow = 0.7 + WarmGlow = 0.4 + CoolGlow = 0.15 + Palette = "green" + +# ── Audio thread ───────────────────────────────────────────────────── + +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..