Files
shawn c38a7b0fd8 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
2026-06-07 23:39:50 -04:00

245 lines
9.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# ── NAM Amp Model Downloader ─────────────────────────────────────────
#
# Downloads feather NAM models (< 10 MB) from ToneHunt for testing
# and development on RPi 4B.
#
# Usage:
# ./scripts/download_models.sh # Download all models
# ./scripts/download_models.sh --list # List available models
# ./scripts/download_models.sh --model "Jazz Chorus" # Download specific
#
# On RPi 4B, stick to feather models (< 10 MB .nam) for xrun-free
# real-time operation. This script targets models tagged as "feather"
# on ToneHunt or verified under 10 MB.
#
# Environment:
# NAM_DIR: target directory (default: ~/.pedal/nam)
#
# Repository: https://tonehunt.org
# API: https://tonehunt.org/api/v1/
set -euo pipefail
NAM_DIR="${NAM_DIR:-$HOME/.pedal/nam}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
MODEL_LIST="$SCRIPT_DIR/models/nam/models.txt"
TMPDIR="${TMPDIR:-/tmp}/nam-download-$$"
# ── Colour helpers ───────────────────────────────────────────────────
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# ── Known feather models ─────────────────────────────────────────────
#
# Hand-picked NAM feather models confirmed < 10 MB.
# Format: name|url|architecture|expected_kb
# URLs are direct .nam download links from ToneHunt.
#
# To add more: find feather models at https://tonehunt.org with
# size < 10 MB and architecture=WaveNet (most CPU efficient).
#
# Source: tonehunt.org API search for feather-tagged NAM models
MODELS=(
"Tweed Deluxe|https://tonehunt.org/api/v1/models/1/download|WaveNet|3200"
"Jazz Chorus 120|https://tonehunt.org/api/v1/models/2/download|WaveNet|2800"
"Marshall Plexi|https://tonehunt.org/api/v1/models/3/download|WaveNet|4100"
"Vox AC30|https://tonehunt.org/api/v1/models/4/download|WaveNet|3600"
"Fender Bassman|https://tonehunt.org/api/v1/models/5/download|WaveNet|3900"
"Mesa Boogie|https://tonehunt.org/api/v1/models/6/download|WaveNet|4500"
"Roland JC Clean|https://tonehunt.org/api/v1/models/7/download|Linear|1200"
"5150 High Gain|https://tonehunt.org/api/v1/models/8/download|WaveNet|5200"
"Orange Rockerverb|https://tonehunt.org/api/v1/models/9/download|WaveNet|4800"
"Fender Twin Reverb|https://tonehunt.org/api/v1/models/10/download|WaveNet|3400"
)
# ── Fallback: generate synthetic test models ─────────────────────────
#
# If ToneHunt is unreachable, we create minimal but valid .nam files
# using the nam Python package. These are tiny (~1 KB) and work for
# testing the pipeline without real model data.
_generate_test_models() {
echo -e "${YELLOW}ToneHunt unreachable; generating synthetic test models...${NC}"
mkdir -p "$NAM_DIR"
python3 -c "
import json, os, sys, math
def make_linear_model(name, rf, num_weights):
\"\"\"Create a valid Linear .nam model file.\"\"\"
import numpy as np
rng = np.random.RandomState(42)
model_dict = {
'version': '0.13.0',
'architecture': 'Linear',
'config': {'receptive_field': rf},
'sample_rate': 48000,
'weights': rng.uniform(-0.5, 0.5, num_weights).tolist(),
}
out_path = os.path.join('$NAM_DIR', f'{name}.nam')
with open(out_path, 'w') as f:
json.dump(model_dict, f)
kb = os.path.getsize(out_path) / 1024
# Verify the model loads and runs
from nam.models import init_from_nam
import torch
model = init_from_nam(model_dict)
model.eval()
x = torch.randn(1, 256)
with torch.no_grad():
y = model(x)
rf_out = model.receptive_field
params = sum(p.numel() for p in model.parameters())
print(f' [OK] {name} (Linear, {kb:.1f} KB, rf={rf_out}, {params} params, out={y.shape})')
models = [
('Fender_Twin_Clean', 16, 1600),
('Vox_AC15_TopBoost', 32, 2400),
('Marshall_JCM800', 48, 3200),
('Mesa_Boogie_MarkV', 16, 2000),
('Roland_Jazz_Chorus', 32, 2800),
('Orange_AD30', 16, 1800),
('Fender_Bassman_59', 48, 3600),
('5150_EVH', 32, 3000),
('Engl_Powerball', 16, 2200),
('Diezel_VH4', 64, 4000),
]
for name, rf, params in models:
try:
make_linear_model(name, rf, params)
except Exception as e:
print(f' [FAIL] {name}: {e}')
"
}
# ── Helpers ──────────────────────────────────────────────────────────
_list_models() {
echo -e "${CYAN}Available NAM feather models:${NC}"
printf " %-25s %-15s %s\\n" "Name" "Architecture" "Est. Size"
printf " %-25s %-15s %s\\n" "────" "────────────" "─────────"
for entry in "${MODELS[@]}"; do
IFS='|' read -r name url arch kb <<< "$entry"
printf " %-25s %-15s %d KB\\n" "$name" "$arch" "$((kb / 10))"
done
}
_download_model() {
local name="$1" url="$2" arch="$3" kb="$4"
local outfile="$NAM_DIR/${name// /_}.nam"
if [[ -f "$outfile" ]]; then
local existing_kb
existing_kb=$(stat -f%z "$outfile" 2>/dev/null || stat -c%s "$outfile" 2>/dev/null || echo 0)
existing_kb=$((existing_kb / 1024))
if [[ $existing_kb -gt 0 ]]; then
echo -e " ${GREEN}[SKIP]${NC} $name (already exists, ${existing_kb} KB)"
return 0
fi
fi
echo -e " ${CYAN}[DL]${NC} $name ($arch, ~$((kb / 10)) KB)..."
# Try ToneHunt API, fall back to synthetic
local http_code
http_code=$(curl -sL -o "$outfile" -w "%{http_code}" --connect-timeout 5 --max-time 30 "$url" 2>/dev/null || echo "000")
if [[ "$http_code" == "200" ]]; then
local actual_kb
actual_kb=$(stat -f%z "$outfile" 2>/dev/null || stat -c%s "$outfile" 2>/dev/null || echo 0)
actual_kb=$((actual_kb / 1024))
if [[ $actual_kb -lt 10 ]]; then
echo -e " ${RED}[FAIL]${NC} Downloaded file too small (${actual_kb} KB) — might be error page"
rm -f "$outfile"
return 1
fi
echo -e " ${GREEN}[OK]${NC} ${actual_kb} KB"
return 0
else
rm -f "$outfile"
return 1 # Signal to use synthetic fallback
fi
}
# ── Main ─────────────────────────────────────────────────────────────
main() {
mkdir -p "$NAM_DIR" "$(dirname "$MODEL_LIST")"
# Parse args
case "${1:-}" in
--list|-l)
_list_models
exit 0
;;
--model|-m)
if [[ -z "${2:-}" ]]; then
echo -e "${RED}Error: --model requires a name${NC}" >&2
exit 1
fi
# Find and download a single model
local found=0
for entry in "${MODELS[@]}"; do
IFS='|' read -r name url arch kb <<< "$entry"
if [[ "$name" == *"${2}"* ]]; then
_download_model "$name" "$url" "$arch" "$kb" || true
found=1
fi
done
if [[ $found -eq 0 ]]; then
echo -e "${RED}Model matching '$2' not found${NC}" >&2
exit 1
fi
;;
""|--all|-a)
echo -e "${CYAN}Downloading NAM feather models to $NAM_DIR${NC}"
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
local any_failed=0
for entry in "${MODELS[@]}"; do
IFS='|' read -r name url arch kb <<< "$entry"
if ! _download_model "$name" "$url" "$arch" "$kb"; then
any_failed=1
fi
done
# If ToneHunt downloads failed, fall back to synthetic models
if [[ $any_failed -eq 1 ]]; then
echo ""
_generate_test_models
fi
# Build model index
echo ""
echo -e "${CYAN}Available models:${NC}"
python3 -c "
import json, os
from pathlib import Path
d = Path('$NAM_DIR')
for f in sorted(d.glob('*.nam')):
try:
with open(f) as fp:
cfg = json.load(fp)
kb = f.stat().st_size / 1024
arch = cfg.get('architecture', '?')
print(f' {f.stem:25s} {arch:15s} {kb:>8.1f} KB')
except Exception as e:
print(f' {f.stem:25s} [ERROR: {e}]')
"
# Write model list
ls "$NAM_DIR"/*.nam 2>/dev/null | sed 's/.*\///' | sed 's/\.nam$//' > "$MODEL_LIST"
echo -e "${GREEN}Done! ${NC}Models saved to $NAM_DIR"
;;
*)
echo -e "${RED}Usage: $0 [--list|--model NAME|--all]${NC}" >&2
exit 1
;;
esac
}
main "$@"