openvibers/openvibe.live Commits
Kiosk companion and hardware scripts moved to OpenVibe.Extensions (ADR-023, W18): browser-extension/ and hardware/ keep only redirect READMEs pointing at OpenVibe.Extensions kiosk-companion/ and hardware/ (moved unchanged at 9c5aa64)
Changes
@@@ -87,4 +87,4 @@ Every file below is also served on the site at `/docs/<name>` (rendered by `serv- [docs/performance-audit.md](docs/performance-audit.md) — Measurements and tools- [SETUP.md](SETUP.md) — Full deployment guide- [SECURITY_AUDIT.md](SECURITY_AUDIT.md) — Security audit findings--- [hardware/README.md](hardware/README.md) — Raspberry Pi integration++- [hardware/README.md](hardware/README.md) — Raspberry Pi integration (moved to OpenVibe.Extensions `hardware/`; this is a redirect)nnew file mode 100644@@@ -0,0 +1,6 @@++# Moved: the kiosk companion extension++++The OpenVibe Kiosk companion (Firefox new-tab page and history bridge for `/kiosk`) now lives in++[OpenVibe.Extensions `kiosk-companion/`](https://github.com/OpenVibers/OpenVibe.Extensions/tree/main/kiosk-companion),++the home of OpenVibe's first-party client surfaces (ADR-023). It moved unchanged; its history before++the move is in this repository's git log under `browser-extension/openvibe-kiosk-companion/`.ddeleted file mode 100644@@@ -1,56 +0,0 @@--# OpenVibe Kiosk Companion (Firefox)----A tiny Firefox extension that gives the OpenVibe.Live **`/kiosk#input`** address bar--access to your Firefox **history** so it can autocomplete like a real browser bar.--It can also set the kiosk as your **new-tab page**.----## What it does----- Injects a content script only on `openvibe.live/kiosk` that bridges the page to a-- background script which queries `browser.history` and returns your top matches-- (ranked by visit count + recency). History never leaves your browser.--- Overrides the new-tab page to open `https://openvibe.live/kiosk#input` **with the-- kiosk's omnibar focused instead of the browser address bar**. It does this by opening-- the kiosk in a fresh tab and closing the temporary new-tab page (Firefox focuses the web-- page on a real tab navigation, but keeps focus on the URL bar if you merely update the-- new-tab tab). (Don't want the new-tab override? Delete the `chrome_url_overrides` block-- from `manifest.json`.)----The kiosk page detects the companion automatically — no configuration. Without the--extension the omnibar still works, it just falls back to its own on-page history.----## Permissions----- `history` — to read your browsing history for autocomplete.--- Runs its content script only on `*://openvibe.live/kiosk*`.----## Install — quick test (temporary, gone on restart)----1. Open `about:debugging#/runtime/this-firefox`.--2. **Load Temporary Add-on…** → pick this folder's `manifest.json`.--3. Open a new tab (or visit `openvibe.live/kiosk#input`) and start typing —-- history suggestions appear.----## Install — permanent----Firefox only runs signed extensions permanently. Sign it (free) with Mozilla:----```bash--npm install --global web-ext--cd browser-extension/openvibe-kiosk-companion--# Get API credentials at https://addons.mozilla.org/developers/addon/api/key/--web-ext sign --api-key=YOUR_JWT_ISSUER --api-secret=YOUR_JWT_SECRET --channel=unlisted--```----This produces a signed `.xpi` in `web-ext-artifacts/`. Install it via--`about:addons` → gear icon → **Install Add-on From File…**.----(Alternatively, Firefox Developer Edition / ESR / Nightly can run unsigned--extensions by setting `xpinstall.signatures.required = false` in `about:config`.)----## Files----- `manifest.json` — MV3 manifest (Firefox).--- `background.js` — queries `browser.history`, ranks results.--- `history-bridge.js` — content script that relays page ⇄ background.--- `newtab.html` / `newtab.js` — new-tab override → kiosk.ddeleted file mode 100644@@@ -1,33 +0,0 @@--/**-- * Background script — the only context allowed to touch browser.history.-- * The content script (history-bridge.js) relays the kiosk page's queries here,-- * we search history, and return ranked matches. Nothing leaves the browser.-- */--const api = (typeof browser !== "undefined") ? browser : chrome;----api.runtime.onMessage.addListener((msg) => {-- if (!msg || msg.type !== "openvibe-history-query") return;-- const q = String(msg.q || "").trim();-- if (!q) return Promise.resolve({ items: [] });---- return api.history.search({-- text: q,-- maxResults: 30,-- startTime: 0 // search all of history-- }).then((results) => {-- const seen = new Set();-- const items = (results || [])-- .filter((r) => r.url && /^https?:/i.test(r.url))-- .map((r) => ({-- url: r.url,-- title: r.title || "",-- visitCount: r.visitCount || 0,-- lastVisit: r.lastVisitTime || 0-- }))-- // Rank by how often + how recently you've visited.-- .sort((a, b) => (b.visitCount - a.visitCount) || (b.lastVisit - a.lastVisit))-- .filter((r) => (seen.has(r.url) ? false : (seen.add(r.url), true)))-- .slice(0, 8);-- return { items };-- }).catch(() => ({ items: [] }));--});ddeleted file mode 100644@@@ -1,38 +0,0 @@--/**-- * Content script injected into openvibe.live/kiosk. It is the bridge between-- * the page (which can't read history) and the background script (which can):-- *-- * page --postMessage(history-query)--> this --runtime.sendMessage--> background-- * page <--postMessage(history-result)-- this <--response-------------- background-- *-- * Only same-origin page messages are honoured, and replies are posted back with-- * the page's own origin as targetOrigin, so history never leaks to other sites.-- */--const api = (typeof browser !== "undefined") ? browser : chrome;--const ORIGIN = window.location.origin;----function announce() {-- window.postMessage({ source: "openvibe-kiosk-ext", type: "ready" }, ORIGIN);--}--// Announce both immediately and once the page's listener is surely attached.--announce();--window.addEventListener("DOMContentLoaded", announce);----window.addEventListener("message", (event) => {-- if (event.source !== window) return;-- const d = event.data;-- if (!d || d.source !== "openvibe-kiosk") return;---- if (d.type === "ping") { announce(); return; }---- if (d.type === "history-query") {-- const { q, id } = d;-- api.runtime.sendMessage({ type: "openvibe-history-query", q })-- .then((resp) => {-- window.postMessage({ source: "openvibe-kiosk-ext", type: "history-result", id, q, items: (resp && resp.items) || [] }, ORIGIN);-- })-- .catch(() => {-- window.postMessage({ source: "openvibe-kiosk-ext", type: "history-result", id, q, items: [] }, ORIGIN);-- });-- }--});ddeleted file mode 100644@@@ -1,26 +0,0 @@--{-- "manifest_version": 3,-- "name": "OpenVibe Kiosk Companion",-- "version": "1.1.0",-- "description": "Lets the OpenVibe.Live /kiosk address bar autocomplete from your Firefox history, and (optionally) sets the kiosk as your new-tab page.",-- "browser_specific_settings": {-- "gecko": {-- "id": "[email protected]",-- "strict_min_version": "115.0"-- }-- },-- "permissions": ["history"],-- "background": {-- "scripts": ["background.js"]-- },-- "content_scripts": [-- {-- "matches": ["*://openvibe.live/kiosk*", "*://www.openvibe.live/kiosk*"],-- "js": ["history-bridge.js"],-- "run_at": "document_start"-- }-- ],-- "chrome_url_overrides": {-- "newtab": "newtab.html"-- }--}ddeleted file mode 100644@@@ -1,11 +0,0 @@--<!doctype html>--<html>--<head>--<meta charset="utf-8">--<title>New Tab</title>--<style>html,body{margin:0;height:100%;background:#08090c}</style>--</head>--<body>--<script src="newtab.js"></script>--</body>--</html>ddeleted file mode 100644@@@ -1,33 +0,0 @@--// Open the kiosk with the PAGE focused instead of the address bar.--//--// The trick (same one the "Custom New Tab" extensions use): simply navigating this--// temporary new-tab tab to a URL leaves focus on the address bar. But opening the URL--// in a FRESH tab and then closing this temporary tab makes Firefox focus the web page —--// so the kiosk's omnibar input gets the caret. Falls back to a plain redirect if the--// tabs API isn't available.--const api = (typeof browser !== "undefined") ? browser : chrome;--const TARGET = "https://openvibe.live/kiosk#input";----(async () => {-- try {-- const tab = await api.tabs.getCurrent();-- if (!tab || tab.id == null) { location.replace(TARGET); return; }---- const created = await api.tabs.create({-- url: TARGET,-- index: tab.index,-- windowId: tab.windowId,-- active: true-- });---- // Keep the replacement in the same tab group, if any (best-effort).-- if (api.tabs.group && tab.groupId != null && tab.groupId > -1) {-- try { await api.tabs.group({ groupId: tab.groupId, tabIds: created.id }); } catch (e) { /* ignore */ }-- }---- await api.tabs.remove(tab.id);-- try { api.history.deleteUrl({ url: location.href }); } catch (e) { /* ignore */ }-- } catch (e) {-- location.replace(TARGET); // fallback: at least land on the kiosk-- }--})();@@@ -1,273 +1,6 @@--# OpenVibe.Live — Hardware Integration++# Moved: hardware integration--Scripts for streaming from and controlling hardware devices (Raspberry Pi, single-board computers, etc.) with OpenVibe.Live.----## Requirements----- **Python 3.7+**--- **FFmpeg** — `sudo apt install ffmpeg`--- **websocket-client** — `pip3 install websocket-client`--- For TTS: **espeak** — `sudo apt install espeak`--- For GPIO: **gpiozero** — `pip3 install gpiozero` (Raspberry Pi only)--- For PulseAudio/PipeWire audio: **pulseaudio-utils** — `sudo apt install pulseaudio-utils`----## Quick Start (Bash Script)----The easiest way to stream. Supports camera, screen capture, OBS virtual camera, and audio-only modes.----```bash--# Basic camera stream--./scripts/start-stream.sh YOUR_STREAM_KEY localhost 9710----# Screen capture--MODE=screen ./scripts/start-stream.sh YOUR_KEY localhost 9710----# OBS Virtual Camera → JSMPEG--MODE=obs ./scripts/start-stream.sh YOUR_KEY localhost 9710----# Audio only (no video)--MODE=audioonly ./scripts/start-stream.sh YOUR_KEY localhost 9711----# HD 720p with PulseAudio--RESOLUTION=1280x720 BITRATE=1200k AUDIO=pulse FPS=30 ./scripts/start-stream.sh YOUR_KEY localhost 9710----# Disable audio--NOAUDIO=1 ./scripts/start-stream.sh YOUR_KEY localhost 9710----# Custom camera device--VIDEO_DEV=/dev/video2 ./scripts/start-stream.sh YOUR_KEY localhost 9710--```----### Environment Variables----| Variable | Default | Description |--|----------|---------|-------------|--| `MODE` | camera | `camera`, `screen`, `obs`, or `audioonly` |--| `RESOLUTION` | 640x480 | Video resolution |--| `FPS` | 24 | Frames per second |--| `BITRATE` | 800k | Video bitrate |--| `AUDIO_BITRATE` | 128k | Audio bitrate |--| `AUDIO` | alsa | `alsa` or `pulse` |--| `NOAUDIO` | 0 | Set to `1` to disable audio |--| `VIDEO_DEV` | auto | Camera device path (e.g. `/dev/video0`) |--| `SCREEN_RES` | 1920x1080 | Screen capture resolution (screen mode only) |--| `DISPLAY_ID` | :0.0 | X11 display (screen mode only) |----## Finding Your Devices----### Camera / Video----```bash--# List all video devices--v4l2-ctl --list-devices----# List supported formats and resolutions--v4l2-ctl -d /dev/video0 --list-formats-ext----# Test camera with a snapshot--ffmpeg -f v4l2 -i /dev/video0 -frames 1 test.jpg----# macOS: list devices--ffmpeg -f avfoundation -list_devices true -i ""--```----### Audio / Microphone----```bash--# List ALSA audio devices--arecord -l----# List PulseAudio / PipeWire sources--pactl list short sources----# Test microphone (record 5 seconds, play back)--arecord -d 5 -f cd test.wav && aplay test.wav----# PipeWire users — check audio sources--pw-cli list-objects | grep -A2 "node.name"--```----### OBS Virtual Camera----```bash--# 1. Start OBS Studio--# 2. Go to Tools → Start Virtual Camera--# 3. Check which device it created:--v4l2-ctl --list-devices--# Usually appears as /dev/video2 or /dev/video4----# 4. Stream it (with PulseAudio for desktop audio):--ffmpeg -f v4l2 -i /dev/video2 -f pulse -i default \-- -f mpegts -codec:v mpeg1video -s 640x480 -b:v 500k -bf 0 \-- -codec:a mp2 -b:a 128k -ar 44100 -ac 1 -muxdelay 0.001 \-- http://SERVER:PORT/KEY/640/480/--```----### Raspberry Pi Camera (libcamera)----```bash--# Test if camera works--libcamera-hello -t 5000----# Stream via pipe to FFmpeg--libcamera-vid -t 0 --inline --codec mjpeg --width 640 --height 480 -o - | \-- ffmpeg -i pipe:0 -f mpegts -codec:v mpeg1video -s 640x480 -b:v 350k -bf 0 \-- -muxdelay 0.001 http://SERVER:PORT/KEY/640/480/----# With audio--libcamera-vid -t 0 --inline --codec mjpeg --width 640 --height 480 -o - | \-- ffmpeg -i pipe:0 -f alsa -i default \-- -f mpegts -codec:v mpeg1video -s 640x480 -b:v 350k -bf 0 \-- -codec:a mp2 -b:a 128k -ar 44100 -ac 1 -muxdelay 0.001 \-- http://SERVER:PORT/KEY/640/480/--```----## FFmpeg Command Reference----### JSMPEG Constraints----JSMPEG uses canvas-based MPEG1 decoding. You **must** use:--- Video codec: `mpeg1video` (not h264/h265)--- Audio codec: `mp2` (not aac/opus)--- Container: `mpegts`--- No B-frames: `-bf 0`----### Common FFmpeg Flags----| Flag | Description | Example |--|------|-------------|---------|--| `-f v4l2` | Linux camera input | `-f v4l2 -i /dev/video0` |--| `-f avfoundation` | macOS camera input | `-f avfoundation -i "0:0"` |--| `-f x11grab` | Screen capture | `-f x11grab -s 1920x1080 -i :0.0` |--| `-f alsa` | ALSA audio input | `-f alsa -i default` |--| `-f pulse` | PulseAudio input | `-f pulse -i default` |--| `-s WxH` | Output resolution | `-s 640x480` |--| `-b:v` | Video bitrate | `-b:v 800k` |--| `-b:a` | Audio bitrate | `-b:a 128k` |--| `-r` | Frame rate | `-r 30` |--| `-ar` | Audio sample rate | `-ar 44100` |--| `-ac` | Audio channels | `-ac 1` (mono), `-ac 2` (stereo) |--| `-bf 0` | Disable B-frames | Required for JSMPEG |--| `-q:v` | Quality (2=best, 31=worst) | `-q:v 5` |--| `-muxdelay` | Mux delay (lower = less latency) | `-muxdelay 0.001` |--| `-framerate` | Input capture FPS | `-framerate 30` |--| `-video_size` | Input capture resolution | `-video_size 1280x720` |----### Recommended Presets----| Device | Resolution | Video Bitrate | Audio | Notes |--|--------|-----------|---------------|-------|-------|--| Pi Zero | 320x240 | 200k | 64k mono | Use `-r 15` for stability |--| Pi 3/4 | 640x480 | 350–500k | 128k mono | Good balance |--| Desktop | 1280x720 | 800k–1.5M | 128k stereo | Smooth 30fps |--| High Quality | 1920x1080 | 2–4M | 192k stereo | Requires good upload |----## Streamer Script (Python)----Captures video (USB camera / Pi Camera) and audio (ALSA mic), then pushes to OpenVibe.Live via JSMPEG or RTMP.----```bash--# JSMPEG streaming (recommended for low-latency)--python3 streamer.py --key YOUR_STREAM_KEY --server openvibe.live----# RTMP streaming (for OBS compatibility)--python3 streamer.py --key YOUR_STREAM_KEY --server openvibe.live --protocol rtmp----# Custom settings--python3 streamer.py --key YOUR_KEY --server openvibe.live \-- --resolution 1280x720 --fps 30 --bitrate 1500 \-- --video-device /dev/video0 --audio-device default--```----### Options----| Flag | Default | Description |--|------|---------|-------------|--| `--key` | (required) | Stream key from dashboard |--| `--server` | localhost | OpenVibe.Live server |--| `--protocol` | jsmpeg | `jsmpeg` or `rtmp` |--| `--resolution` | 640x480 | Video resolution |--| `--fps` | 24 | Frames per second |--| `--bitrate` | 800 | Video bitrate (kbps) |--| `--no-audio` | false | Disable audio capture |--| `--dry-run` | false | Print command without running |----## Controller Script----Connects to OpenVibe.Live control WebSocket and executes viewer commands on the hardware (motors, servos, LEDs, TTS).----```bash--# Basic (simulation mode — prints commands)--python3 controller.py --key YOUR_STREAM_KEY --server openvibe.live----# With GPIO enabled (Raspberry Pi)--python3 controller.py --key YOUR_KEY --server openvibe.live \-- --enable-gpio --config gpio-config.json--```----### GPIO Config Example----Create `gpio-config.json`:----```json--{-- "gpio": {-- "motor_left": { "forward": 17, "backward": 18 },-- "motor_right": { "forward": 22, "backward": 23 },-- "servos": { "tilt": 12, "pan": 13 },-- "leds": { "main": 25, "status": 24 }-- }--}--```----### Supported Commands----| Command | Action | Hardware |--|---------|--------|----------|--| `forward` | Move forward 0.5s | Motors |--| `backward` | Move backward 0.5s | Motors |--| `left` | Turn left 0.3s | Motors |--| `right` | Turn right 0.3s | Motors |--| `stop` | Stop all motors | Motors |--| `tts` | Text-to-speech | espeak |--| `led_on` | Turn LED on | GPIO |--| `led_off` | Turn LED off | GPIO |--| `horn` | Play horn sound | Speaker |--| `servo_up` | Tilt servo up | Servo |--| `servo_down` | Tilt servo down | Servo |--| `servo_center` | Center servo | Servo |----## Raspberry Pi Setup----```bash--# Install system dependencies--sudo apt update--sudo apt install ffmpeg espeak python3-pip pulseaudio-utils----# Install Python packages--pip3 install websocket-client gpiozero----# Enable camera--sudo raspi-config # Interface Options → Camera → Enable----# Test camera--ffmpeg -f v4l2 -i /dev/video0 -frames 1 test.jpg----# Run both scripts--python3 streamer.py --key YOUR_KEY --server openvibe.live &--python3 controller.py --key YOUR_KEY --server openvibe.live --enable-gpio--```----## Troubleshooting----| Problem | Solution |--|---------|----------|--| No video device | Run `ls /dev/video*`. If empty, check camera connection. Try `sudo modprobe uvcvideo`. |--| No audio device | Run `arecord -l`. If empty, plug in a USB mic or check `pulseaudio --check`. |--| Device busy | Close other camera apps (OBS, Cheese, etc.) and retry. |--| Connection refused | Check server is running. Check firewall: `sudo ufw allow 9710`. |--| High latency | Lower resolution, reduce bitrate, ensure `-muxdelay 0.001`. |--| Choppy video | Upload bandwidth too low for bitrate. Reduce `-b:v` or resolution. |--| Permission denied | `sudo usermod -aG video $USER` then log out/in. |--| macOS camera | Use `-f avfoundation -i "0:0"` instead of `-f v4l2 -i /dev/video0`. |--| PipeWire no sound | Use `-f pulse -i default` (PipeWire is PulseAudio-compatible). |++The Raspberry Pi / single-board-computer scripts (`streamer.py`, `controller.py` and their guide)++now live in [OpenVibe.Extensions `hardware/`](https://github.com/OpenVibers/OpenVibe.Extensions/tree/main/hardware),++the home of OpenVibe's first-party client surfaces (ADR-023). They moved unchanged; their history++before the move is in this repository's git log under `hardware/`.ddeleted file mode 100644@@@ -1,278 +0,0 @@--#!/usr/bin/env python3--"""--OpenVibe.Live — Hardware Controller Script--Connects to OpenVibe.Live control WebSocket and executes commands--on Raspberry Pi GPIO, servos, motors, TTS, etc.----Usage:-- python3 controller.py --key YOUR_STREAM_KEY --server openvibe.live----Requirements:-- - websocket-client: pip3 install websocket-client-- - For TTS: espeak or pico2wave (apt install espeak)-- - For GPIO: RPi.GPIO or gpiozero (pip3 install gpiozero)--"""----import argparse--import json--import subprocess--import sys--import signal--import time--import threading----try:-- import websocket--except ImportError:-- print("[ERROR] websocket-client not installed. Run: pip3 install websocket-client")-- sys.exit(1)----# Try to import GPIO (Raspberry Pi only)--GPIO_AVAILABLE = False--try:-- from gpiozero import Motor, Servo, LED-- GPIO_AVAILABLE = True--except ImportError:-- pass----DEFAULT_SERVER = "localhost"--DEFAULT_PORT = 3000----# ═══════════════════════════════════════════════════════════════--# Command Handlers--# ═══════════════════════════════════════════════════════════════----class HardwareController:-- """Handles incoming commands and maps them to hardware actions."""---- def __init__(self, config=None):-- self.config = config or {}-- self.motors = {}-- self.servos = {}-- self.leds = {}---- if GPIO_AVAILABLE and self.config.get("enable_gpio"):-- self._init_gpio()---- def _init_gpio(self):-- """Initialize GPIO devices based on config."""-- gpio = self.config.get("gpio", {})---- # Motors (L298N or similar H-bridge)-- if "motor_left" in gpio:-- pins = gpio["motor_left"]-- self.motors["left"] = Motor(pins["forward"], pins["backward"])-- if "motor_right" in gpio:-- pins = gpio["motor_right"]-- self.motors["right"] = Motor(pins["forward"], pins["backward"])---- # Servos-- for name, pin in gpio.get("servos", {}).items():-- self.servos[name] = Servo(pin)---- # LEDs-- for name, pin in gpio.get("leds", {}).items():-- self.leds[name] = LED(pin)---- print(f"[GPIO] Motors: {list(self.motors.keys())}, Servos: {list(self.servos.keys())}, LEDs: {list(self.leds.keys())}")---- def handle_command(self, command, data=None):-- """Route a command to the appropriate handler."""-- handlers = {-- "forward": self.cmd_forward,-- "backward": self.cmd_backward,-- "left": self.cmd_left,-- "right": self.cmd_right,-- "stop": self.cmd_stop,-- "tts": self.cmd_tts,-- "led_on": self.cmd_led_on,-- "led_off": self.cmd_led_off,-- "horn": self.cmd_horn,-- "servo_up": self.cmd_servo_up,-- "servo_down": self.cmd_servo_down,-- "servo_center": self.cmd_servo_center,-- }---- handler = handlers.get(command)-- if handler:-- print(f"[CMD] {command} {data or ''}")-- handler(data)-- else:-- print(f"[CMD] Unknown command: {command}")---- # Movement commands-- def cmd_forward(self, data=None):-- duration = (data or {}).get("duration", 0.5)-- if self.motors:-- for m in self.motors.values():-- m.forward()-- threading.Timer(duration, self.cmd_stop).start()-- else:-- print(" → [SIM] Moving forward")---- def cmd_backward(self, data=None):-- duration = (data or {}).get("duration", 0.5)-- if self.motors:-- for m in self.motors.values():-- m.backward()-- threading.Timer(duration, self.cmd_stop).start()-- else:-- print(" → [SIM] Moving backward")---- def cmd_left(self, data=None):-- duration = (data or {}).get("duration", 0.3)-- if "left" in self.motors and "right" in self.motors:-- self.motors["left"].backward()-- self.motors["right"].forward()-- threading.Timer(duration, self.cmd_stop).start()-- else:-- print(" → [SIM] Turning left")---- def cmd_right(self, data=None):-- duration = (data or {}).get("duration", 0.3)-- if "left" in self.motors and "right" in self.motors:-- self.motors["left"].forward()-- self.motors["right"].backward()-- threading.Timer(duration, self.cmd_stop).start()-- else:-- print(" → [SIM] Turning right")---- def cmd_stop(self, data=None):-- for m in self.motors.values():-- m.stop()-- print(" → [SIM] Stopped" if not self.motors else " → Motors stopped")---- # TTS-- def cmd_tts(self, data=None):-- text = (data or {}).get("text", "hello from OpenVibe.Live")-- try:-- subprocess.Popen(["espeak", "-s", "150", text],-- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)-- print(f" → TTS: {text}")-- except FileNotFoundError:-- print(f" → [SIM] TTS (espeak not installed): {text}")---- # LED-- def cmd_led_on(self, data=None):-- name = (data or {}).get("name", "main")-- if name in self.leds:-- self.leds[name].on()-- print(f" → LED {name} ON")---- def cmd_led_off(self, data=None):-- name = (data or {}).get("name", "main")-- if name in self.leds:-- self.leds[name].off()-- print(f" → LED {name} OFF")---- # Horn/buzzer-- def cmd_horn(self, data=None):-- duration = (data or {}).get("duration", 0.5)-- try:-- subprocess.Popen(["aplay", "-d", str(duration), "/usr/share/sounds/alsa/Front_Center.wav"],-- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)-- except FileNotFoundError:-- print(" → [SIM] Horn!")---- # Servo-- def cmd_servo_up(self, data=None):-- name = (data or {}).get("name", "tilt")-- if name in self.servos:-- self.servos[name].value = min(1, self.servos[name].value + 0.2)-- print(f" → Servo {name} UP")---- def cmd_servo_down(self, data=None):-- name = (data or {}).get("name", "tilt")-- if name in self.servos:-- self.servos[name].value = max(-1, self.servos[name].value - 0.2)-- print(f" → Servo {name} DOWN")---- def cmd_servo_center(self, data=None):-- name = (data or {}).get("name", "tilt")-- if name in self.servos:-- self.servos[name].mid()-- print(f" → Servo {name} CENTER")------# ═══════════════════════════════════════════════════════════════--# WebSocket Client--# ═══════════════════════════════════════════════════════════════----def connect(args, controller):-- """Connect to OpenVibe.Live control WebSocket."""-- protocol = "wss" if args.ssl else "ws"-- url = f"{protocol}://{args.server}:{args.port}/ws/control"-- reconnect_delay = 5---- def on_open(ws):-- print(f"[WS] Connected to {url}")-- ws.send(json.dumps({-- "type": "join",-- "role": "hardware",-- "streamKey": args.key,-- }))---- def on_message(ws, message):-- try:-- msg = json.loads(message)-- if msg.get("type") == "command":-- controller.handle_command(msg["command"], msg.get("data"))-- elif msg.get("type") == "error":-- print(f"[WS ERROR] {msg.get('message')}")-- elif msg.get("type") == "ping":-- ws.send(json.dumps({"type": "pong"}))-- except json.JSONDecodeError:-- print(f"[WS] Invalid message: {message}")---- def on_error(ws, error):-- print(f"[WS ERROR] {error}")---- def on_close(ws, code, reason):-- print(f"[WS] Disconnected (code={code}, reason={reason})")-- if not args.no_reconnect:-- print(f"[WS] Reconnecting in {reconnect_delay}s...")-- time.sleep(reconnect_delay)-- connect(args, controller)---- ws = websocket.WebSocketApp(url,-- on_open=on_open,-- on_message=on_message,-- on_error=on_error,-- on_close=on_close)---- ws.run_forever()----def main():-- parser = argparse.ArgumentParser(description="OpenVibe.Live — Hardware Controller")-- parser.add_argument("--key", required=True, help="Your OpenVibe.Live stream key")-- parser.add_argument("--server", default=DEFAULT_SERVER, help=f"Server hostname (default: {DEFAULT_SERVER})")-- parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Server port (default: {DEFAULT_PORT})")-- parser.add_argument("--ssl", action="store_true", help="Use WSS")-- parser.add_argument("--no-reconnect", action="store_true", help="Disable auto-reconnect")-- parser.add_argument("--enable-gpio", action="store_true", help="Enable GPIO (Raspberry Pi only)")-- parser.add_argument("--config", help="Path to GPIO config JSON file")-- args = parser.parse_args()---- config = {"enable_gpio": args.enable_gpio}-- if args.config:-- try:-- with open(args.config) as f:-- config = json.load(f)-- config["enable_gpio"] = args.enable_gpio-- except (FileNotFoundError, json.JSONDecodeError) as e:-- print(f"[WARN] Failed to load config: {e}")---- controller = HardwareController(config)---- print(f"\n🏕️ OpenVibe.Live — Hardware Controller")-- print(f" Server: {args.server}:{args.port}")-- print(f" GPIO : {'enabled' if args.enable_gpio and GPIO_AVAILABLE else 'simulation mode'}")-- print(f" Press Ctrl+C to stop.\n")---- signal.signal(signal.SIGINT, lambda s, f: sys.exit(0))-- signal.signal(signal.SIGTERM, lambda s, f: sys.exit(0))---- connect(args, controller)----if __name__ == "__main__":-- main()ddeleted file mode 100644@@@ -1,177 +0,0 @@--#!/usr/bin/env python3--"""--OpenVibe.Live — FFmpeg Streamer Script--Captures video/audio from Raspberry Pi camera + mic and streams--to OpenVibe.Live via JSMPEG (HTTP POST of MPEG-TS) or RTMP.----Usage:-- python3 streamer.py --key YOUR_STREAM_KEY --server openvibe.live-- python3 streamer.py --key YOUR_STREAM_KEY --protocol rtmp --server openvibe.live----Requirements:-- - FFmpeg installed (apt install ffmpeg)-- - Camera: USB cam or Pi Camera (v4l2)-- - Microphone: USB mic or built-in (ALSA)--"""----import argparse--import subprocess--import sys--import signal--import os----DEFAULT_SERVER = "localhost"--DEFAULT_VIDEO_PORT = 9710--DEFAULT_RTMP_PORT = 1935----def get_video_device():-- """Find available video device."""-- for dev in ["/dev/video0", "/dev/video1"]:-- if os.path.exists(dev):-- return dev-- return None----def get_audio_device():-- """Find available ALSA audio device."""-- try:-- result = subprocess.run(["arecord", "-l"], capture_output=True, text=True)-- if "card" in result.stdout.lower():-- return "default"-- except FileNotFoundError:-- pass-- return None----def build_jsmpeg_command(args):-- """Build FFmpeg command for JSMPEG streaming (MPEG-TS over HTTP POST)."""-- video_dev = args.video_device or get_video_device()-- audio_dev = args.audio_device or get_audio_device()---- if not video_dev:-- print("[ERROR] No video device found. Specify with --video-device")-- sys.exit(1)---- cmd = ["ffmpeg"]---- # Input: video-- cmd += ["-f", "v4l2", "-framerate", str(args.fps), "-video_size", args.resolution, "-i", video_dev]---- # Input: audio (if available)-- if audio_dev and not args.no_audio:-- cmd += ["-f", "alsa", "-i", audio_dev]---- # Video encoding: MPEG1 for JSMPEG-- cmd += [-- "-c:v", "mpeg1video",-- "-b:v", f"{args.bitrate}k",-- "-r", str(args.fps),-- "-bf", "0",-- ]---- # Audio encoding-- if audio_dev and not args.no_audio:-- cmd += ["-c:a", "mp2", "-ar", "44100", "-ac", "1", "-b:a", "64k"]-- else:-- cmd += ["-an"]---- # Output: HTTP POST to JSMPEG relay-- url = f"http://{args.server}:{args.video_port}/{args.key}"-- cmd += ["-f", "mpegts", url]---- return cmd----def build_rtmp_command(args):-- """Build FFmpeg command for RTMP streaming."""-- video_dev = args.video_device or get_video_device()-- audio_dev = args.audio_device or get_audio_device()---- if not video_dev:-- print("[ERROR] No video device found. Specify with --video-device")-- sys.exit(1)---- cmd = ["ffmpeg"]---- # Input: video-- cmd += ["-f", "v4l2", "-framerate", str(args.fps), "-video_size", args.resolution, "-i", video_dev]---- # Input: audio-- if audio_dev and not args.no_audio:-- cmd += ["-f", "alsa", "-i", audio_dev]---- # Video encoding: H.264-- cmd += [-- "-c:v", "libx264",-- "-preset", "ultrafast",-- "-tune", "zerolatency",-- "-b:v", f"{args.bitrate}k",-- "-maxrate", f"{args.bitrate}k",-- "-bufsize", f"{args.bitrate * 2}k",-- "-g", str(args.fps * 2),-- ]---- # Audio encoding: AAC-- if audio_dev and not args.no_audio:-- cmd += ["-c:a", "aac", "-ar", "44100", "-ac", "1", "-b:a", "96k"]-- else:-- cmd += ["-an"]---- # Output: RTMP-- url = f"rtmp://{args.server}:{args.rtmp_port}/live/{args.key}"-- cmd += ["-f", "flv", url]---- return cmd----def main():-- parser = argparse.ArgumentParser(description="OpenVibe.Live — Stream from Raspberry Pi / Linux")-- parser.add_argument("--key", required=True, help="Your OpenVibe.Live stream key")-- parser.add_argument("--server", default=DEFAULT_SERVER, help=f"Server hostname (default: {DEFAULT_SERVER})")-- parser.add_argument("--protocol", choices=["jsmpeg", "rtmp"], default="jsmpeg", help="Streaming protocol")-- parser.add_argument("--video-device", help="Video device path (e.g. /dev/video0)")-- parser.add_argument("--audio-device", help="ALSA audio device (e.g. default, hw:1,0)")-- parser.add_argument("--resolution", default="640x480", help="Video resolution (default: 640x480)")-- parser.add_argument("--fps", type=int, default=24, help="Frames per second (default: 24)")-- parser.add_argument("--bitrate", type=int, default=800, help="Video bitrate in kbps (default: 800)")-- parser.add_argument("--video-port", type=int, default=DEFAULT_VIDEO_PORT, help=f"JSMPEG video port (default: {DEFAULT_VIDEO_PORT})")-- parser.add_argument("--rtmp-port", type=int, default=DEFAULT_RTMP_PORT, help=f"RTMP port (default: {DEFAULT_RTMP_PORT})")-- parser.add_argument("--no-audio", action="store_true", help="Disable audio")-- parser.add_argument("--dry-run", action="store_true", help="Print FFmpeg command without running")-- args = parser.parse_args()---- if args.protocol == "jsmpeg":-- cmd = build_jsmpeg_command(args)-- else:-- cmd = build_rtmp_command(args)---- print(f"\n🏕️ OpenVibe.Live — {args.protocol.upper()} Streaming")-- print(f" Server : {args.server}")-- print(f" Video : {args.resolution} @ {args.fps}fps, {args.bitrate}kbps")-- print(f" Audio : {'disabled' if args.no_audio else 'enabled'}")-- print(f" Command: {' '.join(cmd)}\n")---- if args.dry_run:-- print("[DRY RUN] Command not executed.")-- return---- # Run FFmpeg-- proc = None-- def signal_handler(sig, frame):-- print("\n[INFO] Stopping stream...")-- if proc:-- proc.terminate()-- sys.exit(0)---- signal.signal(signal.SIGINT, signal_handler)-- signal.signal(signal.SIGTERM, signal_handler)---- print("[INFO] Starting stream... Press Ctrl+C to stop.")-- try:-- proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)-- _, stderr = proc.communicate()-- if proc.returncode != 0:-- print(f"[ERROR] FFmpeg exited with code {proc.returncode}")-- print(stderr.decode()[-500:] if stderr else "")-- except FileNotFoundError:-- print("[ERROR] FFmpeg not found. Install with: sudo apt install ffmpeg")-- sys.exit(1)----if __name__ == "__main__":-- main()
@@ -87,4 +87,4 @@ Every file below is also served on the site at `/docs/<name>` (rendered by `serv
@@ -87,4 +87,4 @@ Every file below is also served on the site at `/docs/<name>` (rendered by `serv
- [docs/performance-audit.md](docs/performance-audit.md) — Measurements and tools
- [docs/performance-audit.md](docs/performance-audit.md) — Measurements and tools
- [SETUP.md](SETUP.md) — Full deployment guide
- [SETUP.md](SETUP.md) — Full deployment guide
- [SECURITY_AUDIT.md](SECURITY_AUDIT.md) — Security audit findings
- [SECURITY_AUDIT.md](SECURITY_AUDIT.md) — Security audit findings
-- [hardware/README.md](hardware/README.md) — Raspberry Pi integration
+- [hardware/README.md](hardware/README.md) — Raspberry Pi integration (moved to OpenVibe.Extensions `hardware/`; this is a redirect)
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+# Moved: the kiosk companion extension
+
+The OpenVibe Kiosk companion (Firefox new-tab page and history bridge for `/kiosk`) now lives in
+[OpenVibe.Extensions `kiosk-companion/`](https://github.com/OpenVibers/OpenVibe.Extensions/tree/main/kiosk-companion),
+the home of OpenVibe's first-party client surfaces (ADR-023). It moved unchanged; its history before
+the move is in this repository's git log under `browser-extension/openvibe-kiosk-companion/`.
deleted file mode 100644
deleted file mode 100644
@@ -1,56 +0,0 @@
@@ -1,56 +0,0 @@
-# OpenVibe Kiosk Companion (Firefox)
-
-A tiny Firefox extension that gives the OpenVibe.Live **`/kiosk#input`** address bar
-access to your Firefox **history** so it can autocomplete like a real browser bar.
-It can also set the kiosk as your **new-tab page**.
-
-## What it does
-
-- Injects a content script only on `openvibe.live/kiosk` that bridges the page to a
- background script which queries `browser.history` and returns your top matches
- (ranked by visit count + recency). History never leaves your browser.
-- Overrides the new-tab page to open `https://openvibe.live/kiosk#input` **with the
- kiosk's omnibar focused instead of the browser address bar**. It does this by opening
- the kiosk in a fresh tab and closing the temporary new-tab page (Firefox focuses the web
- page on a real tab navigation, but keeps focus on the URL bar if you merely update the
- new-tab tab). (Don't want the new-tab override? Delete the `chrome_url_overrides` block
- from `manifest.json`.)
-
-The kiosk page detects the companion automatically — no configuration. Without the
-extension the omnibar still works, it just falls back to its own on-page history.
-
-## Permissions
-
-- `history` — to read your browsing history for autocomplete.
-- Runs its content script only on `*://openvibe.live/kiosk*`.
-
-## Install — quick test (temporary, gone on restart)
-
-1. Open `about:debugging#/runtime/this-firefox`.
-2. **Load Temporary Add-on…** → pick this folder's `manifest.json`.
-3. Open a new tab (or visit `openvibe.live/kiosk#input`) and start typing —
- history suggestions appear.
-
-## Install — permanent
-
-Firefox only runs signed extensions permanently. Sign it (free) with Mozilla:
-
-```bash
-npm install --global web-ext
-cd browser-extension/openvibe-kiosk-companion
-# Get API credentials at https://addons.mozilla.org/developers/addon/api/key/
-web-ext sign --api-key=YOUR_JWT_ISSUER --api-secret=YOUR_JWT_SECRET --channel=unlisted
-```
-
-This produces a signed `.xpi` in `web-ext-artifacts/`. Install it via
-`about:addons` → gear icon → **Install Add-on From File…**.
-
-(Alternatively, Firefox Developer Edition / ESR / Nightly can run unsigned
-extensions by setting `xpinstall.signatures.required = false` in `about:config`.)
-
-## Files
-
-- `manifest.json` — MV3 manifest (Firefox).
-- `background.js` — queries `browser.history`, ranks results.
-- `history-bridge.js` — content script that relays page ⇄ background.
-- `newtab.html` / `newtab.js` — new-tab override → kiosk.
deleted file mode 100644
deleted file mode 100644
@@ -1,33 +0,0 @@
@@ -1,33 +0,0 @@
-/**
- * Background script — the only context allowed to touch browser.history.
- * The content script (history-bridge.js) relays the kiosk page's queries here,
- * we search history, and return ranked matches. Nothing leaves the browser.
- */
-const api = (typeof browser !== "undefined") ? browser : chrome;
-
-api.runtime.onMessage.addListener((msg) => {
- if (!msg || msg.type !== "openvibe-history-query") return;
- const q = String(msg.q || "").trim();
- if (!q) return Promise.resolve({ items: [] });
-
- return api.history.search({
- text: q,
- maxResults: 30,
- startTime: 0 // search all of history
- }).then((results) => {
- const seen = new Set();
- const items = (results || [])
- .filter((r) => r.url && /^https?:/i.test(r.url))
- .map((r) => ({
- url: r.url,
- title: r.title || "",
- visitCount: r.visitCount || 0,
- lastVisit: r.lastVisitTime || 0
- }))
- // Rank by how often + how recently you've visited.
- .sort((a, b) => (b.visitCount - a.visitCount) || (b.lastVisit - a.lastVisit))
- .filter((r) => (seen.has(r.url) ? false : (seen.add(r.url), true)))
- .slice(0, 8);
- return { items };
- }).catch(() => ({ items: [] }));
-});
deleted file mode 100644
deleted file mode 100644
@@ -1,38 +0,0 @@
@@ -1,38 +0,0 @@
-/**
- * Content script injected into openvibe.live/kiosk. It is the bridge between
- * the page (which can't read history) and the background script (which can):
- *
- * page --postMessage(history-query)--> this --runtime.sendMessage--> background
- * page <--postMessage(history-result)-- this <--response-------------- background
- *
- * Only same-origin page messages are honoured, and replies are posted back with
- * the page's own origin as targetOrigin, so history never leaks to other sites.
- */
-const api = (typeof browser !== "undefined") ? browser : chrome;
-const ORIGIN = window.location.origin;
-
-function announce() {
- window.postMessage({ source: "openvibe-kiosk-ext", type: "ready" }, ORIGIN);
-}
-// Announce both immediately and once the page's listener is surely attached.
-announce();
-window.addEventListener("DOMContentLoaded", announce);
-
-window.addEventListener("message", (event) => {
- if (event.source !== window) return;
- const d = event.data;
- if (!d || d.source !== "openvibe-kiosk") return;
-
- if (d.type === "ping") { announce(); return; }
-
- if (d.type === "history-query") {
- const { q, id } = d;
- api.runtime.sendMessage({ type: "openvibe-history-query", q })
- .then((resp) => {
- window.postMessage({ source: "openvibe-kiosk-ext", type: "history-result", id, q, items: (resp && resp.items) || [] }, ORIGIN);
- })
- .catch(() => {
- window.postMessage({ source: "openvibe-kiosk-ext", type: "history-result", id, q, items: [] }, ORIGIN);
- });
- }
-});
deleted file mode 100644
deleted file mode 100644
@@ -1,26 +0,0 @@
@@ -1,26 +0,0 @@
-{
- "manifest_version": 3,
- "name": "OpenVibe Kiosk Companion",
- "version": "1.1.0",
- "description": "Lets the OpenVibe.Live /kiosk address bar autocomplete from your Firefox history, and (optionally) sets the kiosk as your new-tab page.",
- "browser_specific_settings": {
- "gecko": {
- "id": "[email protected]",
- "strict_min_version": "115.0"
- }
- },
- "permissions": ["history"],
- "background": {
- "scripts": ["background.js"]
- },
- "content_scripts": [
- {
- "matches": ["*://openvibe.live/kiosk*", "*://www.openvibe.live/kiosk*"],
- "js": ["history-bridge.js"],
- "run_at": "document_start"
- }
- ],
- "chrome_url_overrides": {
- "newtab": "newtab.html"
- }
-}
deleted file mode 100644
deleted file mode 100644
@@ -1,11 +0,0 @@
@@ -1,11 +0,0 @@
-<!doctype html>
-<html>
-<head>
-<meta charset="utf-8">
-<title>New Tab</title>
-<style>html,body{margin:0;height:100%;background:#08090c}</style>
-</head>
-<body>
-<script src="newtab.js"></script>
-</body>
-</html>
deleted file mode 100644
deleted file mode 100644
@@ -1,33 +0,0 @@
@@ -1,33 +0,0 @@
-// Open the kiosk with the PAGE focused instead of the address bar.
-//
-// The trick (same one the "Custom New Tab" extensions use): simply navigating this
-// temporary new-tab tab to a URL leaves focus on the address bar. But opening the URL
-// in a FRESH tab and then closing this temporary tab makes Firefox focus the web page —
-// so the kiosk's omnibar input gets the caret. Falls back to a plain redirect if the
-// tabs API isn't available.
-const api = (typeof browser !== "undefined") ? browser : chrome;
-const TARGET = "https://openvibe.live/kiosk#input";
-
-(async () => {
- try {
- const tab = await api.tabs.getCurrent();
- if (!tab || tab.id == null) { location.replace(TARGET); return; }
-
- const created = await api.tabs.create({
- url: TARGET,
- index: tab.index,
- windowId: tab.windowId,
- active: true
- });
-
- // Keep the replacement in the same tab group, if any (best-effort).
- if (api.tabs.group && tab.groupId != null && tab.groupId > -1) {
- try { await api.tabs.group({ groupId: tab.groupId, tabIds: created.id }); } catch (e) { /* ignore */ }
- }
-
- await api.tabs.remove(tab.id);
- try { api.history.deleteUrl({ url: location.href }); } catch (e) { /* ignore */ }
- } catch (e) {
- location.replace(TARGET); // fallback: at least land on the kiosk
- }
-})();
@@ -1,273 +1,6 @@
@@ -1,273 +1,6 @@
-# OpenVibe.Live — Hardware Integration
+# Moved: hardware integration
-Scripts for streaming from and controlling hardware devices (Raspberry Pi, single-board computers, etc.) with OpenVibe.Live.
-
-## Requirements
-
-- **Python 3.7+**
-- **FFmpeg** — `sudo apt install ffmpeg`
-- **websocket-client** — `pip3 install websocket-client`
-- For TTS: **espeak** — `sudo apt install espeak`
-- For GPIO: **gpiozero** — `pip3 install gpiozero` (Raspberry Pi only)
-- For PulseAudio/PipeWire audio: **pulseaudio-utils** — `sudo apt install pulseaudio-utils`
-
-## Quick Start (Bash Script)
-
-The easiest way to stream. Supports camera, screen capture, OBS virtual camera, and audio-only modes.
-
-```bash
-# Basic camera stream
-./scripts/start-stream.sh YOUR_STREAM_KEY localhost 9710
-
-# Screen capture
-MODE=screen ./scripts/start-stream.sh YOUR_KEY localhost 9710
-
-# OBS Virtual Camera → JSMPEG
-MODE=obs ./scripts/start-stream.sh YOUR_KEY localhost 9710
-
-# Audio only (no video)
-MODE=audioonly ./scripts/start-stream.sh YOUR_KEY localhost 9711
-
-# HD 720p with PulseAudio
-RESOLUTION=1280x720 BITRATE=1200k AUDIO=pulse FPS=30 ./scripts/start-stream.sh YOUR_KEY localhost 9710
-
-# Disable audio
-NOAUDIO=1 ./scripts/start-stream.sh YOUR_KEY localhost 9710
-
-# Custom camera device
-VIDEO_DEV=/dev/video2 ./scripts/start-stream.sh YOUR_KEY localhost 9710
-```
-
-### Environment Variables
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `MODE` | camera | `camera`, `screen`, `obs`, or `audioonly` |
-| `RESOLUTION` | 640x480 | Video resolution |
-| `FPS` | 24 | Frames per second |
-| `BITRATE` | 800k | Video bitrate |
-| `AUDIO_BITRATE` | 128k | Audio bitrate |
-| `AUDIO` | alsa | `alsa` or `pulse` |
-| `NOAUDIO` | 0 | Set to `1` to disable audio |
-| `VIDEO_DEV` | auto | Camera device path (e.g. `/dev/video0`) |
-| `SCREEN_RES` | 1920x1080 | Screen capture resolution (screen mode only) |
-| `DISPLAY_ID` | :0.0 | X11 display (screen mode only) |
-
-## Finding Your Devices
-
-### Camera / Video
-
-```bash
-# List all video devices
-v4l2-ctl --list-devices
-
-# List supported formats and resolutions
-v4l2-ctl -d /dev/video0 --list-formats-ext
-
-# Test camera with a snapshot
-ffmpeg -f v4l2 -i /dev/video0 -frames 1 test.jpg
-
-# macOS: list devices
-ffmpeg -f avfoundation -list_devices true -i ""
-```
-
-### Audio / Microphone
-
-```bash
-# List ALSA audio devices
-arecord -l
-
-# List PulseAudio / PipeWire sources
-pactl list short sources
-
-# Test microphone (record 5 seconds, play back)
-arecord -d 5 -f cd test.wav && aplay test.wav
-
-# PipeWire users — check audio sources
-pw-cli list-objects | grep -A2 "node.name"
-```
-
-### OBS Virtual Camera
-
-```bash
-# 1. Start OBS Studio
-# 2. Go to Tools → Start Virtual Camera
-# 3. Check which device it created:
-v4l2-ctl --list-devices
-# Usually appears as /dev/video2 or /dev/video4
-
-# 4. Stream it (with PulseAudio for desktop audio):
-ffmpeg -f v4l2 -i /dev/video2 -f pulse -i default \
- -f mpegts -codec:v mpeg1video -s 640x480 -b:v 500k -bf 0 \
- -codec:a mp2 -b:a 128k -ar 44100 -ac 1 -muxdelay 0.001 \
- http://SERVER:PORT/KEY/640/480/
-```
-
-### Raspberry Pi Camera (libcamera)
-
-```bash
-# Test if camera works
-libcamera-hello -t 5000
-
-# Stream via pipe to FFmpeg
-libcamera-vid -t 0 --inline --codec mjpeg --width 640 --height 480 -o - | \
- ffmpeg -i pipe:0 -f mpegts -codec:v mpeg1video -s 640x480 -b:v 350k -bf 0 \
- -muxdelay 0.001 http://SERVER:PORT/KEY/640/480/
-
-# With audio
-libcamera-vid -t 0 --inline --codec mjpeg --width 640 --height 480 -o - | \
- ffmpeg -i pipe:0 -f alsa -i default \
- -f mpegts -codec:v mpeg1video -s 640x480 -b:v 350k -bf 0 \
- -codec:a mp2 -b:a 128k -ar 44100 -ac 1 -muxdelay 0.001 \
- http://SERVER:PORT/KEY/640/480/
-```
-
-## FFmpeg Command Reference
-
-### JSMPEG Constraints
-
-JSMPEG uses canvas-based MPEG1 decoding. You **must** use:
-- Video codec: `mpeg1video` (not h264/h265)
-- Audio codec: `mp2` (not aac/opus)
-- Container: `mpegts`
-- No B-frames: `-bf 0`
-
-### Common FFmpeg Flags
-
-| Flag | Description | Example |
-|------|-------------|---------|
-| `-f v4l2` | Linux camera input | `-f v4l2 -i /dev/video0` |
-| `-f avfoundation` | macOS camera input | `-f avfoundation -i "0:0"` |
-| `-f x11grab` | Screen capture | `-f x11grab -s 1920x1080 -i :0.0` |
-| `-f alsa` | ALSA audio input | `-f alsa -i default` |
-| `-f pulse` | PulseAudio input | `-f pulse -i default` |
-| `-s WxH` | Output resolution | `-s 640x480` |
-| `-b:v` | Video bitrate | `-b:v 800k` |
-| `-b:a` | Audio bitrate | `-b:a 128k` |
-| `-r` | Frame rate | `-r 30` |
-| `-ar` | Audio sample rate | `-ar 44100` |
-| `-ac` | Audio channels | `-ac 1` (mono), `-ac 2` (stereo) |
-| `-bf 0` | Disable B-frames | Required for JSMPEG |
-| `-q:v` | Quality (2=best, 31=worst) | `-q:v 5` |
-| `-muxdelay` | Mux delay (lower = less latency) | `-muxdelay 0.001` |
-| `-framerate` | Input capture FPS | `-framerate 30` |
-| `-video_size` | Input capture resolution | `-video_size 1280x720` |
-
-### Recommended Presets
-
-| Device | Resolution | Video Bitrate | Audio | Notes |
-|--------|-----------|---------------|-------|-------|
-| Pi Zero | 320x240 | 200k | 64k mono | Use `-r 15` for stability |
-| Pi 3/4 | 640x480 | 350–500k | 128k mono | Good balance |
-| Desktop | 1280x720 | 800k–1.5M | 128k stereo | Smooth 30fps |
-| High Quality | 1920x1080 | 2–4M | 192k stereo | Requires good upload |
-
-## Streamer Script (Python)
-
-Captures video (USB camera / Pi Camera) and audio (ALSA mic), then pushes to OpenVibe.Live via JSMPEG or RTMP.
-
-```bash
-# JSMPEG streaming (recommended for low-latency)
-python3 streamer.py --key YOUR_STREAM_KEY --server openvibe.live
-
-# RTMP streaming (for OBS compatibility)
-python3 streamer.py --key YOUR_STREAM_KEY --server openvibe.live --protocol rtmp
-
-# Custom settings
-python3 streamer.py --key YOUR_KEY --server openvibe.live \
- --resolution 1280x720 --fps 30 --bitrate 1500 \
- --video-device /dev/video0 --audio-device default
-```
-
-### Options
-
-| Flag | Default | Description |
-|------|---------|-------------|
-| `--key` | (required) | Stream key from dashboard |
-| `--server` | localhost | OpenVibe.Live server |
-| `--protocol` | jsmpeg | `jsmpeg` or `rtmp` |
-| `--resolution` | 640x480 | Video resolution |
-| `--fps` | 24 | Frames per second |
-| `--bitrate` | 800 | Video bitrate (kbps) |
-| `--no-audio` | false | Disable audio capture |
-| `--dry-run` | false | Print command without running |
-
-## Controller Script
-
-Connects to OpenVibe.Live control WebSocket and executes viewer commands on the hardware (motors, servos, LEDs, TTS).
-
-```bash
-# Basic (simulation mode — prints commands)
-python3 controller.py --key YOUR_STREAM_KEY --server openvibe.live
-
-# With GPIO enabled (Raspberry Pi)
-python3 controller.py --key YOUR_KEY --server openvibe.live \
- --enable-gpio --config gpio-config.json
-```
-
-### GPIO Config Example
-
-Create `gpio-config.json`:
-
-```json
-{
- "gpio": {
- "motor_left": { "forward": 17, "backward": 18 },
- "motor_right": { "forward": 22, "backward": 23 },
- "servos": { "tilt": 12, "pan": 13 },
- "leds": { "main": 25, "status": 24 }
- }
-}
-```
-
-### Supported Commands
-
-| Command | Action | Hardware |
-|---------|--------|----------|
-| `forward` | Move forward 0.5s | Motors |
-| `backward` | Move backward 0.5s | Motors |
-| `left` | Turn left 0.3s | Motors |
-| `right` | Turn right 0.3s | Motors |
-| `stop` | Stop all motors | Motors |
-| `tts` | Text-to-speech | espeak |
-| `led_on` | Turn LED on | GPIO |
-| `led_off` | Turn LED off | GPIO |
-| `horn` | Play horn sound | Speaker |
-| `servo_up` | Tilt servo up | Servo |
-| `servo_down` | Tilt servo down | Servo |
-| `servo_center` | Center servo | Servo |
-
-## Raspberry Pi Setup
-
-```bash
-# Install system dependencies
-sudo apt update
-sudo apt install ffmpeg espeak python3-pip pulseaudio-utils
-
-# Install Python packages
-pip3 install websocket-client gpiozero
-
-# Enable camera
-sudo raspi-config # Interface Options → Camera → Enable
-
-# Test camera
-ffmpeg -f v4l2 -i /dev/video0 -frames 1 test.jpg
-
-# Run both scripts
-python3 streamer.py --key YOUR_KEY --server openvibe.live &
-python3 controller.py --key YOUR_KEY --server openvibe.live --enable-gpio
-```
-
-## Troubleshooting
-
-| Problem | Solution |
-|---------|----------|
-| No video device | Run `ls /dev/video*`. If empty, check camera connection. Try `sudo modprobe uvcvideo`. |
-| No audio device | Run `arecord -l`. If empty, plug in a USB mic or check `pulseaudio --check`. |
-| Device busy | Close other camera apps (OBS, Cheese, etc.) and retry. |
-| Connection refused | Check server is running. Check firewall: `sudo ufw allow 9710`. |
-| High latency | Lower resolution, reduce bitrate, ensure `-muxdelay 0.001`. |
-| Choppy video | Upload bandwidth too low for bitrate. Reduce `-b:v` or resolution. |
-| Permission denied | `sudo usermod -aG video $USER` then log out/in. |
-| macOS camera | Use `-f avfoundation -i "0:0"` instead of `-f v4l2 -i /dev/video0`. |
-| PipeWire no sound | Use `-f pulse -i default` (PipeWire is PulseAudio-compatible). |
+The Raspberry Pi / single-board-computer scripts (`streamer.py`, `controller.py` and their guide)
+now live in [OpenVibe.Extensions `hardware/`](https://github.com/OpenVibers/OpenVibe.Extensions/tree/main/hardware),
+the home of OpenVibe's first-party client surfaces (ADR-023). They moved unchanged; their history
+before the move is in this repository's git log under `hardware/`.
deleted file mode 100644
deleted file mode 100644
@@ -1,278 +0,0 @@
@@ -1,278 +0,0 @@
-#!/usr/bin/env python3
-"""
-OpenVibe.Live — Hardware Controller Script
-Connects to OpenVibe.Live control WebSocket and executes commands
-on Raspberry Pi GPIO, servos, motors, TTS, etc.
-
-Usage:
- python3 controller.py --key YOUR_STREAM_KEY --server openvibe.live
-
-Requirements:
- - websocket-client: pip3 install websocket-client
- - For TTS: espeak or pico2wave (apt install espeak)
- - For GPIO: RPi.GPIO or gpiozero (pip3 install gpiozero)
-"""
-
-import argparse
-import json
-import subprocess
-import sys
-import signal
-import time
-import threading
-
-try:
- import websocket
-except ImportError:
- print("[ERROR] websocket-client not installed. Run: pip3 install websocket-client")
- sys.exit(1)
-
-# Try to import GPIO (Raspberry Pi only)
-GPIO_AVAILABLE = False
-try:
- from gpiozero import Motor, Servo, LED
- GPIO_AVAILABLE = True
-except ImportError:
- pass
-
-DEFAULT_SERVER = "localhost"
-DEFAULT_PORT = 3000
-
-# ═══════════════════════════════════════════════════════════════
-# Command Handlers
-# ═══════════════════════════════════════════════════════════════
-
-class HardwareController:
- """Handles incoming commands and maps them to hardware actions."""
-
- def __init__(self, config=None):
- self.config = config or {}
- self.motors = {}
- self.servos = {}
- self.leds = {}
-
- if GPIO_AVAILABLE and self.config.get("enable_gpio"):
- self._init_gpio()
-
- def _init_gpio(self):
- """Initialize GPIO devices based on config."""
- gpio = self.config.get("gpio", {})
-
- # Motors (L298N or similar H-bridge)
- if "motor_left" in gpio:
- pins = gpio["motor_left"]
- self.motors["left"] = Motor(pins["forward"], pins["backward"])
- if "motor_right" in gpio:
- pins = gpio["motor_right"]
- self.motors["right"] = Motor(pins["forward"], pins["backward"])
-
- # Servos
- for name, pin in gpio.get("servos", {}).items():
- self.servos[name] = Servo(pin)
-
- # LEDs
- for name, pin in gpio.get("leds", {}).items():
- self.leds[name] = LED(pin)
-
- print(f"[GPIO] Motors: {list(self.motors.keys())}, Servos: {list(self.servos.keys())}, LEDs: {list(self.leds.keys())}")
-
- def handle_command(self, command, data=None):
- """Route a command to the appropriate handler."""
- handlers = {
- "forward": self.cmd_forward,
- "backward": self.cmd_backward,
- "left": self.cmd_left,
- "right": self.cmd_right,
- "stop": self.cmd_stop,
- "tts": self.cmd_tts,
- "led_on": self.cmd_led_on,
- "led_off": self.cmd_led_off,
- "horn": self.cmd_horn,
- "servo_up": self.cmd_servo_up,
- "servo_down": self.cmd_servo_down,
- "servo_center": self.cmd_servo_center,
- }
-
- handler = handlers.get(command)
- if handler:
- print(f"[CMD] {command} {data or ''}")
- handler(data)
- else:
- print(f"[CMD] Unknown command: {command}")
-
- # Movement commands
- def cmd_forward(self, data=None):
- duration = (data or {}).get("duration", 0.5)
- if self.motors:
- for m in self.motors.values():
- m.forward()
- threading.Timer(duration, self.cmd_stop).start()
- else:
- print(" → [SIM] Moving forward")
-
- def cmd_backward(self, data=None):
- duration = (data or {}).get("duration", 0.5)
- if self.motors:
- for m in self.motors.values():
- m.backward()
- threading.Timer(duration, self.cmd_stop).start()
- else:
- print(" → [SIM] Moving backward")
-
- def cmd_left(self, data=None):
- duration = (data or {}).get("duration", 0.3)
- if "left" in self.motors and "right" in self.motors:
- self.motors["left"].backward()
- self.motors["right"].forward()
- threading.Timer(duration, self.cmd_stop).start()
- else:
- print(" → [SIM] Turning left")
-
- def cmd_right(self, data=None):
- duration = (data or {}).get("duration", 0.3)
- if "left" in self.motors and "right" in self.motors:
- self.motors["left"].forward()
- self.motors["right"].backward()
- threading.Timer(duration, self.cmd_stop).start()
- else:
- print(" → [SIM] Turning right")
-
- def cmd_stop(self, data=None):
- for m in self.motors.values():
- m.stop()
- print(" → [SIM] Stopped" if not self.motors else " → Motors stopped")
-
- # TTS
- def cmd_tts(self, data=None):
- text = (data or {}).get("text", "hello from OpenVibe.Live")
- try:
- subprocess.Popen(["espeak", "-s", "150", text],
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
- print(f" → TTS: {text}")
- except FileNotFoundError:
- print(f" → [SIM] TTS (espeak not installed): {text}")
-
- # LED
- def cmd_led_on(self, data=None):
- name = (data or {}).get("name", "main")
- if name in self.leds:
- self.leds[name].on()
- print(f" → LED {name} ON")
-
- def cmd_led_off(self, data=None):
- name = (data or {}).get("name", "main")
- if name in self.leds:
- self.leds[name].off()
- print(f" → LED {name} OFF")
-
- # Horn/buzzer
- def cmd_horn(self, data=None):
- duration = (data or {}).get("duration", 0.5)
- try:
- subprocess.Popen(["aplay", "-d", str(duration), "/usr/share/sounds/alsa/Front_Center.wav"],
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
- except FileNotFoundError:
- print(" → [SIM] Horn!")
-
- # Servo
- def cmd_servo_up(self, data=None):
- name = (data or {}).get("name", "tilt")
- if name in self.servos:
- self.servos[name].value = min(1, self.servos[name].value + 0.2)
- print(f" → Servo {name} UP")
-
- def cmd_servo_down(self, data=None):
- name = (data or {}).get("name", "tilt")
- if name in self.servos:
- self.servos[name].value = max(-1, self.servos[name].value - 0.2)
- print(f" → Servo {name} DOWN")
-
- def cmd_servo_center(self, data=None):
- name = (data or {}).get("name", "tilt")
- if name in self.servos:
- self.servos[name].mid()
- print(f" → Servo {name} CENTER")
-
-
-# ═══════════════════════════════════════════════════════════════
-# WebSocket Client
-# ═══════════════════════════════════════════════════════════════
-
-def connect(args, controller):
- """Connect to OpenVibe.Live control WebSocket."""
- protocol = "wss" if args.ssl else "ws"
- url = f"{protocol}://{args.server}:{args.port}/ws/control"
- reconnect_delay = 5
-
- def on_open(ws):
- print(f"[WS] Connected to {url}")
- ws.send(json.dumps({
- "type": "join",
- "role": "hardware",
- "streamKey": args.key,
- }))
-
- def on_message(ws, message):
- try:
- msg = json.loads(message)
- if msg.get("type") == "command":
- controller.handle_command(msg["command"], msg.get("data"))
- elif msg.get("type") == "error":
- print(f"[WS ERROR] {msg.get('message')}")
- elif msg.get("type") == "ping":
- ws.send(json.dumps({"type": "pong"}))
- except json.JSONDecodeError:
- print(f"[WS] Invalid message: {message}")
-
- def on_error(ws, error):
- print(f"[WS ERROR] {error}")
-
- def on_close(ws, code, reason):
- print(f"[WS] Disconnected (code={code}, reason={reason})")
- if not args.no_reconnect:
- print(f"[WS] Reconnecting in {reconnect_delay}s...")
- time.sleep(reconnect_delay)
- connect(args, controller)
-
- ws = websocket.WebSocketApp(url,
- on_open=on_open,
- on_message=on_message,
- on_error=on_error,
- on_close=on_close)
-
- ws.run_forever()
-
-def main():
- parser = argparse.ArgumentParser(description="OpenVibe.Live — Hardware Controller")
- parser.add_argument("--key", required=True, help="Your OpenVibe.Live stream key")
- parser.add_argument("--server", default=DEFAULT_SERVER, help=f"Server hostname (default: {DEFAULT_SERVER})")
- parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Server port (default: {DEFAULT_PORT})")
- parser.add_argument("--ssl", action="store_true", help="Use WSS")
- parser.add_argument("--no-reconnect", action="store_true", help="Disable auto-reconnect")
- parser.add_argument("--enable-gpio", action="store_true", help="Enable GPIO (Raspberry Pi only)")
- parser.add_argument("--config", help="Path to GPIO config JSON file")
- args = parser.parse_args()
-
- config = {"enable_gpio": args.enable_gpio}
- if args.config:
- try:
- with open(args.config) as f:
- config = json.load(f)
- config["enable_gpio"] = args.enable_gpio
- except (FileNotFoundError, json.JSONDecodeError) as e:
- print(f"[WARN] Failed to load config: {e}")
-
- controller = HardwareController(config)
-
- print(f"\n🏕️ OpenVibe.Live — Hardware Controller")
- print(f" Server: {args.server}:{args.port}")
- print(f" GPIO : {'enabled' if args.enable_gpio and GPIO_AVAILABLE else 'simulation mode'}")
- print(f" Press Ctrl+C to stop.\n")
-
- signal.signal(signal.SIGINT, lambda s, f: sys.exit(0))
- signal.signal(signal.SIGTERM, lambda s, f: sys.exit(0))
-
- connect(args, controller)
-
-if __name__ == "__main__":
- main()
deleted file mode 100644
deleted file mode 100644
@@ -1,177 +0,0 @@
@@ -1,177 +0,0 @@
-#!/usr/bin/env python3
-"""
-OpenVibe.Live — FFmpeg Streamer Script
-Captures video/audio from Raspberry Pi camera + mic and streams
-to OpenVibe.Live via JSMPEG (HTTP POST of MPEG-TS) or RTMP.
-
-Usage:
- python3 streamer.py --key YOUR_STREAM_KEY --server openvibe.live
- python3 streamer.py --key YOUR_STREAM_KEY --protocol rtmp --server openvibe.live
-
-Requirements:
- - FFmpeg installed (apt install ffmpeg)
- - Camera: USB cam or Pi Camera (v4l2)
- - Microphone: USB mic or built-in (ALSA)
-"""
-
-import argparse
-import subprocess
-import sys
-import signal
-import os
-
-DEFAULT_SERVER = "localhost"
-DEFAULT_VIDEO_PORT = 9710
-DEFAULT_RTMP_PORT = 1935
-
-def get_video_device():
- """Find available video device."""
- for dev in ["/dev/video0", "/dev/video1"]:
- if os.path.exists(dev):
- return dev
- return None
-
-def get_audio_device():
- """Find available ALSA audio device."""
- try:
- result = subprocess.run(["arecord", "-l"], capture_output=True, text=True)
- if "card" in result.stdout.lower():
- return "default"
- except FileNotFoundError:
- pass
- return None
-
-def build_jsmpeg_command(args):
- """Build FFmpeg command for JSMPEG streaming (MPEG-TS over HTTP POST)."""
- video_dev = args.video_device or get_video_device()
- audio_dev = args.audio_device or get_audio_device()
-
- if not video_dev:
- print("[ERROR] No video device found. Specify with --video-device")
- sys.exit(1)
-
- cmd = ["ffmpeg"]
-
- # Input: video
- cmd += ["-f", "v4l2", "-framerate", str(args.fps), "-video_size", args.resolution, "-i", video_dev]
-
- # Input: audio (if available)
- if audio_dev and not args.no_audio:
- cmd += ["-f", "alsa", "-i", audio_dev]
-
- # Video encoding: MPEG1 for JSMPEG
- cmd += [
- "-c:v", "mpeg1video",
- "-b:v", f"{args.bitrate}k",
- "-r", str(args.fps),
- "-bf", "0",
- ]
-
- # Audio encoding
- if audio_dev and not args.no_audio:
- cmd += ["-c:a", "mp2", "-ar", "44100", "-ac", "1", "-b:a", "64k"]
- else:
- cmd += ["-an"]
-
- # Output: HTTP POST to JSMPEG relay
- url = f"http://{args.server}:{args.video_port}/{args.key}"
- cmd += ["-f", "mpegts", url]
-
- return cmd
-
-def build_rtmp_command(args):
- """Build FFmpeg command for RTMP streaming."""
- video_dev = args.video_device or get_video_device()
- audio_dev = args.audio_device or get_audio_device()
-
- if not video_dev:
- print("[ERROR] No video device found. Specify with --video-device")
- sys.exit(1)
-
- cmd = ["ffmpeg"]
-
- # Input: video
- cmd += ["-f", "v4l2", "-framerate", str(args.fps), "-video_size", args.resolution, "-i", video_dev]
-
- # Input: audio
- if audio_dev and not args.no_audio:
- cmd += ["-f", "alsa", "-i", audio_dev]
-
- # Video encoding: H.264
- cmd += [
- "-c:v", "libx264",
- "-preset", "ultrafast",
- "-tune", "zerolatency",
- "-b:v", f"{args.bitrate}k",
- "-maxrate", f"{args.bitrate}k",
- "-bufsize", f"{args.bitrate * 2}k",
- "-g", str(args.fps * 2),
- ]
-
- # Audio encoding: AAC
- if audio_dev and not args.no_audio:
- cmd += ["-c:a", "aac", "-ar", "44100", "-ac", "1", "-b:a", "96k"]
- else:
- cmd += ["-an"]
-
- # Output: RTMP
- url = f"rtmp://{args.server}:{args.rtmp_port}/live/{args.key}"
- cmd += ["-f", "flv", url]
-
- return cmd
-
-def main():
- parser = argparse.ArgumentParser(description="OpenVibe.Live — Stream from Raspberry Pi / Linux")
- parser.add_argument("--key", required=True, help="Your OpenVibe.Live stream key")
- parser.add_argument("--server", default=DEFAULT_SERVER, help=f"Server hostname (default: {DEFAULT_SERVER})")
- parser.add_argument("--protocol", choices=["jsmpeg", "rtmp"], default="jsmpeg", help="Streaming protocol")
- parser.add_argument("--video-device", help="Video device path (e.g. /dev/video0)")
- parser.add_argument("--audio-device", help="ALSA audio device (e.g. default, hw:1,0)")
- parser.add_argument("--resolution", default="640x480", help="Video resolution (default: 640x480)")
- parser.add_argument("--fps", type=int, default=24, help="Frames per second (default: 24)")
- parser.add_argument("--bitrate", type=int, default=800, help="Video bitrate in kbps (default: 800)")
- parser.add_argument("--video-port", type=int, default=DEFAULT_VIDEO_PORT, help=f"JSMPEG video port (default: {DEFAULT_VIDEO_PORT})")
- parser.add_argument("--rtmp-port", type=int, default=DEFAULT_RTMP_PORT, help=f"RTMP port (default: {DEFAULT_RTMP_PORT})")
- parser.add_argument("--no-audio", action="store_true", help="Disable audio")
- parser.add_argument("--dry-run", action="store_true", help="Print FFmpeg command without running")
- args = parser.parse_args()
-
- if args.protocol == "jsmpeg":
- cmd = build_jsmpeg_command(args)
- else:
- cmd = build_rtmp_command(args)
-
- print(f"\n🏕️ OpenVibe.Live — {args.protocol.upper()} Streaming")
- print(f" Server : {args.server}")
- print(f" Video : {args.resolution} @ {args.fps}fps, {args.bitrate}kbps")
- print(f" Audio : {'disabled' if args.no_audio else 'enabled'}")
- print(f" Command: {' '.join(cmd)}\n")
-
- if args.dry_run:
- print("[DRY RUN] Command not executed.")
- return
-
- # Run FFmpeg
- proc = None
- def signal_handler(sig, frame):
- print("\n[INFO] Stopping stream...")
- if proc:
- proc.terminate()
- sys.exit(0)
-
- signal.signal(signal.SIGINT, signal_handler)
- signal.signal(signal.SIGTERM, signal_handler)
-
- print("[INFO] Starting stream... Press Ctrl+C to stop.")
- try:
- proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
- _, stderr = proc.communicate()
- if proc.returncode != 0:
- print(f"[ERROR] FFmpeg exited with code {proc.returncode}")
- print(stderr.decode()[-500:] if stderr else "")
- except FileNotFoundError:
- print("[ERROR] FFmpeg not found. Install with: sudo apt install ffmpeg")
- sys.exit(1)
-
-if __name__ == "__main__":
- main()