ASCII-art YouTube streaming for the Tesla in-car browser. - FastAPI server on a Mac mini, no Docker. - yt-dlp resolver: ID/URL/search. - ffmpeg with -re -fps_mode cfr for source-paced video; trivial drain consumer. Separate ffmpeg for AAC/ADTS audio. - Vendored ASCILINE renderer (MIT) for the binary wire protocol; pure fillText color path, on-demand selection flush. - HMAC PIN-gated cookie; Secure flag scheme-aware so /audio works on plain http during local dev. - LOW preset (120x50 24fps) verified clean on M4: FPS 24/24, JIT ~42ms.
5.7 KiB
ASCILINE YouTube Streamer
Plays any YouTube video (live or VOD) as a real-time ASCII art stream in a web browser. Designed for the Tesla in-car browser, works in any Chromium browser. Runs directly on a Mac mini — no Docker.
Requirements
- macOS (Apple Silicon recommended)
- Python 3.11+
- ffmpeg (Homebrew)
Setup
brew install ffmpeg
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
Or run ./run.sh once and it does the same thing idempotently, prompts
for a PIN, and starts the server.
Running
source .venv/bin/activate
python server.py --pin 1234
The server listens on http://0.0.0.0:8000 by default. Open it,
enter the PIN, then type a YouTube URL, video ID, or search query and
press Play.
| Flag | Default | Description |
|---|---|---|
--pin |
required | 4–8 digit PIN (or ASCIILINE_PIN env var) |
--port |
8000 | TCP port to listen on |
--bind |
0.0.0.0 | Bind address |
--max-source-height |
720 | Maximum source video resolution |
Quality presets
| Preset | Grid | FPS | Notes |
|---|---|---|---|
| Low (default) | 120×50 | 24 | Tesla / target |
| Med | 160×68 | 30 | Desktop |
| High | 200×84 | 30 | Desktop |
LOW is the only preset that's been verified clean on the M4 reference
(FPS 24/24, JIT ~42 ms). MED/HIGH are paint-bound on the per-cell
fillText loop on the M4 — they work, but with visible jitter. The
Tesla in-car browser runs LOW.
Preset changes take effect on the next Play (server restarts ffmpeg with the new dimensions).
Architecture
Browser Mac mini
───────── ────────────────────────────────────────
GET / ──▶ static/index.html (or pin.html if no cookie)
POST /api/auth ──▶ HMAC cookie issued (Secure only on https)
POST /api/play ──▶ resolver.py (yt-dlp) → ResolvedMedia
pipeline.py spawns ffmpeg ×2 (video + audio)
WS /ws/video ◀── INIT message + binary ASCILINE frames
GET /audio ◀── AAC/ADTS chunked stream
Wire format and rendering are vendored from
ASCILINE. See
vendor/asciline/PROTOCOL-NOTES.md.
Pipeline pacing
ffmpeg is invoked with -re -fps_mode cfr -r <target_fps> so frames
arrive evenly paced at the target rate. The consumer is then a trivial
drain — read from the queue, encode, send. Earlier iterations tried
consumer-side pacing (relative sleep, absolute targets, skip-ahead,
re-anchor) and all failed the same way: a bursty producer cannot be
smoothed by any consumer-side scheme. Source-side pacing fixed it in
one flag.
Cloudflare Tunnel
The service is designed to run behind a Cloudflare Tunnel for internet access from the Tesla:
brew install cloudflare/cloudflare/cloudflared
cloudflared tunnel login
cloudflared tunnel create asciline
cloudflared tunnel route dns asciline yourdomain.com
cloudflared tunnel run asciline
The PIN brute-force lockout uses CF-Connecting-IP, so each car/device
is locked out independently — one device's 5 bad attempts does not lock
out the others.
The auth cookie is marked Secure only when the auth request itself
came in over https. Over plain http://localhost (local dev) the
cookie is set without Secure so the browser will actually send it on
the /audio GET; otherwise audio would silently 403 while video kept
working.
Tesla browser notes
- Audio is AAC/ADTS via
<audio>; no extra codecs needed. - Touch targets are ≥ 48 px; PIN keypad has large tap targets.
- Cookie persists 30 days; you should not re-enter the PIN often.
- Preset: stick to LOW.
Known limitations
- A/V drift on long VODs. Video and audio run as two independent ffmpeg processes with no shared clock. They start together but drift is inevitable over hours. Negligible on live streams; audible after 20–40 min on long VODs. Timestamp-level sync is out of scope.
- Signed stream URL expiry (~6 h). YouTube googlevideo.com URLs carry time-limited signatures. Live sessions left running past ~6 h will stop when the manifest URL expires. Press Play again to re-resolve.
- In-memory PIN lockout resets on restart (acceptable for a personal service).
- No seeking, no pause/resume. Stop and re-Play restarts from the beginning (VOD) or live head (live).
- Single active session. A new Play immediately kills the previous.
- MED/HIGH paint-bound on M4. The per-cell
fillTextcolor render caps at ~150–300 K calls/s on Chromium; MED at 30 fps × 10880 cells is near the wall, HIGH is past it. The known fix if MED/HIGH smoothness ever matters is anImageData/putImageDatapixel renderer (thepixelModepath already does this for upstream pixel-mode streams) — NOT a glyph atlas (we tried; LRU thrashes on mode-3 colors and the result regressed to single-digit FPS). - Tesla in-car untested in this code state; numbers above are M4 proxy.
Test matrix
| Test | Input | Expected |
|---|---|---|
| VOD | dQw4w9WgXcQ |
ASCII video, audio, no LIVE badge |
| Live | https://www.youtube.com/watch?v=X4VbdwhkE10 |
ASCII + audio + LIVE badge |
| Search | lofi hip hop |
First search hit, plays |
| Invalid | not-a-real-id-!! |
Error shown in status bar |
Process cleanup
The server registers atexit + SIGTERM/SIGINT handlers. Both ffmpeg
processes get SIGTERM in parallel; SIGKILL after 3 s if needed. No
orphans survive a clean shutdown.
# Verify after Ctrl-C or kill:
ps aux | grep ffmpeg
# Expected: (nothing)