From c2071a9724c528efc5feb3ded48921a85ee987f2 Mon Sep 17 00:00:00 2001 From: Shawn Date: Mon, 8 Jun 2026 10:40:38 -0400 Subject: [PATCH] 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 --- docs/config-audio.md | 66 +++++++++++++++++++++++- src/system/__init__.py | 3 +- src/system/audio.py | 102 +++++++++++++++++++++++++++++++++++-- src/system/config.py | 1 + tests/test_audio_system.py | 68 +++++++++++++++++++++++++ 5 files changed, 234 insertions(+), 6 deletions(-) diff --git a/docs/config-audio.md b/docs/config-audio.md index 6b6e14d..0e19ed9 100644 --- a/docs/config-audio.md +++ b/docs/config-audio.md @@ -291,4 +291,68 @@ raspi-gpio get 18 19 20 21 | 5V | — | Pins 2, 4 | Power | RPi → HAT | | 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. \ No newline at end of file +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. \ No newline at end of file diff --git a/src/system/__init__.py b/src/system/__init__.py index 63b212c..f370482 100644 --- a/src/system/__init__.py +++ b/src/system/__init__.py @@ -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", ] \ No newline at end of file diff --git a/src/system/audio.py b/src/system/audio.py index 0be26a5..e79a78b 100644 --- a/src/system/audio.py +++ b/src/system/audio.py @@ -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 diff --git a/src/system/config.py b/src/system/config.py index 394f259..7acf006 100644 --- a/src/system/config.py +++ b/src/system/config.py @@ -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, diff --git a/tests/test_audio_system.py b/tests/test_audio_system.py index aed15d4..5181d91 100644 --- a/tests/test_audio_system.py +++ b/tests/test_audio_system.py @@ -10,6 +10,7 @@ from system.audio import ( AudioSystem, LATENCY_PROFILES, I2S_CONFIGS, + FOCUSRITE_PROFILES, CONFIG_TXT, JACK_SERVICE_PATH, LIMITS_CONF, @@ -187,6 +188,73 @@ def test_systemd_service_content_custom_device(): 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 ────────────────────────────────────────────────