- 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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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})")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)})"
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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, 2500–4000 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, 2500–4000 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()
|
||||
]
|
||||
@@ -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
|
||||
@@ -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 = ""):
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user