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:
+453
-37
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user