feat(audio): Focusrite 2i2 stereo JACK config for 4CM

Adds:
- AudioConfig.mode field: 'mono' (default) or 'stereo_4cm'
- capture_channels / playback_channels properties for dynamic JACK -i/-o counts
- FOCUSRITE_PROFILES dict with focusrite_2i2_3gen entry
- AudioSystem.channel_mapping_help() static method
- stereo_4cm port auto-connect wiring (4 cable method)
- Systemd service content uses dynamic channel counts
- Default config YAML includes 'mode: mono'
- 11 new tests (31 total pass)
- Focusrite 4CM wiring docs in docs/config-audio.md
This commit is contained in:
2026-06-08 10:40:38 -04:00
parent 80011274f2
commit c2071a9724
5 changed files with 234 additions and 6 deletions
+2 -1
View File
@@ -4,9 +4,10 @@
- setup (WIP): First-boot setup scripts
"""
from .audio import AudioConfig, AudioSystem
from .audio import AudioConfig, AudioSystem, FOCUSRITE_PROFILES
__all__ = [
"AudioConfig",
"AudioSystem",
"FOCUSRITE_PROFILES",
]
+98 -4
View File
@@ -53,6 +53,16 @@ I2S_CONFIGS: dict[str, tuple[str, int, str]] = {
),
}
# ── USB audio interface profiles (non-I2S, e.g. Focusrite) ────────
# Map: short key → (description, expected capture ports, expected playback ports)
FOCUSRITE_PROFILES: dict[str, tuple[str, int, int]] = {
"focusrite_2i2_3gen": (
"Focusrite Scarlett 2i2 3rd gen — USB, 2-in / 2-out, 48 kHz, 24-bit",
2,
2,
),
}
# ── JACK latency profiles ─────────────────────────────────────────
# 48 kHz / 128 frames = 2.67 ms buffer → well under 10 ms RT
# 48 kHz / 64 frames = 1.33 ms buffer → aggressive, may xrun on RPi 4B
@@ -89,6 +99,8 @@ class AudioConfig:
Attributes:
hat_type: I2S HAT type key from I2S_CONFIGS.
profile: Latency profile key from LATENCY_PROFILES.
mode: I/O routing mode: "mono" (default, hw:0,0) or
"stereo_4cm" (Focusrite 2i2 4-cable-method, 2ch I/O).
input_device: ALSA hardware device for capture (e.g. hw:0,0).
output_device: ALSA hardware device for playback (e.g. hw:0,0).
jack_enabled: Whether to start the JACK server.
@@ -98,6 +110,7 @@ class AudioConfig:
hat_type: str = "audioinjector"
profile: str = "standard"
mode: str = "mono"
input_device: str = "hw:0,0"
output_device: str = "hw:0,0"
jack_enabled: bool = True
@@ -127,6 +140,21 @@ class AudioConfig:
"""JACK ALSA device argument (output device drives JACK master)."""
return f"d{self.output_device}"
@property
def capture_channels(self) -> int:
"""Number of JACK capture channels (1 for mono, 2 for stereo_4cm)."""
return 2 if self.mode == "stereo_4cm" else 1
@property
def playback_channels(self) -> int:
"""Number of JACK playback channels (1 for mono, 2 for stereo_4cm)."""
return 2 if self.mode == "stereo_4cm" else 1
@property
def focusrite_profile(self) -> Optional[tuple[str, int, int]]:
"""Resolve Focusrite profile from hat_type, or None if not a Focusrite."""
return FOCUSRITE_PROFILES.get(self.hat_type)
# ═══════════════════════════════════════════════════════════════════
# Audio system manager
@@ -246,7 +274,8 @@ class AudioSystem:
f"-r{profile['rate']}",
"-dalsa",
f"-d{self.config.output_device}",
f"-i2", f"-o2",
f"-i{self.config.capture_channels}",
f"-o{self.config.playback_channels}",
]
logger.info("Starting JACK: %s", " ".join(cmd))
@@ -303,6 +332,52 @@ class AudioSystem:
time.sleep(0.5)
return self.start_jack(timeout=timeout)
# ──────────────────────────────────────────────────────────────
# Channel mapping helpers
# ──────────────────────────────────────────────────────────────
@staticmethod
def channel_mapping_help(mode: str = "mono") -> list[dict]:
"""Describe the JACK port-to-pipeline channel routing.
Args:
mode: ``"mono"`` or ``"stereo_4cm"``.
Returns:
List of dicts with keys: capture_port, pipeline_input, pipeline_output,
playback_port, description.
Raises:
ValueError: If *mode* is not recognised.
"""
if mode == "mono":
return [{
"capture_port": "system:capture_1",
"pipeline_input": "fx_in:input_0",
"pipeline_output": "fx_out:output_0",
"playback_port": "system:playback_1",
"description": "Guitar → FX chain → Output",
}]
elif mode == "stereo_4cm":
return [
{
"capture_port": "system:capture_1",
"pipeline_input": "fx_in:input_0",
"pipeline_output": "fx_out:output_0",
"playback_port": "system:playback_1",
"description": "Guitar (Input 1) → Pre-amp FX chain → Amp Input (Send)",
},
{
"capture_port": "system:capture_2",
"pipeline_input": "fx_in:input_1",
"pipeline_output": "fx_out:output_1",
"playback_port": "system:playback_2",
"description": "FX Send (Input 2) → Post-amp FX chain → Amp FX Return",
},
]
else:
raise ValueError(f"Unknown mode: {mode!r}")
# ──────────────────────────────────────────────────────────────
# JACK port connections
# ──────────────────────────────────────────────────────────────
@@ -310,17 +385,35 @@ class AudioSystem:
def connect_fx_ports(self) -> None:
"""Connect JACK ports for the FX pipeline.
Default wiring:
Mono (default):
system:capture_1 → fx_in:input_0 (guitar → FX chain)
fx_out:output_0 → system:playback_1 (FX chain → output)
Stereo 4CM (Focusrite 2i2):
system:capture_1 → fx_in:input_0 (guitar → pre-amp path)
system:capture_2 → fx_in:input_1 (FX send → post-amp path)
fx_out:output_0 → system:playback_1 (pre-amp → amp input)
fx_out:output_1 → system:playback_2 (post-amp → FX return)
This is a no-op if jack_connect is unavailable (not in PATH)
or if any of the target ports don't exist yet.
"""
connections = [
mono_connections = [
("system:capture_1", "fx_in:input_0"),
("fx_out:output_0", "system:playback_1"),
]
stereo_4cm_connections = [
("system:capture_1", "fx_in:input_0"),
("system:capture_2", "fx_in:input_1"),
("fx_out:output_0", "system:playback_1"),
("fx_out:output_1", "system:playback_2"),
]
connections = (
stereo_4cm_connections
if self.config.mode == "stereo_4cm"
else mono_connections
)
for src, dst in connections:
try:
subprocess.run(
@@ -535,7 +628,8 @@ class AudioSystem:
exec_start = (
f"/usr/bin/jackd -P{profile['rt_priority']} "
f"-p{profile['period']} -n{profile['nperiods']} "
f"-r{profile['rate']} -dalsa -d{config.output_device} -i2 -o2"
f"-r{profile['rate']} -dalsa -d{config.output_device} "
f"-i{config.capture_channels} -o{config.playback_channels}"
)
return f"""[Unit]
Description=JACK Audio Server — Pi Multi-FX Pedal
+1
View File
@@ -20,6 +20,7 @@ DEFAULT_CONFIG: dict = {
"audio": {
"hat_type": "audioinjector",
"profile": "standard",
"mode": "mono",
"input_device": "hw:0,0",
"output_device": "hw:0,0",
"jack_enabled": True,