Add braille renderer with amber demo GIF
- Braille canvas: 2×4 dots per cell (4× resolution vs half-blocks) - Shared palettes, effects, and audio between both renderers - README with both demos and build instructions for each Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
/osc
|
||||
/osc_braille
|
||||
/osc_fast
|
||||
nimcache/
|
||||
demo.png/
|
||||
diag.png/
|
||||
|
||||
20
README.md
20
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
|
||||
|
||||

|
||||
|
||||
## Braille renderer (amber palette)
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
BIN
braille_demo.gif
Normal file
BIN
braille_demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 MiB |
@@ -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)
|
||||
|
||||
155
src/osc/canvas/braille.nim
Normal file
155
src/osc/canvas/braille.nim
Normal file
@@ -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.pixels.len:
|
||||
c.pixels[i] *= factor
|
||||
if c.pixels[i] < MinBright: c.pixels[i] = 0.0
|
||||
|
||||
# ── Braille encoding ─────────────────────────────────────────────────
|
||||
# Braille base: U+2800
|
||||
# Bit positions within the cell:
|
||||
# col 0: bits 0,1,2,6 (top to bottom)
|
||||
# col 1: bits 3,4,5,7 (top to bottom)
|
||||
|
||||
const DotBits = [
|
||||
[0u8, 1, 2, 6], # left column: rows 0-3
|
||||
[3u8, 4, 5, 7], # right column: rows 0-3
|
||||
]
|
||||
|
||||
proc brailleChar(pattern: uint8): string =
|
||||
## Convert an 8-bit dot pattern to a UTF-8 braille character.
|
||||
let codepoint = 0x2800 + pattern.int
|
||||
# UTF-8 encode: braille is U+2800..U+28FF (3 bytes)
|
||||
result = newString(3)
|
||||
result[0] = char(0xE0 or (codepoint shr 12))
|
||||
result[1] = char(0x80 or ((codepoint shr 6) and 0x3F))
|
||||
result[2] = char(0x80 or (codepoint and 0x3F))
|
||||
|
||||
# ── Flush ────────────────────────────────────────────────────────────
|
||||
|
||||
proc tintFor*(c: BrailleCanvas, b: float): Tint {.inline.} =
|
||||
if b > 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..<c.h:
|
||||
for tx in 0..<c.w:
|
||||
# Build the 8-bit dot pattern for this cell
|
||||
var pattern: uint8 = 0
|
||||
var maxB: float = 0.0
|
||||
var anyLit = false
|
||||
|
||||
for col in 0..1:
|
||||
for row in 0..3:
|
||||
let px = tx * 2 + col
|
||||
let py = ty * 4 + row
|
||||
if px < c.pixW and py < c.pixH:
|
||||
let b = c.pixels[c.pixIdx(px, py)]
|
||||
if b > 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
|
||||
@@ -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..<h:
|
||||
@@ -69,7 +69,7 @@ proc crtTurnOn*(c: Canvas) =
|
||||
let gx = rng.rand(w - 1)
|
||||
let gy = rng.rand(h - 1)
|
||||
let ch = GlitchChars[rng.rand(GlitchChars.high)]
|
||||
let tint = if rng.rand(2) == 0: tBright else: tHot
|
||||
let tint = if rng.rand(2) == 0: tWarm else: tHot
|
||||
buf.goto(gx, gy)
|
||||
buf.add "\x1b[0m"
|
||||
buf.add c.ansiFor(tint)
|
||||
@@ -79,7 +79,7 @@ proc crtTurnOn*(c: Canvas) =
|
||||
# Phase 3: Phosphor ramp
|
||||
let p = (elapsed - OnGlitchMs).float / (OnPhosphorMs - OnGlitchMs).float
|
||||
let ch = if p < 0.4: "░" elif p < 0.8: "▒" else: "▓"
|
||||
let tint = if p < 0.4: tDim elif p < 0.7: tNormal else: tBright
|
||||
let tint = if p < 0.4: tDim elif p < 0.7: tNormal else: tWarm
|
||||
buf.add "\x1b[0m"
|
||||
buf.add c.ansiFor(tint)
|
||||
for y in 0..<h:
|
||||
@@ -93,7 +93,7 @@ proc crtTurnOn*(c: Canvas) =
|
||||
for x in 0..<w:
|
||||
let r = lcg(seed)
|
||||
let ch = NoiseChars[int(r shr 16) mod NoiseChars.len]
|
||||
let tint = [tNormal, tBright, tHot, tNormal][int(r shr 24) mod 4]
|
||||
let tint = [tNormal, tWarm, tHot, tNormal][int(r shr 24) mod 4]
|
||||
buf.add "\x1b[0m"
|
||||
buf.add c.ansiFor(tint)
|
||||
buf.add ch
|
||||
@@ -114,7 +114,7 @@ proc crtTurnOn*(c: Canvas) =
|
||||
for x in 0..<w: buf.add "━"
|
||||
elif dist == 1:
|
||||
buf.add "\x1b[0m"
|
||||
buf.add c.ansiFor(tBright)
|
||||
buf.add c.ansiFor(tWarm)
|
||||
for x in 0..<w: buf.add "─"
|
||||
elif y < beamRow:
|
||||
buf.add "\x1b[0m"
|
||||
@@ -148,7 +148,7 @@ proc crtTurnOff*(c: Canvas) =
|
||||
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: "·"
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
157
src/osc_braille.nim
Normal file
157
src/osc_braille.nim
Normal file
@@ -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..<frame.count:
|
||||
frame.samples[i] = [scope.samplesL[i], scope.samplesR[i]]
|
||||
audioChan.send(frame)
|
||||
|
||||
# ── 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 BrailleCanvas, 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 BrailleCanvas, 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 BrailleCanvas, 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() =
|
||||
initTerm()
|
||||
setRawMode()
|
||||
|
||||
var w = termWidth(); var h = termHeight()
|
||||
var hb = newCanvas(w, h, Palette, [HotGlow, WarmGlow, CoolGlow])
|
||||
crtTurnOn(hb)
|
||||
var c = newBrailleCanvas(w, h, Palette, [HotGlow, WarmGlow, CoolGlow])
|
||||
var scope = initScope(w, h)
|
||||
var aud = startAudio()
|
||||
var running = true
|
||||
|
||||
audioChan.open()
|
||||
audioRunning = true
|
||||
var aThread: Thread[ptr AudioCapture]
|
||||
createThread(aThread, audioThread, addr aud)
|
||||
|
||||
while running:
|
||||
let nw = termWidth(); let nh = termHeight()
|
||||
if nw != w or nh != h:
|
||||
w = nw; h = nh; c.resize(w, h); scope.resize(w, h)
|
||||
|
||||
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]
|
||||
|
||||
c.decayPixels(Decay)
|
||||
c.renderTrace(scope)
|
||||
|
||||
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, tNormal, hud),
|
||||
(w - help.len - 1, h - 1, tDim, help)])
|
||||
|
||||
sleep(16)
|
||||
|
||||
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
|
||||
|
||||
audioRunning = false
|
||||
joinThread(aThread)
|
||||
audioChan.close()
|
||||
aud.stop()
|
||||
crtTurnOff(hb)
|
||||
restoreMode()
|
||||
deinitTerm()
|
||||
|
||||
when isMainModule:
|
||||
main()
|
||||
Reference in New Issue
Block a user