nixos/shared/modules/services/motu-m4-combined.md
2025-12-12 10:58:40 -07:00

10 KiB

MOTU M4 Combined Mono Input Module

Purpose

Creates a virtual mono audio source that combines all 4 inputs from a MOTU M4 audio interface into a single input device. This is useful for:

  • Video conferencing (Zoom, Discord, etc.) where you want all inputs mixed together
  • Recording scenarios where you need a simple mono mix
  • Applications that don't support multi-input selection

Architecture

Components

  1. Null Sink (motu_mixer): Internal PulseAudio/PipeWire sink that receives audio from all inputs
  2. 4 Loopback Modules: Route each M4 input → null sink
    • Mic In 1L (Left)
    • Mic In 2R (Right)
    • Line In 3L (Left)
    • Line In 4R (Right)
  3. Remap Source Module: Exposes motu_mixer.monitor as the virtual "MOTU M4 All Inputs (Mono)" source

Audio Flow

M4 Mic In 1L ──┐
M4 Mic In 2R ──┤
M4 Line In 3L ─┼──> [motu_mixer] ──> [monitor] ──> "MOTU M4 All Inputs (Mono)"
M4 Line In 4R ─┘     (null sink)                     (virtual source)

Important: The null sink is internal only - no audio is output to physical devices. The M4's built-in hardware monitoring is used instead.

Device Detection & Auto-Start/Stop

The module is device-aware and only runs when the MOTU M4 is physically connected:

  • Auto-Start: When you plug in the MOTU M4, the systemd service automatically starts
  • Auto-Stop: When you unplug the MOTU M4, the service automatically stops and cleans up
  • Boot Handling: Works correctly whether M4 is connected at boot time or not
  • No Phantom Sources: The virtual source only appears when the hardware is actually present

This is achieved through:

  1. Udev tagging: USB device is tagged for systemd tracking
  2. Device binding: Service uses bindsTo= to tie its lifecycle to the USB device
  3. ExecCondition: Additional validation ensures PipeWire has enumerated the audio sources before setup

Configuration Options

services.motu-m4-combined = {
  enable = true;                    # Enable the module
  user = "username";                # User to run the service as
  devicePrefix = "alsa_input.usb-MOTU_M4_M4MA0824DV-00.HiFi";  # M4 device prefix
  virtualSourceName = "motu_m4_combined";                      # Virtual source name
  virtualSourceDescription = "MOTU M4 All Inputs (Mono)";      # Description shown in apps
  latencyMs = 20;                   # Loopback latency in milliseconds
  usbVendorId = "07fd";             # USB Vendor ID (Mark of the Unicorn)
  usbProductId = "000b";            # USB Product ID (M Series/M4)
};

Implementation Details

Why Systemd Service Instead of PipeWire Config?

Decision: Use systemd user service to create all modules instead of PipeWire declarative config.

Rationale:

  1. PipeWire config limitations: The services.pipewire.extraConfig.pipewire only works for property-based configuration. It cannot reliably create context.objects (null sinks) or load PulseAudio compatibility modules.
  2. Portability: Self-contained service works on any NixOS system with PipeWire, no special PipeWire configuration needed.
  3. Guaranteed ordering: Null sink is created before loopbacks try to connect to it.
  4. Easier debugging: Everything in one service, one log stream to check.

Reference: NixOS PipeWire Wiki - Null Sinks suggests scripts for tasks not covered by declarative config.

Why All Modules in One Service?

Initial Problem: The null sink was created via PipeWire config, but it didn't actually get created. The loopback modules then auto-routed to the default sink (DX1 Headphones), causing unwanted monitoring.

Solution: Move everything into the systemd service:

  • Create null sink FIRST
  • Then create loopbacks (they now have a target)
  • Finally create remap source

This ensures proper ordering and prevents audio from leaking to physical outputs.

Troubleshooting

Audio Playing Through Wrong Output

Symptom: You hear the combined input through your default audio output (headphones, speakers, etc.)

Cause: The motu_mixer null sink wasn't created, so loopbacks auto-routed to the default sink.

Fix:

# Check if null sink exists
pactl list sinks short | grep motu_mixer

# If missing, restart the service
systemctl --user restart motu-m4-combined-setup

# Check service logs
journalctl --user -u motu-m4-combined-setup -n 50

Combined Source Not Appearing

Symptom: "MOTU M4 All Inputs (Mono)" doesn't show up in audio applications.

