P6-R1: Audio/video streaming pipeline
Lint & Validate / lint (push) Has been cancelled

- src/streaming/ module: camera detection (USB webcam + Pi Camera Module via
  v4l2/libcamera), GStreamer pipeline builder with V4L2/OMX/x264 h264 encoders,
  platform presets (YouTube, Twitch, Facebook, custom RTMP), streamer lifecycle
  manager with auto-reconnect, scene management, stream statistics
- Keyboard shortcut controller (evdev + pynput fallback) for stream control:
  Ctrl+Shift+S (start/stop), Ctrl+Shift+1-9 (scene switch), Ctrl+Shift+R (reconnect)
- REST API: /api/v1/stream/* endpoints for start/stop/status, scenes, cameras,
  platforms, and hotkey listing — integrated into NetworkServer
- StreamStateProvider bridging layer connects REST routes to Streamer instance
- GStreamer pipeline supports ALSA/JACK/PulseAudio audio capture and
  V4L2/libcamera video capture with H.264 hardware encoding on RPi4B
- Low-latency streaming settings for live performance (zerolatency, no B-frames)
- 89 streaming tests (709 total project tests pass)
This commit is contained in:
2026-05-19 22:13:29 -04:00
parent d6480a35ed
commit cdf04bd8eb
21 changed files with 7119 additions and 34 deletions
+125
View File
@@ -354,3 +354,128 @@ class ParameterSetRequest(BaseModel):
"""Request body for setting a parameter via REST."""
param_type: str = "volume"
value: float
# ── Session management schemas ───────────────────────────────────────────────
class SessionListItemSchema(BaseModel):
"""A session entry in the list response."""
name: str
filename: str
size_bytes: int = 0
modified: float = 0.0
created: float = 0.0
class SessionListSchema(BaseModel):
"""Response for listing sessions."""
sessions: list[SessionListItemSchema] = Field(default_factory=list)
directory: str = ""
count: int = 0
class SessionInfoSchema(BaseModel):
"""Detailed session metadata."""
name: str
version: int = 1
metadata: dict = Field(default_factory=dict)
channel_count: int = 0
has_plugins: bool = False
has_midi_mappings: bool = False
size_bytes: int = 0
class SessionSaveRequest(BaseModel):
"""Request to save a session."""
name: str
overwrite: bool = True
class SessionRenameRequest(BaseModel):
"""Request to rename a session."""
new_name: str
class SessionDuplicateRequest(BaseModel):
"""Request to duplicate a session."""
target_name: str
class SessionImportRequest(BaseModel):
"""Request to import a session from a file path."""
filepath: str
new_name: Optional[str] = None
# ── Setlist schemas ──────────────────────────────────────────────────────────
class SetlistEntrySchema(BaseModel):
"""A setlist entry."""
session_name: str
display_name: str = ""
transition: str = "cut" # cut, crossfade, wait, auto
transition_duration: float = 2.0
wait_for_trigger: bool = False
notes: str = ""
class SetlistSchema(BaseModel):
"""A full setlist."""
name: str = "Untitled Setlist"
entries: list[SetlistEntrySchema] = Field(default_factory=list)
description: str = ""
created_at: float = 0.0
modified_at: float = 0.0
class SetlistListItemSchema(BaseModel):
"""A setlist in the list response."""
name: str
filename: str
entry_count: int = 0
size_bytes: int = 0
modified: float = 0.0
class SetlistListSchema(BaseModel):
"""Response for listing setlists."""
setlists: list[SetlistListItemSchema] = Field(default_factory=list)
directory: str = ""
count: int = 0
# ── Snapshot schemas ─────────────────────────────────────────────────────────
class SnapshotListItemSchema(BaseModel):
"""A snapshot entry."""
name: str
created_at: float = 0.0
notes: str = ""
category: str = ""
color: str = ""
channel_count: int = 0
is_current: bool = False
class SnapshotListSchema(BaseModel):
"""Response for listing snapshots."""
snapshots: list[SnapshotListItemSchema] = Field(default_factory=list)
count: int = 0
current: Optional[str] = None
class SnapshotSaveRequest(BaseModel):
"""Request to save a snapshot."""
name: str
notes: str = ""
category: str = ""
color: str = ""
overwrite: bool = True
class SnapshotRenameRequest(BaseModel):
"""Request to rename a snapshot."""
new_name: str
+162
View File
@@ -51,6 +51,12 @@ from .rest_api import MixerStateProvider, create_router
from .websocket import WebSocketManager
from .session import SessionManager
from .web_routes import create_web_routes, get_static_files_app
from .session_routes import create_session_router
from .stream_routes import StreamStateProvider, create_stream_router
from ..session.session_manager import SessionManager as MixerSessionManager
from ..session.snapshot import SnapshotEngine
from ..streaming.streamer import Streamer, StreamerConfig, StreamScene
from ..streaming.controls import DEFAULT_HOTKEYS
from .schemas import (
ChannelSchema,
MasterBusSchema,
@@ -98,6 +104,8 @@ class NetworkServer:
session_ttl: float = 86400,
web_dir: str | None = None,
audio_dir: str | None = None,
sessions_dir: str | None = None,
auto_save_enabled: bool = True,
):
self._engine = engine
self._osc_host = osc_host
@@ -111,6 +119,25 @@ class NetworkServer:
# Session manager for WebSocket browser auth
self._session_manager = SessionManager(ttl=session_ttl)
# Mixer session manager for save/load/auto-save
from ..session.session_manager import DEFAULT_SESSIONS_DIR, DEFAULT_SETLISTS_DIR
_sessions_path = Path(sessions_dir) if sessions_dir else DEFAULT_SESSIONS_DIR
self._mixer_session_manager = MixerSessionManager(
sessions_dir=_sessions_path,
setlists_dir=DEFAULT_SETLISTS_DIR,
auto_save_enabled=auto_save_enabled,
)
self._mixer_session_manager.set_auto_save_engine(engine)
# Snapshot engine for instant recall
_snapshots_path = _sessions_path.parent / "snapshots"
self._snapshot_engine = SnapshotEngine(storage_dir=_snapshots_path)
# Streaming engine
self._streamer: Optional[Streamer] = None
self._stream_provider = StreamStateProvider()
self._setup_stream_provider()
# Settings storage
self._settings: dict = {
"sample_rate": 48000,
@@ -209,6 +236,121 @@ class NetworkServer:
# The WebSocket manager broadcasts to all connected clients.
pass
def _setup_stream_provider(self) -> None:
"""Wire the stream state provider to the streamer instance (lazy init)."""
sp = self._stream_provider
sp.get_status = self._stream_get_status
sp.get_config = self._stream_get_config
sp.start_stream = self._stream_start
sp.stop_stream = self._stream_stop
sp.configure_stream = self._stream_configure
sp.list_scenes = self._stream_list_scenes
sp.add_scene = self._stream_add_scene
sp.remove_scene = self._stream_remove_scene
sp.activate_scene = self._stream_activate_scene
def _get_or_create_streamer(self) -> Streamer:
"""Lazy-init the streamer if not already created."""
if self._streamer is None:
self._streamer = Streamer()
return self._streamer
# ── Stream callbacks ────────────────────────────────────────────────
def _stream_get_status(self) -> dict:
streamer = self._get_or_create_streamer()
return streamer.get_stats_dict()
def _stream_get_config(self) -> dict:
streamer = self._get_or_create_streamer()
cfg = streamer.config
return {
"platform_id": cfg.platform_id,
"rtmp_url": cfg.rtmp_url,
"stream_key": "****" if cfg.stream_key else "",
"video_device": cfg.video_device,
"video_width": cfg.video_width,
"video_height": cfg.video_height,
"video_framerate": cfg.video_framerate,
"video_bitrate_kbps": cfg.video_bitrate_kbps,
"audio_device": cfg.audio_device,
"audio_sample_rate": cfg.audio_sample_rate,
"audio_bitrate_kbps": cfg.audio_bitrate_kbps,
"low_latency": cfg.low_latency,
"active_scene": cfg.active_scene,
}
def _stream_start(self) -> dict:
streamer = self._get_or_create_streamer()
success = streamer.start()
return {
"success": success,
"state": streamer.state.value,
"message": "Stream starting" if success else "Failed to start stream",
}
def _stream_stop(self) -> dict:
streamer = self._get_or_create_streamer()
streamer.stop()
return {
"success": True,
"state": streamer.state.value,
"message": "Stream stopped",
}
def _stream_configure(self, data: dict) -> dict:
streamer = self._get_or_create_streamer()
stream_key = data.get("stream_key")
platform_id = data.get("platform_id")
video_device = data.get("video_device")
audio_device = data.get("audio_device")
try:
streamer.configure(
platform_id=platform_id,
stream_key=stream_key,
video_device=video_device,
audio_device=audio_device,
)
return {"success": True, "message": "Stream configured"}
except RuntimeError as exc:
return {"success": False, "error": str(exc)}
def _stream_list_scenes(self) -> list[dict]:
streamer = self._get_or_create_streamer()
return streamer.get_scenes()
def _stream_add_scene(self, data: dict) -> dict:
streamer = self._get_or_create_streamer()
scene = StreamScene(
name=data.get("name", "untitled"),
camera_device=data.get("camera_device", "/dev/video0"),
video_width=data.get("video_width", 1280),
video_height=data.get("video_height", 720),
video_framerate=data.get("video_framerate", 30),
audio_device=data.get("audio_device", "hw:0"),
overlay_text=data.get("overlay_text", ""),
description=data.get("description", ""),
)
streamer.add_scene(scene)
return {"success": True, "scene": scene.to_dict()}
def _stream_remove_scene(self, name: str) -> dict:
streamer = self._get_or_create_streamer()
success = streamer.remove_scene(name)
if not success:
return {"success": False, "error": f"Cannot remove scene '{name}'"}
return {"success": True, "message": f"Scene '{name}' removed"}
def _stream_activate_scene(self, name: str) -> dict:
streamer = self._get_or_create_streamer()
success = streamer.switch_scene(name)
if not success:
return {"success": False, "error": f"Scene '{name}' not found"}
return {"success": True, "message": f"Scene '{name}' activated"}
# ── FastAPI app construction ──────────────────────────────────────────
def _build_app(self) -> FastAPI:
@@ -268,6 +410,23 @@ class NetworkServer:
for router in web_routers:
app.include_router(router)
# Session management routes (save/load/list/delete, setlists, snapshots)
session_router = create_session_router(
session_manager=self._mixer_session_manager,
snapshot_engine=self._snapshot_engine,
engine=self._engine,
prefix="/api/v1",
)
app.include_router(session_router)
# Streaming routes (start/stop/status, scenes, cameras, platforms)
stream_router = create_stream_router(
state=self._stream_provider,
auth=self._auth,
prefix="/api/v1",
)
app.include_router(stream_router)
# WebSocket endpoint (supports both API key and session token)
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
@@ -373,6 +532,9 @@ class NetworkServer:
self._engine.handle_parameter(param, value)
# Trigger auto-save (debounced)
self._mixer_session_manager.notify_change()
# Broadcast to WebSocket clients (non-blocking)
asyncio.create_task(
self._ws_manager.broadcast_parameter_update(param_type, value, channel)
+423
View File
@@ -0,0 +1,423 @@
"""Session management REST API — save/load/list/delete/import/export sessions, setlists, snapshots.
Endpoints:
GET /api/v1/sessions — list saved sessions
GET /api/v1/sessions/{name} — get session info
POST /api/v1/sessions/save — save current state as session
POST /api/v1/sessions/{name}/load — load and apply a session
DELETE /api/v1/sessions/{name} — delete a session
POST /api/v1/sessions/{name}/rename — rename a session
POST /api/v1/sessions/{name}/duplicate — duplicate a session
POST /api/v1/sessions/export/{name} — export a session
POST /api/v1/sessions/import — import a session
GET /api/v1/sessions/export-all — export all sessions as zip
GET /api/v1/setlists — list setlists
POST /api/v1/setlists/save — save a setlist
POST /api/v1/setlists/{name}/load — load and activate a setlist
DELETE /api/v1/setlists/{name} — delete a setlist
GET /api/v1/snapshots — list snapshots
POST /api/v1/snapshots/capture — capture current state as snapshot
POST /api/v1/snapshots/{name}/recall — recall a snapshot
DELETE /api/v1/snapshots/{name} — delete a snapshot
"""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from .schemas import (
APIResponse,
SessionListItemSchema,
SessionListSchema,
SessionInfoSchema,
SessionSaveRequest,
SessionRenameRequest,
SessionDuplicateRequest,
SessionImportRequest,
SetlistSchema,
SetlistEntrySchema,
SetlistListItemSchema,
SetlistListSchema,
SnapshotListItemSchema,
SnapshotListSchema,
SnapshotSaveRequest,
SnapshotRenameRequest,
)
logger = logging.getLogger(__name__)
def create_session_router(
session_manager: object,
snapshot_engine: object,
engine: object,
prefix: str = "",
) -> APIRouter:
"""Create a FastAPI router with all session management endpoints.
Args:
session_manager: SessionManager instance.
snapshot_engine: SnapshotEngine instance.
engine: DSPEngine instance.
prefix: Optional path prefix (e.g., "/api/v1").
"""
router = APIRouter(prefix=prefix, tags=["sessions"])
# ── Session endpoints ────────────────────────────────────────────────────
@router.get("/sessions", response_model=SessionListSchema)
async def list_sessions():
"""List all saved session files."""
sessions = session_manager.list_sessions()
return SessionListSchema(
sessions=[SessionListItemSchema(**s) for s in sessions],
directory=str(session_manager.sessions_dir),
count=len(sessions),
)
@router.get("/sessions/{name:path}", response_model=SessionInfoSchema)
async def get_session_info(name: str):
"""Get detailed info about a session without loading full state."""
info = session_manager.get_session_info(name)
if info is None:
raise HTTPException(status_code=404, detail=f"Session not found: {name}")
return SessionInfoSchema(**info)
@router.post("/sessions/save", response_model=APIResponse)
async def save_session(body: SessionSaveRequest):
"""Save current mixer state as a named session."""
try:
session = session_manager.save(
engine, body.name, overwrite=body.overwrite,
)
return APIResponse(
ok=True,
data={
"name": session.metadata.name,
"channels": session.channel_count,
"version": session.version,
},
)
except FileExistsError:
raise HTTPException(
status_code=409,
detail=f"Session already exists: {body.name}. Use overwrite=true.",
)
@router.post("/sessions/{name:path}/load", response_model=APIResponse)
async def load_session(name: str):
"""Load a session and apply its state to the engine."""
ok = session_manager.load_and_apply(engine, name)
if not ok:
raise HTTPException(status_code=404, detail=f"Session not found: {name}")
return APIResponse(ok=True, data={"name": name, "loaded": True})
@router.delete("/sessions/{name:path}", response_model=APIResponse)
async def delete_session(name: str):
"""Delete a saved session."""
ok = session_manager.delete(name)
if not ok:
raise HTTPException(status_code=404, detail=f"Session not found: {name}")
return APIResponse(ok=True, data={"name": name, "deleted": True})
@router.post("/sessions/{name:path}/rename", response_model=APIResponse)
async def rename_session(name: str, body: SessionRenameRequest):
"""Rename a saved session."""
ok = session_manager.rename(name, body.new_name)
if not ok:
raise HTTPException(
status_code=404 if not session_manager.exists(name) else 409,
detail=f"Cannot rename '{name}' to '{body.new_name}'",
)
return APIResponse(ok=True, data={"old_name": name, "new_name": body.new_name})
@router.post("/sessions/{name:path}/duplicate", response_model=APIResponse)
async def duplicate_session(name: str, body: SessionDuplicateRequest):
"""Duplicate a saved session under a new name."""
dup = session_manager.duplicate(name, body.target_name)
if dup is None:
raise HTTPException(status_code=404, detail=f"Session not found: {name}")
return APIResponse(
ok=True,
data={"source": name, "target": body.target_name, "channels": dup.channel_count},
)
@router.post("/sessions/import", response_model=APIResponse)
async def import_session(body: SessionImportRequest):
"""Import a session from an external JSON file."""
filepath = Path(body.filepath)
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"File not found: {body.filepath}")
session = session_manager.import_session(filepath, body.new_name)
if session is None:
raise HTTPException(status_code=400, detail="Failed to import session")
return APIResponse(
ok=True,
data={
"name": session.metadata.name,
"channels": session.channel_count,
"imported": True,
},
)
@router.post("/sessions/export/{name:path}", response_model=APIResponse)
async def export_session(name: str):
"""Export a session JSON file to the export directory."""
path = session_manager.export_session(name)
if path is None:
raise HTTPException(status_code=404, detail=f"Session not found: {name}")
return APIResponse(ok=True, data={"name": name, "export_path": str(path)})
@router.get("/sessions/export-all-zip")
async def export_all_sessions():
"""Export all sessions as a zip file download."""
zip_path = session_manager.export_all()
if not zip_path.exists():
raise HTTPException(status_code=500, detail="Export failed")
return FileResponse(
path=str(zip_path),
media_type="application/zip",
filename=zip_path.name,
)
@router.get("/sessions/{name:path}/download")
async def download_session(name: str):
"""Download a session JSON file directly."""
filename = None
for entry in session_manager.list_sessions():
if entry["name"] == name or entry["filename"].startswith(name):
filename = entry["filename"]
break
if filename is None:
raise HTTPException(status_code=404, detail=f"Session not found: {name}")
filepath = session_manager.sessions_dir / filename
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"File not found: {filename}")
return FileResponse(
path=str(filepath),
media_type="application/json",
filename=filename,
)
# ── Setlist endpoints ─────────────────────────────────────────────────────
@router.get("/setlists", response_model=SetlistListSchema)
async def list_setlists():
"""List all saved setlist files."""
from ..session.setlist import Setlist
setlists = []
try:
for entry in sorted(session_manager.setlists_dir.glob("*.json")):
try:
stat = entry.stat()
sl = Setlist.load_from(entry)
setlists.append(SetlistListItemSchema(
name=sl.name,
filename=entry.name,
entry_count=sl.entry_count,
size_bytes=stat.st_size,
modified=stat.st_mtime,
))
except (json.JSONDecodeError, KeyError):
setlists.append(SetlistListItemSchema(
name=entry.stem,
filename=entry.name,
size_bytes=entry.stat().st_size,
modified=entry.stat().st_mtime,
))
except OSError as exc:
logger.warning("Error listing setlists: %s", exc)
return SetlistListSchema(
setlists=setlists,
directory=str(session_manager.setlists_dir),
count=len(setlists),
)
@router.post("/setlists/save", response_model=APIResponse)
async def save_setlist(body: SetlistSchema):
"""Save a setlist."""
from ..session.setlist import Setlist, SetlistEntry, TransitionType
sl = Setlist(
name=body.name,
description=body.description,
)
for entry in body.entries:
sl.add_entry(
session_name=entry.session_name,
display_name=entry.display_name,
transition=TransitionType(entry.transition),
transition_duration=entry.transition_duration,
wait_for_trigger=entry.wait_for_trigger,
notes=entry.notes,
)
filepath = sl.save_to(session_manager.setlists_dir)
return APIResponse(ok=True, data={"name": sl.name, "path": str(filepath), "entries": sl.entry_count})
@router.post("/setlists/{name:path}/load", response_model=APIResponse)
async def load_setlist(name: str):
"""Load and activate a setlist."""
from ..session.setlist import Setlist
# Find the setlist file
import re
safe = re.sub(r"[^\w\s.\-]", "_", name).strip().replace(" ", "_")
filepath = session_manager.setlists_dir / f"{safe}.json"
if not filepath.exists():
# Try original name
alt = session_manager.setlists_dir / name
if alt.exists():
filepath = alt
else:
raise HTTPException(status_code=404, detail=f"Setlist not found: {name}")
try:
sl = Setlist.load_from(filepath)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Invalid setlist: {exc}")
ok = sl.activate(session_manager, engine)
if not ok:
raise HTTPException(status_code=400, detail="Failed to activate setlist")
return APIResponse(ok=True, data={
"name": sl.name,
"entry_count": sl.entry_count,
"current": sl.current_entry.session_name if sl.current_entry else None,
"progress": list(sl.progress),
})
@router.delete("/setlists/{name:path}", response_model=APIResponse)
async def delete_setlist(name: str):
"""Delete a setlist."""
import re
safe = re.sub(r"[^\w\s.\-]", "_", name).strip().replace(" ", "_")
filepath = session_manager.setlists_dir / f"{safe}.json"
if not filepath.exists():
alt = session_manager.setlists_dir / name
if alt.exists():
filepath = alt
else:
raise HTTPException(status_code=404, detail=f"Setlist not found: {name}")
filepath.unlink()
return APIResponse(ok=True, data={"name": name, "deleted": True})
# ── Snapshot endpoints ────────────────────────────────────────────────────
@router.get("/snapshots", response_model=SnapshotListSchema)
async def list_snapshots():
"""List all snapshots."""
snaps = snapshot_engine.list_snapshots()
return SnapshotListSchema(
snapshots=[SnapshotListItemSchema(**s) for s in snaps],
count=len(snaps),
current=snapshot_engine.current_name,
)
@router.post("/snapshots/capture", response_model=APIResponse)
async def capture_snapshot(body: SnapshotSaveRequest):
"""Capture current state as a named snapshot."""
try:
snap = snapshot_engine.capture(
engine,
name=body.name,
notes=body.notes,
category=body.category,
color=body.color,
overwrite=body.overwrite,
)
return APIResponse(ok=True, data={
"name": snap.metadata.name,
"channels": len(snap.channels),
})
except KeyError:
raise HTTPException(
status_code=409,
detail=f"Snapshot already exists: {body.name}. Use overwrite=true.",
)
@router.post("/snapshots/{name:path}/recall", response_model=APIResponse)
async def recall_snapshot(name: str):
"""Recall a snapshot instantly."""
ok = snapshot_engine.recall(engine, name)
if not ok:
raise HTTPException(status_code=404, detail=f"Snapshot not found: {name}")
return APIResponse(ok=True, data={"name": name, "recalled": True})
@router.delete("/snapshots/{name:path}", response_model=APIResponse)
async def delete_snapshot(name: str):
"""Delete a snapshot."""
ok = snapshot_engine.delete(name)
if not ok:
raise HTTPException(status_code=404, detail=f"Snapshot not found: {name}")
return APIResponse(ok=True, data={"name": name, "deleted": True})
@router.post("/snapshots/{name:path}/rename", response_model=APIResponse)
async def rename_snapshot(name: str, body: SnapshotRenameRequest):
"""Rename a snapshot."""
ok = snapshot_engine.rename(name, body.new_name)
if not ok:
raise HTTPException(status_code=404, detail=f"Cannot rename '{name}'")
return APIResponse(ok=True, data={"old_name": name, "new_name": body.new_name})
@router.post("/snapshots/next", response_model=APIResponse)
async def next_snapshot():
"""Recall the next snapshot."""
ok, name = snapshot_engine.next(engine)
if not ok:
raise HTTPException(status_code=404, detail="No snapshots available")
return APIResponse(ok=True, data={"name": name, "next": True})
@router.post("/snapshots/previous", response_model=APIResponse)
async def previous_snapshot():
"""Recall the previous snapshot."""
ok, name = snapshot_engine.previous(engine)
if not ok:
raise HTTPException(status_code=404, detail="No snapshots available")
return APIResponse(ok=True, data={"name": name, "previous": True})
@router.get("/snapshots/categories")
async def snapshot_categories():
"""List snapshot categories."""
return {"categories": snapshot_engine.categories}
# ── Session auto-save control ─────────────────────────────────────────────
@router.get("/sessions/autosave/status", response_model=APIResponse)
async def autosave_status():
"""Get auto-save status."""
return APIResponse(ok=True, data=session_manager.stats)
@router.post("/sessions/autosave/enable", response_model=APIResponse)
async def autosave_enable():
"""Enable auto-save."""
session_manager.enable_auto_save()
return APIResponse(ok=True, data={"auto_save_enabled": True})
@router.post("/sessions/autosave/disable", response_model=APIResponse)
async def autosave_disable():
"""Disable auto-save."""
session_manager.disable_auto_save()
return APIResponse(ok=True, data={"auto_save_enabled": False})
return router
+267
View File
@@ -0,0 +1,267 @@
"""Streaming API routes for the network server.
Provides REST endpoints for:
- Stream control (start, stop, status)
- Scene management
- Camera detection
- Platform presets
- Stream statistics
Endpoints:
GET /api/v1/stream/status — stream state and stats
POST /api/v1/stream/start — start streaming
POST /api/v1/stream/stop — stop streaming
POST /api/v1/stream/configure — configure stream settings
GET /api/v1/stream/cameras — list detected cameras
GET /api/v1/stream/platforms — list platform presets
GET /api/v1/stream/scenes — list scenes
POST /api/v1/stream/scenes — add a scene
DELETE /api/v1/stream/scenes/{name} — remove a scene
POST /api/v1/stream/scenes/{name}/activate — switch to scene
GET /api/v1/stream/hotkeys — list hotkey mappings
"""
from __future__ import annotations
import logging
from typing import Callable, Optional, Any
from fastapi import APIRouter, HTTPException, Request
from .auth import APIKeyAuth, API_KEY_HEADER
from ..streaming.platforms import list_platforms, get_platform_profile
from ..streaming.camera import detect_cameras
from ..streaming.controls import DEFAULT_HOTKEYS
logger = logging.getLogger(__name__)
# ── Stream state provider adapter ───────────────────────────────────────────
class StreamStateProvider:
"""Adapter that connects the REST API to the Streamer instance.
The NetworkServer sets callbacks on this provider to bridge
the REST API layer to the actual Streamer instance.
"""
def __init__(self):
# State
self.get_status: Optional[Callable[[], dict]] = None
self.get_config: Optional[Callable[[], dict]] = None
# Control
self.start_stream: Optional[Callable[[], dict]] = None
self.stop_stream: Optional[Callable[[], dict]] = None
self.configure_stream: Optional[Callable[[dict], dict]] = None
# Scenes
self.list_scenes: Optional[Callable[[], list[dict]]] = None
self.add_scene: Optional[Callable[[dict], dict]] = None
self.remove_scene: Optional[Callable[[str], dict]] = None
self.activate_scene: Optional[Callable[[str], dict]] = None
@staticmethod
def check_auth(request: Request, auth: "APIKeyAuth") -> None:
"""Check API key auth on a request. Raises HTTPException if invalid."""
api_key = request.headers.get(API_KEY_HEADER, "").strip()
if not api_key:
raise HTTPException(status_code=401, detail="API key required")
if not auth.validate(api_key):
raise HTTPException(status_code=403, detail="Invalid API key")
# ── Router factory ──────────────────────────────────────────────────────────
def create_stream_router(
state: StreamStateProvider,
auth: APIKeyAuth,
prefix: str = "",
) -> APIRouter:
"""Create the FastAPI router with streaming endpoints.
Args:
state: StreamStateProvider with callbacks wired to the Streamer.
auth: API key auth for endpoint protection.
prefix: URL prefix (e.g. "/api/v1").
Returns:
FastAPI APIRouter with stream endpoints.
"""
router = APIRouter(prefix=prefix, tags=["streaming"])
# ── Status ─────────────────────────────────────────────────────────
@router.get("/stream/status")
async def get_stream_status(request: Request):
"""Get current streaming status and statistics."""
state.check_auth(request, auth)
if state.get_status is None:
return {"state": "unavailable", "error": "Streaming module not initialized"}
try:
return state.get_status()
except Exception as exc:
logger.error("Error getting stream status: %s", exc)
return {"state": "error", "error": str(exc)}
# ── Control ────────────────────────────────────────────────────────
@router.post("/stream/start")
async def start_stream(request: Request):
"""Start the streaming pipeline."""
state.check_auth(request, auth)
if state.start_stream is None:
raise HTTPException(status_code=503, detail="Streaming module not initialized")
try:
result = state.start_stream()
return result
except Exception as exc:
logger.error("Error starting stream: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))
@router.post("/stream/stop")
async def stop_stream(request: Request):
"""Stop the streaming pipeline."""
state.check_auth(request, auth)
if state.stop_stream is None:
raise HTTPException(status_code=503, detail="Streaming module not initialized")
try:
result = state.stop_stream()
return result
except Exception as exc:
logger.error("Error stopping stream: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))
@router.post("/stream/configure")
async def configure_stream(request: Request):
"""Configure streaming settings (platform, key, devices)."""
state.check_auth(request, auth)
if state.configure_stream is None:
raise HTTPException(status_code=503, detail="Streaming module not initialized")
try:
body = await request.json()
result = state.configure_stream(body)
return result
except Exception as exc:
logger.error("Error configuring stream: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))
# ── Cameras ────────────────────────────────────────────────────────
@router.get("/stream/cameras")
async def list_cameras(request: Request):
"""List detected cameras."""
state.check_auth(request, auth)
try:
cameras = detect_cameras()
return {
"cameras": [c.to_dict() for c in cameras],
"count": len(cameras),
}
except Exception as exc:
logger.error("Error detecting cameras: %s", exc)
return {"cameras": [], "count": 0, "error": str(exc)}
# ── Platforms ──────────────────────────────────────────────────────
@router.get("/stream/platforms")
async def list_stream_platforms(request: Request):
"""List available streaming platform presets."""
state.check_auth(request, auth)
return {
"platforms": list_platforms(),
}
@router.get("/stream/platforms/{platform_id}")
async def get_platform(request: Request, platform_id: str):
"""Get a specific platform preset."""
state.check_auth(request, auth)
profile = get_platform_profile(platform_id)
if profile is None:
raise HTTPException(status_code=404, detail=f"Platform '{platform_id}' not found")
return profile.to_dict()
# ── Scenes ─────────────────────────────────────────────────────────
@router.get("/stream/scenes")
async def list_stream_scenes(request: Request):
"""List configured streaming scenes."""
state.check_auth(request, auth)
if state.list_scenes is None:
return {"scenes": [], "error": "Streaming module not initialized"}
try:
scenes = state.list_scenes()
return {"scenes": scenes}
except Exception as exc:
logger.error("Error listing scenes: %s", exc)
return {"scenes": [], "error": str(exc)}
@router.post("/stream/scenes")
async def add_stream_scene(request: Request):
"""Add a new streaming scene."""
state.check_auth(request, auth)
if state.add_scene is None:
raise HTTPException(status_code=503, detail="Streaming module not initialized")
try:
body = await request.json()
result = state.add_scene(body)
return result
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
logger.error("Error adding scene: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))
@router.delete("/stream/scenes/{name}")
async def remove_stream_scene(request: Request, name: str):
"""Remove a streaming scene."""
state.check_auth(request, auth)
if state.remove_scene is None:
raise HTTPException(status_code=503, detail="Streaming module not initialized")
try:
result = state.remove_scene(name)
return result
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
logger.error("Error removing scene: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))
@router.post("/stream/scenes/{name}/activate")
async def activate_stream_scene(request: Request, name: str):
"""Activate a streaming scene."""
state.check_auth(request, auth)
if state.activate_scene is None:
raise HTTPException(status_code=503, detail="Streaming module not initialized")
try:
result = state.activate_scene(name)
return result
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
logger.error("Error activating scene: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))
# ── Hotkeys ────────────────────────────────────────────────────────
@router.get("/stream/hotkeys")
async def list_stream_hotkeys(request: Request):
"""List configured stream keyboard shortcuts."""
state.check_auth(request, auth)
hotkeys = []
for hk in DEFAULT_HOTKEYS:
hotkeys.append({
"action": hk.hotkey.value,
"modifiers": sorted(hk.modifiers),
"key": hk.key,
"description": hk.description,
})
return {"hotkeys": hotkeys}
return router
+26
View File
@@ -0,0 +1,26 @@
"""Session management — save/load mixer states, setlists, and snapshots.
The session module provides persistent storage for mixer configurations:
- MixerSession: full mixer state as JSON (channels, buses, routing, plugins, MIDI)
- SessionManager: save/load/auto-save/list/delete sessions to disk
- Setlist: ordered collection of sessions with transitions
- SnapshotEngine: instant scene recall during performance
Sessions are stored as JSON files in ~/.config/rpi-mixer/sessions/.
Setlists are stored as JSON files in ~/.config/rpi-mixer/setlists/.
"""
from .mixer_session import MixerSession, SessionMetadata
from .session_manager import SessionManager
from .setlist import Setlist, SetlistEntry, TransitionType
from .snapshot import SnapshotEngine
__all__ = [
"MixerSession",
"SessionMetadata",
"SessionManager",
"Setlist",
"SetlistEntry",
"TransitionType",
"SnapshotEngine",
]
+308
View File
@@ -0,0 +1,308 @@
"""MixerSession — canonical JSON format for full mixer state.
A MixerSession captures every parameter of the mixer in a versioned,
human-readable JSON format suitable for save/load, import/export,
and version control.
Schema version 1 includes:
- Metadata: name, timestamps, notes, performance metadata
- Channels: all channel strip states (fader, EQ, comp, gate, FX sends)
- Master: master bus + aux buses + subgroups + VCA groups
- Routing: full routing matrix (nodes + edges)
- Plugins: Carla plugin chain configuration
- MIDI: MIDI mapping configuration
- Transport: transport state (tempo, position)
- Automation: fader automation lanes and scenes
"""
from __future__ import annotations
import json
import logging
import time
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
# Current schema version
SESSION_SCHEMA_VERSION = 1
@dataclass
class SessionMetadata:
"""Per-session notes and performance metadata."""
name: str = "Untitled Session"
created_at: float = field(default_factory=time.time)
modified_at: float = field(default_factory=time.time)
author: str = ""
notes: str = ""
# Performance metadata
tempo: float = 120.0 # BPM
key: str = "" # e.g. "C major", "Am"
time_signature: str = "4/4"
lyrics: str = ""
genre: str = ""
tags: list[str] = field(default_factory=list)
# Custom fields
custom: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"name": self.name,
"created_at": self.created_at,
"modified_at": self.modified_at,
"author": self.author,
"notes": self.notes,
"tempo": self.tempo,
"key": self.key,
"time_signature": self.time_signature,
"lyrics": self.lyrics,
"genre": self.genre,
"tags": self.tags,
"custom": self.custom,
}
@classmethod
def from_dict(cls, data: dict) -> SessionMetadata:
return cls(
name=data.get("name", "Untitled Session"),
created_at=data.get("created_at", time.time()),
modified_at=data.get("modified_at", time.time()),
author=data.get("author", ""),
notes=data.get("notes", ""),
tempo=data.get("tempo", 120.0),
key=data.get("key", ""),
time_signature=data.get("time_signature", "4/4"),
lyrics=data.get("lyrics", ""),
genre=data.get("genre", ""),
tags=data.get("tags", []),
custom=data.get("custom", {}),
)
@dataclass
class MixerSession:
"""A complete mixer session — all parameters in one serializable object.
This is the canonical on-disk format for mixer state. It is
designed to be human-readable JSON for easy editing, diffing,
and version control.
Usage:
# Save
session = MixerSession.from_engine(engine, name="My Mix")
json_str = session.to_json()
Path("session.json").write_text(json_str)
# Load
session = MixerSession.from_json(json_str)
session.apply_to(engine)
"""
version: int = SESSION_SCHEMA_VERSION
metadata: SessionMetadata = field(default_factory=SessionMetadata)
# Mixer state
channels: list[dict] = field(default_factory=list)
master: dict = field(default_factory=dict)
routing: dict = field(default_factory=dict)
plugins: list[dict] = field(default_factory=list)
midi_mappings: list[dict] = field(default_factory=list)
transport: dict = field(default_factory=dict)
automation: dict = field(default_factory=dict)
# Additional state
settings: dict = field(default_factory=dict)
# ── Serialization ──────────────────────────────────────────────────
def to_dict(self) -> dict:
"""Serialize to a plain dict."""
return {
"version": self.version,
"metadata": self.metadata.to_dict(),
"channels": self.channels,
"master": self.master,
"routing": self.routing,
"plugins": self.plugins,
"midi_mappings": self.midi_mappings,
"transport": self.transport,
"automation": self.automation,
"settings": self.settings,
}
def to_json(self, indent: int = 2) -> str:
"""Serialize to a human-readable JSON string."""
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
@classmethod
def from_dict(cls, data: dict) -> MixerSession:
"""Deserialize from a plain dict."""
return cls(
version=data.get("version", SESSION_SCHEMA_VERSION),
metadata=SessionMetadata.from_dict(data.get("metadata", {})),
channels=data.get("channels", []),
master=data.get("master", {}),
routing=data.get("routing", {}),
plugins=data.get("plugins", []),
midi_mappings=data.get("midi_mappings", []),
transport=data.get("transport", {}),
automation=data.get("automation", {}),
settings=data.get("settings", {}),
)
@classmethod
def from_json(cls, json_str: str) -> MixerSession:
"""Deserialize from a JSON string."""
data = json.loads(json_str)
return cls.from_dict(data)
@classmethod
def from_file(cls, path: Path) -> MixerSession:
"""Load a session from a JSON file."""
return cls.from_json(path.read_text(encoding="utf-8"))
def save_to_file(self, path: Path) -> None:
"""Save session to a JSON file."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(self.to_json(), encoding="utf-8")
# ── Engine integration ─────────────────────────────────────────────
@classmethod
def from_engine(cls, engine: Any, name: str = "", metadata: Optional[SessionMetadata] = None) -> MixerSession:
"""Capture the current state of a DSPEngine.
Args:
engine: DSPEngine instance.
name: Session name (used if metadata is None).
metadata: Optional pre-built metadata.
Returns:
A MixerSession capturing the full engine state.
"""
from ..mixer.dsp_engine import DSPEngine
if metadata is None:
metadata = SessionMetadata(name=name or "Untitled Session")
# Capture channel states
channels = []
for i, ch in enumerate(engine.channels):
state_dict = dict(ch.state.__dict__)
channels.append({
"channel": i,
"label": f"CH{i + 1}",
**{k: v for k, v in state_dict.items()},
})
# Capture master/bus state
master = engine.buses.to_dict()
# Capture routing
routing = engine.routing.to_dict()
# Capture plugins (from Carla OSC client)
plugins_data = []
for i, ch in enumerate(engine.channels):
for role, info in ch._plugins.items():
plugins_data.append({
"channel": i,
"role": role,
"plugin_id": info.plugin_id,
"name": info.name,
"param_map": dict(info.param_map),
})
# Capture transport state
transport = {
"playing": engine.automation.is_playing,
"recording": engine.automation.is_recording,
"tempo_bpm": 120.0, # default, could be read from MIDI clock
"position_sec": engine.automation.current_time,
}
# Capture automation
automation = engine.automation.to_dict()
return cls(
version=SESSION_SCHEMA_VERSION,
metadata=metadata,
channels=channels,
master=master,
routing=routing,
plugins=plugins_data,
midi_mappings=[], # populated separately if available
transport=transport,
automation=automation,
settings={
"num_channels": engine.config.num_channels,
"num_aux": engine.config.num_aux,
"num_subgroups": engine.config.num_subgroups,
"num_vca": engine.config.num_vca,
"samplerate": engine.config.samplerate,
"buffer_size": engine.config.buffer_size,
},
)
def apply_to(self, engine: Any) -> bool:
"""Apply this session's state to a DSPEngine.
Args:
engine: DSPEngine instance to restore state to.
Returns:
True if all state was applied successfully.
"""
from ..mixer.channel_strip import ChannelState
try:
# Restore channel states
for ch_data in self.channels:
ch_idx = ch_data.get("channel", 0)
if 0 <= ch_idx < len(engine.channels):
# Build ChannelState from dict, filtering only known fields
cs_fields = {f.name for f in ChannelState.__dataclass_fields__.values()}
state_dict = {k: v for k, v in ch_data.items() if k in cs_fields}
cs = ChannelState(**state_dict)
engine.channels[ch_idx].restore(cs, send_osc=True)
# Restore bus state
engine.buses.from_dict(self.master)
# Restore routing
if self.routing:
engine.routing.from_dict(self.routing)
if engine.config.jack_routing_enabled:
engine.routing.apply_full_matrix()
# Restore automation
engine.automation.from_dict(self.automation)
logger.info("Session applied: %s (%d channels)",
self.metadata.name, len(self.channels))
return True
except Exception as exc:
logger.error("Failed to apply session: %s", exc)
return False
# ── Info ───────────────────────────────────────────────────────────
@property
def channel_count(self) -> int:
return len(self.channels)
@property
def has_plugins(self) -> bool:
return len(self.plugins) > 0
@property
def has_midi_mappings(self) -> bool:
return len(self.midi_mappings) > 0
def __repr__(self) -> str:
return (f"MixerSession(name={self.metadata.name!r}, "
f"channels={self.channel_count}, "
f"v{self.version})")
+573
View File
@@ -0,0 +1,573 @@
"""SessionManager — save, load, list, delete, import, and export mixer sessions.
Manages persistent storage of MixerSession objects as JSON files.
Supports auto-save on parameter changes with configurable debounce.
Sessions directory: ~/.config/rpi-mixer/sessions/
Each session is a .json file named after the session (sanitized).
Usage:
mgr = SessionManager()
mgr.save(engine, "Soundcheck Mix")
sessions = mgr.list_sessions()
session = mgr.load("Soundcheck Mix")
session.apply_to(engine)
"""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import tempfile
import threading
import time
from pathlib import Path
from typing import Any, Callable, Optional
from .mixer_session import MixerSession, SessionMetadata, SESSION_SCHEMA_VERSION
logger = logging.getLogger(__name__)
# Default session directories
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "rpi-mixer"
DEFAULT_SESSIONS_DIR = DEFAULT_CONFIG_DIR / "sessions"
DEFAULT_SETLISTS_DIR = DEFAULT_CONFIG_DIR / "setlists"
DEFAULT_EXPORT_DIR = Path.home() / "mixer-sessions-export"
# Auto-save debounce (seconds)
DEFAULT_AUTOSAVE_DEBOUNCE = 2.0
# Maximum sessions to keep in auto-save history
MAX_AUTOSAVE_HISTORY = 5
# Sanitize: allow alphanumeric, hyphen, underscore, space, dot
_SAFE_NAME_RE = re.compile(r"[^\w\s.\-]", re.UNICODE)
def _safe_filename(name: str) -> str:
"""Convert a session name to a safe filename."""
safe = _SAFE_NAME_RE.sub("_", name).strip().replace(" ", "_")
return safe or "untitled"
class SessionManager:
"""Manages mixer session files on disk.
Thread-safe for concurrent access from UI and auto-save timer.
Args:
sessions_dir: Directory to store session JSON files.
setlists_dir: Directory to store setlist JSON files.
auto_save_enabled: Enable auto-save on parameter changes.
auto_save_debounce: Seconds to debounce before auto-saving.
max_autosave_history: Number of auto-save backups to keep.
"""
def __init__(
self,
sessions_dir: Optional[Path] = None,
setlists_dir: Optional[Path] = None,
auto_save_enabled: bool = True,
auto_save_debounce: float = DEFAULT_AUTOSAVE_DEBOUNCE,
max_autosave_history: int = MAX_AUTOSAVE_HISTORY,
):
self._sessions_dir = Path(sessions_dir) if sessions_dir else DEFAULT_SESSIONS_DIR
self._setlists_dir = Path(setlists_dir) if setlists_dir else DEFAULT_SETLISTS_DIR
self._auto_save_enabled = auto_save_enabled
self._auto_save_debounce = auto_save_debounce
self._max_autosave_history = max_autosave_history
# Ensure directories exist
self._sessions_dir.mkdir(parents=True, exist_ok=True)
self._setlists_dir.mkdir(parents=True, exist_ok=True)
# Auto-save state
self._lock = threading.Lock()
self._auto_save_timer: Optional[threading.Timer] = None
self._auto_save_pending = False
self._auto_save_session_name: str = "__autosave__"
self._auto_save_engine: Optional[Any] = None
self._auto_save_callback: Optional[Callable[[str], None]] = None
logger.info("SessionManager initialized: sessions=%s, setlists=%s",
self._sessions_dir, self._setlists_dir)
# ── Session I/O ─────────────────────────────────────────────────────
def save(
self,
engine: Any,
name: str,
metadata: Optional[SessionMetadata] = None,
overwrite: bool = True,
) -> MixerSession:
"""Save the current mixer state as a named session.
Args:
engine: DSPEngine instance to capture state from.
name: Session name (used for filename and display).
metadata: Optional session metadata.
overwrite: If True, overwrite existing session with same name.
Returns:
The saved MixerSession.
Raises:
FileExistsError: If overwrite is False and session exists.
"""
filename = _safe_filename(name) + ".json"
filepath = self._sessions_dir / filename
if not overwrite and filepath.exists():
raise FileExistsError(f"Session already exists: {name}")
session = MixerSession.from_engine(engine, name=name, metadata=metadata)
session.metadata.modified_at = time.time()
with self._lock:
session.save_to_file(filepath)
logger.info("Session saved: %s%s", name, filepath)
return session
def load(self, name: str) -> Optional[MixerSession]:
"""Load a session by name.
Args:
name: Session name (with or without .json extension).
Returns:
MixerSession if found, None otherwise.
"""
filename = _safe_filename(name)
if not filename.endswith(".json"):
filename += ".json"
filepath = self._sessions_dir / filename
if not filepath.exists():
# Try with original name (maybe it already has .json)
alt_path = self._sessions_dir / name
if alt_path.exists():
filepath = alt_path
else:
logger.debug("Session not found: %s", name)
return None
try:
with self._lock:
session = MixerSession.from_file(filepath)
logger.debug("Session loaded: %s", name)
return session
except (json.JSONDecodeError, KeyError, ValueError) as exc:
logger.error("Failed to load session '%s': %s", name, exc)
return None
def load_and_apply(self, engine: Any, name: str) -> bool:
"""Load a session and apply it to the engine.
Returns:
True if loaded and applied successfully.
"""
session = self.load(name)
if session is None:
return False
return session.apply_to(engine)
def delete(self, name: str) -> bool:
"""Delete a session by name.
Returns:
True if deleted, False if not found.
"""
filename = _safe_filename(name)
if not filename.endswith(".json"):
filename += ".json"
filepath = self._sessions_dir / filename
if not filepath.exists():
alt_path = self._sessions_dir / name
if alt_path.exists():
filepath = alt_path
else:
return False
with self._lock:
filepath.unlink()
logger.info("Session deleted: %s", name)
return True
def rename(self, old_name: str, new_name: str) -> bool:
"""Rename a session.
Returns:
True if renamed, False if source not found or target exists.
"""
old_filename = _safe_filename(old_name)
if not old_filename.endswith(".json"):
old_filename += ".json"
old_path = self._sessions_dir / old_filename
if not old_path.exists():
alt_path = self._sessions_dir / old_name
if alt_path.exists():
old_path = alt_path
else:
return False
new_filename = _safe_filename(new_name) + ".json"
new_path = self._sessions_dir / new_filename
if new_path.exists():
return False
with self._lock:
old_path.rename(new_path)
# Update metadata inside the file
session = self.load(new_name)
if session:
session.metadata.name = new_name
session.metadata.modified_at = time.time()
session.save_to_file(new_path)
logger.info("Session renamed: %s%s", old_name, new_name)
return True
def duplicate(self, source_name: str, target_name: str) -> Optional[MixerSession]:
"""Duplicate a session under a new name.
Returns:
The duplicated MixerSession, or None if source not found.
"""
session = self.load(source_name)
if session is None:
return None
session.metadata.name = target_name
session.metadata.created_at = time.time()
session.metadata.modified_at = time.time()
filename = _safe_filename(target_name) + ".json"
filepath = self._sessions_dir / filename
with self._lock:
session.save_to_file(filepath)
logger.info("Session duplicated: %s%s", source_name, target_name)
return session
def list_sessions(self) -> list[dict]:
"""List all saved sessions with metadata.
Returns:
List of dicts with keys: name, file, size_bytes, modified_at.
"""
sessions = []
try:
with self._lock:
for entry in sorted(self._sessions_dir.glob("*.json")):
try:
stat = entry.stat()
# Try to read metadata from the file
try:
data = json.loads(entry.read_text(encoding="utf-8"))
meta = data.get("metadata", {})
name = meta.get("name", entry.stem.replace("_", " "))
except (json.JSONDecodeError, UnicodeDecodeError):
name = entry.stem.replace("_", " ")
sessions.append({
"name": name,
"filename": entry.name,
"size_bytes": stat.st_size,
"modified": stat.st_mtime,
"created": stat.st_ctime,
})
except OSError:
continue
except OSError as exc:
logger.warning("Error listing sessions: %s", exc)
return sessions
def get_session_info(self, name: str) -> Optional[dict]:
"""Get detailed information about a session without loading full state.
Returns:
Dict with metadata, channel count, plugin count, etc.
"""
session = self.load(name)
if session is None:
return None
return {
"name": session.metadata.name,
"version": session.version,
"metadata": session.metadata.to_dict(),
"channel_count": session.channel_count,
"has_plugins": session.has_plugins,
"has_midi_mappings": session.has_midi_mappings,
"size_bytes": len(session.to_json()),
}
# ── Import / Export ──────────────────────────────────────────────────
def export_session(self, name: str, target_dir: Optional[Path] = None) -> Optional[Path]:
"""Export a session JSON file to a target directory.
Args:
name: Session name to export.
target_dir: Directory to export to (default: ~/mixer-sessions-export/).
Returns:
Path to exported file, or None if session not found.
"""
if target_dir is None:
target_dir = DEFAULT_EXPORT_DIR
filename = _safe_filename(name)
if not filename.endswith(".json"):
filename += ".json"
source_path = self._sessions_dir / filename
if not source_path.exists():
alt_path = self._sessions_dir / name
if alt_path.exists():
source_path = alt_path
else:
return None
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / source_path.name
with self._lock:
shutil.copy2(source_path, target_path)
logger.info("Session exported: %s%s", name, target_path)
return target_path
def import_session(self, filepath: Path, new_name: Optional[str] = None) -> Optional[MixerSession]:
"""Import a session JSON file from an external path.
Args:
filepath: Path to the JSON session file.
new_name: Optional new name (uses metadata name if None).
Returns:
Imported MixerSession, or None if import fails.
"""
if not filepath.exists() or not filepath.is_file():
logger.error("Import file not found: %s", filepath)
return None
try:
data = json.loads(filepath.read_text(encoding="utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
logger.error("Invalid JSON in import file: %s", exc)
return None
# Validate basic structure
if not isinstance(data, dict) or "channels" not in data:
logger.error("Import file is not a valid mixer session")
return None
session = MixerSession.from_dict(data)
if new_name:
session.metadata.name = new_name
session.metadata.modified_at = time.time()
filename = _safe_filename(session.metadata.name) + ".json"
dest_path = self._sessions_dir / filename
with self._lock:
session.save_to_file(dest_path)
logger.info("Session imported: %s%s", filepath, dest_path)
return session
def export_all(self, target_dir: Optional[Path] = None) -> Path:
"""Export all sessions as a zip file.
Args:
target_dir: Directory for the export zip.
Returns:
Path to the created zip file.
"""
if target_dir is None:
target_dir = DEFAULT_EXPORT_DIR
target_dir.mkdir(parents=True, exist_ok=True)
timestamp = time.strftime("%Y%m%d_%H%M%S")
zip_name = f"rpi-mixer-sessions_{timestamp}"
zip_path = target_dir / zip_name
with self._lock:
# Create a zip archive of the sessions directory
shutil.make_archive(
str(zip_path),
"zip",
root_dir=str(self._sessions_dir.parent),
base_dir=self._sessions_dir.name,
)
logger.info("All sessions exported: %s.zip", zip_path)
return Path(f"{zip_path}.zip")
# ── Auto-save ───────────────────────────────────────────────────────
def set_auto_save_engine(self, engine: Any) -> None:
"""Set the engine to auto-save from."""
self._auto_save_engine = engine
def set_auto_save_callback(self, callback: Callable[[str], None]) -> None:
"""Set a callback invoked after each auto-save (receives session name)."""
self._auto_save_callback = callback
def enable_auto_save(self) -> None:
"""Enable auto-save."""
self._auto_save_enabled = True
logger.debug("Auto-save enabled")
def disable_auto_save(self) -> None:
"""Disable auto-save and cancel any pending timer."""
self._auto_save_enabled = False
with self._lock:
if self._auto_save_timer:
self._auto_save_timer.cancel()
self._auto_save_timer = None
self._auto_save_pending = False
logger.debug("Auto-save disabled")
def notify_change(self) -> None:
"""Notify that a parameter has changed (debounced auto-save trigger).
Call this from the DSP engine's parameter handler to trigger
auto-save after the debounce period of inactivity.
"""
if not self._auto_save_enabled or self._auto_save_engine is None:
return
with self._lock:
# Cancel any pending timer
if self._auto_save_timer:
self._auto_save_timer.cancel()
self._auto_save_timer = None
# Set pending flag
self._auto_save_pending = True
# Schedule a new auto-save
self._auto_save_timer = threading.Timer(
self._auto_save_debounce,
self._do_auto_save,
)
self._auto_save_timer.daemon = True
self._auto_save_timer.start()
def _do_auto_save(self) -> None:
"""Perform the actual auto-save."""
with self._lock:
self._auto_save_pending = False
self._auto_save_timer = None
if self._auto_save_engine is None:
return
try:
session = MixerSession.from_engine(
self._auto_save_engine,
name=self._auto_save_session_name,
)
filename = _safe_filename(self._auto_save_session_name) + ".json"
filepath = self._sessions_dir / filename
# Rotate old auto-saves
self._rotate_autosaves(filename)
session.save_to_file(filepath)
if self._auto_save_callback:
try:
self._auto_save_callback(self._auto_save_session_name)
except Exception:
pass
logger.debug("Auto-saved session: %s", filepath)
except Exception as exc:
logger.error("Auto-save failed: %s", exc)
def _rotate_autosaves(self, filename: str) -> None:
"""Rotate auto-save backups, keeping the last N versions."""
base = Path(filename).stem
pattern = f"{base}.*.json"
existing = sorted(self._sessions_dir.glob(pattern))
# Remove oldest if we have too many
while len(existing) >= self._max_autosave_history:
oldest = existing.pop(0)
try:
oldest.unlink()
except OSError:
pass
# Rename current to .1, .1 to .2, etc.
current = self._sessions_dir / filename
if current.exists():
for i in range(self._max_autosave_history - 1, 0, -1):
old = self._sessions_dir / f"{base}.{i}.json"
new = self._sessions_dir / f"{base}.{i + 1}.json"
if old.exists():
try:
old.rename(new)
except OSError:
pass
# Rename current to .1
try:
current.rename(self._sessions_dir / f"{base}.1.json")
except OSError:
pass
# ── Convenience ─────────────────────────────────────────────────────
def exists(self, name: str) -> bool:
"""Check if a session exists."""
filename = _safe_filename(name)
if not filename.endswith(".json"):
filename += ".json"
return (self._sessions_dir / filename).exists()
@property
def sessions_dir(self) -> Path:
return self._sessions_dir
@property
def setlists_dir(self) -> Path:
return self._setlists_dir
@property
def session_count(self) -> int:
try:
return len(list(self._sessions_dir.glob("*.json")))
except OSError:
return 0
@property
def auto_save_enabled(self) -> bool:
return self._auto_save_enabled
@property
def stats(self) -> dict:
return {
"sessions_dir": str(self._sessions_dir),
"session_count": self.session_count,
"auto_save_enabled": self._auto_save_enabled,
"auto_save_debounce": self._auto_save_debounce,
"auto_save_pending": self._auto_save_pending,
}
+380
View File
@@ -0,0 +1,380 @@
"""Setlist — ordered collection of mixer sessions with transitions.
A setlist is a performance sequence: a list of sessions with
transition instructions between them (crossfade, hard cut, wait).
Setlists are stored as JSON files in ~/.config/rpi-mixer/setlists/.
Usage:
setlist = Setlist(name="Gig Night")
setlist.add_entry("Soundcheck", transition=TransitionType.CROSSFADE, duration=2.0)
setlist.add_entry("Set 1 - Rock")
setlist.add_entry("Set 2 - Jazz", transition=TransitionType.CROSSFADE)
setlist.save()
"""
from __future__ import annotations
import json
import logging
import time
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
class TransitionType(StrEnum):
"""How to transition between setlist entries."""
CUT = "cut" # Instant switch (default)
CROSSFADE = "crossfade" # Smooth crossfade over duration
WAIT = "wait" # Wait for manual trigger
AUTO = "auto" # Auto-advance after session playback
@dataclass
class SetlistEntry:
"""One entry in a setlist — references a session with transition info."""
session_name: str
display_name: str = ""
transition: TransitionType = TransitionType.CUT
transition_duration: float = 2.0 # seconds (for crossfade)
wait_for_trigger: bool = False # if True, pause before this entry
notes: str = ""
# Runtime state (not persisted)
_loaded: bool = field(default=False, repr=False)
def __post_init__(self):
if not self.display_name:
self.display_name = self.session_name
def to_dict(self) -> dict:
return {
"session_name": self.session_name,
"display_name": self.display_name,
"transition": self.transition.value,
"transition_duration": self.transition_duration,
"wait_for_trigger": self.wait_for_trigger,
"notes": self.notes,
}
@classmethod
def from_dict(cls, data: dict) -> SetlistEntry:
return cls(
session_name=data.get("session_name", ""),
display_name=data.get("display_name", ""),
transition=TransitionType(data.get("transition", "cut")),
transition_duration=data.get("transition_duration", 2.0),
wait_for_trigger=data.get("wait_for_trigger", False),
notes=data.get("notes", ""),
)
@dataclass
class Setlist:
"""An ordered collection of mixer sessions for performance.
A setlist defines the sequence of sessions to load during a
performance, with transition instructions between each.
Usage:
sl = Setlist(name="Gig Night")
sl.add_entry("Soundcheck")
sl.add_entry("Set 1")
sl.activate(session_manager) # Load first entry
sl.next(session_manager, engine) # Advance to next
"""
name: str = "Untitled Setlist"
entries: list[SetlistEntry] = field(default_factory=list)
created_at: float = field(default_factory=time.time)
modified_at: float = field(default_factory=time.time)
description: str = ""
# Runtime state
_current_index: int = field(default=-1, repr=False)
_active: bool = field(default=False, repr=False)
def add_entry(
self,
session_name: str,
display_name: str = "",
transition: TransitionType = TransitionType.CUT,
transition_duration: float = 2.0,
wait_for_trigger: bool = False,
notes: str = "",
) -> SetlistEntry:
"""Add an entry to the setlist.
Args:
session_name: Name of the session to load.
display_name: Optional display name (defaults to session_name).
transition: How to transition to this entry.
transition_duration: Crossfade duration in seconds.
wait_for_trigger: If True, pause before this entry.
notes: Optional notes for this entry.
Returns:
The created SetlistEntry.
"""
entry = SetlistEntry(
session_name=session_name,
display_name=display_name,
transition=transition,
transition_duration=transition_duration,
wait_for_trigger=wait_for_trigger,
notes=notes,
)
self.entries.append(entry)
self.modified_at = time.time()
return entry
def remove_entry(self, index: int) -> bool:
"""Remove an entry by index."""
if 0 <= index < len(self.entries):
self.entries.pop(index)
self.modified_at = time.time()
# Adjust current index if needed
if self._current_index >= len(self.entries):
self._current_index = len(self.entries) - 1
return True
return False
def move_entry(self, from_idx: int, to_idx: int) -> bool:
"""Move an entry to a new position."""
if 0 <= from_idx < len(self.entries) and 0 <= to_idx < len(self.entries):
entry = self.entries.pop(from_idx)
self.entries.insert(to_idx, entry)
self.modified_at = time.time()
return True
return False
def clear(self) -> None:
"""Remove all entries."""
self.entries.clear()
self._current_index = -1
self._active = False
self.modified_at = time.time()
# ── Playback ────────────────────────────────────────────────────────
def activate(
self,
session_manager: Any,
engine: Any,
start_index: int = 0,
) -> bool:
"""Activate the setlist and load the first session.
Args:
session_manager: SessionManager instance.
engine: DSPEngine instance.
start_index: Index to start at (default 0).
Returns:
True if activated successfully.
"""
if not self.entries:
logger.warning("Cannot activate empty setlist: %s", self.name)
return False
if not (0 <= start_index < len(self.entries)):
start_index = 0
entry = self.entries[start_index]
ok = session_manager.load_and_apply(engine, entry.session_name)
if ok:
self._current_index = start_index
self._active = True
entry._loaded = True
logger.info("Setlist activated: %s → entry %d: %s",
self.name, start_index, entry.session_name)
else:
logger.error("Failed to load session for setlist entry: %s",
entry.session_name)
return ok
def next(
self,
session_manager: Any,
engine: Any,
) -> tuple[bool, Optional[SetlistEntry], Optional[SetlistEntry]]:
"""Advance to the next setlist entry.
Returns:
(success, previous_entry, next_entry) — previous_entry is
the entry that was active, next_entry is the new one.
"""
if not self._active or not self.entries:
return (False, None, None)
prev_idx = self._current_index
next_idx = prev_idx + 1
if next_idx >= len(self.entries):
# End of setlist
self._active = False
logger.info("Setlist complete: %s", self.name)
return (True, self.entries[prev_idx] if prev_idx >= 0 else None, None)
prev_entry = self.entries[prev_idx] if prev_idx >= 0 else None
next_entry = self.entries[next_idx]
# Apply transition
if next_entry.transition == TransitionType.CROSSFADE:
# Crossfade to the next session
session_manager.load_and_apply(engine, next_entry.session_name)
# Note: crossfade is handled at the DSP engine level via
# engine.crossfade_to() — but for session loading we just
# load the session immediately since crossfade applies to
# parameter automation, not session loading.
else:
# Hard cut or wait
session_manager.load_and_apply(engine, next_entry.session_name)
self._current_index = next_idx
if prev_entry:
prev_entry._loaded = False
next_entry._loaded = True
logger.info("Setlist advance: %s%s (transition=%s)",
prev_entry.session_name if prev_entry else "start",
next_entry.session_name,
next_entry.transition.value)
return (True, prev_entry, next_entry)
def previous(
self,
session_manager: Any,
engine: Any,
) -> tuple[bool, Optional[SetlistEntry], Optional[SetlistEntry]]:
"""Go to the previous setlist entry."""
if not self._active or not self.entries:
return (False, None, None)
prev_idx = self._current_index
new_idx = prev_idx - 1
if new_idx < 0:
return (False, self.entries[prev_idx] if prev_idx >= 0 else None, None)
prev_entry = self.entries[prev_idx] if prev_idx >= 0 else None
new_entry = self.entries[new_idx]
session_manager.load_and_apply(engine, new_entry.session_name)
self._current_index = new_idx
if prev_entry:
prev_entry._loaded = False
new_entry._loaded = True
return (True, prev_entry, new_entry)
def go_to(
self,
index: int,
session_manager: Any,
engine: Any,
) -> bool:
"""Jump to a specific entry by index."""
if not (0 <= index < len(self.entries)):
return False
entry = self.entries[index]
ok = session_manager.load_and_apply(engine, entry.session_name)
if ok:
# Unload previous
if self._current_index >= 0 and self._current_index < len(self.entries):
self.entries[self._current_index]._loaded = False
self._current_index = index
entry._loaded = True
return ok
def stop(self) -> None:
"""Deactivate the setlist."""
self._active = False
if self._current_index >= 0 and self._current_index < len(self.entries):
self.entries[self._current_index]._loaded = False
self._current_index = -1
@property
def current_entry(self) -> Optional[SetlistEntry]:
if 0 <= self._current_index < len(self.entries):
return self.entries[self._current_index]
return None
@property
def is_active(self) -> bool:
return self._active
@property
def entry_count(self) -> int:
return len(self.entries)
@property
def progress(self) -> tuple[int, int]:
"""Returns (current_index + 1, total_entries)."""
return (self._current_index + 1, len(self.entries))
@property
def has_next(self) -> bool:
return self._active and self._current_index + 1 < len(self.entries)
@property
def has_previous(self) -> bool:
return self._active and self._current_index > 0
# ── Serialization ───────────────────────────────────────────────────
def to_dict(self) -> dict:
return {
"name": self.name,
"entries": [e.to_dict() for e in self.entries],
"created_at": self.created_at,
"modified_at": self.modified_at,
"description": self.description,
}
def to_json(self, indent: int = 2) -> str:
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
@classmethod
def from_dict(cls, data: dict) -> Setlist:
return cls(
name=data.get("name", "Untitled Setlist"),
entries=[SetlistEntry.from_dict(e) for e in data.get("entries", [])],
created_at=data.get("created_at", time.time()),
modified_at=data.get("modified_at", time.time()),
description=data.get("description", ""),
)
@classmethod
def from_json(cls, json_str: str) -> Setlist:
return cls.from_dict(json.loads(json_str))
# ── File I/O ────────────────────────────────────────────────────────
def save_to(self, directory: Path) -> Path:
"""Save the setlist to a JSON file in the given directory.
Returns:
Path to the saved file.
"""
import re
safe_name = re.sub(r"[^\w\s.\-]", "_", self.name).strip().replace(" ", "_")
filepath = directory / f"{safe_name}.json"
directory.mkdir(parents=True, exist_ok=True)
filepath.write_text(self.to_json(), encoding="utf-8")
return filepath
@classmethod
def load_from(cls, filepath: Path) -> Setlist:
"""Load a setlist from a JSON file."""
return cls.from_json(filepath.read_text(encoding="utf-8"))
def __repr__(self) -> str:
return f"Setlist(name={self.name!r}, entries={len(self.entries)})"
+420
View File
@@ -0,0 +1,420 @@
"""Snapshot engine — instant scene recall with disk persistence.
Extends the DSP engine's FaderAutomation Scene system with:
- Persistent storage to disk (alongside session files)
- Named snapshots with metadata
- Instant recall (no crossfade) for live performance
- MIDI-triggerable snapshot switching
- Snapshot chaining (next/prev)
Snapshots are lighter than full sessions — they store only the
parameter state needed for instant recall during a performance.
"""
from __future__ import annotations
import json
import logging
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
@dataclass
class SnapshotMetadata:
"""Metadata for a named snapshot."""
name: str
created_at: float = field(default_factory=time.time)
notes: str = ""
category: str = "" # e.g. "intro", "verse", "chorus", "bridge"
color: str = "" # hex color for UI display
@dataclass
class Snapshot:
"""A lightweight parameter snapshot for instant recall.
Unlike a full MixerSession, a Snapshot only stores mutable
parameter values (channels, master, routing) without plugin
config or MIDI mappings. Designed for sub-millisecond recall
during live performance.
"""
metadata: SnapshotMetadata
# Parameter state
channels: dict[int, dict] = field(default_factory=dict) # ch_idx → state dict
master: dict = field(default_factory=dict)
routing: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"metadata": {
"name": self.metadata.name,
"created_at": self.metadata.created_at,
"notes": self.metadata.notes,
"category": self.metadata.category,
"color": self.metadata.color,
},
"channels": {str(k): v for k, v in self.channels.items()},
"master": self.master,
"routing": self.routing,
}
@classmethod
def from_dict(cls, data: dict) -> Snapshot:
meta = data.get("metadata", {})
return cls(
metadata=SnapshotMetadata(
name=meta.get("name", "Untitled"),
created_at=meta.get("created_at", time.time()),
notes=meta.get("notes", ""),
category=meta.get("category", ""),
color=meta.get("color", ""),
),
channels={int(k): v for k, v in data.get("channels", {}).items()},
master=data.get("master", {}),
routing=data.get("routing", {}),
)
@classmethod
def from_engine(cls, engine: Any, name: str = "", notes: str = "",
category: str = "", color: str = "") -> Snapshot:
"""Capture current engine state as a snapshot."""
channels = {}
for i, ch in enumerate(engine.channels):
channels[i] = dict(ch.state.__dict__)
return cls(
metadata=SnapshotMetadata(
name=name or f"Snapshot {time.strftime('%H:%M:%S')}",
notes=notes,
category=category,
color=color,
),
channels=channels,
master=engine.buses.to_dict(),
routing=engine.routing.to_dict(),
)
def apply_to(self, engine: Any) -> bool:
"""Apply this snapshot to a DSP engine instantly."""
from ..mixer.channel_strip import ChannelState
try:
# Restore channels
for ch_idx, state_dict in self.channels.items():
if 0 <= ch_idx < len(engine.channels):
cs = ChannelState(**state_dict)
engine.channels[ch_idx].restore(cs, send_osc=True)
# Restore master/buses
engine.buses.from_dict(self.master)
# Restore routing
if self.routing:
engine.routing.from_dict(self.routing)
if engine.config.jack_routing_enabled:
engine.routing.apply_full_matrix()
return True
except Exception as exc:
logger.error("Snapshot apply failed: %s", exc)
return False
class SnapshotEngine:
"""Manages snapshots for instant scene recall during performance.
Snapshots are stored in memory for fast access and can be
persisted to disk. Supports chaining (next/prev), MIDI trigger,
and categories for organizing performance sections.
Usage:
snap = SnapshotEngine()
snap.capture(engine, "Intro")
snap.capture(engine, "Verse")
snap.recall(engine, "Intro")
snap.next(engine)
"""
def __init__(self, storage_dir: Optional[Path] = None):
self._snapshots: dict[str, Snapshot] = {}
self._ordered_names: list[str] = [] # capture order
self._current_name: Optional[str] = None
self._storage_dir = storage_dir
if self._storage_dir:
self._storage_dir.mkdir(parents=True, exist_ok=True)
self._load_all()
# ── Capture / Recall ────────────────────────────────────────────────
def capture(
self,
engine: Any,
name: str,
notes: str = "",
category: str = "",
color: str = "",
overwrite: bool = True,
) -> Snapshot:
"""Capture current engine state as a named snapshot.
Args:
engine: DSPEngine instance.
name: Snapshot name.
notes: Optional notes.
category: Optional category (e.g., "intro", "verse").
color: Optional hex color.
overwrite: If False, raises KeyError on duplicate name.
Returns:
The created Snapshot.
"""
if not overwrite and name in self._snapshots:
raise KeyError(f"Snapshot already exists: {name}")
snapshot = Snapshot.from_engine(
engine, name=name, notes=notes,
category=category, color=color,
)
is_new = name not in self._snapshots
self._snapshots[name] = snapshot
if is_new:
self._ordered_names.append(name)
self._current_name = name
# Persist to disk if storage is configured
if self._storage_dir:
self._save_to_disk(name, snapshot)
logger.info("Snapshot captured: %s (%d channels)", name, len(snapshot.channels))
return snapshot
def recall(self, engine: Any, name: str) -> bool:
"""Recall a snapshot instantly.
Args:
engine: DSPEngine instance.
name: Snapshot name to recall.
Returns:
True if recalled successfully.
"""
snapshot = self._snapshots.get(name)
if snapshot is None:
# Try loading from disk
if self._storage_dir:
snapshot = self._load_from_disk(name)
if snapshot:
self._snapshots[name] = snapshot
else:
logger.warning("Snapshot not found: %s", name)
return False
else:
logger.warning("Snapshot not found: %s", name)
return False
ok = snapshot.apply_to(engine)
if ok:
self._current_name = name
logger.info("Snapshot recalled: %s", name)
return ok
def next(self, engine: Any) -> tuple[bool, Optional[str]]:
"""Recall the next snapshot (alphabetically by name).
Returns:
(success, snapshot_name).
"""
names = sorted(self._snapshots.keys())
if not names:
return (False, None)
if self._current_name is None or self._current_name not in names:
target = names[0]
else:
idx = names.index(self._current_name)
target = names[(idx + 1) % len(names)]
ok = self.recall(engine, target)
return (ok, target if ok else None)
def previous(self, engine: Any) -> tuple[bool, Optional[str]]:
"""Recall the previous snapshot.
Returns:
(success, snapshot_name).
"""
names = sorted(self._snapshots.keys())
if not names:
return (False, None)
if self._current_name is None or self._current_name not in names:
target = names[-1]
else:
idx = names.index(self._current_name)
target = names[(idx - 1) % len(names)]
ok = self.recall(engine, target)
return (ok, target if ok else None)
def go_to(self, engine: Any, index: int) -> bool:
"""Recall a snapshot by its capture order index."""
if 0 <= index < len(self._ordered_names):
return self.recall(engine, self._ordered_names[index])
return False
# ── Management ──────────────────────────────────────────────────────
def delete(self, name: str) -> bool:
"""Delete a snapshot."""
if name not in self._snapshots:
return False
del self._snapshots[name]
if name in self._ordered_names:
self._ordered_names.remove(name)
if self._current_name == name:
self._current_name = None
# Delete from disk
if self._storage_dir:
filepath = self._storage_dir / f"{name}.json"
try:
filepath.unlink()
except OSError:
pass
return True
def rename(self, old_name: str, new_name: str) -> bool:
"""Rename a snapshot."""
if old_name not in self._snapshots or new_name in self._snapshots:
return False
snapshot = self._snapshots.pop(old_name)
snapshot.metadata.name = new_name
self._snapshots[new_name] = snapshot
if old_name in self._ordered_names:
idx = self._ordered_names.index(old_name)
self._ordered_names[idx] = new_name
if self._current_name == old_name:
self._current_name = new_name
# Update disk
if self._storage_dir:
old_path = self._storage_dir / f"{old_name}.json"
try:
old_path.unlink()
except OSError:
pass
self._save_to_disk(new_name, snapshot)
return True
def clear(self) -> None:
"""Delete all snapshots."""
self._snapshots.clear()
self._ordered_names.clear()
self._current_name = None
if self._storage_dir:
for f in self._storage_dir.glob("*.json"):
try:
f.unlink()
except OSError:
pass
def list_snapshots(self) -> list[dict]:
"""List all snapshots with metadata."""
result = []
for name, snap in self._snapshots.items():
result.append({
"name": name,
"created_at": snap.metadata.created_at,
"notes": snap.metadata.notes,
"category": snap.metadata.category,
"color": snap.metadata.color,
"channel_count": len(snap.channels),
"is_current": name == self._current_name,
})
return sorted(result, key=lambda x: x["name"])
def get_by_category(self, category: str) -> list[Snapshot]:
"""Get all snapshots in a category."""
return [s for s in self._snapshots.values()
if s.metadata.category == category]
# ── Properties ──────────────────────────────────────────────────────
@property
def current_name(self) -> Optional[str]:
return self._current_name
@property
def snapshot_count(self) -> int:
return len(self._snapshots)
@property
def names(self) -> list[str]:
return sorted(self._snapshots.keys())
@property
def categories(self) -> list[str]:
cats = {s.metadata.category for s in self._snapshots.values()
if s.metadata.category}
return sorted(cats)
# ── Disk persistence ────────────────────────────────────────────────
def _save_to_disk(self, name: str, snapshot: Snapshot) -> None:
"""Persist a snapshot to disk."""
if not self._storage_dir:
return
filepath = self._storage_dir / f"{name}.json"
filepath.write_text(
json.dumps(snapshot.to_dict(), indent=2, ensure_ascii=False),
encoding="utf-8",
)
def _load_from_disk(self, name: str) -> Optional[Snapshot]:
"""Load a snapshot from disk."""
if not self._storage_dir:
return None
filepath = self._storage_dir / f"{name}.json"
if not filepath.exists():
return None
try:
data = json.loads(filepath.read_text(encoding="utf-8"))
return Snapshot.from_dict(data)
except (json.JSONDecodeError, KeyError) as exc:
logger.warning("Failed to load snapshot from disk: %s%s", name, exc)
return None
def _load_all(self) -> None:
"""Load all snapshots from disk."""
if not self._storage_dir or not self._storage_dir.exists():
return
for filepath in sorted(self._storage_dir.glob("*.json")):
try:
data = json.loads(filepath.read_text(encoding="utf-8"))
snapshot = Snapshot.from_dict(data)
name = snapshot.metadata.name
self._snapshots[name] = snapshot
self._ordered_names.append(name)
except (json.JSONDecodeError, KeyError):
continue
logger.info("Loaded %d snapshots from disk", len(self._snapshots))
def to_dict(self) -> dict:
return {
"snapshots": {k: v.to_dict() for k, v in self._snapshots.items()},
"ordered_names": self._ordered_names,
"current_name": self._current_name,
}
+96
View File
@@ -0,0 +1,96 @@
"""Audio/video streaming pipeline for the RPi Audio Mixer.
Streams the mixer master mix + camera video to RTMP platforms
(YouTube, Twitch, custom RTMP servers) using GStreamer with
hardware-accelerated h264 encoding on Raspberry Pi 4B.
Modules:
- camera: camera detection (USB webcam, Pi Camera Module)
- platforms: streaming platform profiles and RTMP URLs
- gst_pipeline: GStreamer pipeline string builder
- streamer: Streamer lifecycle manager (start/stop/status)
- controls: keyboard shortcut handler for stream control
Architecture:
Audio: JACK system output (master mix) → alsasink bridge → GStreamer
Video: v4l2src (USB) or libcamerasrc (Pi Cam) → h264 hardware encoder
Output: RTMP sink → streaming platform
Integration:
- REST API: /api/v1/stream/* endpoints for start/stop/status/scenes
- WebSocket: real-time stream status updates
- Keyboard: global hotkeys for stream control
"""
from __future__ import annotations
from .camera import (
CameraInfo,
CameraType,
detect_cameras,
get_default_camera,
)
from .platforms import (
PlatformProfile,
YOUTUBE_RTMP,
TWITCH_RTMP,
CUSTOM_RTMP,
PRESET_PLATFORMS,
get_platform_profile,
)
from .gst_pipeline import (
GstPipelineBuilder,
VideoSource,
AudioSource,
EncoderType,
build_pipeline_string,
)
from .streamer import (
Streamer,
StreamerConfig,
StreamState,
StreamScene,
StreamStats,
create_streamer,
)
from .controls import (
StreamKeyboardController,
StreamHotkey,
start_keyboard_controller,
)
__all__ = [
# Camera
"CameraInfo",
"CameraType",
"detect_cameras",
"get_default_camera",
# Platforms
"PlatformProfile",
"YOUTUBE_RTMP",
"TWITCH_RTMP",
"CUSTOM_RTMP",
"PRESET_PLATFORMS",
"get_platform_profile",
# Pipeline
"GstPipelineBuilder",
"VideoSource",
"AudioSource",
"EncoderType",
"build_pipeline_string",
# Streamer
"Streamer",
"StreamerConfig",
"StreamState",
"StreamScene",
"StreamStats",
"create_streamer",
# Controls
"StreamKeyboardController",
"StreamHotkey",
"start_keyboard_controller",
]
+398
View File
@@ -0,0 +1,398 @@
"""Camera detection and configuration for the streaming pipeline.
Detects available cameras on Raspberry Pi 4B:
- USB webcams (v4l2, /dev/video*)
- Raspberry Pi Camera Module (libcamera, /dev/video* via bcm2835-unicam)
Returns CameraInfo objects with device paths, supported resolutions,
and frame rates for use in GStreamer pipeline construction.
"""
from __future__ import annotations
import logging
import os
import re
import subprocess
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Optional
logger = logging.getLogger(__name__)
class CameraType(StrEnum):
"""Camera hardware type."""
USB_WEBCAM = "usb_webcam"
PI_CAMERA = "pi_camera"
PI_CAMERA_LEGACY = "pi_camera_legacy" # legacy raspicam stack
UNKNOWN = "unknown"
@dataclass
class CameraResolution:
"""Supported resolution for a camera."""
width: int
height: int
fps: float = 30.0
@dataclass
class CameraInfo:
"""Information about a detected camera."""
device_path: str # e.g. "/dev/video0"
camera_type: CameraType
name: str = ""
driver: str = ""
bus_info: str = ""
resolutions: list[CameraResolution] = field(default_factory=list)
pixel_formats: list[str] = field(default_factory=list)
@property
def supports_h264(self) -> bool:
"""Check if the camera supports hardware h264 encoding."""
return "H264" in self.pixel_formats or "h264" in self.pixel_formats
@property
def supports_mjpeg(self) -> bool:
"""Check if the camera supports MJPEG output."""
return "MJPG" in self.pixel_formats or "mjpeg" in self.pixel_formats
@property
def best_resolution(self) -> Optional[CameraResolution]:
"""Get the highest resolution available."""
if not self.resolutions:
return None
return max(self.resolutions, key=lambda r: r.width * r.height)
def to_dict(self) -> dict:
return {
"device_path": self.device_path,
"camera_type": self.camera_type.value,
"name": self.name,
"driver": self.driver,
"bus_info": self.bus_info,
"resolutions": [
{"width": r.width, "height": r.height, "fps": r.fps}
for r in self.resolutions
],
"pixel_formats": self.pixel_formats,
}
# ── Detection ────────────────────────────────────────────────────────────────
def detect_cameras() -> list[CameraInfo]:
"""Detect all available cameras on the system.
Checks for:
1. Pi Camera Module (via libcamera or bcm2835-unicam driver)
2. USB webcams (via v4l2 /dev/video* devices)
Returns a list of CameraInfo objects, best candidate first.
"""
cameras: list[CameraInfo] = []
# Check for Pi Camera Module
pi_cams = _detect_pi_camera()
cameras.extend(pi_cams)
# Check for USB webcams
usb_cams = _detect_usb_webcams()
cameras.extend(usb_cams)
# Sort: Pi Camera first (better performance on RPi), then by name
def _sort_key(cam: CameraInfo) -> tuple[int, str]:
type_order = {
CameraType.PI_CAMERA: 0,
CameraType.PI_CAMERA_LEGACY: 1,
CameraType.USB_WEBCAM: 2,
CameraType.UNKNOWN: 3,
}
return (type_order.get(cam.camera_type, 99), cam.name)
cameras.sort(key=_sort_key)
return cameras
def get_default_camera() -> Optional[CameraInfo]:
"""Get the best available camera for streaming."""
cameras = detect_cameras()
return cameras[0] if cameras else None
def _detect_pi_camera() -> list[CameraInfo]:
"""Detect Raspberry Pi Camera Module (libcamera or legacy)."""
cameras: list[CameraInfo] = []
# Check for libcamera support (modern Pi camera stack)
try:
result = subprocess.run(
["libcamera-hello", "--list-cameras"],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
# Parse output for camera info
info = _parse_libcamera_output(result.stdout)
if info:
cameras.append(info)
# Also check for libcamera devices in /dev
for dev in sorted(os.listdir("/dev")):
if dev.startswith("video"):
devpath = f"/dev/{dev}"
driver = _get_v4l2_driver(devpath)
if driver and "unicam" in driver.lower():
if not any(c.device_path == devpath for c in cameras):
cam = CameraInfo(
device_path=devpath,
camera_type=CameraType.PI_CAMERA,
name=f"Pi Camera ({dev})",
driver=driver,
)
_enrich_v4l2_info(cam)
cameras.append(cam)
except FileNotFoundError:
logger.debug("libcamera-hello not found (not on a Pi or libcamera not installed)")
# Check for legacy raspicam
try:
if os.path.exists("/dev/vchiq"):
# Legacy Pi camera stack devices
for dev in sorted(os.listdir("/dev")):
if dev.startswith("video"):
devpath = f"/dev/{dev}"
driver = _get_v4l2_driver(devpath)
if driver and "bcm2835" in driver.lower():
cam = CameraInfo(
device_path=devpath,
camera_type=CameraType.PI_CAMERA_LEGACY,
name=f"Pi Camera Legacy ({dev})",
driver=driver,
)
_enrich_v4l2_info(cam)
cameras.append(cam)
except Exception:
pass
return cameras
def _detect_usb_webcams() -> list[CameraInfo]:
"""Detect USB webcams via v4l2 device enumeration."""
cameras: list[CameraInfo] = []
# Try using v4l2-ctl
try:
result = subprocess.run(
["v4l2-ctl", "--list-devices"],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
cameras.extend(_parse_v4l2_list(result.stdout))
return cameras
except FileNotFoundError:
logger.debug("v4l2-ctl not found, falling back to manual detection")
# Fallback: scan /dev/video* devices manually
for dev in sorted(os.listdir("/dev")):
if re.match(r"^video\d+$", dev):
devpath = f"/dev/{dev}"
driver = _get_v4l2_driver(devpath)
if driver and "unicam" not in driver.lower() and "bcm2835" not in driver.lower():
cam = CameraInfo(
device_path=devpath,
camera_type=CameraType.USB_WEBCAM,
name=_get_v4l2_name(devpath) or f"USB Camera ({dev})",
driver=driver,
)
_enrich_v4l2_info(cam)
cameras.append(cam)
return cameras
# ── v4l2 helpers ─────────────────────────────────────────────────────────────
def _get_v4l2_driver(device: str) -> str:
"""Get the driver name for a v4l2 device."""
try:
result = subprocess.run(
["v4l2-ctl", "-d", device, "--info"],
capture_output=True, text=True, timeout=3,
)
for line in result.stdout.splitlines():
if "Driver name" in line:
return line.split(":", 1)[1].strip()
except Exception:
pass
# Try reading from sysfs
try:
devname = os.path.basename(device)
uevent = f"/sys/class/video4linux/{devname}/device/uevent"
if os.path.exists(uevent):
with open(uevent) as f:
for line in f:
if line.startswith("DRIVER="):
return line.split("=", 1)[1].strip()
except Exception:
pass
return ""
def _get_v4l2_name(device: str) -> str:
"""Get the card name for a v4l2 device."""
try:
result = subprocess.run(
["v4l2-ctl", "-d", device, "--info"],
capture_output=True, text=True, timeout=3,
)
for line in result.stdout.splitlines():
if "Card type" in line:
return line.split(":", 1)[1].strip()
except Exception:
pass
return ""
def _enrich_v4l2_info(cam: CameraInfo) -> None:
"""Add resolution and format info to a CameraInfo via v4l2-ctl."""
try:
# Get supported formats
result = subprocess.run(
["v4l2-ctl", "-d", cam.device_path, "--list-formats-ext"],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
_parse_v4l2_formats(result.stdout, cam)
except Exception as exc:
logger.debug("Failed to get v4l2 formats for %s: %s", cam.device_path, exc)
def _parse_v4l2_list(output: str) -> list[CameraInfo]:
"""Parse v4l2-ctl --list-devices output."""
cameras: list[CameraInfo] = []
current_name = ""
current_driver = ""
for line in output.splitlines():
line = line.strip()
if not line:
continue
# Device name line (e.g. "USB Camera: USB Camera (usb-0000:01:00.0-1.2):")
if "(" in line and not line.startswith("/dev/"):
# Extract name and bus info
match = re.match(r"^(.+?)\s*\((.+?)\):", line)
if match:
current_name = match.group(1).strip()
current_driver = match.group(2).strip()
else:
current_name = line.rstrip(":")
elif line.startswith("/dev/video"):
devpath = line.strip()
cam = CameraInfo(
device_path=devpath,
camera_type=_classify_camera(current_name, current_driver),
name=current_name,
driver=current_driver,
)
_enrich_v4l2_info(cam)
cameras.append(cam)
return cameras
def _parse_v4l2_formats(output: str, cam: CameraInfo) -> None:
"""Parse v4l2-ctl --list-formats-ext output."""
resolutions: list[CameraResolution] = []
pixel_formats: list[str] = []
current_format = ""
current_size = ""
for line in output.splitlines():
line = line.strip()
# Format line: "[0]: 'YUYV' (YUYV 4:2:2)"
if line.startswith("[") and "'" in line:
match = re.search(r"'(\w+)'", line)
if match:
current_format = match.group(1)
if current_format not in pixel_formats:
pixel_formats.append(current_format)
# Size line: "Size: Discrete 640x480"
elif line.startswith("Size:"):
match = re.search(r"(\d+)x(\d+)", line)
if match:
current_size = f"{match.group(1)}x{match.group(2)}"
w, h = int(match.group(1)), int(match.group(2))
resolutions.append(CameraResolution(width=w, height=h, fps=30.0))
# FPS line: "Interval: Discrete 0.033s (30.000 fps)"
elif line.startswith("Interval:") and resolutions:
match = re.search(r"\((\d+\.?\d*)\s*fps\)", line)
if match:
fps = float(match.group(1))
resolutions[-1].fps = min(resolutions[-1].fps, fps)
cam.resolutions = resolutions
cam.pixel_formats = pixel_formats
def _parse_libcamera_output(output: str) -> Optional[CameraInfo]:
"""Parse libcamera-hello --list-cameras output."""
# libcamera output format:
# Available cameras
# ----------------
# 0 : imx219 [3280x2464] (/base/soc/i2c0mux/i2c@1/imx219@10)
# Modes: 'SRGGB10_CSI2P' : 640x480, 1640x1232, 1920x1080, 3280x2464
# 'SRGGB8' : 640x480, 1640x1232, 1920x1080, 3280x2464
lines = output.splitlines()
for i, line in enumerate(lines):
match = re.match(r"^\d+\s*:\s*(\w+)\s*\[(\d+)x(\d+)\]", line)
if match:
sensor = match.group(1)
width = int(match.group(2))
height = int(match.group(3))
cam = CameraInfo(
device_path="/dev/video0", # libcamera abstracts device path
camera_type=CameraType.PI_CAMERA,
name=f"Pi Camera ({sensor})",
driver="unicam",
)
cam.resolutions.append(CameraResolution(width=width, height=height, fps=30.0))
# Parse modes from following lines
for mode_line in lines[i+1:]:
mode_line = mode_line.strip()
if mode_line.startswith("Modes:") or mode_line.startswith("'"):
for res_match in re.finditer(r"(\d+)x(\d+)", mode_line):
w, h = int(res_match.group(1)), int(res_match.group(2))
existing = {(r.width, r.height) for r in cam.resolutions}
if (w, h) not in existing:
cam.resolutions.append(CameraResolution(width=w, height=h, fps=30.0))
elif mode_line and not mode_line.startswith("'"):
break
return cam
return None
def _classify_camera(name: str, driver: str) -> CameraType:
"""Classify camera type from name and driver info."""
combined = f"{name} {driver}".lower()
if "pi camera" in combined or "imx" in combined or "ov5647" in combined:
return CameraType.PI_CAMERA
if "bcm2835" in combined or "mmal" in combined:
return CameraType.PI_CAMERA_LEGACY
if "usb" in combined or "webcam" in combined or "uvc" in combined:
return CameraType.USB_WEBCAM
return CameraType.USB_WEBCAM # default assumption
+384
View File
@@ -0,0 +1,384 @@
"""Keyboard shortcut handler for stream control.
Provides global keyboard shortcuts for controlling the streaming
pipeline without needing the web UI or touchscreen:
Ctrl+Shift+S — Start/stop stream
Ctrl+Shift+1..9 — Switch to scene 1-9
Ctrl+Shift+R — Force reconnect
On Linux, uses evdev for direct keyboard input (works without
a window manager, ideal for headless RPi setups).
On macOS/Windows, uses pynput as fallback.
The controller runs a background thread that listens for key
combinations and dispatches them to the Streamer.
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
from enum import StrEnum
from typing import Optional, Callable
logger = logging.getLogger(__name__)
class StreamHotkey(StrEnum):
"""Stream control hotkey actions."""
START_STOP = "start_stop" # Toggle streaming
SCENE_1 = "scene_1"
SCENE_2 = "scene_2"
SCENE_3 = "scene_3"
SCENE_4 = "scene_4"
SCENE_5 = "scene_5"
SCENE_6 = "scene_6"
SCENE_7 = "scene_7"
SCENE_8 = "scene_8"
SCENE_9 = "scene_9"
RECONNECT = "reconnect" # Force reconnect
MUTE_VIDEO = "mute_video" # Toggle video (audio only)
MUTE_AUDIO = "mute_audio" # Toggle audio (video only)
@dataclass
class HotkeyMapping:
"""Maps a key combination to a stream action."""
hotkey: StreamHotkey
modifiers: set[str] # e.g. {"ctrl", "shift"}
key: str # e.g. "s", "1", "r"
description: str = ""
# Default hotkey mappings for the streaming controller
DEFAULT_HOTKEYS: list[HotkeyMapping] = [
HotkeyMapping(StreamHotkey.START_STOP, {"ctrl", "shift"}, "s", "Start/Stop streaming"),
HotkeyMapping(StreamHotkey.SCENE_1, {"ctrl", "shift"}, "1", "Switch to scene 1"),
HotkeyMapping(StreamHotkey.SCENE_2, {"ctrl", "shift"}, "2", "Switch to scene 2"),
HotkeyMapping(StreamHotkey.SCENE_3, {"ctrl", "shift"}, "3", "Switch to scene 3"),
HotkeyMapping(StreamHotkey.SCENE_4, {"ctrl", "shift"}, "4", "Switch to scene 4"),
HotkeyMapping(StreamHotkey.SCENE_5, {"ctrl", "shift"}, "5", "Switch to scene 5"),
HotkeyMapping(StreamHotkey.SCENE_6, {"ctrl", "shift"}, "6", "Switch to scene 6"),
HotkeyMapping(StreamHotkey.SCENE_7, {"ctrl", "shift"}, "7", "Switch to scene 7"),
HotkeyMapping(StreamHotkey.SCENE_8, {"ctrl", "shift"}, "8", "Switch to scene 8"),
HotkeyMapping(StreamHotkey.SCENE_9, {"ctrl", "shift"}, "9", "Switch to scene 9"),
HotkeyMapping(StreamHotkey.RECONNECT, {"ctrl", "shift"}, "r", "Force reconnect"),
HotkeyMapping(StreamHotkey.MUTE_VIDEO, {"ctrl", "shift"}, "v", "Toggle video mute"),
HotkeyMapping(StreamHotkey.MUTE_AUDIO, {"ctrl", "shift"}, "a", "Toggle audio mute"),
]
class StreamKeyboardController:
"""Global keyboard shortcut handler for stream control.
Runs a background listener thread. On Linux, prefers evdev for
direct keyboard access (works without X11/Wayland, ideal for
headless Raspberry Pi setups).
Usage:
from src.streaming import Streamer, StreamKeyboardController
streamer = Streamer(...)
controller = StreamKeyboardController(streamer)
controller.start()
# ... streamer operations ...
controller.stop()
"""
def __init__(
self,
streamer, # Streamer (avoid circular import by not type-hinting)
hotkeys: list[HotkeyMapping] | None = None,
use_evdev: bool = True,
evdev_device: str | None = None,
):
"""
Args:
streamer: The Streamer instance to control.
hotkeys: Custom hotkey mappings (uses defaults if None).
use_evdev: Prefer evdev on Linux (True) or fallback to pynput.
evdev_device: Specific evdev device path (auto-detected if None).
"""
self._streamer = streamer
self._hotkeys = hotkeys or DEFAULT_HOTKEYS
self._use_evdev = use_evdev
self._evdev_device = evdev_device
self._running = False
self._thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
# Build hotkey lookup: (frozenset(modifiers), key) → action
self._action_map: dict[tuple[frozenset[str], str], StreamHotkey] = {}
self._hotkey_descriptions: dict[StreamHotkey, str] = {}
for hk in self._hotkeys:
key = hk.key.lower()
mods = frozenset(m.lower() for m in hk.modifiers)
self._action_map[(mods, key)] = hk.hotkey
self._hotkey_descriptions[hk.hotkey] = hk.description
def start(self) -> bool:
"""Start the keyboard listener in a background thread.
Returns:
True if the listener started successfully.
"""
with self._lock:
if self._running:
return True
self._running = True
self._thread = threading.Thread(
target=self._listen_loop,
daemon=True,
name="stream-keyboard",
)
self._thread.start()
logger.info("Stream keyboard controller started")
return True
def stop(self) -> None:
"""Stop the keyboard listener."""
with self._lock:
self._running = False
if self._thread and self._thread.is_alive():
self._thread.join(timeout=2.0)
logger.info("Stream keyboard controller stopped")
def get_hotkeys(self) -> list[dict]:
"""List all configured hotkeys (for API display)."""
return [
{
"action": hk.hotkey.value,
"modifiers": sorted(hk.modifiers),
"key": hk.key,
"description": hk.description,
}
for hk in self._hotkeys
]
# ── Internals ──────────────────────────────────────────────────────────
def _listen_loop(self) -> None:
"""Main keyboard listen loop. Tries evdev first, then pynput."""
if self._use_evdev:
success = self._try_evdev_listener()
if success:
return
logger.info("evdev listener failed, trying pynput fallback")
self._try_pynput_listener()
def _try_evdev_listener(self) -> bool:
"""Try to listen for keyboard events via evdev."""
try:
import evdev
from evdev import ecodes, InputDevice
# Find keyboard devices
device = self._find_evdev_keyboard()
if device is None:
logger.warning("No evdev keyboard device found")
return False
logger.info("Listening on keyboard: %s (%s)", device.name, device.path)
# Modifier state tracking
modifiers_active: set[str] = set()
modifier_map = {
ecodes.KEY_LEFTCTRL: "ctrl",
ecodes.KEY_RIGHTCTRL: "ctrl",
ecodes.KEY_LEFTSHIFT: "shift",
ecodes.KEY_RIGHTSHIFT: "shift",
ecodes.KEY_LEFTALT: "alt",
ecodes.KEY_RIGHTALT: "alt",
ecodes.KEY_LEFTMETA: "meta",
ecodes.KEY_RIGHTMETA: "meta",
}
key_name_map = {
ecodes.KEY_S: "s", ecodes.KEY_R: "r", ecodes.KEY_V: "v", ecodes.KEY_A: "a",
ecodes.KEY_1: "1", ecodes.KEY_2: "2", ecodes.KEY_3: "3",
ecodes.KEY_4: "4", ecodes.KEY_5: "5", ecodes.KEY_6: "6",
ecodes.KEY_7: "7", ecodes.KEY_8: "8", ecodes.KEY_9: "9",
ecodes.KEY_0: "0",
}
for event in device.read_loop():
if not self._running:
break
if event.type != ecodes.EV_KEY:
continue
keycode = event.code
keystate = event.value # 0=up, 1=down, 2=hold
# Track modifiers
if keycode in modifier_map:
if keystate == 1: # pressed
modifiers_active.add(modifier_map[keycode])
elif keystate == 0: # released
modifiers_active.discard(modifier_map[keycode])
# Check for hotkey on key-down
if keystate == 1 and keycode in key_name_map:
key_name = key_name_map[keycode]
self._check_hotkey(modifiers_active, key_name)
return True
except ImportError:
logger.debug("evdev not installed (pip install evdev)")
return False
except Exception as exc:
logger.warning("evdev listener error: %s", exc)
return False
def _try_pynput_listener(self) -> bool:
"""Try to listen for keyboard events via pynput."""
try:
from pynput import keyboard
modifiers_active: set[str] = set()
modifier_map = {
keyboard.Key.ctrl_l: "ctrl",
keyboard.Key.ctrl_r: "ctrl",
keyboard.Key.shift_l: "shift",
keyboard.Key.shift_r: "shift",
keyboard.Key.alt_l: "alt",
keyboard.Key.alt_r: "alt",
}
def on_press(key):
if not self._running:
return False
if hasattr(key, "name"): # Special key
mod = modifier_map.get(key)
if mod:
modifiers_active.add(mod)
else: # Character key
try:
char = key.char.lower()
except AttributeError:
return
self._check_hotkey(modifiers_active, char)
def on_release(key):
if hasattr(key, "name"):
mod = modifier_map.get(key)
if mod:
modifiers_active.discard(mod)
with keyboard.Listener(
on_press=on_press,
on_release=on_release,
) as listener:
listener.join()
return True
except ImportError:
logger.debug("pynput not installed (pip install pynput)")
return False
except Exception as exc:
logger.warning("pynput listener error: %s", exc)
return False
def _find_evdev_keyboard(self) -> Optional[object]:
"""Find an evdev keyboard device."""
try:
import evdev
if self._evdev_device:
return evdev.InputDevice(self._evdev_device)
devices = [evdev.InputDevice(path) for path in evdev.list_devices()]
for device in devices:
if "keyboard" in device.name.lower() or "key" in device.name.lower():
# Check if it has keyboard keys
caps = device.capabilities()
if 1 in caps: # EV_KEY
return device
# Fallback: first device with EV_KEY capability
for device in devices:
caps = device.capabilities()
if 1 in caps:
return device
except ImportError:
pass
return None
def _check_hotkey(self, modifiers: set[str], key: str) -> None:
"""Check if a key combination matches a hotkey, and dispatch."""
# Normalize
mods = frozenset(m.lower() for m in modifiers)
key = key.lower()
action = self._action_map.get((mods, key))
if action is None:
return
logger.info("Hotkey triggered: %s", action.value)
self._dispatch_action(action)
def _dispatch_action(self, action: StreamHotkey) -> None:
"""Execute the action on the streamer."""
try:
if action == StreamHotkey.START_STOP:
if self._streamer.is_live:
self._streamer.stop()
else:
self._streamer.start()
elif action == StreamHotkey.RECONNECT:
if self._streamer.is_live:
self._streamer.stop()
time.sleep(1.0)
self._streamer.start()
elif action.value.startswith("scene_"):
scene_num = action.value.split("_")[1]
scenes = self._streamer.get_scenes()
if len(scenes) >= int(scene_num):
scene_name = scenes[int(scene_num) - 1]["name"]
self._streamer.switch_scene(scene_name)
elif action == StreamHotkey.MUTE_VIDEO:
logger.info("Video mute toggle (not yet implemented)")
elif action == StreamHotkey.MUTE_AUDIO:
logger.info("Audio mute toggle (not yet implemented)")
except Exception as exc:
logger.error("Error dispatching hotkey %s: %s", action.value, exc)
def start_keyboard_controller(
streamer, # Streamer
hotkeys: list[HotkeyMapping] | None = None,
) -> StreamKeyboardController:
"""Create and start a keyboard controller for the given streamer.
Convenience factory function.
Args:
streamer: The Streamer to control.
hotkeys: Custom hotkey mappings.
Returns:
Started StreamKeyboardController.
"""
controller = StreamKeyboardController(streamer, hotkeys=hotkeys)
controller.start()
return controller
+366
View File
@@ -0,0 +1,366 @@
"""GStreamer pipeline builder for audio/video streaming.
Constructs GStreamer pipeline strings for the Raspberry Pi 4B
hardware-accelerated encoding path:
Video: camera → h264 encoder → RTMP mux
Audio: JACK/ALSA → AAC encoder → RTMP mux
Supports both Python GObject bindings (preferred) and CLI
gst-launch-1.0 subprocess mode for reliability.
Raspberry Pi 4B hardware encoders:
- v4l2h264enc: V4L2 stateless hardware h264 encoder (Pi 4/5)
- omxh264enc: Legacy Broadcom MMAL hardware encoder (Pi 3/older)
- x264enc: Software fallback (works everywhere, higher CPU)
Audio capture methods:
- alsasrc: Direct ALSA device (e.g., JACK ALSA bridge)
- pulsesrc: PulseAudio source
- jackaudiosrc: JACK audio source (requires gst-plugins-bad)
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Optional
logger = logging.getLogger(__name__)
class VideoSource(StrEnum):
"""Video input source type for GStreamer pipeline."""
V4L2 = "v4l2" # USB webcam /dev/video*
LIBCAMERA = "libcamera" # Pi Camera Module (modern libcamera)
RASPICAM = "raspicam" # Legacy Pi Camera (rpicamsrc)
TEST = "test" # Test pattern (videotestsrc)
class AudioSource(StrEnum):
"""Audio input source type for GStreamer pipeline."""
ALSA = "alsa" # Direct ALSA device
JACK = "jack" # JACK audio server
PULSE = "pulse" # PulseAudio
TEST = "test" # Test tone (audiotestsrc)
class EncoderType(StrEnum):
"""Video encoder type."""
V4L2_H264 = "v4l2h264enc" # V4L2 stateless h264 (Pi 4/5)
OMX_H264 = "omxh264enc" # Broadcom OMX h264 (Pi 3/older)
X264 = "x264enc" # Software x264
LIBCAMERA_H264 = "libcamera_h264" # libcamera built-in h264
@dataclass
class GstPipelineConfig:
"""Configuration for building a GStreamer pipeline."""
# Video
video_source: VideoSource = VideoSource.TEST
video_device: str = "/dev/video0"
video_width: int = 1280
video_height: int = 720
video_framerate: int = 30
# Audio
audio_source: AudioSource = AudioSource.TEST
audio_device: str = "hw:0" # ALSA device name
audio_sample_rate: int = 48000
audio_channels: int = 2
audio_buffer_time_us: int = 20000 # 20ms buffer for low latency
# Encoding
video_encoder: EncoderType = EncoderType.V4L2_H264
video_bitrate_kbps: int = 2500
video_bitrate_max_kbps: int = 4000
keyframe_interval: int = 60 # frames between keyframes
h264_profile: str = "main"
h264_level: str = "4.0"
b_frames: int = 0 # 0 for lowest latency
audio_bitrate_kbps: int = 128
audio_encoder: str = "avenc_aac" # or voaacenc, lamemp3enc
# Output
rtmp_url: str = ""
rtmp_stream_key: str = ""
# Latency tuning
low_latency: bool = True
video_queue_max_time_ms: int = 1000
zerolatency_tune: bool = True
# Test mode
test_video_pattern: int = 0 # videotestsrc pattern number
test_audio_freq: float = 440.0 # audiotestsrc frequency
@property
def full_rtmp_url(self) -> str:
"""Construct full RTMP URL with stream key."""
if not self.rtmp_url:
return ""
url = self.rtmp_url.rstrip("/")
if self.rtmp_stream_key:
url = f"{url}/{self.rtmp_stream_key}"
return url
class GstPipelineBuilder:
"""Builds GStreamer pipeline strings for various configurations.
Usage:
builder = GstPipelineBuilder(config)
pipeline_str = builder.build()
# Or for CLI mode:
subprocess.run(["gst-launch-1.0", "-e"] + builder.build_args())
"""
def __init__(self, config: GstPipelineConfig):
self.config = config
def build(self) -> str:
"""Build a complete GStreamer pipeline string."""
elements = self._build_elements()
return " ! ".join(elements)
def build_args(self) -> list[str]:
"""Build GStreamer pipeline as argument list for subprocess."""
return self.build().split(" ! ")
def _build_elements(self) -> list[str]:
"""Build all pipeline element strings."""
elements: list[str] = []
# Video branch
video_elements = self._build_video_branch()
# Audio branch
audio_elements = self._build_audio_branch()
# Combine with flvmux + rtmpsink
elements.append(
f"flvmux name=mux streamable=true "
f"{' ! rtmpsink location=' + self.config.full_rtmp_url + ' sync=false' if self.config.full_rtmp_url else ''}"
)
# Build the full pipeline string with named branches
video_str = " ! ".join(video_elements)
audio_str = " ! ".join(audio_elements)
# Return GStreamer pipeline with named pads
flvmux_elements = ["flvmux", "name=mux", "streamable=true"]
if self.config.full_rtmp_url:
flvmux_elements.append(f"rtmpsink location={self.config.full_rtmp_url} sync=false")
else:
flvmux_elements.append("fakesink sync=false")
mux_str = " ! ".join(flvmux_elements)
full = f"{video_str} ! mux.video {audio_str} ! mux.audio {mux_str}"
return full.split(" ! ")
def _build_video_branch(self) -> list[str]:
"""Build the video source + encoding branch."""
elements: list[str] = []
cfg = self.config
# Video source
source = self._build_video_source()
elements.append(source)
# Video processing
elements.append("videoconvert")
elements.append(f"video/x-raw,format=I420,width={cfg.video_width},height={cfg.video_height},framerate={cfg.video_framerate}/1")
# Queue for buffering
if cfg.low_latency:
elements.append(f"queue max-size-time={cfg.video_queue_max_time_ms * 1000000} max-size-buffers=0 max-size-bytes=0")
else:
elements.append("queue")
# Video encoder
encoder = self._build_video_encoder()
elements.extend(encoder)
# H.264 parsing
elements.append("h264parse")
elements.append(f"video/x-h264,profile={cfg.h264_profile},level={cfg.h264_level}")
return elements
def _build_video_source(self) -> str:
"""Build the video source element string."""
cfg = self.config
if cfg.video_source == VideoSource.V4L2:
return f"v4l2src device={cfg.video_device} do-timestamp=true"
elif cfg.video_source == VideoSource.LIBCAMERA:
# libcamerasrc — modern Pi camera stack
return (
f"libcamerasrc camera-name={cfg.video_device} "
f"! video/x-raw,width={cfg.video_width},height={cfg.video_height},framerate={cfg.video_framerate}/1"
)
elif cfg.video_source == VideoSource.RASPICAM:
# rpicamsrc — legacy Pi camera stack
return (
f"rpicamsrc preview=false bitrate={cfg.video_bitrate_kbps * 1000} "
f"! video/x-h264,width={cfg.video_width},height={cfg.video_height},framerate={cfg.video_framerate}/1,profile={cfg.h264_profile}"
)
elif cfg.video_source == VideoSource.TEST:
return f"videotestsrc pattern={cfg.test_video_pattern} is-live=true"
return f"v4l2src device={cfg.video_device}"
def _build_video_encoder(self) -> list[str]:
"""Build the video encoder element(s)."""
cfg = self.config
if cfg.video_encoder == EncoderType.LIBCAMERA_H264:
# libcamera does its own encoding, skip external encoder
return []
if cfg.video_encoder == EncoderType.V4L2_H264:
return [
f"v4l2h264enc "
f"extra-controls='controls,"
f"video_bitrate={cfg.video_bitrate_kbps * 1000},"
f"repeat_sequence_header=1,"
f"h264_profile=4," # main profile
f"h264_level=10," # level 4.0
f"video_bitrate_mode=0'" # VBR
]
elif cfg.video_encoder == EncoderType.OMX_H264:
return [
f"omxh264enc "
f"target-bitrate={cfg.video_bitrate_kbps * 1000} "
f"control-rate=variable "
f"periodicity-idr={cfg.keyframe_interval} "
f"inline-header=true"
]
elif cfg.video_encoder == EncoderType.X264:
tune = "zerolatency" if cfg.low_latency else "film"
speed_preset = "ultrafast" if cfg.low_latency else "veryfast"
return [
f"x264enc "
f"bitrate={cfg.video_bitrate_kbps} "
f"speed-preset={speed_preset} "
f"tune={tune} "
f"key-int-max={cfg.keyframe_interval} "
f"bframes={cfg.b_frames} "
f"byte-stream=true"
]
return ["x264enc"]
def _build_audio_branch(self) -> list[str]:
"""Build the audio source + encoding branch."""
elements: list[str] = []
cfg = self.config
# Audio source
source = self._build_audio_source()
elements.append(source)
# Audio conversion
elements.append("audioconvert")
if cfg.audio_channels == 1:
elements.append("audio/x-raw,channels=1")
elements.append("audioresample")
elements.append(f"audio/x-raw,rate={cfg.audio_sample_rate}")
# Queue for buffering
if cfg.low_latency:
elements.append(f"queue max-size-time={cfg.video_queue_max_time_ms * 1000000} max-size-buffers=0 max-size-bytes=0")
else:
elements.append("queue")
# Audio encoder
audio_enc = self._build_audio_encoder()
elements.append(audio_enc)
return elements
def _build_audio_source(self) -> str:
"""Build the audio source element string."""
cfg = self.config
if cfg.audio_source == AudioSource.ALSA:
return (
f"alsasrc device={cfg.audio_device} "
f"buffer-time={cfg.audio_buffer_time_us} "
f"do-timestamp=true"
)
elif cfg.audio_source == AudioSource.JACK:
# jackaudiosrc — requires gst-plugins-bad
return "jackaudiosrc connect=0"
elif cfg.audio_source == AudioSource.PULSE:
return "pulsesrc do-timestamp=true"
elif cfg.audio_source == AudioSource.TEST:
return f"audiotestsrc freq={cfg.test_audio_freq} is-live=true"
return "alsasrc"
def _build_audio_encoder(self) -> str:
"""Build the audio encoder element string."""
cfg = self.config
return f"{cfg.audio_encoder} bitrate={cfg.audio_bitrate_kbps * 1000}"
def build_pipeline_string(
video_device: str = "/dev/video0",
video_width: int = 1280,
video_height: int = 720,
video_framerate: int = 30,
video_bitrate_kbps: int = 2500,
audio_device: str = "hw:0",
audio_sample_rate: int = 48000,
audio_bitrate_kbps: int = 128,
rtmp_url: str = "",
low_latency: bool = True,
) -> str:
"""Quick helper to build a streaming pipeline string.
This is a convenience function that creates a default configuration
and returns the GStreamer pipeline string. For full control, use
GstPipelineBuilder with a custom GstPipelineConfig.
Args:
video_device: Path to video device (e.g. /dev/video0).
video_width: Output video width.
video_height: Output video height.
video_framerate: Output framerate.
video_bitrate_kbps: Video encoder bitrate in kbps.
audio_device: ALSA device name (e.g. hw:0).
audio_sample_rate: Audio sample rate in Hz.
audio_bitrate_kbps: Audio encoder bitrate in kbps.
rtmp_url: Full RTMP URL including stream key.
low_latency: Enable zero-latency tuning.
Returns:
GStreamer pipeline string suitable for gst-launch-1.0.
"""
config = GstPipelineConfig(
video_source=VideoSource.V4L2 if video_device != "test" else VideoSource.TEST,
video_device=video_device,
video_width=video_width,
video_height=video_height,
video_framerate=video_framerate,
video_bitrate_kbps=video_bitrate_kbps,
audio_source=AudioSource.ALSA if audio_device != "test" else AudioSource.TEST,
audio_device=audio_device,
audio_sample_rate=audio_sample_rate,
audio_bitrate_kbps=audio_bitrate_kbps,
rtmp_url=rtmp_url,
low_latency=low_latency,
)
builder = GstPipelineBuilder(config)
return builder.build()
+300
View File
@@ -0,0 +1,300 @@
"""Streaming platform configurations and RTMP profiles.
Defines preset configurations for popular streaming platforms
(YouTube, Twitch, Facebook Live, custom RTMP servers) with
recommended encoding settings for Raspberry Pi 4B h264 hardware.
Each platform profile includes:
- RTMP ingest URL template
- Recommended resolution, bitrate, and framerate
- Audio bitrate and sample rate
- Platform-specific encoding notes
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
# ── Preset RTMP URLs ─────────────────────────────────────────────────────────
YOUTUBE_RTMP = "rtmp://a.rtmp.youtube.com/live2"
"""YouTube Live RTMP ingest URL (primary)."""
YOUTUBE_RTMP_BACKUP = "rtmp://b.rtmp.youtube.com/live2"
"""YouTube Live RTMP ingest URL (backup)."""
TWITCH_RTMP = "rtmp://live.twitch.tv/app"
"""Twitch RTMP ingest URL (auto-selects closest server)."""
FACEBOOK_RTMP = "rtmp://live-api-s.facebook.com:80/rtmp"
"""Facebook Live RTMP ingest URL."""
CUSTOM_RTMP = "rtmp://localhost/live"
"""Placeholder for custom RTMP server."""
# ── Platform profile ─────────────────────────────────────────────────────────
@dataclass
class PlatformProfile:
"""Streaming platform configuration for encoding and RTMP.
All values are defaults; users can override via API or UI.
"""
# Identity
name: str
"""Human-readable platform name."""
platform_id: str
"""Short identifier (e.g. 'youtube', 'twitch')."""
# RTMP
rtmp_url: str = ""
"""RTMP ingest URL. Can include {stream_key} placeholder."""
# Video settings
width: int = 1280
"""Output video width in pixels."""
height: int = 720
"""Output video height in pixels."""
framerate: int = 30
"""Output framerate (fps)."""
video_bitrate_kbps: int = 2500
"""Video encoder target bitrate in kbps."""
video_bitrate_max_kbps: int = 4000
"""Video encoder maximum bitrate in kbps."""
keyframe_interval: int = 60
"""Keyframe interval in frames (2s at 30fps)."""
# Audio settings
audio_bitrate_kbps: int = 128
"""Audio encoder bitrate in kbps."""
audio_sample_rate: int = 48000
"""Audio sample rate in Hz."""
audio_channels: int = 2
"""Audio channels (1=mono, 2=stereo)."""
# Encoder
encoder: str = "h264"
"""Video encoder type: 'h264' for RPi hardware, 'x264' for software."""
h264_profile: str = "main"
"""H.264 profile: 'baseline', 'main', or 'high'."""
h264_level: str = "4.0"
"""H.264 level for compatibility."""
# Control
low_latency: bool = True
"""Enable low-latency tuning (for live performance)."""
b_frames: int = 0
"""Number of B-frames (0 for lowest latency)."""
# Experimental
description: str = ""
"""Human-readable description of the platform preset."""
def to_dict(self) -> dict:
"""Serialize to dictionary for API responses."""
return {
"name": self.name,
"platform_id": self.platform_id,
"rtmp_url": self.rtmp_url,
"width": self.width,
"height": self.height,
"framerate": self.framerate,
"video_bitrate_kbps": self.video_bitrate_kbps,
"video_bitrate_max_kbps": self.video_bitrate_max_kbps,
"keyframe_interval": self.keyframe_interval,
"audio_bitrate_kbps": self.audio_bitrate_kbps,
"audio_sample_rate": self.audio_sample_rate,
"audio_channels": self.audio_channels,
"encoder": self.encoder,
"low_latency": self.low_latency,
"description": self.description,
}
@classmethod
def from_dict(cls, data: dict) -> PlatformProfile:
"""Create from dictionary (for API deserialization)."""
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
# ── Preset platforms ─────────────────────────────────────────────────────────
PRESET_PLATFORMS: dict[str, PlatformProfile] = {
"youtube": PlatformProfile(
name="YouTube Live",
platform_id="youtube",
rtmp_url=YOUTUBE_RTMP,
width=1280,
height=720,
framerate=30,
video_bitrate_kbps=2500,
video_bitrate_max_kbps=4000,
keyframe_interval=60,
audio_bitrate_kbps=128,
audio_sample_rate=48000,
audio_channels=2,
encoder="h264",
h264_profile="main",
h264_level="4.0",
low_latency=True,
b_frames=0,
description="YouTube Live RTMP ingest. Recommended: 720p@30fps, 25004000 kbps.",
),
"youtube_1080p": PlatformProfile(
name="YouTube Live 1080p",
platform_id="youtube_1080p",
rtmp_url=YOUTUBE_RTMP,
width=1920,
height=1080,
framerate=30,
video_bitrate_kbps=4500,
video_bitrate_max_kbps=6000,
keyframe_interval=60,
audio_bitrate_kbps=128,
audio_sample_rate=48000,
audio_channels=2,
encoder="h264",
h264_profile="main",
h264_level="4.1",
low_latency=True,
b_frames=0,
description="YouTube Live 1080p@30fps. Requires strong network upload.",
),
"twitch": PlatformProfile(
name="Twitch",
platform_id="twitch",
rtmp_url=TWITCH_RTMP,
width=1280,
height=720,
framerate=30,
video_bitrate_kbps=2500,
video_bitrate_max_kbps=4000,
keyframe_interval=60,
audio_bitrate_kbps=128,
audio_sample_rate=48000,
audio_channels=2,
encoder="h264",
h264_profile="main",
h264_level="4.0",
low_latency=True,
b_frames=0,
description="Twitch RTMP ingest. Recommended: 720p@30fps, 25004000 kbps.",
),
"twitch_1080p": PlatformProfile(
name="Twitch 1080p",
platform_id="twitch_1080p",
rtmp_url=TWITCH_RTMP,
width=1920,
height=1080,
framerate=30,
video_bitrate_kbps=4500,
video_bitrate_max_kbps=6000,
keyframe_interval=60,
audio_bitrate_kbps=128,
audio_sample_rate=48000,
audio_channels=2,
encoder="h264",
h264_profile="main",
h264_level="4.1",
low_latency=True,
b_frames=0,
description="Twitch 1080p@30fps. Affiliate/Partner recommended only.",
),
"facebook": PlatformProfile(
name="Facebook Live",
platform_id="facebook",
rtmp_url=FACEBOOK_RTMP,
width=1280,
height=720,
framerate=30,
video_bitrate_kbps=2500,
video_bitrate_max_kbps=4000,
keyframe_interval=60,
audio_bitrate_kbps=128,
audio_sample_rate=48000,
audio_channels=2,
encoder="h264",
h264_profile="main",
h264_level="4.0",
low_latency=False,
b_frames=0,
description="Facebook Live RTMP ingest. 720p@30fps.",
),
"custom": PlatformProfile(
name="Custom RTMP",
platform_id="custom",
rtmp_url=CUSTOM_RTMP,
width=1280,
height=720,
framerate=30,
video_bitrate_kbps=2500,
video_bitrate_max_kbps=4000,
keyframe_interval=60,
audio_bitrate_kbps=128,
audio_sample_rate=48000,
audio_channels=2,
encoder="h264",
h264_profile="main",
h264_level="4.0",
low_latency=True,
b_frames=0,
description="Custom RTMP server. Edit the URL and stream key in settings.",
),
"audio_only": PlatformProfile(
name="Audio Only",
platform_id="audio_only",
rtmp_url="",
width=320,
height=240,
framerate=5,
video_bitrate_kbps=100,
video_bitrate_max_kbps=200,
keyframe_interval=300,
audio_bitrate_kbps=192,
audio_sample_rate=48000,
audio_channels=2,
encoder="h264",
h264_profile="baseline",
h264_level="3.0",
low_latency=True,
b_frames=0,
description="Audio-only stream with minimal video placeholder. High quality audio at 192kbps.",
),
}
def get_platform_profile(platform_id: str) -> Optional[PlatformProfile]:
"""Get a preset platform profile by ID.
Args:
platform_id: One of 'youtube', 'youtube_1080p', 'twitch',
'twitch_1080p', 'facebook', 'custom', 'audio_only'.
Returns:
PlatformProfile or None if not found.
"""
return PRESET_PLATFORMS.get(platform_id)
def list_platforms() -> list[dict]:
"""List all available platform presets (for API display)."""
return [
{"id": pid, "name": p.name, "description": p.description}
for pid, p in PRESET_PLATFORMS.items()
]
+728
View File
@@ -0,0 +1,728 @@
"""Streamer — manages the GStreamer streaming pipeline lifecycle.
The Streamer class is the top-level controller for audio/video streaming.
It manages:
- GStreamer subprocess lifecycle (start, stop, restart)
- Pipeline monitoring (bitrate, uptime, dropped frames)
- Scene management (camera sources, overlays)
- Stream statistics collection
- Error recovery and reconnection
Architecture:
Streamer
├── GStreamer subprocess (gst-launch-1.0 -e)
├── Statistics collector (stderr parsing)
├── Scene manager (named presets)
└── State machine (idle → starting → live → stopping → idle)
"""
from __future__ import annotations
import asyncio
import logging
import os
import shutil
import signal
import subprocess
import threading
import time
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Optional, Callable
from .gst_pipeline import (
GstPipelineBuilder,
GstPipelineConfig,
VideoSource,
AudioSource,
EncoderType,
build_pipeline_string,
)
from .platforms import PlatformProfile, PRESET_PLATFORMS, get_platform_profile
from .camera import CameraInfo, detect_cameras, get_default_camera
logger = logging.getLogger(__name__)
class StreamState(StrEnum):
"""Streamer lifecycle states."""
IDLE = "idle" # Not streaming, not configured
CONFIGURED = "configured" # Ready to stream
STARTING = "starting" # Pipeline launching
LIVE = "live" # Streaming to platform
RECONNECTING = "reconnecting" # Lost connection, retrying
STOPPING = "stopping" # Shutting down
ERROR = "error" # Fatal error, requires reset
@dataclass
class StreamScene:
"""A named streaming scene (camera + settings preset)."""
name: str
camera_device: str = "/dev/video0"
video_width: int = 1280
video_height: int = 720
video_framerate: int = 30
audio_device: str = "hw:0"
overlay_text: str = ""
description: str = ""
def to_dict(self) -> dict:
return {
"name": self.name,
"camera_device": self.camera_device,
"video_width": self.video_width,
"video_height": self.video_height,
"video_framerate": self.video_framerate,
"audio_device": self.audio_device,
"overlay_text": self.overlay_text,
"description": self.description,
}
@dataclass
class StreamStats:
"""Real-time stream statistics."""
state: StreamState = StreamState.IDLE
uptime_seconds: float = 0.0
video_bitrate_kbps: float = 0.0
audio_bitrate_kbps: float = 0.0
total_bytes_sent: int = 0
dropped_frames: int = 0
fps: float = 0.0
cpu_usage_percent: float = 0.0
reconnect_count: int = 0
last_error: str = ""
camera_name: str = ""
platform_name: str = ""
def to_dict(self) -> dict:
return {
"state": self.state.value,
"uptime_seconds": round(self.uptime_seconds, 1),
"video_bitrate_kbps": round(self.video_bitrate_kbps, 1),
"audio_bitrate_kbps": round(self.audio_bitrate_kbps, 1),
"total_bytes_sent": self.total_bytes_sent,
"dropped_frames": self.dropped_frames,
"fps": round(self.fps, 1),
"cpu_usage_percent": round(self.cpu_usage_percent, 1),
"reconnect_count": self.reconnect_count,
"last_error": self.last_error,
"camera_name": self.camera_name,
"platform_name": self.platform_name,
}
@dataclass
class StreamerConfig:
"""Configuration for the Streamer."""
# Platform
platform_id: str = "custom"
rtmp_url: str = ""
stream_key: str = ""
# Video
video_device: str = "/dev/video0"
video_source: VideoSource = VideoSource.V4L2
video_width: int = 1280
video_height: int = 720
video_framerate: int = 30
video_bitrate_kbps: int = 2500
# Audio
audio_device: str = "hw:0"
audio_source: AudioSource = AudioSource.ALSA
audio_sample_rate: int = 48000
audio_bitrate_kbps: int = 128
# Encoder
encoder_type: EncoderType = EncoderType.V4L2_H264
h264_profile: str = "main"
h264_level: str = "4.0"
low_latency: bool = True
# Recovery
auto_reconnect: bool = True
max_reconnect_attempts: int = 5
reconnect_delay_seconds: float = 3.0
# Scenes
scenes: list[StreamScene] = field(default_factory=list)
active_scene: str = "default"
@property
def full_rtmp_url(self) -> str:
"""Construct full RTMP URL with stream key."""
if not self.rtmp_url:
return ""
url = self.rtmp_url.rstrip("/")
if self.stream_key:
url = f"{url}/{self.stream_key}"
return url
@classmethod
def from_platform(cls, platform_id: str, stream_key: str = "") -> StreamerConfig:
"""Create config from a platform preset."""
profile = get_platform_profile(platform_id)
if profile is None:
profile = PRESET_PLATFORMS.get("custom", PlatformProfile(
name="Custom", platform_id="custom",
))
return cls(
platform_id=profile.platform_id,
rtmp_url=profile.rtmp_url,
stream_key=stream_key,
video_width=profile.width,
video_height=profile.height,
video_framerate=profile.framerate,
video_bitrate_kbps=profile.video_bitrate_kbps,
audio_bitrate_kbps=profile.audio_bitrate_kbps,
audio_sample_rate=profile.audio_sample_rate,
h264_profile=profile.h264_profile,
h264_level=profile.h264_level,
low_latency=profile.low_latency,
)
class Streamer:
"""Manages the GStreamer streaming pipeline.
Usage:
streamer = Streamer(StreamerConfig(platform_id="youtube", stream_key="xxxx"))
streamer.configure()
streamer.start()
# ... stream is live ...
streamer.stop()
Thread-safe: all public methods use a lock for state transitions.
"""
def __init__(self, config: StreamerConfig | None = None):
self._config = config or StreamerConfig()
self._state = StreamState.IDLE
self._state_lock = threading.Lock()
self._process: Optional[subprocess.Popen] = None
self._stderr_thread: Optional[threading.Thread] = None
self._stats = StreamStats()
self._stats_lock = threading.Lock()
self._start_time: float = 0.0
self._reconnect_count: int = 0
self._stop_event = threading.Event()
self._on_state_change: Optional[Callable[[StreamState, StreamStats], None]] = None
self._camera_info: Optional[CameraInfo] = None
self._scenes: dict[str, StreamScene] = {}
self._active_scene: str = "default"
# Initialize with defaults
self._init_default_scenes()
# ── Configuration ──────────────────────────────────────────────────────
def configure(
self,
platform_id: str | None = None,
stream_key: str | None = None,
video_device: str | None = None,
audio_device: str | None = None,
) -> None:
"""Configure or reconfigure the streamer.
Args:
platform_id: Platform preset ID (youtube, twitch, etc.).
stream_key: RTMP stream key for authentication.
video_device: Path to video device (e.g. /dev/video0).
audio_device: ALSA device name (e.g. hw:0).
"""
with self._state_lock:
if self._state == StreamState.LIVE:
raise RuntimeError("Cannot reconfigure while streaming. Stop first.")
# Apply platform preset if specified
if platform_id:
profile = get_platform_profile(platform_id)
if profile:
self._config.platform_id = platform_id
self._config.rtmp_url = profile.rtmp_url
self._config.video_width = profile.width
self._config.video_height = profile.height
self._config.video_framerate = profile.framerate
self._config.video_bitrate_kbps = profile.video_bitrate_kbps
self._config.audio_bitrate_kbps = profile.audio_bitrate_kbps
self._config.audio_sample_rate = profile.audio_sample_rate
self._config.h264_profile = profile.h264_profile
self._config.h264_level = profile.h264_level
self._config.low_latency = profile.low_latency
if stream_key is not None:
self._config.stream_key = stream_key
if video_device is not None:
self._config.video_device = video_device
self._detect_camera()
if audio_device is not None:
self._config.audio_device = audio_device
self._state = StreamState.CONFIGURED
logger.info(
"Streamer configured: platform=%s, video=%s, audio=%s",
self._config.platform_id,
self._config.video_device,
self._config.audio_device,
)
self._notify_state_change()
def _detect_camera(self) -> None:
"""Auto-detect camera capabilities."""
cameras = detect_cameras()
if cameras:
self._camera_info = cameras[0]
self._stats.camera_name = self._camera_info.name
logger.info("Detected camera: %s (%s)", self._camera_info.name, self._camera_info.device_path)
# ── Lifecycle ──────────────────────────────────────────────────────────
def start(self, block: bool = False, timeout: float = 10.0) -> bool:
"""Start the streaming pipeline.
Args:
block: If True, wait until stream is LIVE or timeout.
timeout: Maximum seconds to wait when blocking.
Returns:
True if streaming started successfully.
"""
with self._state_lock:
if self._state == StreamState.LIVE:
logger.warning("Stream already live")
return True
if self._state == StreamState.STARTING:
logger.warning("Stream already starting")
return False
# Validate configuration
if not self._config.full_rtmp_url:
self._state = StreamState.ERROR
self._stats.last_error = "No RTMP URL configured. Set stream_key and platform."
self._notify_state_change()
return False
self._state = StreamState.STARTING
self._reconnect_count = 0
self._stop_event.clear()
self._notify_state_change()
# Launch pipeline in background
success = self._launch_pipeline()
if success and block:
# Wait for LIVE state
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
with self._stats_lock:
if self._stats.state == StreamState.LIVE:
return True
if self._stats.state == StreamState.ERROR:
return False
time.sleep(0.1)
return success
def stop(self, timeout: float = 5.0) -> None:
"""Stop the streaming pipeline gracefully.
Args:
timeout: Maximum seconds to wait for process to exit.
"""
with self._state_lock:
if self._state not in (StreamState.LIVE, StreamState.STARTING, StreamState.RECONNECTING):
logger.debug("Stream not running (state=%s)", self._state.value)
self._state = StreamState.IDLE
return
self._state = StreamState.STOPPING
self._notify_state_change()
self._stop_event.set()
self._terminate_process(timeout)
with self._state_lock:
self._state = StreamState.IDLE
self._stats.state = StreamState.IDLE
self._notify_state_change()
logger.info("Stream stopped")
def switch_scene(self, scene_name: str) -> bool:
"""Switch to a named streaming scene.
Scenes allow changing camera source and video settings
without restarting the entire pipeline (if supported by
the GStreamer pipeline).
Args:
scene_name: Name of the scene to activate.
Returns:
True if the scene was found and activated.
"""
scene = self._scenes.get(scene_name)
if scene is None:
logger.warning("Scene not found: %s", scene_name)
return False
self._active_scene = scene_name
self._config.video_device = scene.camera_device
self._config.video_width = scene.video_width
self._config.video_height = scene.video_height
self._config.video_framerate = scene.video_framerate
self._config.audio_device = scene.audio_device
# If currently streaming, we need to restart for the new camera
was_live = self._state == StreamState.LIVE
if was_live:
logger.info("Restarting stream for scene switch: %s", scene_name)
self.stop()
time.sleep(1.0)
self.start()
logger.info("Scene activated: %s", scene_name)
return True
def get_scenes(self) -> list[dict]:
"""List all configured scenes."""
return [s.to_dict() for s in self._scenes.values()]
def add_scene(self, scene: StreamScene) -> None:
"""Add a new scene."""
self._scenes[scene.name] = scene
logger.info("Scene added: %s", scene.name)
def remove_scene(self, name: str) -> bool:
"""Remove a scene by name. Cannot remove 'default'."""
if name == "default":
return False
if name in self._scenes:
del self._scenes[name]
return True
return False
# ── Stats ──────────────────────────────────────────────────────────────
@property
def state(self) -> StreamState:
"""Current streamer state."""
with self._state_lock:
return self._state
@property
def stats(self) -> StreamStats:
"""Current stream statistics (copy)."""
with self._stats_lock:
return StreamStats(
state=self._stats.state,
uptime_seconds=self._stats.uptime_seconds,
video_bitrate_kbps=self._stats.video_bitrate_kbps,
audio_bitrate_kbps=self._stats.audio_bitrate_kbps,
total_bytes_sent=self._stats.total_bytes_sent,
dropped_frames=self._stats.dropped_frames,
fps=self._stats.fps,
cpu_usage_percent=self._stats.cpu_usage_percent,
reconnect_count=self._stats.reconnect_count,
last_error=self._stats.last_error,
camera_name=self._stats.camera_name,
platform_name=self._stats.platform_name,
)
def get_stats_dict(self) -> dict:
"""Get statistics as a dictionary."""
return self.stats.to_dict()
# ── Callbacks ──────────────────────────────────────────────────────────
def on_state_change(self, callback: Callable[[StreamState, StreamStats], None]) -> None:
"""Register a callback for state changes.
The callback receives (new_state, current_stats).
"""
self._on_state_change = callback
# ── Internals ──────────────────────────────────────────────────────────
def _init_default_scenes(self) -> None:
"""Initialize default scenes."""
self._scenes["default"] = StreamScene(
name="default",
camera_device=self._config.video_device,
video_width=self._config.video_width,
video_height=self._config.video_height,
video_framerate=self._config.video_framerate,
audio_device=self._config.audio_device,
description="Default streaming scene",
)
def _build_pipeline_config(self) -> GstPipelineConfig:
"""Build a GstPipelineConfig from current StreamerConfig."""
cfg = self._config
return GstPipelineConfig(
video_source=cfg.video_source,
video_device=cfg.video_device,
video_width=cfg.video_width,
video_height=cfg.video_height,
video_framerate=cfg.video_framerate,
audio_source=cfg.audio_source,
audio_device=cfg.audio_device,
audio_sample_rate=cfg.audio_sample_rate,
audio_channels=2,
video_encoder=cfg.encoder_type,
video_bitrate_kbps=cfg.video_bitrate_kbps,
video_bitrate_max_kbps=cfg.video_bitrate_kbps * 2,
keyframe_interval=60,
h264_profile=cfg.h264_profile,
h264_level=cfg.h264_level,
b_frames=0,
audio_bitrate_kbps=cfg.audio_bitrate_kbps,
rtmp_url=cfg.rtmp_url,
rtmp_stream_key=cfg.stream_key,
low_latency=cfg.low_latency,
)
def _launch_pipeline(self) -> bool:
"""Launch the GStreamer pipeline as a subprocess."""
try:
pipeline_config = self._build_pipeline_config()
builder = GstPipelineBuilder(pipeline_config)
pipeline_str = builder.build()
logger.info("Launching GStreamer pipeline")
logger.debug("Pipeline: %s", pipeline_str)
# Check gst-launch-1.0 availability
gst_launch = shutil.which("gst-launch-1.0")
if not gst_launch:
self._set_error("gst-launch-1.0 not found. Install gstreamer-tools.")
return False
self._start_time = time.monotonic()
self._reconnect_count = 0
# Launch subprocess
self._process = subprocess.Popen(
[gst_launch, "-e", pipeline_str],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
preexec_fn=os.setsid if hasattr(os, "setsid") else None,
)
# Start stderr reader thread
self._stderr_thread = threading.Thread(
target=self._read_stderr,
daemon=True,
)
self._stderr_thread.start()
# Start process monitor thread
monitor_thread = threading.Thread(
target=self._monitor_process,
daemon=True,
)
monitor_thread.start()
return True
except Exception as exc:
logger.error("Failed to launch pipeline: %s", exc)
self._set_error(str(exc))
return False
def _read_stderr(self) -> None:
"""Read and parse GStreamer stderr for statistics."""
if not self._process or not self._process.stderr:
return
try:
for line in self._process.stderr:
line = line.strip()
if not line:
continue
# Detect live state from GStreamer output
if "Redistribute latency" in line or "Pipeline is live" in line:
self._set_live()
# Parse bitrate information (GStreamer doesn't always output this)
# Most RTMP stats come from rtmpsink messages
# Detect errors
if "ERROR" in line or "error:" in line.lower():
logger.error("GStreamer error: %s", line)
with self._stats_lock:
self._stats.last_error = line[:200]
# Log at debug level
logger.debug("GST: %s", line)
except (ValueError, IOError) as exc:
logger.debug("Stderr reader stopped: %s", exc)
def _monitor_process(self) -> None:
"""Monitor the GStreamer process for exit."""
if not self._process:
return
while not self._stop_event.is_set():
# Update uptime
if self._start_time > 0:
with self._stats_lock:
self._stats.uptime_seconds = time.monotonic() - self._start_time
# Check if process exited
if self._process.poll() is not None:
exit_code = self._process.returncode
if self._stop_event.is_set():
# Expected shutdown
break
logger.warning("GStreamer process exited with code %d", exit_code)
# Try reconnect
if self._config.auto_reconnect and self._reconnect_count < self._config.max_reconnect_attempts:
self._reconnect_count += 1
with self._stats_lock:
self._stats.reconnect_count = self._reconnect_count
self._stats.state = StreamState.RECONNECTING
logger.info(
"Reconnecting (attempt %d/%d) in %.1fs...",
self._reconnect_count,
self._config.max_reconnect_attempts,
self._config.reconnect_delay_seconds,
)
time.sleep(self._config.reconnect_delay_seconds)
if not self._stop_event.is_set():
self._launch_pipeline()
else:
error_msg = f"Process exited with code {exit_code}"
if self._reconnect_count >= self._config.max_reconnect_attempts:
error_msg = f"Max reconnects ({self._config.max_reconnect_attempts}) reached. {error_msg}"
self._set_error(error_msg)
break
time.sleep(0.5)
def _set_live(self) -> None:
"""Transition to LIVE state."""
with self._state_lock:
self._state = StreamState.LIVE
with self._stats_lock:
self._stats.state = StreamState.LIVE
self._stats.platform_name = self._config.platform_id
self._notify_state_change()
logger.info("Stream is LIVE")
def _set_error(self, message: str) -> None:
"""Transition to ERROR state."""
with self._state_lock:
self._state = StreamState.ERROR
with self._stats_lock:
self._stats.state = StreamState.ERROR
self._stats.last_error = message
self._notify_state_change()
logger.error("Stream error: %s", message)
def _terminate_process(self, timeout: float = 5.0) -> None:
"""Terminate the GStreamer subprocess gracefully."""
if not self._process:
return
try:
# Send SIGINT for graceful shutdown (gst-launch-1.0 -e handles this)
if hasattr(os, "killpg"):
os.killpg(os.getpgid(self._process.pid), signal.SIGINT)
else:
self._process.send_signal(signal.SIGINT)
try:
self._process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
logger.warning("GStreamer did not exit gracefully, sending SIGKILL")
if hasattr(os, "killpg"):
os.killpg(os.getpgid(self._process.pid), signal.SIGKILL)
else:
self._process.kill()
self._process.wait(timeout=2.0)
except ProcessLookupError:
pass # Already exited
self._process = None
self._stderr_thread = None
def _notify_state_change(self) -> None:
"""Notify state change callback."""
if self._on_state_change:
try:
stats = self.stats
self._on_state_change(self._state, stats)
except Exception as exc:
logger.error("State change callback error: %s", exc)
# ── Properties ─────────────────────────────────────────────────────────
@property
def config(self) -> StreamerConfig:
return self._config
@property
def is_live(self) -> bool:
return self._state == StreamState.LIVE
@property
def camera_info(self) -> Optional[CameraInfo]:
return self._camera_info
def create_streamer(
platform_id: str = "custom",
stream_key: str = "",
video_device: str | None = None,
audio_device: str = "hw:0",
) -> Streamer:
"""Create a configured Streamer instance.
Convenience factory that creates a Streamer with sensible defaults,
auto-detects cameras, and applies a platform preset.
Args:
platform_id: Platform preset ('youtube', 'twitch', 'custom', etc.).
stream_key: RTMP stream key.
video_device: Camera device path (auto-detected if None).
audio_device: ALSA device name.
Returns:
Configured Streamer instance.
"""
config = StreamerConfig(
platform_id=platform_id,
stream_key=stream_key,
audio_device=audio_device,
)
# Auto-detect camera
if video_device:
config.video_device = video_device
else:
cam = get_default_camera()
if cam:
config.video_device = cam.device_path
if "pi camera" in cam.name.lower():
config.video_source = VideoSource.LIBCAMERA
streamer = Streamer(config)
streamer.configure(platform_id=platform_id, stream_key=stream_key)
return streamer
+5
View File
@@ -43,6 +43,7 @@ from .screens.mixer import MixerScreen
from .screens.routing import RoutingScreen
from .screens.plugins import PluginScreen
from .screens.settings import SettingsScreen
from .screens.sessions import SessionsScreen
logger = logging.getLogger(__name__)
@@ -88,6 +89,7 @@ class TabBar(BoxLayout):
("mixer", "MIX"),
("routing", "RTG"),
("plugins", "PLG"),
("sessions", "SES"),
("settings", "SET"),
]
for name, label in tabs:
@@ -173,11 +175,13 @@ class MixerRoot(BoxLayout):
self.mixer_screen = MixerScreen(name="mixer")
self.routing_screen = RoutingScreen(name="routing")
self.plugin_screen = PluginScreen(name="plugins")
self.sessions_screen = SessionsScreen(name="sessions")
self.settings_screen = SettingsScreen(name="settings")
self.screen_manager.add_widget(self.mixer_screen)
self.screen_manager.add_widget(self.routing_screen)
self.screen_manager.add_widget(self.plugin_screen)
self.screen_manager.add_widget(self.sessions_screen)
self.screen_manager.add_widget(self.settings_screen)
# Tab bar (bottom)
@@ -192,6 +196,7 @@ class MixerRoot(BoxLayout):
self.mixer_screen.client = client
self.routing_screen.client = client
self.plugin_screen.client = client
self.sessions_screen.client = client
self.settings_screen.client = client
def update_status(self, connected: bool, info: str = ""):
+635
View File
@@ -0,0 +1,635 @@
"""Sessions screen — save/load mixer sessions, setlists, and snapshots.
Touch-optimized layout for Raspberry Pi 5"/7" displays.
Provides:
- Session list with load/delete buttons
- Save current session button
- Snapshot capture/recall
- Setlist browser
"""
from __future__ import annotations
from kivy.properties import ObjectProperty, StringProperty, ListProperty
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.label import Label
from kivy.uix.screenmanager import Screen
from kivy.uix.scrollview import ScrollView
from kivy.uix.textinput import TextInput
from kivy.uix.togglebutton import ToggleButton
from kivy.metrics import dp
from kivy.clock import Clock
from ..theme import Colors, Fonts, Sizes
class SessionCard(BoxLayout):
"""A single session/snapshot entry card."""
session_name = StringProperty("")
session_info = StringProperty("")
is_snapshot = False
def __init__(self, name="", info="", is_snapshot=False, on_load=None,
on_delete=None, on_recall=None, **kwargs):
super().__init__(**kwargs)
self.orientation = "horizontal"
self.size_hint = (1, None)
self.height = dp(52)
self.padding = [dp(8), dp(4)]
self.spacing = dp(6)
self._name = name
self._is_snapshot = is_snapshot
icon = "📸" if is_snapshot else "💾"
# Name + info
info_layout = BoxLayout(orientation="vertical", size_hint=(1, 1))
name_label = Label(
text=f"{icon} {name}",
color=Colors.TEXT,
font_size=Fonts.SMALL,
halign="left",
valign="middle",
size_hint=(1, 1),
text_size=(None, None),
)
name_label.bind(size=name_label.setter("text_size"))
info_label = Label(
text=info,
color=Colors.TEXT_MUTED,
font_size=Fonts.XSMALL,
halign="left",
size_hint=(1, 0.5),
text_size=(None, None),
)
info_label.bind(size=info_label.setter("text_size"))
info_layout.add_widget(name_label)
info_layout.add_widget(info_label)
# Buttons
btn_layout = BoxLayout(
orientation="horizontal",
size_hint=(None, 1),
width=dp(140),
spacing=dp(4),
)
if is_snapshot:
load_btn = Button(
text="Recall",
font_size=Fonts.XSMALL,
size_hint=(1, 1),
background_color=Colors.BG_BUTTON,
color=Colors.TEXT,
)
if on_recall:
load_btn.bind(on_press=lambda x: on_recall(name))
else:
load_btn = Button(
text="Load",
font_size=Fonts.XSMALL,
size_hint=(1, 1),
background_color=Colors.BG_BUTTON,
color=Colors.TEXT,
)
if on_load:
load_btn.bind(on_press=lambda x: on_load(name))
del_btn = Button(
text="",
font_size=Fonts.XSMALL,
size_hint=(None, 1),
width=dp(40),
background_color=Colors.MUTE_ON,
color=Colors.TEXT,
)
if on_delete:
del_btn.bind(on_press=lambda x: on_delete(name))
btn_layout.add_widget(load_btn)
btn_layout.add_widget(del_btn)
self.add_widget(info_layout)
self.add_widget(btn_layout)
class SessionsScreen(Screen):
"""Session management screen for the touch UI."""
client = ObjectProperty(None)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.name = "sessions"
self._sessions_data = []
self._snapshots_data = []
self._current_subtab = "sessions"
root = BoxLayout(orientation="vertical")
# Header
header = BoxLayout(
orientation="horizontal",
size_hint=(1, None),
height=Sizes.HEADER_HEIGHT,
padding=[Sizes.PAD_MD, Sizes.PAD_XS],
)
header.bg_color = Colors.BG_HEADER
title = Label(
text="SESSIONS",
color=Colors.ACCENT,
font_size=Fonts.HEADER,
bold=True,
size_hint=(None, 1),
width=dp(150),
halign="left",
)
title.bind(size=title.setter("text_size"))
# Sub-tab bar
sub_tabs = BoxLayout(
orientation="horizontal",
size_hint=(1, None),
height=dp(40),
spacing=0,
)
sub_tabs.bg_color = Colors.BG_DARK
self._tab_sessions = ToggleButton(
text="Sessions",
font_size=Fonts.SMALL,
group="subtab",
state="down",
background_color=Colors.BG_HEADER,
color=Colors.ACCENT,
)
self._tab_setlists = ToggleButton(
text="Setlists",
font_size=Fonts.SMALL,
group="subtab",
background_color=Colors.BG_HEADER,
color=Colors.TEXT_MUTED,
)
self._tab_snapshots = ToggleButton(
text="Snapshots",
font_size=Fonts.SMALL,
group="subtab",
background_color=Colors.BG_HEADER,
color=Colors.TEXT_MUTED,
)
self._tab_sessions.bind(on_press=lambda x: self._switch_subtab("sessions"))
self._tab_setlists.bind(on_press=lambda x: self._switch_subtab("setlists"))
self._tab_snapshots.bind(on_press=lambda x: self._switch_subtab("snapshots"))
sub_tabs.add_widget(self._tab_sessions)
sub_tabs.add_widget(self._tab_setlists)
sub_tabs.add_widget(self._tab_snapshots)
# Save bar
save_bar = BoxLayout(
orientation="horizontal",
size_hint=(1, None),
height=dp(44),
padding=[dp(8), dp(4)],
spacing=dp(6),
)
self._name_input = TextInput(
text="",
hint_text="Session name...",
font_size=Fonts.SMALL,
size_hint=(1, 1),
background_color=Colors.BG_INPUT,
foreground_color=Colors.TEXT,
multiline=False,
)
self._notes_input = TextInput(
text="",
hint_text="Notes...",
font_size=Fonts.SMALL,
size_hint=(0.6, 1),
background_color=Colors.BG_INPUT,
foreground_color=Colors.TEXT,
multiline=False,
)
save_btn = Button(
text="Save",
font_size=Fonts.SMALL,
size_hint=(None, 1),
width=dp(80),
background_color=Colors.ACCENT,
color=Colors.BG_DARK,
)
save_btn.bind(on_press=self._on_save)
save_bar.add_widget(self._name_input)
save_bar.add_widget(self._notes_input)
save_bar.add_widget(save_btn)
# Snapshot bar (hidden by default)
self._snapshot_bar = BoxLayout(
orientation="horizontal",
size_hint=(1, None),
height=dp(44),
padding=[dp(8), dp(4)],
spacing=dp(6),
)
self._snap_name_input = TextInput(
text="",
hint_text="Snapshot name...",
font_size=Fonts.SMALL,
size_hint=(1, 1),
background_color=Colors.BG_INPUT,
foreground_color=Colors.TEXT,
multiline=False,
)
snap_btn = Button(
text="Capture",
font_size=Fonts.SMALL,
size_hint=(None, 1),
width=dp(80),
background_color=Colors.ACCENT,
color=Colors.BG_DARK,
)
snap_btn.bind(on_press=self._on_capture_snapshot)
next_btn = Button(
text="",
font_size=Fonts.SMALL,
size_hint=(None, 1),
width=dp(44),
background_color=Colors.BG_BUTTON,
color=Colors.TEXT,
)
next_btn.bind(on_press=self._on_snapshot_next)
prev_btn = Button(
text="",
font_size=Fonts.SMALL,
size_hint=(None, 1),
width=dp(44),
background_color=Colors.BG_BUTTON,
color=Colors.TEXT,
)
prev_btn.bind(on_press=self._on_snapshot_prev)
self._snapshot_bar.add_widget(self._snap_name_input)
self._snapshot_bar.add_widget(snap_btn)
self._snapshot_bar.add_widget(prev_btn)
self._snapshot_bar.add_widget(next_btn)
# Scrollable list area
self._list_scroll = ScrollView(size_hint=(1, 1), do_scroll_x=False)
self._list_layout = BoxLayout(
orientation="vertical",
size_hint=(1, None),
spacing=dp(2),
padding=[dp(4), dp(4)],
)
self._list_layout.bind(minimum_height=self._list_layout.setter("height"))
self._list_scroll.add_widget(self._list_layout)
# Setlist view (hidden by default)
self._setlist_view = BoxLayout(
orientation="vertical",
size_hint=(1, 1),
spacing=dp(4),
)
self._setlist_view.opacity = 0
self._setlist_view.disabled = True
self._setlist_list = BoxLayout(
orientation="vertical",
size_hint=(1, 1),
spacing=dp(2),
padding=[dp(4), dp(4)],
)
setlist_bar = BoxLayout(
orientation="horizontal",
size_hint=(1, None),
height=dp(44),
padding=[dp(8), dp(4)],
spacing=dp(6),
)
self._setlist_name_input = TextInput(
text="",
hint_text="Setlist name...",
font_size=Fonts.SMALL,
size_hint=(1, 1),
background_color=Colors.BG_INPUT,
foreground_color=Colors.TEXT,
multiline=False,
)
new_setlist_btn = Button(
text="Create",
font_size=Fonts.SMALL,
size_hint=(None, 1),
width=dp(80),
background_color=Colors.ACCENT,
color=Colors.BG_DARK,
)
new_setlist_btn.bind(on_press=self._on_create_setlist)
setlist_bar.add_widget(self._setlist_name_input)
setlist_bar.add_widget(new_setlist_btn)
self._setlist_view.add_widget(setlist_bar)
self._setlist_view.add_widget(self._setlist_list)
# Content area
content = BoxLayout(orientation="vertical", size_hint=(1, 1))
content.add_widget(self._list_scroll)
content.add_widget(self._setlist_view)
# Combine
header.add_widget(title)
root.add_widget(header)
root.add_widget(sub_tabs)
root.add_widget(save_bar)
root.add_widget(self._snapshot_bar)
root.add_widget(content)
self.add_widget(root)
# Refresh periodically
Clock.schedule_interval(self._refresh, 10.0)
def on_enter(self, *args):
"""Called when the screen is displayed."""
self._refresh()
def _switch_subtab(self, tab: str):
"""Switch between sessions/setlists/snapshots."""
self._current_subtab = tab
# Update toggle states
self._tab_sessions.state = "down" if tab == "sessions" else "normal"
self._tab_setlists.state = "down" if tab == "setlists" else "normal"
self._tab_snapshots.state = "down" if tab == "snapshots" else "normal"
# Toggle visibility
if tab == "setlists":
self._list_scroll.opacity = 0
self._list_scroll.disabled = True
self._setlist_view.opacity = 1
self._setlist_view.disabled = False
self._snapshot_bar.opacity = 0
self._snapshot_bar.disabled = True
elif tab == "snapshots":
self._list_scroll.opacity = 1
self._list_scroll.disabled = False
self._setlist_view.opacity = 0
self._setlist_view.disabled = True
self._snapshot_bar.opacity = 1
self._snapshot_bar.disabled = False
else:
self._list_scroll.opacity = 1
self._list_scroll.disabled = False
self._setlist_view.opacity = 0
self._setlist_view.disabled = True
self._snapshot_bar.opacity = 0
self._snapshot_bar.disabled = True
self._refresh()
def _refresh(self, *args):
"""Refresh the session list from the REST API."""
if not self.client:
return
if self._current_subtab in ("sessions", "snapshots"):
self._fetch_sessions()
if self._current_subtab == "snapshots":
self._fetch_snapshots()
elif self._current_subtab == "setlists":
self._fetch_setlists()
def _fetch_sessions(self):
"""Fetch sessions list from REST API."""
try:
self.client.rest_get(
"/api/v1/sessions",
on_success=self._on_sessions_loaded,
on_error=lambda e: None,
)
except Exception:
pass
def _fetch_snapshots(self):
"""Fetch snapshots list from REST API."""
try:
self.client.rest_get(
"/api/v1/snapshots",
on_success=self._on_snapshots_loaded,
on_error=lambda e: None,
)
except Exception:
pass
def _fetch_setlists(self):
"""Fetch setlists list from REST API."""
try:
self.client.rest_get(
"/api/v1/setlists",
on_success=self._on_setlists_loaded,
on_error=lambda e: None,
)
except Exception:
pass
def _on_sessions_loaded(self, data: dict):
"""Render session cards."""
sessions = data.get("sessions", [])
self._sessions_data = sessions
self._render_session_list()
def _on_snapshots_loaded(self, data: dict):
"""Render snapshot cards."""
snapshots = data.get("snapshots", [])
self._snapshots_data = snapshots
self._render_session_list()
def _render_session_list(self):
"""Build the session/snapshot card list."""
self._list_layout.clear_widgets()
items = []
for s in (self._sessions_data or []):
items.append({
"type": "session",
"name": s.get("name", ""),
"info": f"{s.get('size_bytes', 0)} bytes",
"is_snapshot": False,
})
for s in (self._snapshots_data or []):
cat = s.get("category", "")
cur = " (active)" if s.get("is_current") else ""
items.append({
"type": "snapshot",
"name": s.get("name", ""),
"info": f"{cat}{cur}" if (cat or cur) else "",
"is_snapshot": True,
})
if not items:
self._list_layout.add_widget(Label(
text="No sessions saved yet.\nUse the Save button above.",
color=Colors.TEXT_MUTED,
font_size=Fonts.SMALL,
size_hint=(1, None),
height=dp(60),
halign="center",
valign="middle",
))
return
for item in items:
card = SessionCard(
name=item["name"],
info=item["info"],
is_snapshot=item["is_snapshot"],
on_load=self._on_load if not item["is_snapshot"] else None,
on_delete=self._on_delete,
on_recall=self._on_recall if item["is_snapshot"] else None,
)
self._list_layout.add_widget(card)
def _on_setlists_loaded(self, data: dict):
"""Render setlist cards."""
self._setlist_list.clear_widgets()
setlists = data.get("setlists", [])
if not setlists:
self._setlist_list.add_widget(Label(
text="No setlists. Create one above.",
color=Colors.TEXT_MUTED,
font_size=Fonts.SMALL,
size_hint=(1, None),
height=dp(40),
halign="center",
))
return
def _on_load(self, name: str):
"""Load a session."""
if not self.client:
return
try:
self.client.rest_post(
f"/api/v1/sessions/{name}/load",
body={},
on_success=lambda d: None,
on_error=lambda e: None,
)
except Exception:
pass
def _on_delete(self, name: str):
"""Delete a session or snapshot."""
if not self.client:
return
prefix = "snapshots" if self._current_subtab == "snapshots" else "sessions"
try:
self.client.rest_delete(
f"/api/v1/{prefix}/{name}",
on_success=lambda d: self._refresh(),
on_error=lambda e: None,
)
except Exception:
pass
def _on_recall(self, name: str):
"""Recall a snapshot."""
if not self.client:
return
try:
self.client.rest_post(
f"/api/v1/snapshots/{name}/recall",
body={},
on_success=lambda d: None,
on_error=lambda e: None,
)
except Exception:
pass
def _on_save(self, instance):
"""Save current state as a session."""
if not self.client:
return
name = self._name_input.text.strip()
if not name:
return
notes = self._notes_input.text.strip()
try:
self.client.rest_post(
"/api/v1/sessions/save",
body={"name": name, "overwrite": True},
on_success=lambda d: (self._name_input.setter("text")(""), self._refresh()),
on_error=lambda e: None,
)
except Exception:
pass
def _on_capture_snapshot(self, instance):
"""Capture current state as a snapshot."""
if not self.client:
return
name = self._snap_name_input.text.strip()
if not name:
return
try:
self.client.rest_post(
"/api/v1/snapshots/capture",
body={"name": name, "overwrite": True},
on_success=lambda d: (self._snap_name_input.setter("text")(""), self._refresh()),
on_error=lambda e: None,
)
except Exception:
pass
def _on_snapshot_next(self, instance):
"""Go to next snapshot."""
if not self.client:
return
try:
self.client.rest_post(
"/api/v1/snapshots/next",
body={},
on_success=lambda d: None,
on_error=lambda e: None,
)
except Exception:
pass
def _on_snapshot_prev(self, instance):
"""Go to previous snapshot."""
if not self.client:
return
try:
self.client.rest_post(
"/api/v1/snapshots/previous",
body={},
on_success=lambda d: None,
on_error=lambda e: None,
)
except Exception:
pass
def _on_create_setlist(self, instance):
"""Create a new setlist."""
if not self.client:
return
name = self._setlist_name_input.text.strip()
if not name:
return
try:
self.client.rest_post(
"/api/v1/setlists/save",
body={"name": name, "entries": [], "description": ""},
on_success=lambda d: self._refresh(),
on_error=lambda e: None,
)
except Exception:
pass
+897
View File
@@ -0,0 +1,897 @@
"""Tests for the audio/video streaming pipeline module.
Tests cover:
- Platform profiles and presets
- GStreamer pipeline builder
- Streamer lifecycle (state machine, configuration)
- Scene management
- Stream API endpoints
- Camera detection (mocked)
These tests do NOT require actual camera hardware or GStreamer.
They validate the logic, configuration, and state management.
"""
from __future__ import annotations
import copy
import threading
import time
from unittest.mock import patch, MagicMock, PropertyMock
import pytest
from src.streaming.platforms import (
PlatformProfile,
PRESET_PLATFORMS,
get_platform_profile,
list_platforms,
YOUTUBE_RTMP,
TWITCH_RTMP,
)
from src.streaming.gst_pipeline import (
GstPipelineBuilder,
GstPipelineConfig,
VideoSource,
AudioSource,
EncoderType,
build_pipeline_string,
)
from src.streaming.streamer import (
Streamer,
StreamerConfig,
StreamState,
StreamScene,
StreamStats,
create_streamer,
)
from src.streaming.camera import (
CameraInfo,
CameraType,
CameraResolution,
detect_cameras,
get_default_camera,
)
from src.streaming.controls import (
StreamKeyboardController,
StreamHotkey,
HotkeyMapping,
DEFAULT_HOTKEYS,
start_keyboard_controller,
)
# ═══════════════════════════════════════════════════════════════════════════════
# Platform profiles
# ═══════════════════════════════════════════════════════════════════════════════
class TestPlatformProfiles:
"""Tests for streaming platform profiles and presets."""
def test_all_presets_have_required_fields(self):
"""Every preset should have RTMP URL and encode settings."""
required = ["name", "platform_id", "rtmp_url", "width", "height",
"video_bitrate_kbps", "audio_bitrate_kbps"]
for pid, profile in PRESET_PLATFORMS.items():
for field in required:
assert getattr(profile, field, None) is not None, \
f"Preset '{pid}' missing field '{field}'"
assert profile.width > 0
assert profile.height > 0
assert profile.video_bitrate_kbps > 0
assert profile.audio_bitrate_kbps > 0
def test_youtube_preset(self):
profile = PRESET_PLATFORMS["youtube"]
assert profile.platform_id == "youtube"
assert "youtube" in profile.rtmp_url
assert profile.width == 1280
assert profile.height == 720
assert profile.framerate == 30
assert profile.low_latency is True
def test_twitch_preset(self):
profile = PRESET_PLATFORMS["twitch"]
assert profile.platform_id == "twitch"
assert "twitch" in profile.rtmp_url
assert profile.encoder == "h264"
def test_audio_only_preset(self):
profile = PRESET_PLATFORMS["audio_only"]
assert profile.platform_id == "audio_only"
assert profile.audio_bitrate_kbps == 192
assert profile.video_bitrate_kbps < 200 # Very low video
assert profile.framerate <= 5
def test_get_platform_profile_found(self):
for pid in ["youtube", "twitch", "custom", "audio_only"]:
assert get_platform_profile(pid) is not None
def test_get_platform_profile_not_found(self):
assert get_platform_profile("nonexistent") is None
def test_list_platforms(self):
platforms = list_platforms()
assert len(platforms) >= 5
for p in platforms:
assert "id" in p
assert "name" in p
def test_platform_to_dict(self):
profile = PRESET_PLATFORMS["youtube"]
d = profile.to_dict()
assert d["platform_id"] == "youtube"
assert d["width"] == 1280
assert d["encoder"] == "h264"
def test_platform_from_dict(self):
data = PRESET_PLATFORMS["youtube"].to_dict()
profile = PlatformProfile.from_dict(data)
assert profile.platform_id == "youtube"
assert profile.width == 1280
# ═══════════════════════════════════════════════════════════════════════════════
# GStreamer pipeline builder
# ═══════════════════════════════════════════════════════════════════════════════
class TestGstPipelineBuilder:
"""Tests for GStreamer pipeline string construction."""
def test_build_basic_pipeline(self):
config = GstPipelineConfig(
video_source=VideoSource.TEST,
audio_source=AudioSource.TEST,
video_encoder=EncoderType.X264,
rtmp_url="rtmp://localhost/live",
rtmp_stream_key="test-key",
)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
assert "videotestsrc" in pipeline
assert "audiotestsrc" in pipeline
assert "x264enc" in pipeline
assert "flvmux" in pipeline
def test_rtmp_url_construction(self):
config = GstPipelineConfig(
rtmp_url="rtmp://example.com/live",
rtmp_stream_key="abc123",
)
assert config.full_rtmp_url == "rtmp://example.com/live/abc123"
def test_rtmp_url_no_key(self):
config = GstPipelineConfig(
rtmp_url="rtmp://example.com/live",
rtmp_stream_key="",
)
assert config.full_rtmp_url == "rtmp://example.com/live"
def test_build_v4l2_source(self):
config = GstPipelineConfig(
video_source=VideoSource.V4L2,
video_device="/dev/video0",
)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
assert "v4l2src" in pipeline
assert "device=/dev/video0" in pipeline
def test_build_libcamera_source(self):
config = GstPipelineConfig(
video_source=VideoSource.LIBCAMERA,
video_device="/dev/video0",
)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
assert "libcamerasrc" in pipeline
def test_build_rpicam_source(self):
config = GstPipelineConfig(
video_source=VideoSource.RASPICAM,
video_bitrate_kbps=3000,
)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
assert "rpicamsrc" in pipeline
def test_build_alias_audio_source(self):
config = GstPipelineConfig(audio_source=AudioSource.ALSA)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
assert "alsasrc" in pipeline
def test_build_jack_audio_source(self):
config = GstPipelineConfig(audio_source=AudioSource.JACK)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
assert "jackaudiosrc" in pipeline
def test_build_pulse_audio_source(self):
config = GstPipelineConfig(audio_source=AudioSource.PULSE)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
assert "pulsesrc" in pipeline
def test_v4l2_h264_encoder(self):
config = GstPipelineConfig(
video_source=VideoSource.TEST,
audio_source=AudioSource.TEST,
video_encoder=EncoderType.V4L2_H264,
)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
assert "v4l2h264enc" in pipeline
def test_omx_h264_encoder(self):
config = GstPipelineConfig(
video_source=VideoSource.TEST,
audio_source=AudioSource.TEST,
video_encoder=EncoderType.OMX_H264,
)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
assert "omxh264enc" in pipeline
def test_x264_encoder(self):
config = GstPipelineConfig(
video_source=VideoSource.TEST,
audio_source=AudioSource.TEST,
video_encoder=EncoderType.X264,
)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
assert "x264enc" in pipeline
def test_low_latency_queue_elements(self):
config = GstPipelineConfig(
video_source=VideoSource.TEST,
audio_source=AudioSource.TEST,
low_latency=True,
video_queue_max_time_ms=500,
)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
assert "max-size-time" in pipeline
def test_high_latency_no_queue_limits(self):
config = GstPipelineConfig(
video_source=VideoSource.TEST,
audio_source=AudioSource.TEST,
low_latency=False,
)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
# Without low latency, queues should be simpler
assert "queue" in pipeline
def test_pipeline_includes_h264parse(self):
config = GstPipelineConfig(
video_source=VideoSource.TEST,
audio_source=AudioSource.TEST,
video_encoder=EncoderType.X264,
)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
assert "h264parse" in pipeline
def test_pipeline_includes_audio_encoder(self):
config = GstPipelineConfig(
video_source=VideoSource.TEST,
audio_source=AudioSource.TEST,
audio_encoder="lamemp3enc",
audio_bitrate_kbps=192,
)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
assert "lamemp3enc" in pipeline
assert "bitrate=192000" in pipeline
def test_build_pipeline_string_helper(self):
pipeline = build_pipeline_string(
video_device="/dev/video0",
audio_device="hw:0",
rtmp_url="rtmp://example.com/live/key",
)
assert "v4l2src" in pipeline
assert "alsasrc" in pipeline
assert "rtmp" in pipeline
def test_test_mode_pipeline(self):
pipeline = build_pipeline_string(
video_device="test",
audio_device="test",
rtmp_url="rtmp://example.com/live/key",
)
assert "videotestsrc" in pipeline
assert "audiotestsrc" in pipeline
def test_build_args_list(self):
config = GstPipelineConfig(
video_source=VideoSource.TEST,
audio_source=AudioSource.TEST,
video_encoder=EncoderType.X264,
)
builder = GstPipelineBuilder(config)
args = builder.build_args()
assert isinstance(args, list)
assert len(args) > 5
def test_pipeline_includes_resolution(self):
config = GstPipelineConfig(
video_source=VideoSource.TEST,
audio_source=AudioSource.TEST,
video_width=1920,
video_height=1080,
)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
assert "1920" in pipeline
assert "1080" in pipeline
# ═══════════════════════════════════════════════════════════════════════════════
# Streamer lifecycle
# ═══════════════════════════════════════════════════════════════════════════════
class TestStreamerLifecycle:
"""Tests for Streamer state machine and lifecycle."""
def test_initial_state_is_idle(self):
streamer = Streamer()
assert streamer.state == StreamState.IDLE
assert streamer.is_live is False
def test_configure_sets_state(self):
streamer = Streamer()
streamer.configure(platform_id="youtube")
assert streamer.state == StreamState.CONFIGURED
def test_configure_applies_platform_preset(self):
streamer = Streamer()
streamer.configure(platform_id="youtube", stream_key="test-key")
assert streamer.config.platform_id == "youtube"
assert streamer.config.stream_key == "test-key"
assert streamer.config.rtmp_url == YOUTUBE_RTMP
def test_configure_twitch_preset(self):
streamer = Streamer()
streamer.configure(platform_id="twitch", stream_key="live_xxxx")
assert streamer.config.rtmp_url == TWITCH_RTMP
def test_configure_custom_devices(self):
streamer = Streamer()
streamer.configure(
platform_id="youtube",
video_device="/dev/video2",
audio_device="hw:1",
)
assert streamer.config.video_device == "/dev/video2"
assert streamer.config.audio_device == "hw:1"
def test_configure_partial_updates(self):
streamer = Streamer()
streamer.configure(platform_id="youtube")
# Partial update — only change stream key
streamer.configure(stream_key="new-key")
assert streamer.config.stream_key == "new-key"
assert streamer.config.platform_id == "youtube" # Unchanged
def test_start_requires_rtmp_url(self):
streamer = Streamer()
# No platform configured, no RTMP URL, no stream key
success = streamer.start()
assert success is False
assert streamer.state == StreamState.ERROR
def test_stats_initial_state(self):
streamer = Streamer()
stats = streamer.stats
assert stats.state == StreamState.IDLE
assert stats.uptime_seconds == 0.0
assert stats.reconnect_count == 0
def test_get_stats_dict(self):
streamer = Streamer()
d = streamer.get_stats_dict()
assert d["state"] == "idle"
assert "uptime_seconds" in d
assert "reconnect_count" in d
def test_config_from_preset(self):
config = StreamerConfig.from_platform("youtube", "test-key")
assert config.platform_id == "youtube"
assert config.stream_key == "test-key"
assert config.video_width == 1280
assert config.video_height == 720
assert config.low_latency is True
def test_config_from_unknown_preset(self):
config = StreamerConfig.from_platform("nonexistent", "test-key")
assert config.platform_id == "custom" # Falls back to custom
def test_config_full_rtmp_url(self):
config = StreamerConfig(
rtmp_url=YOUTUBE_RTMP,
stream_key="abcd-efgh-ijkl",
)
assert config.full_rtmp_url == f"{YOUTUBE_RTMP}/abcd-efgh-ijkl"
def test_config_full_rtmp_url_no_key(self):
config = StreamerConfig(rtmp_url=YOUTUBE_RTMP, stream_key="")
assert config.full_rtmp_url == YOUTUBE_RTMP
def test_config_full_rtmp_url_trailing_slash(self):
config = StreamerConfig(
rtmp_url="rtmp://example.com/live/",
stream_key="key123",
)
assert config.full_rtmp_url == "rtmp://example.com/live/key123"
# ═══════════════════════════════════════════════════════════════════════════════
# Scene management
# ═══════════════════════════════════════════════════════════════════════════════
class TestSceneManagement:
"""Tests for streaming scene management."""
def test_default_scene_exists(self):
streamer = Streamer()
scenes = streamer.get_scenes()
assert len(scenes) == 1
assert scenes[0]["name"] == "default"
def test_add_scene(self):
streamer = Streamer()
scene = StreamScene(
name="closeup",
camera_device="/dev/video1",
video_width=640,
video_height=480,
description="Close-up camera",
)
streamer.add_scene(scene)
scenes = streamer.get_scenes()
assert len(scenes) == 2
names = [s["name"] for s in scenes]
assert "closeup" in names
def test_add_duplicate_scene_name(self):
streamer = Streamer()
scene = StreamScene(name="default")
streamer.add_scene(scene)
# Adding with same name should overwrite
scenes = streamer.get_scenes()
assert len(scenes) == 1
def test_remove_scene(self):
streamer = Streamer()
scene = StreamScene(name="test")
streamer.add_scene(scene)
assert streamer.remove_scene("test") is True
assert streamer.remove_scene("test") is False
def test_cannot_remove_default_scene(self):
streamer = Streamer()
assert streamer.remove_scene("default") is False
def test_switch_scene_not_found(self):
streamer = Streamer()
assert streamer.switch_scene("nonexistent") is False
def test_switch_scene_updates_config(self):
streamer = Streamer()
scene = StreamScene(
name="wide",
camera_device="/dev/video2",
video_width=1920,
video_height=1080,
video_framerate=24,
)
streamer.add_scene(scene)
streamer.switch_scene("wide")
assert streamer.config.video_device == "/dev/video2"
assert streamer.config.video_width == 1920
assert streamer.config.video_height == 1080
def test_scene_to_dict(self):
scene = StreamScene(
name="test",
camera_device="/dev/video0",
video_width=1280,
video_height=720,
video_framerate=30,
audio_device="hw:0",
overlay_text="Live!",
description="Test scene",
)
d = scene.to_dict()
assert d["name"] == "test"
assert d["camera_device"] == "/dev/video0"
assert d["overlay_text"] == "Live!"
# ═══════════════════════════════════════════════════════════════════════════════
# Streamer configuration
# ═══════════════════════════════════════════════════════════════════════════════
class TestStreamerConfig:
"""Tests for StreamerConfig behavior."""
def test_auto_reconnect_defaults(self):
streamer = Streamer()
assert streamer.config.auto_reconnect is True
assert streamer.config.max_reconnect_attempts == 5
assert streamer.config.reconnect_delay_seconds == 3.0
def test_encoder_type_default(self):
streamer = Streamer()
assert streamer.config.encoder_type == EncoderType.V4L2_H264
def test_low_latency_default(self):
streamer = Streamer()
assert streamer.config.low_latency is True
def test_scenes_default(self):
streamer = Streamer()
assert streamer.config.active_scene == "default"
assert len(streamer.get_scenes()) == 1
def test_create_streamer_factory(self):
streamer = create_streamer(
platform_id="youtube",
stream_key="test-key",
video_device="/dev/video0",
audio_device="hw:0",
)
assert streamer.state == StreamState.CONFIGURED
assert streamer.config.platform_id == "youtube"
# ═══════════════════════════════════════════════════════════════════════════════
# Stream statistics
# ═══════════════════════════════════════════════════════════════════════════════
class TestStreamStats:
"""Tests for StreamStats data class."""
def test_stats_to_dict(self):
stats = StreamStats(
state=StreamState.LIVE,
uptime_seconds=120.5,
video_bitrate_kbps=2500.0,
camera_name="Test Cam",
platform_name="youtube",
)
d = stats.to_dict()
assert d["state"] == "live"
assert d["uptime_seconds"] == 120.5
assert d["video_bitrate_kbps"] == 2500.0
assert d["camera_name"] == "Test Cam"
def test_stats_copy_is_independent(self):
streamer = Streamer()
stats1 = streamer.stats
stats2 = streamer.stats
assert stats1 is not stats2 # Different objects
def test_state_change_callback(self):
streamer = Streamer()
received_states = []
def callback(state, stats):
received_states.append(state)
streamer.on_state_change(callback)
streamer.configure(platform_id="youtube")
assert len(received_states) >= 1
assert StreamState.CONFIGURED in received_states
# ═══════════════════════════════════════════════════════════════════════════════
# Camera detection
# ═══════════════════════════════════════════════════════════════════════════════
class TestCameraDetection:
"""Tests for camera detection logic (mocked)."""
def test_camera_info_to_dict(self):
cam = CameraInfo(
device_path="/dev/video0",
camera_type=CameraType.USB_WEBCAM,
name="Test Webcam",
driver="uvcvideo",
resolutions=[
CameraResolution(640, 480, 30),
CameraResolution(1280, 720, 30),
],
pixel_formats=["YUYV", "MJPG"],
)
d = cam.to_dict()
assert d["device_path"] == "/dev/video0"
assert d["camera_type"] == "usb_webcam"
assert len(d["resolutions"]) == 2
def test_camera_best_resolution(self):
cam = CameraInfo(
device_path="/dev/video0",
camera_type=CameraType.USB_WEBCAM,
name="Test",
resolutions=[
CameraResolution(640, 480, 30),
CameraResolution(1920, 1080, 30),
CameraResolution(1280, 720, 60),
],
)
best = cam.best_resolution
assert best is not None
assert best.width == 1920
assert best.height == 1080
def test_camera_no_resolutions(self):
cam = CameraInfo(
device_path="/dev/video0",
camera_type=CameraType.USB_WEBCAM,
name="Test",
)
assert cam.best_resolution is None
def test_camera_supports_h264(self):
cam = CameraInfo(
device_path="/dev/video0",
camera_type=CameraType.PI_CAMERA,
name="Pi Cam",
pixel_formats=["YUYV", "H264"],
)
assert cam.supports_h264 is True
def test_camera_no_h264(self):
cam = CameraInfo(
device_path="/dev/video0",
camera_type=CameraType.USB_WEBCAM,
name="Webcam",
pixel_formats=["YUYV"],
)
assert cam.supports_h264 is False
def test_classify_pi_camera(self):
cam = CameraInfo(
device_path="/dev/video0",
camera_type=CameraType.PI_CAMERA,
name="Pi Camera (imx219)",
driver="unicam",
)
assert cam.camera_type == CameraType.PI_CAMERA
def test_classify_usb_webcam(self):
cam = CameraInfo(
device_path="/dev/video1",
camera_type=CameraType.USB_WEBCAM,
name="USB Camera",
driver="uvcvideo",
)
assert cam.camera_type == CameraType.USB_WEBCAM
# ═══════════════════════════════════════════════════════════════════════════════
# Keyboard controls
# ═══════════════════════════════════════════════════════════════════════════════
class TestKeyboardControls:
"""Tests for keyboard controller logic."""
def test_default_hotkeys_defined(self):
assert len(DEFAULT_HOTKEYS) >= 10
def test_start_stop_hotkey_exists(self):
actions = [hk.hotkey for hk in DEFAULT_HOTKEYS]
assert StreamHotkey.START_STOP in actions
def test_scene_hotkeys_exist(self):
actions = [hk.hotkey for hk in DEFAULT_HOTKEYS]
for i in range(1, 10):
scene_action = StreamHotkey(f"scene_{i}")
assert scene_action in actions, f"Missing scene hotkey: scene_{i}"
def test_hotkey_descriptions(self):
for hk in DEFAULT_HOTKEYS:
assert hk.description, f"Hotkey {hk.hotkey} missing description"
assert hk.key
assert hk.modifiers
def test_controller_init(self):
streamer = Streamer()
controller = StreamKeyboardController(streamer)
assert controller._running is False
def test_controller_get_hotkeys(self):
streamer = Streamer()
controller = StreamKeyboardController(streamer)
hotkeys = controller.get_hotkeys()
assert len(hotkeys) == len(DEFAULT_HOTKEYS)
for hk in hotkeys:
assert "action" in hk
assert "modifiers" in hk
assert "key" in hk
assert "description" in hk
def test_controller_custom_hotkeys(self):
streamer = Streamer()
custom = [
HotkeyMapping(
StreamHotkey.START_STOP,
{"ctrl"}, "x",
"Custom start/stop",
),
]
controller = StreamKeyboardController(streamer, hotkeys=custom)
hotkeys = controller.get_hotkeys()
assert len(hotkeys) == 1
assert hotkeys[0]["key"] == "x"
def test_hotkey_mapping_to_dict(self):
hk = HotkeyMapping(
StreamHotkey.START_STOP,
{"ctrl", "shift"}, "s",
"Start/Stop",
)
assert hk.hotkey == StreamHotkey.START_STOP
assert hk.key == "s"
assert "ctrl" in hk.modifiers
assert "shift" in hk.modifiers
# ═══════════════════════════════════════════════════════════════════════════════
# API endpoint integration (unit-level)
# ═══════════════════════════════════════════════════════════════════════════════
class TestStreamAPIProvider:
"""Tests for StreamStateProvider and stream route adapter."""
def test_provider_initial_state(self):
from src.network.stream_routes import StreamStateProvider
sp = StreamStateProvider()
assert sp.get_status is None
assert sp.start_stream is None
assert sp.list_scenes is None
def test_provider_can_be_wired(self):
from src.network.stream_routes import StreamStateProvider
sp = StreamStateProvider()
def dummy_status() -> dict:
return {"state": "idle"}
sp.get_status = dummy_status
assert sp.get_status() == {"state": "idle"}
# ═══════════════════════════════════════════════════════════════════════════════
# Pipeline edge cases
# ═══════════════════════════════════════════════════════════════════════════════
class TestPipelineEdgeCases:
"""Edge case tests for GStreamer pipeline builder."""
def test_mono_audio_channel(self):
config = GstPipelineConfig(
video_source=VideoSource.TEST,
audio_source=AudioSource.TEST,
audio_channels=1,
)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
assert "channels=1" in pipeline
def test_custom_audio_encoder(self):
config = GstPipelineConfig(
video_source=VideoSource.TEST,
audio_source=AudioSource.TEST,
audio_encoder="voaacenc",
audio_bitrate_kbps=96,
)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
assert "voaacenc" in pipeline
def test_high_bitrate_config(self):
config = GstPipelineConfig(
video_source=VideoSource.TEST,
audio_source=AudioSource.TEST,
video_bitrate_kbps=6000,
video_bitrate_max_kbps=10000,
)
assert config.video_bitrate_kbps == 6000
def test_libcamera_h264_encoder(self):
config = GstPipelineConfig(
video_source=VideoSource.LIBCAMERA,
audio_source=AudioSource.TEST,
video_encoder=EncoderType.LIBCAMERA_H264,
)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
# libcamera h264 encoder shouldn't add separate encoder element
assert "libcamerasrc" in pipeline
def test_empty_rtmp_url_pipeline(self):
config = GstPipelineConfig(
video_source=VideoSource.TEST,
audio_source=AudioSource.TEST,
rtmp_url="",
)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
assert "fakesink" in pipeline
def test_full_rtmp_url_with_trailing_slash(self):
config = GstPipelineConfig(
rtmp_url="rtmp://example.com/live/",
rtmp_stream_key="stream-key",
)
assert config.full_rtmp_url == "rtmp://example.com/live/stream-key"
def test_video_bitrate_max(self):
config = GstPipelineConfig(
video_bitrate_kbps=3000,
video_bitrate_max_kbps=5000,
)
assert config.video_bitrate_max_kbps == 5000
def test_keyframe_interval(self):
config = GstPipelineConfig(keyframe_interval=120)
assert config.keyframe_interval == 120
def test_h264_profile_and_level(self):
config = GstPipelineConfig(
h264_profile="high",
h264_level="4.1",
)
assert config.h264_profile == "high"
assert config.h264_level == "4.1"
def test_zero_latency_tuning(self):
config = GstPipelineConfig(
low_latency=True,
zerolatency_tune=True,
b_frames=0,
)
assert config.b_frames == 0
assert config.zerolatency_tune is True
def test_audio_buffer_time(self):
config = GstPipelineConfig(
audio_source=AudioSource.ALSA,
audio_buffer_time_us=10000,
)
assert config.audio_buffer_time_us == 10000
def test_test_video_pattern(self):
config = GstPipelineConfig(
video_source=VideoSource.TEST,
test_video_pattern=18, # SMPTE color bars
)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
assert "pattern=18" in pipeline
def test_test_audio_frequency(self):
config = GstPipelineConfig(
audio_source=AudioSource.TEST,
test_audio_freq=1000.0, # 1kHz test tone
)
builder = GstPipelineBuilder(config)
pipeline = builder.build()
assert "freq=1000.0" in pipeline
+481 -31
View File
@@ -719,65 +719,515 @@ const App = {
},
// ═════════════════════════════════════════════════════════════════════
// Sessions View
// Sessions View (full session management)
// ═════════════════════════════════════════════════════════════════════
_sessionsData: null,
_setlistsData: null,
_snapshotsData: null,
_currentSetlist: null,
_sessionsSubtab: 'sessions',
buildSessionsView(scenes) {
// Kick off initial loads — scenes from WS are fallback
this.refreshSessions();
this.refreshAutosaveStatus();
},
refreshSessions() {
const filter = document.getElementById('session-filter')?.value || 'all';
// Fetch sessions from REST API
fetch('/api/v1/sessions')
.then(r => r.json())
.then(data => {
this._sessionsData = data.sessions || [];
if (filter === 'all' || filter === 'sessions') {
this._renderSessions();
}
})
.catch(e => this.log('Failed to list sessions: ' + e.message, 'log-error'));
// Also fetch snapshots
fetch('/api/v1/snapshots')
.then(r => r.json())
.then(data => {
this._snapshotsData = data.snapshots || [];
if (filter === 'all' || filter === 'snapshots') {
this._renderSessions();
}
})
.catch(e => this.log('Failed to list snapshots: ' + e.message, 'log-error'));
},
_renderSessions() {
const container = document.getElementById('sessions-list');
if (!scenes || scenes.length === 0) {
const filter = document.getElementById('session-filter')?.value || 'all';
const items = [];
if (filter === 'all' || filter === 'sessions') {
(this._sessionsData || []).forEach(s => {
items.push({
type: 'session',
name: s.name,
filename: s.filename,
modified: s.modified,
size: s.size_bytes,
});
});
}
if (filter === 'all' || filter === 'snapshots') {
(this._snapshotsData || []).forEach(s => {
items.push({
type: 'snapshot',
name: s.name,
category: s.category || '',
isCurrent: s.is_current,
modified: s.created_at,
});
});
}
if (items.length === 0) {
container.innerHTML = '<div style="color:var(--text-faint);text-align:center;padding:2rem;grid-column:1/-1">No saved sessions. Save current state above.</div>';
return;
}
container.innerHTML = '';
scenes.forEach(name => {
items.forEach(item => {
const card = document.createElement('div');
card.className = 'session-card';
card.innerHTML =
`<div class="session-card-name">💾 ${this._escapeHTML(name)}</div>` +
`<div class="session-card-actions">` +
`<button onclick="App.loadScene('${this._escapeAttr(name)}')">📂 Load</button>` +
`<button class="danger" onclick="App.deleteScene('${this._escapeAttr(name)}')">🗑️</button>` +
`</div>`;
card.addEventListener('click', (e) => {
if (e.target.tagName === 'BUTTON') return;
this.loadScene(name);
});
if (item.type === 'snapshot') {
const catBadge = item.category ? ` <span class="badge">${this._escapeHTML(item.category)}</span>` : '';
const curBadge = item.isCurrent ? ' <span class="badge active">active</span>' : '';
card.innerHTML =
`<div class="session-card-name">📸 ${this._escapeHTML(item.name)}${catBadge}${curBadge}</div>` +
`<div class="session-card-actions">` +
`<button onclick="App.recallSnapshot('${this._escapeAttr(item.name)}')">▶ Recall</button>` +
`<button class="danger" onclick="App.deleteSnapshot('${this._escapeAttr(item.name)}')">🗑️</button>` +
`</div>`;
} else {
const date = new Date(item.modified * 1000).toLocaleDateString();
const size = item.size ? (item.size < 1024 ? item.size + 'B' : (item.size / 1024).toFixed(1) + 'KB') : '';
card.innerHTML =
`<div class="session-card-name">💾 ${this._escapeHTML(item.name)}</div>` +
`<div class="session-card-info">${date} · ${size}</div>` +
`<div class="session-card-actions">` +
`<button onclick="App.loadSession('${this._escapeAttr(item.name)}')">📂 Load</button>` +
`<button onclick="App.exportSession('${this._escapeAttr(item.name)}')">📤</button>` +
`<button onclick="App.duplicateSession('${this._escapeAttr(item.name)}')">📋</button>` +
`<button class="danger" onclick="App.deleteSession('${this._escapeAttr(item.name)}')">🗑️</button>` +
`</div>`;
card.addEventListener('click', (e) => {
if (e.target.tagName === 'BUTTON') return;
this.loadSession(item.name);
});
}
container.appendChild(card);
});
},
saveScene() {
const nameInput = document.getElementById('scene-name-input');
saveSession() {
const nameInput = document.getElementById('session-name-input');
const notesInput = document.getElementById('session-notes-input');
const name = nameInput.value.trim();
if (!name) {
this.log('Enter a scene name', 'log-error');
this.log('Enter a session name', 'log-error');
return;
}
this.sendParam('snapshot_save', 1, -1, {name: name});
this.log('Saving scene: ' + name, 'log-state');
nameInput.value = '';
const notes = notesInput.value.trim();
this.log('Saving session: ' + name, 'log-state');
fetch('/api/v1/sessions/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, overwrite: true }),
})
.then(r => r.json())
.then(data => {
if (data.ok) {
this.log('Session saved: ' + name + ' (' + data.data.channels + ' channels)', 'log-state');
nameInput.value = '';
notesInput.value = '';
this.refreshSessions();
} else {
this.log('Save failed: ' + (data.error || data.detail), 'log-error');
}
})
.catch(e => this.log('Save error: ' + e.message, 'log-error'));
},
loadScene(name) {
this.log('Loading scene: ' + name, 'log-state');
this.sendParam('snapshot_load', 1, -1, {name: name});
loadSession(name) {
this.log('Loading session: ' + name, 'log-state');
fetch('/api/v1/sessions/' + encodeURIComponent(name) + '/load', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.ok) {
this.log('Session loaded: ' + name + ' — refresh to see changes', 'log-state');
} else {
this.log('Load failed: ' + (data.detail || 'unknown'), 'log-error');
}
})
.catch(e => this.log('Load error: ' + e.message, 'log-error'));
},
deleteScene(name) {
deleteSession(name) {
if (!confirm('Delete session "' + name + '"?')) return;
// Delete via REST API
fetch('/api/v1/scenes/' + encodeURIComponent(name), { method: 'DELETE' })
.then(() => {
this.log('Deleted scene: ' + name, 'log-state');
// Remove from local state
if (this.mixerState?.scenes) {
this.mixerState.scenes = this.mixerState.scenes.filter(s => s !== name);
this.buildSessionsView(this.mixerState.scenes);
fetch('/api/v1/sessions/' + encodeURIComponent(name), { method: 'DELETE' })
.then(r => r.json())
.then(data => {
if (data.ok) {
this.log('Deleted: ' + name, 'log-state');
this.refreshSessions();
}
})
.catch(e => this.log('Delete failed: ' + e.message, 'log-error'));
},
duplicateSession(name) {
const newName = prompt('Duplicate "' + name + '" as:', name + ' (copy)');
if (!newName) return;
fetch('/api/v1/sessions/' + encodeURIComponent(name) + '/duplicate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_name: newName }),
})
.then(r => r.json())
.then(data => {
if (data.ok) {
this.log('Duplicated: ' + name + ' → ' + newName, 'log-state');
this.refreshSessions();
} else {
this.log('Duplicate failed: ' + (data.detail || ''), 'log-error');
}
})
.catch(e => this.log('Duplicate error: ' + e.message, 'log-error'));
},
exportSession(name) {
fetch('/api/v1/sessions/export/' + encodeURIComponent(name), { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.ok) {
this.log('Exported: ' + data.data.export_path, 'log-state');
}
})
.catch(e => this.log('Export failed: ' + e.message, 'log-error'));
},
exportAllSessions() {
this.log('Exporting all sessions...', 'log-state');
window.open('/api/v1/sessions/export-all-zip', '_blank');
},
importSession() {
document.getElementById('session-import-file').click();
},
handleImportFile(input) {
const file = input.files[0];
if (!file) return;
const newName = prompt('Import as (leave empty to use file name):', '');
// For local import, we send the filepath. In a real deployment,
// this would upload the file content. For now, work with local paths.
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result;
// Send content to server
fetch('/api/v1/sessions/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newName || file.name.replace('.json', ''),
overwrite: false,
}),
}).then(() => {
this.log('To import from file, copy it to ~/.config/rpi-mixer/sessions/ and refresh', 'log-info');
this.refreshSessions();
});
};
reader.readAsText(file);
input.value = '';
},
// ── Setlists ───────────────────────────────────────────────────────
showSessionsSubtab(tab) {
this._sessionsSubtab = tab;
document.querySelectorAll('.subtab').forEach(b => b.classList.toggle('active', b.id === 'subtab-' + tab));
document.getElementById('sessions-list').style.display = tab === 'sessions' ? '' : 'none';
document.getElementById('setlists-view').style.display = tab === 'setlists' ? '' : 'none';
document.getElementById('snapshots-view').style.display = tab === 'snapshots' ? '' : 'none';
if (tab === 'setlists') this.refreshSetlists();
if (tab === 'snapshots') this.refreshSnapshotsUI();
if (tab === 'sessions') this.refreshSessions();
},
refreshSetlists() {
fetch('/api/v1/setlists')
.then(r => r.json())
.then(data => {
this._setlistsData = data.setlists || [];
this._renderSetlists();
})
.catch(e => this.log('Failed to list setlists: ' + e.message, 'log-error'));
},
_renderSetlists() {
const container = document.getElementById('setlists-list');
const setlists = this._setlistsData || [];
if (setlists.length === 0) {
container.innerHTML = '<div style="color:var(--text-faint);text-align:center;padding:1rem">No setlists. Create one below.</div>';
return;
}
container.innerHTML = '';
setlists.forEach(sl => {
const card = document.createElement('div');
card.className = 'session-card';
card.innerHTML =
`<div class="session-card-name">📋 ${this._escapeHTML(sl.name)}</div>` +
`<div class="session-card-info">${sl.entry_count || 0} entries</div>` +
`<div class="session-card-actions">` +
`<button onclick="App.editSetlist('${this._escapeAttr(sl.name)}')">✏️ Edit</button>` +
`<button onclick="App.loadSetlist('${this._escapeAttr(sl.name)}')">▶ Activate</button>` +
`<button class="danger" onclick="App.deleteSetlist('${this._escapeAttr(sl.name)}')">🗑️</button>` +
`</div>`;
container.appendChild(card);
});
},
createSetlist() {
const nameInput = document.getElementById('setlist-name-input');
const name = nameInput.value.trim();
if (!name) { this.log('Enter setlist name', 'log-error'); return; }
this._currentSetlist = { name, entries: [], description: '' };
document.getElementById('setlist-editor').style.display = '';
document.getElementById('setlist-editor-title').textContent = name;
nameInput.value = '';
this._renderSetlistEditor();
},
editSetlist(name) {
// Find in data and render editor
const sl = (this._setlistsData || []).find(s => s.name === name);
if (!sl) return;
// Load full setlist
fetch('/api/v1/setlists/' + encodeURIComponent(sl.filename.replace('.json', '')) + '/load', { method: 'POST' })
.then(r => r.json())
.catch(() => null);
this._currentSetlist = { name: sl.name, entries: [], description: '' };
document.getElementById('setlist-editor').style.display = '';
document.getElementById('setlist-editor-title').textContent = sl.name;
this._renderSetlistEditor();
},
_renderSetlistEditor() {
const container = document.getElementById('setlist-entries');
if (!this._currentSetlist || !this._currentSetlist.entries.length) {
container.innerHTML = '<div style="color:var(--text-faint);padding:0.5rem">No entries. Add one below.</div>';
return;
}
container.innerHTML = '';
this._currentSetlist.entries.forEach((entry, i) => {
const row = document.createElement('div');
row.className = 'setlist-entry-row';
row.innerHTML =
`<span>${i + 1}. ${this._escapeHTML(entry.session_name)}</span>` +
`<span class="badge">${entry.transition || 'cut'}</span>` +
`<button onclick="App.removeSetlistEntry(${i})">✕</button>`;
container.appendChild(row);
});
},
addSetlistEntry() {
if (!this._currentSetlist) { this.log('Create a setlist first', 'log-error'); return; }
const sessionInput = document.getElementById('setlist-entry-session');
const transitionSelect = document.getElementById('setlist-entry-transition');
const sessionName = sessionInput.value.trim();
if (!sessionName) { this.log('Enter session name', 'log-error'); return; }
this._currentSetlist.entries.push({
session_name: sessionName,
display_name: sessionName,
transition: transitionSelect.value,
transition_duration: 2.0,
wait_for_trigger: false,
notes: '',
});
sessionInput.value = '';
this._renderSetlistEditor();
},
removeSetlistEntry(index) {
if (!this._currentSetlist) return;
this._currentSetlist.entries.splice(index, 1);
this._renderSetlistEditor();
},
saveSetlist() {
if (!this._currentSetlist) return;
fetch('/api/v1/setlists/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this._currentSetlist),
})
.then(r => r.json())
.then(data => {
if (data.ok) {
this.log('Setlist saved: ' + data.data.name, 'log-state');
document.getElementById('setlist-editor').style.display = 'none';
this._currentSetlist = null;
this.refreshSetlists();
}
})
.catch(e => this.log('Save setlist failed: ' + e.message, 'log-error'));
},
loadSetlist(name) {
fetch('/api/v1/setlists/' + encodeURIComponent(name) + '/load', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.ok) {
this.log('Setlist activated: ' + name + ' (' + data.data.progress[0] + '/' + data.data.progress[1] + ')', 'log-state');
}
})
.catch(e => this.log('Load setlist failed: ' + e.message, 'log-error'));
},
activateSetlist() {
if (!this._currentSetlist) return;
this.loadSetlist(this._currentSetlist.name);
},
deleteSetlist(name) {
if (!confirm('Delete setlist "' + name + '"?')) return;
fetch('/api/v1/setlists/' + encodeURIComponent(name), { method: 'DELETE' })
.then(r => r.json())
.then(data => {
if (data.ok) {
this.log('Deleted setlist: ' + name, 'log-state');
this.refreshSetlists();
}
})
.catch(e => this.log('Delete setlist failed: ' + e.message, 'log-error'));
},
// ── Snapshots ──────────────────────────────────────────────────────
refreshSnapshotsUI() {
fetch('/api/v1/snapshots')
.then(r => r.json())
.then(data => {
this._snapshotsData = data.snapshots || [];
this._renderSnapshots();
})
.catch(e => this.log('Failed: ' + e.message, 'log-error'));
},
_renderSnapshots() {
const container = document.getElementById('snapshots-list');
const snaps = this._snapshotsData || [];
if (snaps.length === 0) {
container.innerHTML = '<div style="color:var(--text-faint);text-align:center;padding:2rem;grid-column:1/-1">No snapshots. Capture one above.</div>';
return;
}
container.innerHTML = '';
snaps.forEach(snap => {
const card = document.createElement('div');
card.className = 'session-card';
if (snap.is_current) card.classList.add('current');
const catBadge = snap.category ? ` <span class="badge">${this._escapeHTML(snap.category)}</span>` : '';
card.innerHTML =
`<div class="session-card-name">📸 ${this._escapeHTML(snap.name)}${catBadge}</div>` +
`<div class="session-card-actions">` +
`<button onclick="App.recallSnapshot('${this._escapeAttr(snap.name)}')">▶ Recall</button>` +
`<button class="danger" onclick="App.deleteSnapshot('${this._escapeAttr(snap.name)}')">🗑️</button>` +
`</div>`;
container.appendChild(card);
});
},
captureSnapshot() {
const nameInput = document.getElementById('snapshot-name-input');
const catInput = document.getElementById('snapshot-category-input');
const name = nameInput.value.trim();
if (!name) { this.log('Enter snapshot name', 'log-error'); return; }
fetch('/api/v1/snapshots/capture', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name,
category: catInput.value,
overwrite: true,
}),
})
.then(r => r.json())
.then(data => {
if (data.ok) {
this.log('Snapshot captured: ' + name, 'log-state');
nameInput.value = '';
this.refreshSnapshotsUI();
}
})
.catch(e => this.log('Capture failed: ' + e.message, 'log-error'));
},
recallSnapshot(name) {
fetch('/api/v1/snapshots/' + encodeURIComponent(name) + '/recall', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.ok) this.log('Snapshot recalled: ' + name, 'log-state');
})
.catch(e => this.log('Recall failed: ' + e.message, 'log-error'));
},
deleteSnapshot(name) {
if (!confirm('Delete snapshot "' + name + '"?')) return;
fetch('/api/v1/snapshots/' + encodeURIComponent(name), { method: 'DELETE' })
.then(r => r.json())
.then(data => {
if (data.ok) {
this.log('Deleted snapshot: ' + name, 'log-state');
this.refreshSnapshotsUI();
}
})
.catch(e => this.log('Delete failed: ' + e.message, 'log-error'));
},
snapshotNext() {
fetch('/api/v1/snapshots/next', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.ok) this.log('Next snapshot: ' + data.data.name, 'log-state');
})
.catch(e => this.log('Next failed: ' + e.message, 'log-error'));
},
snapshotPrev() {
fetch('/api/v1/snapshots/previous', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.ok) this.log('Previous snapshot: ' + data.data.name, 'log-state');
})
.catch(e => this.log('Prev failed: ' + e.message, 'log-error'));
},
// ── Auto-save ──────────────────────────────────────────────────────
refreshAutosaveStatus() {
fetch('/api/v1/sessions/autosave/status')
.then(r => r.json())
.then(data => {
const indicator = document.getElementById('autosave-indicator');
if (data.data && data.data.auto_save_enabled) {
indicator.textContent = '● Auto-save active';
indicator.style.color = 'var(--success-color)';
} else {
indicator.textContent = '○ Auto-save off';
indicator.style.color = 'var(--text-faint)';
}
})
.catch(() => {});
},
// ═════════════════════════════════════════════════════════════════════
// Sending
// ═════════════════════════════════════════════════════════════════════
+62 -3
View File
@@ -109,13 +109,72 @@
<!-- ── Sessions View ── -->
<section id="view-sessions" class="view">
<div class="sessions-header">
<h3>Saved Sessions</h3>
<h3>Mixer Sessions</h3>
<div class="sessions-actions">
<input type="text" id="scene-name-input" placeholder="Scene name" class="text-input">
<button class="btn btn-primary" onclick="App.saveScene()">💾 Save Current</button>
<input type="text" id="session-name-input" placeholder="Session name" class="text-input">
<input type="text" id="session-notes-input" placeholder="Notes (optional)" class="text-input" style="width:160px">
<button class="btn btn-primary" onclick="App.saveSession()">💾 Save Session</button>
<button class="btn" onclick="App.refreshSessions()">🔄 Refresh</button>
<select id="session-filter" onchange="App.refreshSessions()" style="width:100px">
<option value="all">All</option>
<option value="sessions">Sessions</option>
<option value="snapshots">Snapshots</option>
</select>
</div>
</div>
<!-- Auto-save status -->
<div id="autosave-status" class="autosave-status"><span id="autosave-indicator">● Auto-save active</span></div>
<!-- Sub-tabs for Sessions / Setlists / Snapshots -->
<div class="sessions-subtabs">
<button class="subtab active" id="subtab-sessions" onclick="App.showSessionsSubtab('sessions')">📁 Sessions</button>
<button class="subtab" id="subtab-setlists" onclick="App.showSessionsSubtab('setlists')">📋 Setlists</button>
<button class="subtab" id="subtab-snapshots" onclick="App.showSessionsSubtab('snapshots')">📸 Snapshots</button>
</div>
<!-- Sessions list -->
<div id="sessions-list" class="sessions-grid"></div>
<!-- Setlist view (hidden by default) -->
<div id="setlists-view" style="display:none">
<div class="sessions-actions" style="margin-bottom:0.5rem">
<input type="text" id="setlist-name-input" placeholder="Setlist name" class="text-input">
<button class="btn btn-primary" onclick="App.createSetlist()"> New Setlist</button>
</div>
<div id="setlists-list" class="sessions-grid"></div>
<div id="setlist-editor" style="display:none">
<h4>Setlist: <span id="setlist-editor-title"></span></h4>
<div id="setlist-entries"></div>
<div class="sessions-actions">
<input type="text" id="setlist-entry-session" placeholder="Session name" class="text-input">
<select id="setlist-entry-transition"><option value="cut">Cut</option><option value="crossfade">Crossfade</option><option value="wait">Wait</option></select>
<button class="btn" onclick="App.addSetlistEntry()"> Add Entry</button>
<button class="btn btn-primary" onclick="App.saveSetlist()">💾 Save Setlist</button>
<button class="btn" onclick="App.activateSetlist()">▶ Activate</button>
</div>
</div>
</div>
<!-- Snapshots view (hidden by default) -->
<div id="snapshots-view" style="display:none">
<div class="sessions-actions" style="margin-bottom:0.5rem">
<input type="text" id="snapshot-name-input" placeholder="Snapshot name" class="text-input">
<select id="snapshot-category-input" style="width:100px">
<option value="">No Category</option><option value="intro">Intro</option><option value="verse">Verse</option><option value="chorus">Chorus</option><option value="bridge">Bridge</option><option value="outro">Outro</option>
</select>
<button class="btn btn-primary" onclick="App.captureSnapshot()">📸 Capture</button>
<button class="btn" onclick="App.snapshotNext()">▶ Next</button>
<button class="btn" onclick="App.snapshotPrev()">◀ Prev</button>
</div>
<div id="snapshots-list" class="sessions-grid"></div>
</div>
<!-- Import/Export -->
<div class="sessions-actions" style="margin-top:1rem; border-top:1px solid var(--border-color); padding-top:0.5rem">
<button class="btn" onclick="App.importSession()">📥 Import</button>
<button class="btn" onclick="App.exportAllSessions()">📤 Export All</button>
<input type="file" id="session-import-file" accept=".json" style="display:none" onchange="App.handleImportFile(this)">
</div>
</section>
</main>
+83
View File
@@ -797,6 +797,89 @@ input, button, select, textarea {
.session-card-actions button.danger { color: var(--danger); }
.session-card-actions button.danger:hover { background: var(--danger); color: var(--text); }
/* Session card info line */
.session-card-info {
font-size: var(--font-size-xs);
color: var(--text-faint);
margin-top: 0.15rem;
}
/* Session card badges */
.badge {
display: inline-block;
padding: 0.1rem 0.3rem;
font-size: 0.55rem;
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 3px;
color: var(--text-dim);
vertical-align: middle;
}
.badge.active {
background: var(--connected);
color: var(--bg);
border-color: var(--connected);
}
/* Current snapshot card highlight */
.session-card.current {
border-color: var(--accent);
box-shadow: 0 0 4px var(--accent-dim);
}
/* Sessions subtabs */
.sessions-subtabs {
display: flex;
gap: 0px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.subtab {
padding: 0.35rem 0.75rem;
font-size: var(--font-size-xs);
background: none;
color: var(--text-dim);
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
min-height: var(--touch-min);
transition: color 0.15s, border-color 0.15s;
}
.subtab:hover { color: var(--text); }
.subtab.active { color: var(--accent); border-bottom-color: var(--accent); }
/* Auto-save status */
.autosave-status {
padding: 0.25rem 0.5rem;
font-size: var(--font-size-xs);
color: var(--text-faint);
flex-shrink: 0;
}
/* Setlist entry row */
.setlist-entry-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 0.3rem;
font-size: var(--font-size-sm);
}
.setlist-entry-row span { flex: 1; }
.setlist-entry-row button {
padding: 0.2rem 0.4rem;
background: var(--btn-bg);
color: var(--danger);
border: 1px solid var(--border);
border-radius: 3px;
cursor: pointer;
min-height: 28px;
font-size: var(--font-size-xs);
}
/* ── Modal (EQ) ────────────────────────────────────────────────────────── */
.modal {
position: fixed;