# 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.

- Source: https://nabkey.com/tinkerings/audiobook-zones
- Author: Chris Nabkey
- Published: 2026-04-25
- Built: 2014
- Status: shipped
- Stack: Go, MPD, Python, Ubuntu, AngularJS, espeak

{/* deslop-ignore no-loose-ends: finished 2014 project; nothing left open. */}
{/* deslop-ignore no-questions: a build write-up with no open question to ask. */}

A small Linux appliance that played audiobooks in different rooms of a
facility, each room on its own. Pause one, rewind another, browse a shared
library on a tablet. I built it for a family member, because none of the
off-the-shelf options did this well or cheaply enough.

## Architecture

<figure class="diagram">
  *Diagram: Audiobook zones architecture: a Go HTTP service fans requests out to N MPD instances; each instance drives its own ALSA output and physical zone; all instances share one library on disk.* ([view it on nabkey.com](https://nabkey.com/tinkerings/audiobook-zones))
  <figcaption>Multi-zone MPD service. Library is shared (hot); each zone is its own MPD process and ALSA output, so pause/play/seek are independent.</figcaption>
</figure>

MPD's multi-output mode mixes at the ALSA layer, so you can't pause one output
without the others. So each zone is a separate MPD instance, with its own port,
state file, sound card, playlist, clock and volume. Twelve by default, all
sharing one music directory.

## The Go service

The AngularJS UI talked to a small Go HTTP service, not to MPD. It used
[`fhs/gompd`](https://github.com/fhs/gompd) for the MPD protocol and
`gorilla/mux` for routing.

- `/mpd/{command}`: status, library, current track, pause, resume,
  fast-forward, rewind.
- `/tracks/...`: changes to the playlist.

```go
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` and `rwd` jumps clamp at the start and end of the track. An
audiobook chapter is sometimes only a minute and a half, and without the clamp
the seek ran off the edge.

## The Python configurator

Twelve zones meant twelve `mpd.conf` files, twelve Upstart scripts and twelve
state directories. A Python script generated them from a config string with
`NNN` placeholders:

```python
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))
```

`textToWav` bakes an **espeak** greeting into each zone's music directory, so
the first sound out of every speaker after provisioning is *"Hello world. This
is zone three."* in a robot voice. If the right number comes out of the right
speaker, the wiring is right.

## Library

Books are folders on disk with ID3 tags for the metadata, Artist for the
author and Album for the book, and adding one meant SFTPing the folder in,
hitting Rescan in the UI, and letting every MPD update its database. The UI's filter pane narrows the library
to one book, and *Add Book* queues the whole thing. That one button is most of
what anybody ever touched.
