Skip to content

A 2.1 channel DIY audio receiver with S/PDIF input

License

Notifications You must be signed in to change notification settings

jwillikers/piceiver

Repository files navigation

Piceiver

A 2.1 channel DIY audio receiver with S/PDIF input.

Piceiver Pi 5 Isometric

Synopsis

This project is a build of a 2.1 channel receiver based on the Raspberry Pi. Out-of-the-box, it takes in audio from the digital optical input. The receiver leverages PipeWire and WirePlumber for the audio routing and session management. The software and configuration are built on NixOS and managed through a Nix flake in this repository. Nix has quite up-to-date software, is extensively customizable, and ensures that builds are reproducible.

Features
  • 2.1 channel output

  • Digital optical audio input

  • Low-latency

  • Realtime - for the lowest possible latency

  • AirPlay

  • Bluetooth

  • DLNA Digital Media Renderer

  • Cast directly from Jellyfin

  • Multi-room audio

  • Integrates with Music Assistant and Home Assistant

  • MIDI Synthesizer

Hardware

The total price comes to a whopping $249.02. That’s still a bit pricey, so I’d love to get the cost down to below $200 at the very least. However, I’m very much inclined for everything to be supported in the Linux kernel. It’s bad enough that I have to make a concession for the Amp HAT, which requires using Raspberry Pi’s fork of the Linux kernel. If you’re reading this Raspberry Pi, please upstream your audio HATs. I’d like to find a cheaper, newer PCIe sound card with in-kernel Linux support to provide the digital optical input and, potentially, an LFE output. The current Sound Blaster card requires enabling extra configuration options, i.e. CONFIG_SND_HDA_INTEL, in the kernel config as well as adding a special config.txt overlay. As for using HATs to provide these features, I’ll gladly switch over if and when they are supported upstream. Since kernel has to be compiled anyways, realtime preemption is enabled to further reduce latency.

Assembly

Assembling the Piceiver is very straightforward. In fact, I’m not going to go in to much detail. There are a couple of important things that require explanation.

It’s expected that the Piceiver will be connected to the network via ethernet and not through WiFi. To use WiFi, the image will need to be customized.

Do not supply power through the USB-C connector on the Raspberry Pi. I recommend covering the USB-C connector on the Raspberry Pi with a small strip of electrical tape so that you don’t forget.

Piceiver Pi 5 Side 1

The digital optical input is the bottom digital optical connector on the Sound Blaster card.

Piceiver Pi 5 Digital Optical Input Connection

If you use a reasonably large gauge of speaker wire, it will take some work to fit the wire into the screw terminals on the Amp HAT. Take some time to shrink the wire a smidgen by twisting the ends.

Speaker Cables

Here’s a picture showing all of the cables attached except for the barrel power plug.

Piceiver Pi 5 Connections

Getting Started

Installation is done by building a system image which is flashed directly to an SD card. This can all be built and customized locally with Nix. Unfortunately, unless you’re building this on an aarch64 machine, it will take a significant amount of time to build. The initial build probably took about two full days for me. I need to tweak the kernel config to avoid building a bunch of unnecessary things, like drivers for AMD and Nouveau GPUs.

  1. Install an implementation of Nix, such as Lix demonstrated in the following command. Enable support for flakes when prompted.

    curl -sSf -L https://install.lix.systems/lix | sh -s -- install
  2. Clone this repository.

    git clone [email protected]:jwillikers/piceiver.git
  3. Change into the project directory.

    cd piceiver
  4. Build the SD card image. Prefix the command with systemd-inhibit to prevent your computer from sleeping. This will take a long time. Like, days in my case. The default, basic-sd-image package, produces a minimal image that requires no extra configuration. There is an alternative package, full-sd-image, which is more fully-featured, including more integrations, but requires customization and additional set up.

    systemd-inhibit nix build --accept-flake-config
    💡

    If for any reason the build fails or your computer locks up, there’s a good chance that it’s related to Nix attempting to build too many jobs simultaneously or not having adequate RAM space to hold the build directory for a package. These issues can be fixed with configuration options for the Nix daemon in /etc/nix/nix.conf. Use the max-jobs option to limit the number of simultaneous jobs. To build only a single job at a time, this would look like max-jobs = 1 in the config file.

    To prevent running out space in RAM, set the build-dir option to a path that is located on disk. The default tmp directory is usually stored in a special filesystem backed by RAM. To set this to /var/tmp/nix-daemon, the line in the config will look like build-dir = /var/tmp/nix-daemon. Be sure to create this directory.

    sudo mkdir --parents /var/tmp/nix-daemon

    To apply changes in /etc/nix/nix.conf, restart the Nix daemon.

    sudo systemctl restart nix-daemon.service
  5. Once the image is ready, insert the SD card into your computer.

  6. Use lsblk to find the SD card. This will probably be a device like /dev/mmcblkX or possibly /dev/sdX.

    lsblk
  7. Flash the SD card with the image. Replace the /dev/mmcblkX device path with yours.

    🔥

    Using the wrong device path could wreck your entire computer or precious data on an attached disk, so be careful to use the right path. Or just use a safe graphical application to flash the image to your SD card.

    nix develop --command bash -c 'sudo env "PATH=$PATH" zstdcat result/sd-image/nixos-sd-image-*-aarch64-linux.img.zst | dd bs=1M status=progress of=/dev/mmcblkX'

When booting the Piceiver for the first time, give it a few extra minutes to start working as it has to resize the filesystem.

Key-based authentication is required for the root user. So, unless you’ve configured that, log in as the user jordan with the default password opW6&Aa. The root password is V2psT!t0. I recommend configuring the authorized keys for the root user as well as your own user in the NixOS configuration. This is done for the jordan user here. With SSH keys configured, I recommend completely disabling password authentication for security. Also, you should change the default passwords for the users. See the Deploy section for how to deploy such configuration changes to a Piceiver that’s already running.

Deploy

You may want to update or make changes to an existing Piceiver instance. Such changes might include supplying your own SSH keys for authentication, altering the default user, changing passwords, or applying credentials for certain services. It is possible to apply such changes as well as updates to an already running instance by using deploy-rs. This should save your microSD cards from an tortured and all too brief existence. The instructions here describe how to deploy updates to an existing Piceiver server. It is assumed that you’ve already cloned the repository and changed to its directory.

  1. First, make your desired modifications to the configuration.

  2. Activate the development environment with Nix to pull in the correct version of deploy-rs.

    nix develop
  3. Deploy. This will prompt for the sudo password of the user jordan, which is opW6&Aa by default.

    systemd-inhibit deploy --interactive-sudo true --ssh-user jordan .#piceiver
    💡

    After deploying your own SSH key for authentication of the root user, the --interactive-sudo true and --ssh-user jordan options can be omitted.

System Organization

The PipeWire and WirePlumber sessions run under the dedicated core user account. Almost all audio-related services run under this user’s account because they need to interact with the PipeWire daemon. The exception is the Snapcast server, which runs as a system service under a dedicated user because it only handles audio over the network. The PipeWire configuration creates a virtual sink that forwards audio to both the DigiAmp+ HAT and the USB audio interface. A loopback device is created which connects the digital optical input on the Sound Blaster card to this sink. To reduce latency, I’ve lowered the quantum as low as possible until just before audio begins to stutter. The WirePlumber configuration sets the correct device profile for the Sound Blaster card in addition to several other important tweaks like optimizations for the USB output and preventing the digital optical input from being suspended. The default sink, default source, plus initial volume levels are configured for WirePlumber by a systemd service which runs a few seconds after the WirePlumber service starts. Most audio applications interact directly with PipeWire, but a single holdout, the Snapcast client, is only capable of using PulseAudio’s API. Thus, the PipeWire’s PulseAudio daemon is also running.

The audio routing is pretty much hard-coded for everything. Audio from the digital optical input is assumed to require low latency and high reliability, and thus is routed directly to the combined stereo and sub output. The digital optical input is connected to my TV, which is why it’s configured this way. The synthesizer is also routed to the combined output because that also requires low latency All other inputs are over the network and audio only, so they are all connected to Snapcast. The hard-coded behavior is great when you know exactly how you want everything to be routed, so this setup works really well for me. Plus, it’s one less thing I need to think about or troubleshoot. To make it possible to switch between outputs, I’d need to add a button and some kind of indicator to the Piceiver so you could properly switch between them on the device.

AirPlay

AirPlay 1 and 2 are supported via Shairport Sync. Two instances of Shairport-Sync run simultaneously to provide support for both AirPlay 1 and AirPlay 2. It works very nicely. PipeWire’s RAOP Discover module can be used to automatically discover and stream directly to the Piceiver. The following instructions document how to accomplish this.

ℹ️

Make sure that the ephemeral port range is open in the firewall on the device from which you are streaming.

  1. Create the configuration directory for PipeWire for your user.

    mkdir --parents ~/.config/pipewire/pipewire.conf.d
  2. Configure the RAOP Discover module in a config file fragment.

    ~/.config/pipewire/pipewire.conf.d/raop-discover.conf
    context.modules = [
    {   name = libpipewire-module-raop-discover
        args = {
            stream.rules = [
                {   matches = [
                        {    raop.ip = "~.*"
                        }
                    ]
                    actions = {
                        create-stream = {
                            stream.props = {
                                media.class = "Audio/Sink"
                            }
                        }
                    }
                }
            ]
        }
    }
    ]
  3. Restart PipeWire.

    systemctl --user restart pipewire

Bluetooth

Bluetooth streaming is supported. Just pair your device with the receiver. The Piceiver is only discoverable for the first five minutes after it boots. Since it has no way to either display a pin or enter one, it accepts connections from anyone. The timeout limits the window where an unwanted guest may hijack your receiver. Only one device may be connected at a time. If you get a prompt for a pin code for some reason, try entering 0000. It can be a bit finicky pairing my Android phone, so just give it a couple minutes after it disconnects to reconnect and get everything figured out. My wife’s iPhone paired much more easily over Bluetooth. A dedicated button to enter Bluetooth pairing mode would be really helpful. I’ve not yet tested whether Bluetooth MIDI works.

DLNA Digital Media Renderer

Rygel provides a DLNA/UPnP Digital Media Renderer which can be used to playback audio from services that support the protocol.

Jellyfin

If you have a Jellyfin media server, you can cast directly to the Piceiver via Mopidy and the Mopidy-Jellyfin plugin. This requires the user credentials and the address of your Jellyfin server. Once configured, Jellyfin’s web interface can be used to cast directly to the device. I’m planning on adding support for using secrets to populate credentials like this in the image. That could well end up being super complicated and not be worth it if you just want to get things set up. It’s possible to configure credentials locally in the repository and deploy them to your server by following the instructions in the Deploy section.

💡

A Mopidy web server is available at piceiver.local:6680/iris/. The UI is provided by the Iris extension. This is nifty if you want to allow others to stream from your Jellyfin instance without requiring them to log to your Jellyfin account as your user.

Multi-room Audio with Snapcast

Multi-room audio is handy feature, it’s been incorporated in the Piceiver thanks to the Snapcast project. I haven’t found anything to package up something to manage multi-room audio via PipeWire, although I’m certain it’s possible. Until someone makes something like that, Snapcast is a great open-source solution for multi-room audio. Since it doesn’t integrate directly with PipeWire, there will likely be an additional level of latency introduced by PipeWire. The Snapcast control webserver is accessible at piceiver.local:1780.

ℹ️

Snapcast introduces a substantial amount of latency in order to synchronize playback between the various playback clients. This isn’t much of a problem when playing music, audio books, or podcasts. However, you’ll want to avoid using it as the sink for video playback or any kind of realtime audio interactions such as calls, Mumble, etc.

Snappellite

A Raspberry Zero 2W and DAC Pro HAT would make a great combination for creating a remote playback satellite that you can attach to a set of speakers in another location. Alas, it’s been a couple of years at this point where I can get the darn thing to not kernel panic when using a USB ethernet adapter. So, I recommend a Raspberry PI 4 B instead at this point. If you opt for a USB audio device instead of the DAC Pro, you can even use a mainline kernel! I call it Snappellite for Snapcast Satellite.

Oh, I really need to stop doing the math on how much these component costs. I’ve spent way too much on all of this.

I’ve configured an SD image target for it, snappellite-sd-image. Build it with Nix build.

nix build .#snappellite-sd-image

It still takes forever to build, so feel free to grab some of your favorite on-brand iced tea while you wait.

Stream Directly to the Snapcast Server with PipeWire

PipeWire 1.2.0 added the Snapcast Discover module. This module makes it really easy to set up a stream from any device running PipeWire, like your laptop. Or maybe your phone. I don’t want to assume anything about your sanity or lack thereof. To actually configure the Snapcast server to use the input stream, you’ll probably want to use Snapdroid or alternatively Snapweb directly from your browser. There’s also a bunch of other third-party integrations available. To use this module, configure PipeWire to load it with the appropriate settings on your device. The steps here walk through how to do this.

  1. Create the configuration directory for PipeWire for your user.

    mkdir --parents ~/.config/pipewire/pipewire.conf.d
  2. Drop in and configure the Snapcast Discover module in a config file fragment.

    ~/.config/pipewire/pipewire.conf.d/51-snapcast-discover.conf
    context.modules = [
    {   name = libpipewire-module-snapcast-discover
      args = {
        stream.rules = [
          {   matches = [
              {
                    snapcast.ip = "~.*"
              }
          ]
              actions = {
                  create-stream = {
                      audio.rate = 48000
                      audio.format = S16LE
                      audio.channels = 2
                      audio.position = [ FL FR ]
                      node.name = "Piceiver Snapcast Sink"
                      # If your firewall blocks ephemeral ports, open those ports or open the specific port in the following line and uncomment it.
                      # Only after considering the security implications, of course.
                      # server.address = [ "tcp:4711" ]
                      snapcast.stream-name = "My Laptop"
                      capture = true
                      capture.props = {
                          media.class = "Audio/Sink"
                      }
                  }
              }
          }
        ]
      }
    }
    ]
  3. Restart PipeWire to load the module.

    systemctl --user restart pipewire
  4. Now you should be able see an additional stream available for Snapcast in the app, web interface, or what have you.

Music Assistant

The Piceiver may be integrated with Music Assistant as an external Snapcast server, an AirPlay playback provider, and a UPnP/DLNA player provider. The external Snapcast server option will create a Snapcast stream specific to Music Assistant. This requires manually switching the Snapcast stream back to the default default stream after playing anything through Music Assistant, otherwise you’ll hear nothing. This is a pain, so I recommend using the AirPlay or UPnP/DLNA player providers instead unless you stream everything through Music Assistant. The Piceiver can likewise be incorporated directly in Home Assistant using either the Snapcast or DLNA integrations or directly via Music Assistant.

Synthesizer

USB MIDI keyboards are plug-and-play with the Piceiver thanks to the FluidSynth software synthesizer. Just plug in the keyboard and FluidSynth will translate the MIDI messages and output the audio through the stereo. A systemd service for the core user runs FluidSynth in the background. The command-line flags to the service can be configured via Nix or on the Pi itself by running the following command-line as the core user.

systemctl --user edit fluidsynth.service

After making modifications, be sure to restart the service.

systemctl --user restart fluidsynth.service

Security

The Piceiver is admittedly, not the most secure thing out-of-the-box. It’s running services listening on several ports, including open web interfaces for controlling audio streaming and accessing your media. The Bluetooth is not particularly secure either, since nothing prevents someone from pairing. This device is intended for use in a private network, like a home network, and even there it is still important to consider access and if anyone on your network should be able to control the receiver server.

Switch to the core User

It’s not possible to log in to the core user account, but is possible to use sudo to switch to it. This isn’t possible when using the basic image because that doesn’t have an account with which to log in. Add a root password or another user account, like in the custom image, to be able to log in. The following command can be used to switch to the core user account. I use the fish shell, by the way.

sudo -H -u core fish -c 'cd; fish'

Performance

The following table shows some performance benchmarks which were obtained using pw-top. This table includes my original prototype based off of the Raspberry Pi Compute Module 4, which used Raspberry Pi OS 5 based on Debian Bookworm.

Table 1. Piceiver Performance

Raspberry Pi Model

OS

Kernel

PipeWire Version

WirePlumber Version

Quantum

Rate

Active CPU Usage

Idle CPU Usage

RAM Usage

Latency (μs)

Notes

CM4 8GiB RAM, no WiFi

Raspberry Pi OS 5 (Debian Bookworm)

Linux 6.1.54-rt15

0.82.0

0.4.15

128

48,000

10-20%

5-10%

0.3%

100-400

Without Snapcast and Jellyfin MPV Shim. No LFE or upmixing.

Pi 5 Model B, 8GiB RAM

Raspberry Pi OS 5 (Debian Bookworm)

Linux 6.1.54-rt15

1.0.6

0.5.2

512

48,000

10-20%

5-10%

0.3%

100-200

Todo

There’s a lot left I need to complete. The custom image, which is tailored for my personal usage, still has many outstanding tasks. First, the Nix stuff needs cleaned up. Significantly. My primary focus now is to add support for secrets handling via sops-nix in the configuration to allow me to set things like passwords as well as credentials for my Jellyfin server. Then, there’s still the fact there’s not a proper plan in place for managing and updating the installation. Using a new image every time isn’t gonna fly with flash storage or the value of anybody’s time, so figuring out deployment is a high priority. After that, I need to figure out how to configure Net-SNMP in NixOS, as monitoring is a really nice feature to have in place.

Todo
  • Use a reverse-proxy for the Snapcast and Mopidy servers?

  • Fix the sub flipping on and off when idle.

  • Auto-mute speakers and subwoofer when nothing is being output. I think that the constant input from the digital optical input causes this. However, I have to disable suspend for that node otherwise nothing ever comes through.

  • FCast for streaming, but right now I’d have to write a receiver for audio only myself and then I’d have to write integrations and apps that actually use the protocol.

  • Snapcast microcontroller for playback

  • Copy nixos configuration or flake to /etc/ in the image?

  • Add a button to trigger Bluetooth pairing.

  • Test how well the onboard Bluetooth works for the Pi 5.

  • Use nix-sops for secret management

  • Configure monitoring over Net-SNMP

  • A mechanism for switching the output, so as to choose between the lower latency stereo output or the Snapcast output.

  • Automatic updates?

  • Better filesystem such as Bcachefs or Btrfs

  • Automatically log in to Tailscale

  • Remove a bunch of extra dependencies that nixpkgs pulls in but that isn’t necessary.

  • Script for collecting performance metrics?

  • LFE

  • Bluetooth MIDI

  • SELinux

  • Case

  • Low-cost

Contributing

Contributions in the form of issues, feedback, and even pull requests are welcome. Make sure to adhere to the project’s Code of Conduct.

Open Source Software

This project is built on the hard work of countless open source contributors. A few of these projects are enumerated below.

Code of Conduct

The project’s Code of Conduct is available in the Code of Conduct file.

License

This repository is licensed under the MIT license.

© 2024 Jordan Williams

Authors