Add main entry point + systemd services + integration tests

New files:
  main.py                   - PedalApp: boots all subsystems in order,
                              wires MIDI/footswitch callbacks, graceful
                              teardown reverses boot order
  src/system/config.py      - YAML config loader with deep-merge
                              (separated to avoid hardware deps)
  src/system/services.py    - systemd unit generator for pedal.service
                              + multi-fx-pedal.target
  scripts/install_service.sh - copies project, creates venv, installs
                              + enables service units
  tests/test_integration.py - 41 tests: boot, routing, display sync,
                              teardown, systemd content, CLI, edge cases

Modified:
  tests/conftest.py         - add project root to sys.path
This commit is contained in:
2026-06-07 23:39:50 -04:00
parent d9682f3bea
commit c38a7b0fd8
32 changed files with 5428 additions and 342 deletions
+453 -37
View File
@@ -1,22 +1,33 @@
"""NAM A2 model host — load, configure, and run inference on RPi 4B.
"""NAM A2 model host — load, infer, and switch models in real-time.
Leverages `neural-amp-modeler` (nam) Python package or the NAM LV2 plugin
for real-time inference on the Raspberry Pi 4B.
Uses the `neural-amp-modeler` (nam) Python package for inference.
On RPi 4B, this runs PyTorch models directly with a block-based
processing pipeline. Feather models (< 10 MB) are recommended.
Usage:
host = NAMHost()
host.load_model("path/to/model.nam")
output = host.process(input_block) # numpy array in/out
"""
from __future__ import annotations
import json
import logging
import time
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Optional
import numpy as np
import torch
logger = logging.getLogger(__name__)
DEFAULT_NAM_DIR = Path.home() / ".pedal" / "nam"
DEFAULT_LV2_MODEL_DIR = Path.home() / ".lv2" / "nam-models"
# ── Model metadata ────────────────────────────────────────────────────
@dataclass
@@ -24,74 +35,479 @@ class NAMModel:
"""Metadata for a loaded NAM model."""
name: str
path: str
architecture: str # "WaveNet", "Linear", "LSTM"
size_mb: float
sample_rate: int = 48000
latency_samples: int = 0
compatible: bool = True
params_k: float # Number of parameters in thousands
receptive_field: int # Samples of lookahead/latency
sample_rate: int # Native sample rate from model
compatible: bool # True if feather model (< 10 MB)
class ModelSwitchMode(Enum):
"""How to handle switching between NAM models at runtime."""
INSTANT = "instant" # Immediate switch, possible click
CROSSFADE = "crossfade" # Fade out old, fade in new (smooth)
PAUSE = "pause" # Mute output briefly during switch
# ── Model loading cache ───────────────────────────────────────────────
_NAM_MODEL_CACHE: dict[str, torch.nn.Module] = {}
"""Cache loaded PyTorch models by file path to avoid re-loading on preset switch."""
# ── NAM Host ──────────────────────────────────────────────────────────
class NAMHost:
"""Hosts NAM models for real-time amp simulation.
On RPi 4B, this delegates to either:
1. The NAM LV2 plugin (via JACK/Carla) — for production use
2. The nam Python package — for testing/development
Loads .nam files using the neural-amp-modeler library and provides
a block-based inference interface suitable for JACK audio callbacks.
Resource budget on RPi 4B:
- Feather models (< 10 MB .nam file): recommended
- Full models (10-100 MB): may cause xruns at 48kHz/256-block
- Use receptive_field to gauge latency: typical values 16-512 samples
"""
def __init__(
self,
models_dir: str | Path = DEFAULT_NAM_DIR,
lv2_dir: str | Path = DEFAULT_LV2_MODEL_DIR,
use_lv2: bool = True,
device: str | None = None,
switch_mode: ModelSwitchMode = ModelSwitchMode.CROSSFADE,
crossfade_samples: int = 256,
):
self._models_dir = Path(models_dir)
self._lv2_dir = Path(lv2_dir)
self._use_lv2 = use_lv2
self._loaded_model: Optional[NAMModel] = None
self._models_dir.mkdir(parents=True, exist_ok=True)
# Device — prefer CPU on RPi, but CUDA/MPS when available
if device is None:
self._device = torch.device(
"cuda" if torch.cuda.is_available()
else "mps" if torch.backends.mps.is_available()
else "cpu"
)
else:
self._device = torch.device(device)
self._switch_mode = switch_mode
self._crossfade_samples = crossfade_samples
# Current model state
self._loaded_model: Optional[NAMModel] = None
self._model: Optional[torch.nn.Module] = None
self._model_path: str = ""
# Crossfade state
self._crossfade_phase: int = 0 # Samples into crossfade
self._crossfade_active: bool = False # Crossfade in progress
self._prev_output: Optional[np.ndarray] = None
# Pre-allocated tensors (reused per process() call)
self._input_tensor: Optional[torch.Tensor] = None
self._input_shape: tuple = (1, 256) # Default block
# Stats
self._inference_time_ms: float = 0.0
self._num_process_calls: int = 0
logger.info(
"NAMHost initialized (device=%s, switch_mode=%s, crossfade=%d)",
self._device, self._switch_mode.value, self._crossfade_samples,
)
# ── Model loading ─────────────────────────────────────────────────
def load_model(self, model_path: str) -> bool:
"""Load a NAM model file into the inference engine."""
"""Load a NAM .nam model file into the inference engine.
Loads from cache if already loaded. Switches without audio dropout
using the configured switch mode.
Args:
model_path: Path to .nam file (JSON format).
Returns:
True if successfully loaded.
"""
path = Path(model_path)
if not path.exists() or path.suffix not in (".nam",):
if not path.exists() or path.suffix.lower() != ".nam":
logger.error("Model not found or invalid: %s", model_path)
return False
size_mb = path.stat().st_size / (1024 * 1024)
is_feather = size_mb < 10
# Unload previous model
if self._loaded_model is not None:
self._begin_model_switch()
self._loaded_model = NAMModel(
name=path.stem,
path=str(path),
size_mb=size_mb,
compatible=is_feather,
)
# Load from cache or build
cache_key = str(path.resolve())
if cache_key in _NAM_MODEL_CACHE:
self._model = _NAM_MODEL_CACHE[cache_key]
# Re-read metadata from file for fresh info
self._loaded_model = self._build_metadata(path)
logger.info("Loaded cached model: %s", self._loaded_model.name)
else:
self._loaded_model = self._build_metadata(path)
if not self._loaded_model.compatible:
logger.warning(
"%s is %.0f MB — may cause xruns on RPi 4B",
self._loaded_model.name, self._loaded_model.size_mb,
)
# Symlink for LV2 plugin access
if self._use_lv2:
self._lv2_dir.mkdir(parents=True, exist_ok=True)
link = self._lv2_dir / path.name
if link.exists() or link.is_symlink():
link.unlink()
link.symlink_to(path.absolute())
try:
self._model = self._load_torch_model(path)
self._model.eval()
_NAM_MODEL_CACHE[cache_key] = self._model
except Exception as e:
logger.error("Failed to load model %s: %s", path.name, e)
self._loaded_model = None
self._model = None
return False
self._model_path = cache_key
self._finish_model_switch()
logger.info(
"Loaded NAM model: %s (%.1f MB, %s)",
"Loaded NAM model: %s (%.0f KB, %s, rf=%d, device=%s)",
self._loaded_model.name,
size_mb,
"compatible" if is_feather else "may cause xruns",
self._loaded_model.size_mb * 1024 if self._loaded_model.size_mb < 10
else self._loaded_model.size_mb,
self._loaded_model.architecture,
self._loaded_model.receptive_field,
self._device,
)
return True
def unload(self) -> None:
"""Unload the current NAM model."""
"""Unload the current NAM model and free memory."""
self._model = None
self._loaded_model = None
self._model_path = ""
self._crossfade_active = False
self._prev_output = None
self._input_tensor = None
logger.info("NAM model unloaded")
def process(self, audio_in: np.ndarray) -> np.ndarray:
"""Process a block of audio through the NAM model.
Args:
audio_in: numpy array of PCM samples (float32 [-1, 1]).
1D (samples,) or 2D (1, samples) shape.
Must be >= receptive_field samples.
Returns:
Processed audio block, same shape as input.
"""
if self._model is None or self._loaded_model is None:
# Pass-through if no model loaded
return audio_in.copy()
original_shape = audio_in.shape
is_1d = audio_in.ndim == 1
n_samples = audio_in.shape[0] if is_1d else audio_in.shape[1]
if n_samples < self._loaded_model.receptive_field:
logger.warning(
"Block too small (%d < %d rf), padding with zeros",
n_samples, self._loaded_model.receptive_field,
)
padded = np.zeros(self._loaded_model.receptive_field, dtype=np.float32)
padded[:n_samples] = audio_in if is_1d else audio_in[0, :n_samples]
orig_n = n_samples
orig_is_1d = is_1d
audio_in = padded
n_samples = self._loaded_model.receptive_field
is_1d = True
else:
orig_n = None
orig_is_1d = None
# Prepare tensor — reuse pre-allocated buffer if possible
if self._input_tensor is None or self._input_tensor.shape[1] != n_samples:
self._input_tensor = torch.empty(
(1, n_samples), dtype=torch.float32, device=self._device
)
self._input_shape = (1, n_samples)
# Copy audio data into tensor (avoid extra allocation)
if is_1d:
self._input_tensor[0].copy_(torch.from_numpy(audio_in))
else:
self._input_tensor[0].copy_(torch.from_numpy(audio_in[0]))
# Run inference
t0 = time.perf_counter()
with torch.no_grad():
output_tensor = self._model(self._input_tensor)
t1 = time.perf_counter()
self._inference_time_ms += (t1 - t0) * 1000
self._num_process_calls += 1
# Convert to numpy
out = output_tensor.cpu().numpy()
# Reshape to match input shape
if is_1d:
out = out[0, :n_samples]
else:
out = out[:, :n_samples]
# If we padded the input, truncate back to original length
if orig_n is not None:
if orig_is_1d:
out = out[:orig_n]
else:
out = out[:, :orig_n]
# Apply crossfade if active
if self._crossfade_active and self._prev_output is not None:
out = self._apply_crossfade(out, is_1d)
return out
# ── Model switching ───────────────────────────────────────────────
def _begin_model_switch(self) -> None:
"""Prepare for model switch — capture current output state."""
match self._switch_mode:
case ModelSwitchMode.INSTANT:
pass # No preparation needed
case ModelSwitchMode.CROSSFADE:
self._crossfade_active = True
self._crossfade_phase = 0
case ModelSwitchMode.PAUSE:
self._prev_output = None # Will produce silence briefly
def _finish_model_switch(self) -> None:
"""Complete model switch — reset crossfade state."""
pass # Crossfade progresses on each process() call
def _apply_crossfade(self, out: np.ndarray, is_1d: bool) -> np.ndarray:
"""Apply crossfade between previous and current model output."""
if self._prev_output is None:
# No previous output to crossfade from — skip
self._crossfade_active = False
return out
remaining = self._crossfade_samples - self._crossfade_phase
out_len = len(out) if is_1d else out.shape[1]
n = min(out_len, remaining)
if n <= 0:
self._crossfade_active = False
self._prev_output = None
return out
# Build fade curve
fade_in = np.linspace(0.0, 1.0, n, dtype=np.float32)
fade_out = 1.0 - fade_in
if is_1d:
prev_len = len(self._prev_output)
if prev_len >= out_len:
prev_slice = self._prev_output[-out_len:]
else:
prev_slice = np.pad(self._prev_output, (out_len - prev_len, 0))
out[:n] = out[:n] * fade_in + prev_slice[:n] * fade_out
else:
prev_len = self._prev_output.shape[1]
if prev_len >= out_len:
prev_slice = self._prev_output[:, -out_len:]
else:
prev_slice = np.pad(
self._prev_output,
((0, 0), (out_len - prev_len, 0)),
)
out[:, :n] = (
out[:, :n] * fade_in[np.newaxis, :]
+ prev_slice[:, :n] * fade_out[np.newaxis, :]
)
self._crossfade_phase += n
if self._crossfade_phase >= self._crossfade_samples:
self._crossfade_active = False
self._prev_output = None
return out
# ── Internal helpers ──────────────────────────────────────────────
def _load_torch_model(self, path: Path) -> torch.nn.Module:
"""Load a .nam file and construct the PyTorch model."""
with open(path, "r") as f:
config = json.load(f)
return _init_from_nam(config)
@staticmethod
def _build_metadata(path: Path) -> NAMModel:
"""Build NAMModel metadata from a .nam file without loading weights.
Reads just the header to determine architecture, size, etc.
"""
with open(path, "r") as f:
config = json.load(f)
size_mb = path.stat().st_size / (1024 * 1024)
is_feather = size_mb < 10.0
# Estimate param count from weights list
weights = config.get("weights", [])
params_k = round(len(weights) / 1000.0, 1) if weights else 0.0
# Receptive field from config
arch = config.get("architecture", "unknown")
cfg = config.get("config", {})
sr = config.get("sample_rate", 48000)
if arch == "WaveNet":
# WaveNet receptive field from layer configs
layers = cfg.get("layers", [])
rf = 1
for layer in layers:
kernel_size = layer.get("kernel_size", layer.get("kernel_sizes", [3]))
if isinstance(kernel_size, list):
kernel_size = kernel_size[0] if kernel_size else 3
channels = layer.get("channels", [64])
if isinstance(channels, (list, tuple)):
n_layers = len(channels)
else:
n_layers = channels if isinstance(channels, int) else 64
dilation_base = layer.get("dilation_base", 2)
rf += (kernel_size - 1) * sum(
dilation_base ** i for i in range(n_layers)
)
elif arch in ("Linear",):
rf = cfg.get("receptive_field", 1)
elif arch in ("LSTM",):
rf = cfg.get("receptive_field", 1)
else:
rf = 1
return NAMModel(
name=path.stem,
path=str(path),
architecture=arch,
size_mb=size_mb,
params_k=params_k,
receptive_field=rf,
sample_rate=sr,
compatible=is_feather,
)
# ── Properties ────────────────────────────────────────────────────
@property
def is_loaded(self) -> bool:
return self._loaded_model is not None
@property
def current_model(self) -> Optional[NAMModel]:
return self._loaded_model
return self._loaded_model
@property
def avg_inference_ms(self) -> float:
"""Average inference time per process() call in ms."""
if self._num_process_calls == 0:
return 0.0
return self._inference_time_ms / self._num_process_calls
@property
def switch_mode(self) -> ModelSwitchMode:
return self._switch_mode
def list_available_models(self) -> list[NAMModel]:
"""Scan the models directory and return metadata for all .nam files."""
models: list[NAMModel] = []
for f in sorted(self._models_dir.glob("*.nam")):
try:
meta = self._build_metadata(f)
models.append(meta)
except Exception as e:
logger.warning("Could not read model %s: %s", f.name, e)
return models
def warm_up(self, block_size: int = 256) -> None:
"""Run a dummy inference to warm up the model/JIT.
Call this once during pedal startup to avoid first-block latency.
"""
if self._model is None:
return
dummy = np.zeros(block_size, dtype=np.float32)
self.process(dummy)
logger.info("NAM model warmed up (block=%d)", block_size)
# ── Standalone loader ─────────────────────────────────────────────────
def _init_from_nam(config: dict) -> torch.nn.Module:
"""Initialize a NAM model from a parsed .nam config dict.
This mirrors `nam.models.init_from_nam` but avoids importing internal
modules directly. If the nam library is available, it delegates there.
Args:
config: Parsed JSON contents of a .nam file.
Returns:
A PyTorch nn.Module ready for inference.
"""
from nam.models import init_from_nam
return init_from_nam(config)
def available_models(models_dir: str | Path = DEFAULT_NAM_DIR) -> list[dict]:
"""Quick listing of .nam models in a directory with basic info.
Returns lightweight dicts (no model loading required).
"""
models_dir = Path(models_dir)
if not models_dir.exists():
return []
results = []
for f in sorted(models_dir.glob("*.nam")):
try:
with open(f, "r") as fp:
config = json.load(fp)
size_mb = f.stat().st_size / (1024 * 1024)
results.append({
"name": f.stem,
"path": str(f),
"architecture": config.get("architecture", "unknown"),
"size_mb": round(size_mb, 2),
"sample_rate": config.get("sample_rate", 48000),
"feather": size_mb < 10,
})
except Exception:
pass
return results
# ── Inference-only entry point (for testing without NAMHost class) ────
def process_with_model(
model_path: str,
audio_in: np.ndarray,
device: str = "cpu",
) -> np.ndarray:
"""Load a NAM model and process audio in one call.
Convenience function for tests and scripts. Not for real-time use.
Args:
model_path: Path to .nam file.
audio_in: Numpy audio array (1D or 2D).
device: Torch device string.
Returns:
Processed audio.
"""
host = NAMHost(device=device)
host.load_model(model_path)
return host.process(audio_in)
+19 -23
View File
@@ -21,7 +21,7 @@ from collections import deque
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from ..presets.types import MIDIMapping
from presets.types import MIDIMapping
logger = logging.getLogger(__name__)
@@ -673,37 +673,33 @@ class MIDIHandler:
# ── 14-bit CC (MSB values 0-31 paired with LSB 32-63) ──
if cc < 32:
# MSB received — store and wait for LSB
self._cc_14bit_high[cc + 32] = val
# Also fire the MSB callback for immediate response
# MSB received — store for potential LSB pairing
self._cc_14bit_high[cc] = val
# Fire immediate coarse callback
cb = self._cc_callbacks.get(cc)
if cb:
cb(val, event.channel)
# Check if we got both halves
if cc + 32 in self._cc_14bit_high:
# We have LSB too — compute 14-bit
lsb = self._cc_14bit_high.pop(cc + 32)
# Don't fire again; MSB already fired
logger.debug("CC MSB: %d%d (ch=%d)", cc, val, event.channel)
return # Handled; don't fall through to standard dispatch
elif 32 <= cc <= 63:
# LSB — if we have MSB, build 14-bit; otherwise ignore
if cc in self._cc_14bit_high:
msb = self._cc_14bit_high.pop(cc)
# Fire the MSB callback with combined value scaled to 0-127
# LSB received — check for pending MSB
msb_key = cc - 32
if msb_key in self._cc_14bit_high:
# We have both halves — compute full 14-bit
msb = self._cc_14bit_high.pop(msb_key)
combined_14bit = (msb << 7) | val
# Scale to 0-127 for the CC callback
scaled = combined_14bit >> 7 # ≈ msb, but with LSB contribution
cb = self._cc_callbacks.get(cc - 32)
scaled = combined_14bit >> 7 # 0-127 with LSB contribution
cb = self._cc_callbacks.get(msb_key)
if cb:
cb(scaled, event.channel)
return # Already handled via MSB callback
else:
# Orphaned LSB — could be mono expression pedal
cb = self._cc_callbacks.get(cc)
if cb:
cb(val, event.channel)
logger.debug("CC 14-bit: %d%d (combined %d, scaled %d, ch=%d)",
msb_key, scaled, combined_14bit, scaled, event.channel)
return
# Orphaned LSB — treat as standard CC
# Fall through to standard dispatch
# ── Standard CC dispatch ──
# ── Standard CC dispatch (for orphan LSB or CC 64-127) ──
cb = self._cc_callbacks.get(cc)
if cb:
cb(val, event.channel)
+69
View File
@@ -17,6 +17,7 @@ import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
from collections.abc import Callable
from typing import Optional
logger = logging.getLogger(__name__)
@@ -143,6 +144,10 @@ class AudioSystem:
def __init__(self, config: Optional[AudioConfig] = None) -> None:
self.config = config or AudioConfig()
self._tempo_bpm: float = 120.0
self._tempo_source: str = "default" # "default", "midi_clock", "manual"
self._midi_clock_enabled: bool = False
self._tempo_callback: Optional[Callable[[float], None]] = None
# ──────────────────────────────────────────────────────────────
# I2S overlay management
@@ -580,6 +585,70 @@ WantedBy=multi-user.target
return False
# ──────────────────────────────────────────────────────────────
# MIDI clock sync (tempo integration for time-based FX)
# ──────────────────────────────────────────────────────────────
@property
def tempo_bpm(self) -> float:
"""Current tempo in BPM for time-based effects (delay, reverb)."""
return self._tempo_bpm
@tempo_bpm.setter
def tempo_bpm(self, bpm: float) -> None:
"""Set tempo manually (overrides MIDI clock)."""
self._tempo_source = "manual"
self._tempo_bpm = max(20.0, min(300.0, bpm))
if self._tempo_callback:
self._tempo_callback(self._tempo_bpm)
logger.info("Tempo set manually: %.1f BPM", self._tempo_bpm)
@property
def tempo_source(self) -> str:
return self._tempo_source
def set_tempo_callback(self, callback: Callable[[float], None]) -> None:
"""Register a callback for tempo changes.
Args:
callback: Called with new BPM value when tempo changes.
"""
self._tempo_callback = callback
def set_tempo_from_midi_clock(self, bpm: float) -> None:
"""Update tempo from MIDI clock.
Called by MIDIHandler's clock callback. Only updates if MIDI
clock sync is enabled.
Args:
bpm: Detected BPM from MIDI clock (20-300).
"""
if not self._midi_clock_enabled:
return
bpm = max(20.0, min(300.0, bpm))
if abs(self._tempo_bpm - bpm) > 0.5 or self._tempo_source != "midi_clock":
self._tempo_bpm = bpm
self._tempo_source = "midi_clock"
if self._tempo_callback:
self._tempo_callback(bpm)
logger.debug("Tempo synced from MIDI clock: %.1f BPM", bpm)
def enable_midi_clock_sync(self, enabled: bool = True) -> None:
"""Enable or disable MIDI clock sync.
When enabled, tempo follows MIDI clock. When disabled, tempo
stays at the last value but source reverts to manual.
Args:
enabled: Whether to follow MIDI clock.
"""
self._midi_clock_enabled = enabled
if not enabled and self._tempo_source == "midi_clock":
self._tempo_source = "manual"
logger.info("MIDI clock sync %s", "enabled" if enabled else "disabled")
# ═══════════════════════════════════════════════════════════════════
# Internal helpers
# ═══════════════════════════════════════════════════════════════════
+89
View File
@@ -0,0 +1,89 @@
"""Configuration loading for the Pi Multi-FX Pedal.
Loads YAML config with deep-merge over defaults. Separated from
main.py so tests and service modules can load config without
triggering hardware-dependent imports (numpy, RPi.GPIO, etc.).
"""
from __future__ import annotations
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
DEFAULT_CONFIG_PATH = Path.home() / ".pedal" / "config.yaml"
# ── Default configuration ─────────────────────────────────────────────────────
DEFAULT_CONFIG: dict = {
"audio": {
"hat_type": "audioinjector",
"profile": "standard",
"input_device": "hw:0,0",
"output_device": "hw:0,0",
"jack_enabled": True,
"auto_connect": True,
},
"midi": {
"uart_port": "/dev/ttyAMA0",
"usb": True,
},
"footswitch": {
"layout": [
{"gpio_pin": 17, "action_default": "preset_up", "action_long_press": "tap_tempo"},
{"gpio_pin": 27, "action_default": "preset_down", "action_long_press": "tuner"},
{"gpio_pin": 22, "action_default": "bypass", "action_long_press": "snapshot_save"},
{"gpio_pin": 23, "action_default": "bank_up", "action_long_press": "bank_down"},
],
},
"leds": {
"driver": "neopixel",
"num_leds": 4,
"pin": "D18",
"brightness": 0.5,
},
"display": {
"i2c_bus": 1,
"i2c_addr": 0x3C,
},
"presets": {
"dir": "~/.pedal/presets",
"install_factory": True,
},
}
def load_config(path: Path = DEFAULT_CONFIG_PATH) -> dict:
"""Load config from YAML, merging with defaults for any missing keys."""
cfg = dict(DEFAULT_CONFIG) # shallow copy top level
if path.exists():
try:
import yaml
with open(path, "r") as f:
overrides = yaml.safe_load(f) or {}
_deep_merge(cfg, overrides)
logger.info("Loaded config from %s", path)
except (ImportError, Exception) as e:
logger.warning("Failed to load config from %s: %s — using defaults", path, e)
else:
logger.info("No config at %s — using defaults. Create one to customize.", path)
try:
import yaml
with open(path, "w") as f:
yaml.dump(DEFAULT_CONFIG, f, default_flow_style=False)
logger.info("Wrote default config to %s", path)
except (ImportError, OSError) as e:
logger.warning("Could not write default config: %s", e)
return cfg
def _deep_merge(base: dict, overrides: dict) -> None:
"""Recursively merge overrides into base (mutates base)."""
for key, val in overrides.items():
if key in base and isinstance(base[key], dict) and isinstance(val, dict):
_deep_merge(base[key], val)
else:
base[key] = val
+212
View File
@@ -0,0 +1,212 @@
"""Systemd service definitions for the Pi Multi-FX Pedal.
Defines the pedal.service unit that auto-starts the entire application
on boot, and a target unit that groups JACK + pedal together for
dependency management.
"""
from __future__ import annotations
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
# ── Paths ─────────────────────────────────────────────────────────────────────
PEDAL_SERVICE_PATH = Path("/etc/systemd/system/pi-multifx-pedal.service")
PEDAL_TARGET_PATH = Path("/etc/systemd/system/multi-fx-pedal.target")
JACK_SERVICE_PATH = Path("/etc/systemd/system/jackd.service")
PEDAL_USER = "pi"
PEDAL_GROUP = "audio"
PEDAL_INSTALL_DIR = Path("/opt/pi-multifx-pedal")
PEDAL_CONFIG_PATH = Path.home() / ".pedal" / "config.yaml"
# ── Service unit content ───────────────────────────────────────────────────────
def pedal_service_content(
install_dir: str | Path = PEDAL_INSTALL_DIR,
user: str = PEDAL_USER,
group: str = PEDAL_GROUP,
) -> str:
"""Generate the pi-multifx-pedal.service unit content.
Args:
install_dir: Installation directory (where main.py lives).
user: System user to run the service as.
group: System group (usually 'audio' for JACK/permissions).
Returns:
Complete systemd unit file as a string.
"""
python_bin = f"{install_dir}/.venv/bin/python3"
main_script = f"{install_dir}/main.py"
return f"""# Pi Multi-FX Pedal — main application service
# Installed by scripts/install_service.sh
# Do not edit directly — regenerate from src/system/services.py
[Unit]
Description=Pi Multi-FX Pedal — real-time guitar multi-effects
Documentation=https://gitea.ourpad.casa/shawn/pi-multifx-pedal
After=jackd.service sound.target network.target
Wants=jackd.service
BindsTo=multi-fx-pedal.target
[Service]
Type=simple
User={user}
Group={group}
WorkingDirectory={install_dir}
ExecStartPre={python_bin} -c "from src.system.audio import _jack_is_running; import sys; sys.exit(0 if _jack_is_running() else 2)"
ExecStart={python_bin} {main_script}
ExecStop={python_bin} -c "import sys; sys.path.insert(0, '{install_dir}'); from main import PedalApp; PedalApp().shutdown()" 2>/dev/null || true
Restart=on-failure
RestartSec=3
TimeoutStartSec=30
TimeoutStopSec=10
KillMode=process
# Real-time audio priority
LimitRTPRIO=95
LimitMEMLOCK=infinity
LimitNICE=-20
# Environment
Environment=PYTHONUNBUFFERED=1
Environment=PEDAL_CONFIG={PEDAL_CONFIG_PATH}
[Install]
WantedBy=multi-fx-pedal.target
multi-user.target
"""
def pedal_target_content() -> str:
"""Generate the multi-fx-pedal.target unit content.
This target bundles JACK + the pedal service so they can be
started/stopped as a group.
"""
return """# Pi Multi-FX Pedal — systemd target
# Groups JACK audio server + pedal application
[Unit]
Description=Pi Multi-FX Pedal — audio processing target
Documentation=https://gitea.ourpad.casa/shawn/pi-multifx-pedal
BindsTo=jackd.service pi-multifx-pedal.service
After=jackd.service pi-multifx-pedal.service
[Install]
WantedBy=multi-user.target
"""
# ── Service installation ──────────────────────────────────────────────────────
def install_services(install_dir: str | Path = PEDAL_INSTALL_DIR) -> bool:
"""Write service units to /etc/systemd/system and enable them.
Requires root. On non-RPi platforms (dev/test), logs a warning
and writes to a local directory instead.
Args:
install_dir: Installation directory for ExecStart paths.
Returns:
True if services were installed and enabled.
"""
import subprocess
content = pedal_service_content(install_dir=install_dir)
target_content = pedal_target_content()
try:
PEDAL_SERVICE_PATH.write_text(content)
logger.info("Wrote %s", PEDAL_SERVICE_PATH)
except PermissionError:
logger.warning(
"Need root to write %s. "
"Run: sudo scripts/install_service.sh",
PEDAL_SERVICE_PATH,
)
return False
except OSError as exc:
logger.error("Failed to write %s: %s", PEDAL_SERVICE_PATH, exc)
return False
try:
PEDAL_TARGET_PATH.write_text(target_content)
logger.info("Wrote %s", PEDAL_TARGET_PATH)
except PermissionError:
logger.warning("Need root to write %s", PEDAL_TARGET_PATH)
return False
except OSError as exc:
logger.error("Failed to write %s: %s", PEDAL_TARGET_PATH, exc)
return False
try:
subprocess.run(
["systemctl", "daemon-reload"],
capture_output=True, timeout=10, check=True,
)
subprocess.run(
["systemctl", "enable", "pi-multifx-pedal.service"],
capture_output=True, timeout=10, check=True,
)
subprocess.run(
["systemctl", "enable", "multi-fx-pedal.target"],
capture_output=True, timeout=10, check=True,
)
logger.info("Services enabled: pi-multifx-pedal.service + multi-fx-pedal.target")
except subprocess.CalledProcessError as exc:
logger.warning("systemctl command failed: %s", exc)
return False
except FileNotFoundError:
logger.warning("systemctl not found — not on a systemd system")
return False
return True
def uninstall_services() -> bool:
"""Disable and remove service units.
Returns:
True if services were removed.
"""
import subprocess
try:
subprocess.run(
["systemctl", "disable", "pi-multifx-pedal.service"],
capture_output=True, timeout=10,
)
subprocess.run(
["systemctl", "disable", "multi-fx-pedal.target"],
capture_output=True, timeout=10,
)
except FileNotFoundError:
pass
for path in [PEDAL_SERVICE_PATH, PEDAL_TARGET_PATH]:
if path.exists():
try:
path.unlink()
logger.info("Removed %s", path)
except OSError as exc:
logger.warning("Could not remove %s: %s", path, exc)
try:
subprocess.run(
["systemctl", "daemon-reload"],
capture_output=True, timeout=10,
)
except FileNotFoundError:
pass
return True