Possible causes:

  1. M4 is not connected (most common - service won't start if device is absent)
  2. Service condition check failed (device present but audio sources not ready)
  3. Service failed to start
  4. PipeWire/WirePlumber not running

Fix:

# Check if MOTU M4 USB device is connected
lsusb | grep -i motu

# Check service status (will be inactive if device not connected)
systemctl --user status motu-m4-combined-setup

# Check if M4 is detected in audio system
wpctl status | grep -i motu
pactl list sources short | grep MOTU

# If device is connected but service isn't running, check logs
journalctl --user -u motu-m4-combined-setup -n 50

# Try restarting the service (will only work if device is connected)
systemctl --user restart motu-m4-combined-setup

Duplicate Combined Sources

Symptom: Multiple "MOTU M4 All Inputs (Mono)" sources appear.

Cause: Service was started multiple times without cleanup.

Fix:

# Stop and restart the service (cleanup happens automatically)
systemctl --user restart motu-m4-combined-setup

# Or manually clean up
pactl list modules short | grep -E "loopback.*motu_mixer|remap.*motu_m4_combined|null-sink.*motu_mixer" | cut -f1 | xargs -I {} pactl unload-module {}

Service Management

Restart the Setup

systemctl --user restart motu-m4-combined-setup

Check Service Status

systemctl --user status motu-m4-combined-setup

View Service Logs

journalctl --user -u motu-m4-combined-setup -f

Disable the Service (but keep it in config)

systemctl --user stop motu-m4-combined-setup
systemctl --user disable motu-m4-combined-setup

Manual Cleanup (if service fails)

# List all loaded modules
pactl list modules short

# Unload specific module by ID
pactl unload-module <MODULE_ID>

# Nuclear option: restart PipeWire (clears all modules)
systemctl --user restart pipewire-pulse

Testing & Verification

Verify Null Sink Created

pactl list sinks short | grep motu_mixer
# Expected output: <ID>  motu_mixer  PipeWire  ...

Verify Loopbacks Created

pactl list modules short | grep loopback | grep motu_mixer
# Expected: 4 lines (one for each M4 input)

Verify Virtual Source Available

wpctl status | grep "MOTU M4 All Inputs"
# Expected: Line showing the virtual source in "Sources" section

Test Audio Flow

  1. Speak/play audio into M4 inputs
  2. Verify NO audio plays through your headphones/speakers (unless you explicitly route it)
  3. Open pavucontrol → Recording tab
  4. Select "MOTU M4 All Inputs (Mono)" as source
  5. Check that levels respond to your inputs

Design Decisions Log

2025-01-18: Initial Implementation

Decision: Create module with PipeWire declarative config for null sink + systemd service for loopbacks.

Rationale: Seemed like the "proper NixOS way" to use declarative PipeWire config.

Outcome: Null sink was never created. PipeWire's context.objects in drop-in configs doesn't work reliably.

2025-01-18: Move to Pure Systemd Service

Decision: Move null sink creation into systemd service, remove PipeWire declarative config entirely.

Rationale:

  • NixOS PipeWire wiki explicitly states some tasks need scripts/services
  • Guarantees proper ordering (sink before loopbacks)
  • Self-contained and portable
  • Easier to debug

Outcome: Works perfectly. No audio leakage, clean setup/teardown.

Idempotency & Cleanup

Decision: Add comprehensive ExecStop and ExecStopPost to clean up all modules.

Rationale:

  • Service restarts should not create duplicate modules
  • Failed starts should still allow cleanup
  • Pattern-based matching (grep) is more reliable than tracking module IDs

Implementation:

  • ExecStop: Primary cleanup (loopbacks → remap → null sink, in that order)
  • ExecStopPost: Failsafe cleanup using pattern matching

2025-01-12: Device-Conditional Service Activation

Decision: Bind systemd service lifecycle to MOTU M4 USB device presence.

Rationale:

  • Service should only run when hardware is actually connected
  • Prevents phantom virtual sources appearing when device is absent
  • Automatic start/stop on device plug/unplug events
  • Better user experience and resource management

Implementation:

  1. Udev rule: Tags MOTU M4 USB device (07fd:000b) for systemd tracking
  2. Service binding: Uses bindsTo= to tie service lifecycle to device unit
  3. ExecCondition: Pre-flight check verifies device is present AND PipeWire has enumerated audio sources
  4. Error handling: Removed || true fallbacks since device presence is now guaranteed

Future Improvements

Potential enhancements (not currently needed):

  1. Auto-detection: Detect M4 connection/disconnection and auto-start/stop service Implemented (2025-01-12)
  2. Volume Control: Add per-input volume control before mixing
  3. Stereo Version: Create a stereo combined source option
  4. Multiple Devices: Support for other MOTU interfaces (M2, M6, etc.)
  5. GUI Configuration: NixOS module options exposed via Home Manager GUI

License

This module follows the NixOS project license (MIT).

Maintainer Notes

When updating this module:

  1. Test both scenarios:

    • M4 connected on boot
    • M4 connected after boot
  2. Verify cleanup:

    • Restart service multiple times
    • Check no duplicate modules accumulate
  3. Check audio routing:

    • Confirm NO audio plays through default sink
    • Verify combined source works in applications
  4. Update this documentation with any new findings or changes.