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:
+65
-1
@@ -291,4 +291,68 @@ raspi-gpio get 18 19 20 21
|
|||||||
| 5V | — | Pins 2, 4 | Power | RPi → HAT |
|
| 5V | — | Pins 2, 4 | Power | RPi → HAT |
|
||||||
| GND | — | Pins 6, 9, 14, 25, 30, 34, 39 | Ground | — |
|
| GND | — | Pins 6, 9, 14, 25, 30, 34, 39 | Ground | — |
|
||||||
|
|
||||||
The I2S HAT uses a **stacking header** (2×20 female socket) to pass through all 40 GPIO pins so footswitches, LEDs, and display are accessible from the top of the HAT.
|
The I2S HAT uses a **stacking header** (2×20 female socket) to pass through all 40 GPIO pins so footswitches, LEDs, and display are accessible from the top of the HAT.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Focusrite Scarlett 2i2 — 4CM (Four Cable Method)
|
||||||
|
|
||||||
|
> **Interface:** Focusrite Scarlett 2i2 (3rd gen) USB audio interface
|
||||||
|
> **Mode:** `stereo_4cm` in `audio.mode`
|
||||||
|
> **Device:** `hw:1,0` (USB card index, may vary)
|
||||||
|
|
||||||
|
### Wiring
|
||||||
|
|
||||||
|
```
|
||||||
|
Guitar ─────→ Input 1 (ADC channel 0) ─→ hw:1,0 capture_0 ─→ Pipeline input_0
|
||||||
|
Amp FX Send ─→ Input 2 (ADC channel 1) ─→ hw:1,0 capture_1 ─→ Pipeline input_1
|
||||||
|
|
||||||
|
Pipeline output_0 (pre-amp) ─→ Output 1 (DAC channel 0) ─→ hw:1,0 playback_0 ─→ Amp Input (Send)
|
||||||
|
Pipeline output_1 (post-amp) ─→ Output 2 (DAC channel 1) ─→ hw:1,0 playback_1 ─→ Amp FX Return
|
||||||
|
```
|
||||||
|
|
||||||
|
### JACK Config
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Kill PulseAudio first — it grabs USB audio devices
|
||||||
|
pulseaudio --kill
|
||||||
|
|
||||||
|
# Start JACK at 48kHz/128 frames, 2 capture + 2 playback channels
|
||||||
|
jackd -R -d alsa -d hw:1,0 -r 48000 -p 128 -n 3 -C 2 -P 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Application Config
|
||||||
|
|
||||||
|
Set these fields in `~/.pedal/config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
audio:
|
||||||
|
hat_type: "focusrite_2i2_3gen"
|
||||||
|
profile: "standard"
|
||||||
|
mode: "stereo_4cm"
|
||||||
|
input_device: "hw:1,0"
|
||||||
|
output_device: "hw:1,0"
|
||||||
|
jack_enabled: true
|
||||||
|
auto_connect: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port Auto-Connect
|
||||||
|
|
||||||
|
When `auto_connect: true` and mode is `stereo_4cm`, the system creates these JACK connections:
|
||||||
|
|
||||||
|
| Source | Destination | Signal |
|
||||||
|
|--------|-------------|--------|
|
||||||
|
| `system:capture_1` | `fx_in:input_0` | Guitar → pre-amp DSP |
|
||||||
|
| `system:capture_2` | `fx_in:input_1` | FX Send → post-amp DSP |
|
||||||
|
| `fx_out:output_0` | `system:playback_1` | Pre-amp DSP → amp input |
|
||||||
|
| `fx_out:output_1` | `system:playback_2` | Post-amp DSP → FX return |
|
||||||
|
|
||||||
|
### Channel Mapping Helper
|
||||||
|
|
||||||
|
At runtime, call `AudioSystem.channel_mapping_help("stereo_4cm")` to get these mappings as structured data.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- The Focusrite 2i2 registers as ALSA card index **1** when the I2S HAT is card 0 (Linux arranges by driver probe order). If HDMI audio or other USB devices are present, the index may shift — use `aplay -l` / `arecord -l` to confirm.
|
||||||
|
- 2-in/2-out at 24-bit/48kHz. JACK runs at 24-bit by default over ALSA.
|
||||||
|
- At 48kHz/128 frames × 3 periods, round-trip latency is ~6-8ms — well within the 10ms target for real-time guitar DSP.
|
||||||
@@ -4,9 +4,10 @@
|
|||||||
- setup (WIP): First-boot setup scripts
|
- setup (WIP): First-boot setup scripts
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .audio import AudioConfig, AudioSystem
|
from .audio import AudioConfig, AudioSystem, FOCUSRITE_PROFILES
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AudioConfig",
|
"AudioConfig",
|
||||||
"AudioSystem",
|
"AudioSystem",
|
||||||
|
"FOCUSRITE_PROFILES",
|
||||||
]
|
]
|
||||||
+98
-4
@@ -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 ─────────────────────────────────────────
|
# ── JACK latency profiles ─────────────────────────────────────────
|
||||||
# 48 kHz / 128 frames = 2.67 ms buffer → well under 10 ms RT
|
# 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
|
# 48 kHz / 64 frames = 1.33 ms buffer → aggressive, may xrun on RPi 4B
|
||||||
@@ -89,6 +99,8 @@ class AudioConfig:
|
|||||||
Attributes:
|
Attributes:
|
||||||
hat_type: I2S HAT type key from I2S_CONFIGS.
|
hat_type: I2S HAT type key from I2S_CONFIGS.
|
||||||
profile: Latency profile key from LATENCY_PROFILES.
|
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).
|
input_device: ALSA hardware device for capture (e.g. hw:0,0).
|
||||||
output_device: ALSA hardware device for playback (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.
|
jack_enabled: Whether to start the JACK server.
|
||||||
@@ -98,6 +110,7 @@ class AudioConfig:
|
|||||||
|
|
||||||
hat_type: str = "audioinjector"
|
hat_type: str = "audioinjector"
|
||||||
profile: str = "standard"
|
profile: str = "standard"
|
||||||
|
mode: str = "mono"
|
||||||
input_device: str = "hw:0,0"
|
input_device: str = "hw:0,0"
|
||||||
output_device: str = "hw:0,0"
|
output_device: str = "hw:0,0"
|
||||||
jack_enabled: bool = True
|
jack_enabled: bool = True
|
||||||
@@ -127,6 +140,21 @@ class AudioConfig:
|
|||||||
"""JACK ALSA device argument (output device drives JACK master)."""
|
"""JACK ALSA device argument (output device drives JACK master)."""
|
||||||
return f"d{self.output_device}"
|
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
|
# Audio system manager
|
||||||
@@ -246,7 +274,8 @@ class AudioSystem:
|
|||||||
f"-r{profile['rate']}",
|
f"-r{profile['rate']}",
|
||||||
"-dalsa",
|
"-dalsa",
|
||||||
f"-d{self.config.output_device}",
|
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))
|
logger.info("Starting JACK: %s", " ".join(cmd))
|
||||||
|
|
||||||
@@ -303,6 +332,52 @@ class AudioSystem:
|
|||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
return self.start_jack(timeout=timeout)
|
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
|
# JACK port connections
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
@@ -310,17 +385,35 @@ class AudioSystem:
|
|||||||
def connect_fx_ports(self) -> None:
|
def connect_fx_ports(self) -> None:
|
||||||
"""Connect JACK ports for the FX pipeline.
|
"""Connect JACK ports for the FX pipeline.
|
||||||
|
|
||||||
Default wiring:
|
Mono (default):
|
||||||
system:capture_1 → fx_in:input_0 (guitar → FX chain)
|
system:capture_1 → fx_in:input_0 (guitar → FX chain)
|
||||||
fx_out:output_0 → system:playback_1 (FX chain → output)
|
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)
|
This is a no-op if jack_connect is unavailable (not in PATH)
|
||||||
or if any of the target ports don't exist yet.
|
or if any of the target ports don't exist yet.
|
||||||
"""
|
"""
|
||||||
connections = [
|
mono_connections = [
|
||||||
("system:capture_1", "fx_in:input_0"),
|
("system:capture_1", "fx_in:input_0"),
|
||||||
("fx_out:output_0", "system:playback_1"),
|
("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:
|
for src, dst in connections:
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
@@ -535,7 +628,8 @@ class AudioSystem:
|
|||||||
exec_start = (
|
exec_start = (
|
||||||
f"/usr/bin/jackd -P{profile['rt_priority']} "
|
f"/usr/bin/jackd -P{profile['rt_priority']} "
|
||||||
f"-p{profile['period']} -n{profile['nperiods']} "
|
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]
|
return f"""[Unit]
|
||||||
Description=JACK Audio Server — Pi Multi-FX Pedal
|
Description=JACK Audio Server — Pi Multi-FX Pedal
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ DEFAULT_CONFIG: dict = {
|
|||||||
"audio": {
|
"audio": {
|
||||||
"hat_type": "audioinjector",
|
"hat_type": "audioinjector",
|
||||||
"profile": "standard",
|
"profile": "standard",
|
||||||
|
"mode": "mono",
|
||||||
"input_device": "hw:0,0",
|
"input_device": "hw:0,0",
|
||||||
"output_device": "hw:0,0",
|
"output_device": "hw:0,0",
|
||||||
"jack_enabled": True,
|
"jack_enabled": True,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from system.audio import (
|
|||||||
AudioSystem,
|
AudioSystem,
|
||||||
LATENCY_PROFILES,
|
LATENCY_PROFILES,
|
||||||
I2S_CONFIGS,
|
I2S_CONFIGS,
|
||||||
|
FOCUSRITE_PROFILES,
|
||||||
CONFIG_TXT,
|
CONFIG_TXT,
|
||||||
JACK_SERVICE_PATH,
|
JACK_SERVICE_PATH,
|
||||||
LIMITS_CONF,
|
LIMITS_CONF,
|
||||||
@@ -187,6 +188,73 @@ def test_systemd_service_content_custom_device():
|
|||||||
assert "-dhw:2,0" in content
|
assert "-dhw:2,0" in content
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mode / stereo_4cm tests ───────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_config_mode_default():
|
||||||
|
cfg = AudioConfig()
|
||||||
|
assert cfg.mode == "mono"
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_config_mode_stereo_4cm():
|
||||||
|
cfg = AudioConfig(mode="stereo_4cm")
|
||||||
|
assert cfg.mode == "stereo_4cm"
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_config_channel_counts_mono():
|
||||||
|
cfg = AudioConfig(mode="mono")
|
||||||
|
assert cfg.capture_channels == 1
|
||||||
|
assert cfg.playback_channels == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_config_channel_counts_stereo_4cm():
|
||||||
|
cfg = AudioConfig(mode="stereo_4cm")
|
||||||
|
assert cfg.capture_channels == 2
|
||||||
|
assert cfg.playback_channels == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_config_focusrite_profile_hit():
|
||||||
|
cfg = AudioConfig(hat_type="focusrite_2i2_3gen")
|
||||||
|
prof = cfg.focusrite_profile
|
||||||
|
assert prof is not None
|
||||||
|
desc, cap, play = prof
|
||||||
|
assert "Focusrite" in desc
|
||||||
|
assert cap == 2
|
||||||
|
assert play == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_config_focusrite_profile_miss():
|
||||||
|
cfg = AudioConfig()
|
||||||
|
assert cfg.focusrite_profile is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_focusrite_profiles_entries():
|
||||||
|
for key, (desc, cap, play) in FOCUSRITE_PROFILES.items():
|
||||||
|
assert len(desc) > 10
|
||||||
|
assert isinstance(cap, int) and cap > 0
|
||||||
|
assert isinstance(play, int) and play > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_mapping_help_mono():
|
||||||
|
mapping = AudioSystem.channel_mapping_help("mono")
|
||||||
|
assert len(mapping) == 1
|
||||||
|
assert mapping[0]["capture_port"] == "system:capture_1"
|
||||||
|
assert mapping[0]["playback_port"] == "system:playback_1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_mapping_help_stereo_4cm():
|
||||||
|
mapping = AudioSystem.channel_mapping_help("stereo_4cm")
|
||||||
|
assert len(mapping) == 2
|
||||||
|
assert "Guitar" in mapping[0]["description"]
|
||||||
|
assert "FX Send" in mapping[1]["description"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_mapping_help_unknown():
|
||||||
|
import pytest
|
||||||
|
with pytest.raises(ValueError, match="Unknown mode"):
|
||||||
|
AudioSystem.channel_mapping_help("bogus")
|
||||||
|
|
||||||
|
|
||||||
# ── Module exports ────────────────────────────────────────────────
|
# ── Module exports ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user