Skip to content

Repository files navigation

Telnetvision

Real-time webcam / video → ASCII, streamed to a Synchronet BBS (or any terminal).

Capture on your machine, push out to a tiny relay in the cloud, and BBS callers watch a live half-block / ASCII rendering — CP437 or 24-bit color — right in their terminal. Add live subtitles from your mic. It's a webcam channel for the BBS era.

   HOME (your machine)              CLOUD                          BBS caller
 ┌────────────────────┐        ┌──────────────┐               ┌──────────────┐
 │ producer.py        │        │ service      │   localhost   │ door         │
 │  capture webcam /  │──TLS──▶│  fanout relay│──────────────▶│  renders     │──▶ SyncTERM
 │  HDMI / file / pipe│  push  │  (1 producer │   per caller  │  CP437/ANSI  │
 │  grade + downscale │  out   │   → N callers│               │  half-blocks │
 └────────────────────┘        └──────────────┘               └──────────────┘
        (Python)                    (Go)                            (Go)

Features

  • Sources: webcam, HDMI capture card, OBS virtual camera, video files, RTSP/HTTP streams, or a raw ffmpeg pipe.
  • Renders: half-block "pixel art" () or brightness ramp; 24-bit truecolor or - Live color grading from the producer (saturation / contrast / brightness), applied before send so every caller sees it.
  • Live captions: feed any speech-to-text into a file and the door draws a subtitle bar; includes a whisper.cpp mic setup.
  • Real audio (optional): callers on SyncTERM ≥ 1.10a hear the stream's sound via Audio APCs, kept in sync with the picture; older terminals silently get video-only.
  • One producer → many callers, each paced independently. Latency-bounded: the door drops stale frames instead of letting a slow link build a backlog (delta-encoded, non-blocking output).
  • Dials out from home — no inbound ports on your network. Token-authenticated, TLS-capable ingest.
  • Tune the BBS view live via door.ini — no Synchronet restart.

How it works

The producer captures a frame, color-grades it, downscales to the terminal cell grid, and pushes a compact message (RGB pixels + a render directive + an optional caption) to the service. The service is a domain-agnostic fanout relay: one publisher per channel, many subscribers, keeping only the latest frame per subscriber (drop-to-latest). Each door — launched by Synchronet per caller — turns those pixels into CP437/ANSI half-blocks or a ramp, in the caller's color depth, delta-encoding only the cells that changed, and writes via a non-blocking pacer that never queues more than it can flush (so latency stays bounded on slow links). The wire format is documented in wire.py.

With AUDIO=1 the producer also demuxes/captures sound, chops it into short FLAC/WAV chunks stamped on the same clock as the video frames, and sends them down the same connection. The relay fans audio out on its own per-subscriber queue (a few chunks deep, oldest dropped) next to the latest-frame slot. The door feature-detects the caller's terminal once at session start and, when the caller is a SyncTERM ≥ 1.10a built with libsndfile, streams each chunk as an Audio APC Store/Load/Queue triple; a local queue ledger throttles chunks the caller can't absorb, and video frames are dropped/held against the audio clock so the picture follows the sound. Terminals without the feature just get silent video — no errors on either end.

 HOME                                 CLOUD                     CALLERS
 ffmpeg/webcam ─▶ producer ══ TLS ══▶ service ─▶ door ─▶ ANSI ─▶ SyncTERM
       │             │    FRAME(pts)     │  per-sub:  │ APC audio  ≥1.10a
       └─ audio ─────┘    AUDIO(pts)     │  1 frame + │            (older:
          (same clock)                   │  N chunks  │            video only)

Install

Home (capture) side

python3 -m venv .venv && ./.venv/bin/pip install -r requirements.txt
cp .env.example .env          # set BBS_HOST, BBS_PORT, TOKEN, ...
./stream.sh                   # camera → your BBS, with a live local mirror

Mirror keys: +/- saturation · [ ] contrast · < > brightness · m half-block↔ramp · g ramp glyphs · q quit.

Home — on Windows (32-bit or 64-bit)

The producer is Python and runs on Windows; the bash launchers (stream.sh, etc.) don't, so you invoke producer.py directly. Steps from a fresh Windows box:

  1. Install Python 3 for Windows — the official installer from https://www.python.org/downloads/windows/. Tick "Add Python to PATH" in the installer. Prefer the 64-bit (x64) installer even on 64-bit Windows running a 32-bit BBS — Python's bitness is independent of EleBBS/Synchronet's. The 32-bit installer is fine for a webcam at 640×480, but HD stream sources (HDHomeRun MPEG-TS, RTSP HD cameras, 1080p files) routinely exhaust 32-bit Python's ~2 GB address space as OpenCV's FFmpeg backend buffers decode state. If you see cv2 allocation/OOM errors with an HD source, this is almost always the cause; 64-bit Python fixes it directly.

  2. Install Git for Windows (matches your bitness) — for git clone. It also includes Git Bash, in case you'd rather run the .sh launchers.

  3. Clone, set up the venv, install deps:

    git clone https://github.com/hmderdoc/telnetvision.git
    cd telnetvision
    py -m venv .venv
    .venv\Scripts\activate
    pip install -r requirements.txt
    copy .env.example .env

    Then edit .env and fill in BBS_HOST, BBS_PORT, TOKEN. On 32-bit Windows specifically, pip may not find wheels for the latest opencv-python/numpy — pin a known-compatible older combo:

    pip install "opencv-python==4.8.1.78" "numpy<2"
  4. Run the producer. Two paths:

    • Webcam / 32-bit Python is fine — invoke producer.py directly (one line; flags map 1:1 to .env keys, since stream.sh's .env loader is bash):
      python producer.py --host YOUR_BBS_HOST --port 7600 --token YOUR_TOKEN ^
          --channel cam --cols 80 --rows 24 --tls --insecure --source camera
    • HD stream source (HDHomeRun MPEG-TS, RTSP camera, 1080p file) — use stream-source.bat instead. It does ffmpeg-side pre-downscale to the door's cell grid before piping into the producer, so Python never touches a full HD frame. Required on 32-bit Python or 32-bit Windows (an undownscaled 1080p stream will OOM opencv-python in the 2 GB address space); useful everywhere else for lower memory + latency. Reads the same .env; SOURCE from env, .env, or argv[1]:
      stream-source.bat http://192.168.0.23:5005/auto/v2.1
      Prerequisite: ffmpeg.exe on PATH (see Live captions section for the install pointer).

    The live mirror (the +/- m g q keys) is auto-disabled on Windows because it relies on Unix TTY APIs — the stream itself works fine without it.

    Unattended startup (Windows). Producer auto-reconnects on stalls, retries the initial connect on transient TLS resets, and stream-source.bat auto-finds the venv's python — so the simplest way to run it at boot is Task Scheduler with action Start a programC:\path\to\telnetvision\stream-source.bat, trigger At log on (or At startup if you'd rather not auto-login). Don't put .venv\Scripts\activate in your wrapper batch — without call it replaces the parent batch and aborts it; the script doesn't need activation anyway. If you also want captions, add a second task running python caption-source.py (it has its own retry/restart-friendly loop and writes the same CAPTION_FILE the producer is broadcasting).

For live mic captions on Windows you also need whisper.cpp's whisper-stream.exe. Grab a prebuilt zip from https://github.com/ggml-org/whisper.cpp/releases — pick whisper-blas-bin-Win32.zip (32-bit) or whisper-blas-bin-x64.zip (64-bit). Extract it; the zip ships whisper-stream.exe, SDL2.dll, and a ggml model is downloaded separately. Grab a model the same way the Mac/Linux side does — ggml-base.en.bin from https://huggingface.co/ggerganov/whisper.cpp works.

caption-mic.sh is bash (run it under Git Bash if you'd rather), but you can wire whisper directly without it — the Python filter is cross-platform:

whisper-stream.exe -m models\ggml-base.en.bin -c -1 -t 6 ^
    --step 700 --length 5000 --keep 200 2>whisper.log ^
    | python caption_filter.py C:\Temp\caption.txt

Then run the producer with --caption-file C:\Temp\caption.txt (or set CAPTION_FILE in your environment). Pick a specific input device with -c <SDL index> — the indices are listed in whisper.log after startup (look for Capture device #N: '<name>').

The service/door binaries build for Linux, macOS, Windows, FreeBSD, OpenBSD × amd64/arm64 (plus 386 on Linux/Windows). They run on the BBS box — see below.

BBS / cloud side

Download the release bundle for your OS/arch (or build — see below) and follow packaging/INSTALL.md. In short: run service as a daemon (listens for the producer on :7600, for doors on 127.0.0.1:7601), open only :7600 inbound, and add door as an external program in your BBS.

Two integration paths, both work without rebuilding:

  • Synchronet — add the door with Intercept Standard I/O = Yes and Multiple Concurrent Users = Yes. The BBS bridges the door's stdin/stdout to the caller's connection. Full steps in INSTALL.md §6.
  • RA-family BBSes (EleBBS, RemoteAccess, Mystic, MagickaBBS, …) — configure the door as a regular external program with DOOR32.SYS as the dropfile format. The door auto-detects the dropfile, picks up the inherited socket handle (DOOR32.SYS line 2 when line 1 is 2/telnet), and talks to it directly. No stdio intercept needed. Details in INSTALL.md §"Other BBS packages (DOOR32.SYS)".

Configuration

The producer .env

stream.sh (and live.sh) read settings from a .env file in the project root — copy .env.example to .env and edit. Every key can also be given as a one-off override on the command line (COLS=120 ./stream.sh) or as a producer flag (./stream.sh --cols 120); an explicit override wins over .env. A typical .env:

# Where the relay lives, and the shared secret to publish to it.
BBS_HOST=your.bbs.hostname     # the cloud box running `service`
BBS_PORT=7600                  # its ingest port (matches service -ingest)
TOKEN=cfc29cba...              # matches service -token; make one: openssl rand -hex 16

# Channel name. Must match the door's `channel=` in door.ini (both default `cam`).
CHANNEL=cam                    # change to run a second feed (e.g. CHANNEL=desk)

# How big a picture to send (in terminal cells; a BBS screen is 80x24/25).
COLS=80
ROWS=24

# Transport security.
TLS=1                          # 1 = encrypt (service needs a cert); 0 = plaintext
INSECURE=1                     # 1 = don't verify the cert (self-signed); 0 = verify (real cert)

# Optional extras (safe to omit):
CAPTION_FILE=/tmp/caption.txt  # broadcast this file's last line as a subtitle
# SOURCE=camera                # what to capture (see below)
# CAPTURE=2                    # which audio input to transcribe — index OR name (see Live captions)
# FLIP=0                       # mirror off (auto-mirrors only the default webcam)
# AUDIO=1                      # stream real audio too (see Audio streaming)
Key Example What it does
BBS_HOST futureland.today Hostname/IP of the cloud box running service. The producer dials out to it.
BBS_PORT 7600 The service's ingest port. Must match the service's -ingest.
TOKEN cfc29cba… Shared secret the producer presents to publish. Must equal the service's -token. Generate with openssl rand -hex 16.
CHANNEL cam Channel name the producer publishes to. The service routes by channel: one publisher per channel, many door subscribers. Must match the door's channel= in door.ini (also cam by default). Use a different name to run a second feed alongside (e.g. CHANNEL=desk for a screencap channel, with a second door pointed at it).
COLS / ROWS 80 / 24 Picture size in character cells. 80×24/25 fills a standard BBS screen; bigger looks sharper but only helps callers whose terminals are that large, and costs bandwidth.
TLS 1 1 wraps the connection in TLS (the service must have -tls-cert/-tls-key); 0 is plaintext (and the service must run without a cert).
INSECURE 1 With TLS=1: 1 skips certificate verification — needed for a self-signed cert. Set 0 once you use a real/Let's-Encrypt cert.
SOURCE camera What to capture: camera (default webcam), a device index like 1 (HDMI capture card / OBS virtual cam), a file or URL (clip.mp4, rtsp://…), - (raw stdin from ffmpeg), or test (synthetic).
FLIP 0 Horizontal mirror. Default auto mirrors only the selfie webcam; set 0 to force off (HDMI/files), 1 to force on.
CAPTION_FILE /tmp/caption.txt The producer broadcasts this file's last line as a subtitle bar. Pair with caption-mic.sh. Omit to disable captions.
CAPTURE 2 or "usb audio" Which audio input whisper-stream transcribes. Either the integer SDL device index, or a case-insensitive device-name substring (resolved at startup — survives reorderings when AirPods or other inputs toggle). Run LIST=1 ./caption-mic.sh to dump the current devices. Omit to use the system default.

Render look (saturation, contrast, brightness, half-block↔ramp, ramp glyphs) is adjusted live with keys in the mirror, or seeded with --saturation/--contrast/--brightness/--mode/--ramp. --in-size WxH sets the frame size when SOURCE=-.

The door door.ini

Set in door.ini (re-read per caller, no restart) or pass as -flags:

Key Default Meaning
encoding cp437 glyph charset: cp437 (byte 0xDF) or utf8 ()
color truecolor truecolor (24-bit) or 16 (CGA palette)
saturation / dither 1.8 / true only applied when color = 16
fps 15 max frames/sec (the pacer drops below this on slow links)
hint true show a brief "Q/ESC to quit" banner on launch
channel cam which channel to subscribe to
debug path to log capture-device list + effective fps

A modern SyncTERM caller usually wants encoding = cp437, color = truecolor.

Audio streaming

Callers on SyncTERM ≥ 1.10a built with libsndfile can hear the stream: set AUDIO=1 in .env and the door does the rest. The door probes each caller's terminal once at session start (Q;libsndfile); anything older silently gets video-only — no retries, no errors. Callers can press M to mute/unmute at any time (muting silences immediately and lets the picture free-run; unmuting rejoins the live stream). Audio is never resampled or stretched to chase sync: the door drops or holds video frames against the audio clock instead, so a starved link gets a clean gap rather than a time-warp.

Producer .env keys:

Key Default What it does
AUDIO 0 1 captures and streams audio alongside video.
AUDIO_SOURCE auto Where the sound comes from: auto demuxes a URL/file SOURCE's own track (via ffmpeg) and falls back to the default mic for webcams; mic forces the default input device; ":<name substring>"/":<index>" picks a specific input device. Devices are captured with PortAudio (pip install sounddevice) because ffmpeg's audio-only device capture runs at ~0.4× realtime on macOS — constant chop. Anything else is an ffmpeg input (URL/file).
AUDIO_RATE / AUDIO_CHANNELS 44100 / 2 Capture format. The terminal mixes at 44.1 kHz stereo, but it resamples whatever you send — 22050 / 1 sounds fine for TV/speech and cuts the wire cost 4× (~59 KB/s as WAV instead of ~235). Start there; go up only if the link has headroom.
AUDIO_CODEC auto Chunk container: flac roughly halves bandwidth but needs pip install soundfile; wav works everywhere; auto picks flac when available.
AUDIO_CHUNK_MS 80 Chunk size, 40–200. Smaller = lower latency, more per-chunk overhead.
AUDIO_OFFSET_MS 0 Lip-sync trim. Positive shifts audio earlier relative to video — use it if you hear words after seeing the lips move.

In the producer mirror, a toggles a local audio monitor (ffplay; macOS/Linux). It starts muted — monitoring a mic input would feed back.

Door door.ini keys (all optional):

Key Default Meaning
audio auto auto = feature-detect the caller's terminal; on = skip the probe and trust it; off = never send audio. On a Windows-hosted door auto behaves like off (the synchronous probe can't read the reply there) — use on
audio_channel 2 terminal audio channel, 2–15 (0/1 are cterm-owned and rejected)
audio_slot_start / audio_slot_count 100 / 100 the rotating patch-slot pool (must fit in 0–255)
audio_max_queued 6 latency budget, in chunks: when the caller-side queue piles past it (a source clock running faster than the wall), the stale tail is flushed and playback skips to the present — audio latency stays capped instead of the picture pulling ever further ahead
audio_prebuffer 3 jitter cushion: this many chunks of reserve (as a silent clip) at stream start and after every underrun. Bigger = smoother on jittery links, at ~chunk×N extra latency
audio_resync_secs 60 blind tail cap: after this long of continuous playback with no proof the channel was ever empty, flush once — sheds terminal-side clock-drift latency the ledger can't see (lameboy's periodic resync). 0 disables
sync_slack_ms 120 A/V tolerance before a video frame is dropped (behind audio) or held (ahead)
audio_volume_db -6 channel base level, set once at session start

The service (cloud side)

service is configured by flags (typically baked into the ExecStart= line of packaging/telnetvision.service):

Flag Default Meaning
-token (required) shared secret producers must present — must equal the producer's TOKEN
-ingest :7600 where producers connect (open this port to your home IP)
-consumer 127.0.0.1:7601 where doors subscribe — keep it on localhost
-tls-cert / -tls-key TLS cert/key for the ingest listener (omit both for plaintext, then set TLS=0 on the producer)
-audio-queue 16 per-subscriber audio queue depth, in chunks (oldest dropped when full); independent of the video latest-frame slot

The token is one shared secret used on both ends. Generate it once and put the same value in both places:

openssl rand -hex 16
#   cloud:  service -token <value>      (in telnetvision.service ExecStart)
#   home:   TOKEN=<value>               (in .env)

If they don't match, the service logs bad token and drops the producer. Full daemon/systemd setup is in packaging/INSTALL.md.

Video sources

SOURCE=camera (default), a device index like SOURCE=1 (HDMI capture card or OBS virtual camera), a path/URL (SOURCE=clip.mp4, loops), or SOURCE=- to read raw bgr24 from stdin. The stdin route lets any ffmpeg capture feed the producer — the input flag is OS-specific:

# macOS:    -f avfoundation -i "<screen/device index>"
# Linux:    -f x11grab -i :0.0       (screen)  |  -f v4l2 -i /dev/video0   (webcam)
# Windows:  -f gdigrab -i desktop    (screen)  |  -f dshow -i video="..."  (device)
ffmpeg <input flags above> -pix_fmt bgr24 -s 640x480 -f rawvideo - \
  | SOURCE=- IN_SIZE=640x480 ./stream.sh

streamapp.sh "App Name" launches an app and streams the screen it's on (macOS-only — it uses avfoundation screen capture).

Live captions

caption-mic.sh runs whisper.cpp on a chosen audio input and writes the current line to CAPTION_FILE; the producer broadcasts it.

First install whisper.cpp's whisper-stream:

  • macOS: brew install whisper-cpp
  • Linux: your distro package if available, otherwise build from whisper.cpp (cmake -B build -DWHISPER_SDL2=ON && cmake --build build)
  • Windows (32 or 64-bit): grab a prebuilt zip from the whisper.cpp releases — use whisper-blas-bin-Win32.zip for 32-bit Windows or whisper-blas-bin-x64.zip for 64-bit. (The blas variants are BLAS-accelerated and faster than plain.) Extract; the zip contains whisper-stream.exe, SDL2.dll, and the other tools — no compile needed. Add the Release\ folder to your PATH (or call whisper-stream.exe by full path).
./models/download.sh            # base.en model (or: small.en for accuracy)
./live.sh                       # stream + mic captions in one command (bash; macOS/Linux)

On Windows, the equivalent (no bash required) is models\download.bat base.en, or if curl.exe isn't on PATH, the PowerShell Invoke-WebRequest form in that script's header.

Pick the audio input with CAPTURE=<index> or CAPTURE="<name substring>" — the latter is resilient when devices shuffle (e.g. AirPods toggling reorders SDL indices), at the cost of a ~10s discovery on startup. LIST=1 ./caption-mic.sh dumps the current device list cleanly so you don't have to fish through /tmp/whisper.log. If captions read [Music]/[sound effects], you're on the wrong input — see Troubleshooting.

Captioning the source's own audio (no mic)

When SOURCE is a URL or file that carries its own audio — HDHomeRun MPEG-TS, RTSP cameras, mp4 files, anything ffmpeg can read — caption-source.py demuxes that audio, chunks it, runs whisper-cli per chunk, and writes the cleaned transcription into CAPTION_FILE. The producer is already broadcasting that file, so nothing on the producer side has to change. This is the path to use when there's no local mic, or when you want to caption the content rather than the broadcaster.

Prerequisites:

  • ffmpeg on PATH — separate install from whisper. macOS: brew install ffmpeg. Linux: your distro's package. Windows: grab the "release essentials" zip from https://www.gyan.dev/ffmpeg/builds/, extract, and add the bin\ folder to PATH.
  • whisper-cli on PATH — bundled in whisper.cpp; the prebuilt Windows zip ships whisper-cli.exe alongside whisper-stream.exe.
  • A model in models/ (./models/download.sh base.en on Unix, models\download.bat base.en on Windows).

(If either binary is missing the script fails fast with an install pointer rather than a Python stacktrace.)

SOURCE=http://192.168.0.23:5005/auto/v2.1 python caption-source.py
SOURCE=clip.mp4 CAPTION_MODEL=small.en CHUNK_SECS=8 python caption-source.py

Latency is roughly CHUNK_SECS + transcribe time (~1–3s on CPU for base.en), so 5–8s end-to-end — fine for BBS captioning. Larger chunks = more accurate but laggier; smaller = snappier but choppier mid-sentence.

Note: the producer is also consuming SOURCE for video. Most HTTP/RTSP endpoints allow concurrent consumers; some HDHomeRun configurations don't — if you hit "device busy" errors, point one of them at a local tee or recording instead.

Building from source

(cd service && go build -o ../bin/service .)
(cd door    && go build -o ../bin/door .)

Go cross-compiles to Linux/macOS/Windows × amd64/arm64; the release workflow builds all six. The door's Unix latency-pacer lives behind build tags (io_unix.go) and falls back to blocking I/O on Windows (io_windows.go).

Project layout

producer.py        capture → grade → downscale → push (home)
ascii_cam.py       standalone local viewer (no streaming)
wire.py            wire protocol (framing, FRAME format)
caption_filter.py  shared text-cleaning (drops [Music], ANSI, timestamps...)
caption-mic.sh     whisper-stream on local mic → CAPTION_FILE
caption-source.py  ffmpeg-demux SOURCE audio → whisper-cli chunks → CAPTION_FILE
service/           Go fanout relay (cloud)
door/              Go per-caller renderer (BBS); io_{unix,windows}.go
packaging/         INSTALL.md, door.ini, telnetvision.service (systemd)
models/            download.sh (GGML whisper models; *.bin gitignored)
stream.sh live.sh streamapp.sh caption-mic.sh   launchers

Troubleshooting

Symptom Likely cause / fix
Captions are [Music]/[sound effects] whisper is on the wrong audio input — run LIST=1 ./caption-mic.sh to see devices, then CAPTURE=<index> or CAPTURE="<name>" to pin it
No captions at all mic permission not granted to your terminal (System Settings → Privacy → Microphone)
Screen capture is black DRM-protected content — can't and won't be captured
Door view lags / builds up expected on slow links — the pacer sheds frames; check effective fps with debug = in door.ini
No sound on caller side (a) SyncTERM below 1.10a or built without libsndfile — feature-detect disables audio automatically; (b) confirm with the door log: libsndfile: yes/no printed at session start (set debug = in door.ini); (c) AUDIO=1 not set in .env
Audio and picture drift apart constant offset: trim with AUDIO_OFFSET_MS; growing drift on a slow link: lower AUDIO_RATE (e.g. 22050) or use AUDIO_CODEC=flac to cut bandwidth
Audio is choppy / "bitcrushed" the channel is starving between chunks. First check the door log (debug = in door.ini): recv= should read ~12.5/s at 80ms chunks — lower means the audio source is underdelivering (device capture needs pip install sounddevice; ffmpeg-only device capture runs at ~0.4× realtime on macOS). If recv is healthy: AUDIO_RATE=22050 + AUDIO_CHANNELS=1, raise audio_prebuffer in door.ini, raise AUDIO_CHUNK_MS, lower the door fps
Picture slowly pulls ahead of the sound latency pile-up from clock drift between the source and the caller's audio device. The door caps it automatically (audio_max_queued budget + audio_resync_secs blind flush — resyncs= in the door log counts corrections). If it still creeps, lower audio_resync_secs (e.g. 20) or audio_max_queued (e.g. 4)

Contributing

See CONTRIBUTING.md.

License

MIT.

About

Video to ASCII / CP437 with closed captioning for terminal or BBS

Resources

Contributing

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages