Multi-zone audiobook player.

An audiobook playback appliance with one zone per room — independent MPD instances, a Go HTTP service, AngularJS UI, espeak greetings on boot. Built for a family member.

A small Linux appliance that played audiobooks in different rooms of a facility, independently — pause one, rewind another, browse a shared library on a tablet, and have each zone keep its own clock. Built for a family member with a specific need that none of the off-the-shelf options solved well, or cheaply, enough.

Architecture

library/usr/share/mpd/music — shared by all zonesbrowserAngularJSgo service :6101/mpd/:cmd · /tracksMPD :66001MPD :66002MPD :66NNNzone 1zone 2zone NHTTPMPD/TCP, one connection per requestSFTP into the library; Rescan in the UI tells every MPD to update.12 zones provisioned at once by a Python script that templates per-zone mpd.conf and Upstart jobs.First sound from every speaker on boot is an espeak greeting: "Hello world. This is zone three."
Multi-zone MPD service. Library is shared (hot); each zone is its own MPD process and ALSA output, so pause/play/seek are independent.

The unusual choice is that each zone is its own MPD instance, not a single MPD with multiple outputs. MPD's multi-output mode mixes at the ALSA layer; you can't pause one zone without affecting others. By giving each zone its own MPD process — different port, different state file, different sound card — you get true independence. Each MPD has its own playlist, its own clock, its own volume. Pause one, the others keep going. Twelve of them, by default, all sharing one music directory.

The Go service

The web UI didn't talk to MPD directly. A small Go HTTP service did, with a JSON API: /mpd/{command} for read-mostly operations (status, library, current track, pause, resume, fast-forward, rewind), and /tracks/... for mutating the playlist. It used fhs/gompd for the MPD wire protocol and gorilla/mux for routing.

case param == "longstatus":
    var longstatus string
    status, _ := conn.Status()
    track,  _ := conn.CurrentSong()
    switch {
    case status["state"] == "play":
        longstatus = "Playing - " + track["Album"] + " by " + track["Artist"]
    case status["state"] == "pause":
        longstatus = "Paused - "  + track["Album"] + " by " + track["Artist"]
    default:
        longstatus = "Stopped"
    }
    return longstatus, nil

The 30-second fwd/rwd jumps clamp at the start and end of the track, which sounds trivial — until an audiobook chapter is sometimes only a minute and a half and the seek bar trips over the edge.

The Python configurator

Twelve zones meant twelve mpd.conf files, twelve Upstart init scripts, twelve state directories. That's not something I wanted to bootstrap again. A Python script generated everything from a NNN-templated config string:

for x in range(1, 13):
    xstr = str(x).zfill(2)
    # Generate a per-zone "Hello world. This is zone N" greeting, one-time
    textToWav('Hello world.  This is zone ' + str(x),
              '/usr/share/mpd/music/zone' + xstr)
    directory = "/usr/share/mpd/zone" + xstr
    # ... per-zone mpd.conf and Upstart script written from templates
    mpdconf.write(confstring.replace("NNN", xstr))

That textToWav call was fun. Each zone gets a one-time espeak-synthesized greeting baked into its music directory: "Hello world. This is zone three." The first sound out of every speaker after a fresh provisioning is its own zone number, in a stilted robot voice. It's the cheapest possible installation sanity check - if the right number comes out of the right speaker, your wiring is correct.

Library management

Books lived as folders on disk; ID3 tags carry the metadata (Artist = author, Album = book). Adding content was just SFTP — drop a book's folder in, hit Rescan in the UI, every MPD updated its database. The filter pane in the AngularJS UI let you narrow the library to a single book and Add Book the entire thing to the playlist with one click. That last bit is mostly what the appliance is, in practice.