DEV Community

MadEnvel
MadEnvel

Posted on

Gapless playback with one ALSA device

At some point Kalinka had become quite good at the basic job of a music player. It could open a stream, decode it, send PCM to ALSA, recover from errors and move through a queue reliably.

But it still paused between tracks.

For many albums that is only a minor annoyance. For others it changes the music. Pink Floyd’s The Dark Side of the Moon was developed and performed as one complete piece, and many of its tracks run directly into the next. Parts of The Division Bell use the same kind of transitions. Jean-Michel Jarre’s Oxygène is described on his official site as “one long flowing instrumental journey”. A player that inserts even a short pause makes those albums sound wrong.

That was when gapless playback stopped being a nice extra for me and became something I wanted Kalinka to do properly.

By gapless I mean a narrow, testable property: when two files are meant to be adjacent, the engine must not insert, remove or duplicate PCM samples at their boundary. It should behave as though the two decoded streams were one continuous stream.

This article is about how I implemented that for tracks with the same PCM format while keeping one ALSA device open. It also explains why a transition between different sample rates or channel formats is a different problem, especially if the player is trying to preserve the source format rather than resample everything.

The result, in one paragraph, is this: Kalinka prepares the next decoder before the current track ends, keeps the existing ALSA buffer and PCM handle alive, and asks the output node whether the new stream is compatible. If the complete PCM format matches, playback simply continues. If it does not, the player must either convert the audio or reconfigure the device; Kalinka chooses reconfiguration, so that case is not guaranteed gapless.

Kalinka is an open-source music player for Raspberry Pi and other small Linux systems. Its playback engine is a C++ audio graph using libFLAC++ or minimp3 for decoding and ALSA for output. It is not built around MPD, so track switching, buffering and output-format policy all live inside Kalinka.

The key decision was simple:

Keep the PCM device open for the whole playback session, not for one track.

Why separate ALSA sessions cannot be made gapless

The first implementation anyone is likely to write looks roughly like this:

Track A → open and configure ALSA → write PCM → drain or stop → close
                                                                    ┆
Track B → open and configure ALSA → write PCM → drain or stop → close
Enter fullscreen mode Exit fullscreen mode

The important part of this diagram is not drain() by itself. It is the boundary between two complete PCM sessions.

For ALSA, a PCM handle is a stream with a particular hardware configuration and a state of its own. ALSA knows which frames have been queued and whether the stream is running, prepared or stopped. It does not know that the file which just ended and the file about to begin are two parts of one album.

If the player finishes the whole ALSA cycle at every EOF, it tells the output layer that the stream itself has ended. snd_pcm_drain() is only one possible step in that cycle: it preserves the queued tail, waits for it to play, and then stops the PCM. snd_pcm_drop() stops sooner but discards the tail. Closing the handle, reopening it, applying hardware parameters, filling its buffer and starting it again add further work, but removing any one of those calls does not restore continuity.

The crucial loss is that the queue reaches zero. The first frames of track B are not sitting immediately behind the last frames of track A in the same running PCM buffer. Once A has been treated as a completed ALSA stream, B has to begin as a new run: the handle must be opened or prepared, its format must be valid, some data must be queued, and playback must be started explicitly or through start_threshold.

At 48 kHz neighbouring PCM frames are only about 20.8 microseconds apart. That number is useful for understanding the requirement, but it is not a realistic restart budget. It describes a stream that is already running. ALSA provides no portable operation that means: “start this separately prepared PCM session exactly one frame after the previous session ends”. mmap access can reduce copying, and a low start threshold can reduce initial buffering, but neither creates a shared sample timeline between two stopped-and-started sessions.

This is why the problem cannot be repaired by finding a cleverer sequence of ALSA calls. Once the application has divided playback into one PCM session per track, ALSA has no information or buffered data with which to join those sessions sample for sample. The application has already discarded the continuity it is asking ALSA to reconstruct.

ALSA does have mechanisms for synchronising several PCM streams. snd_pcm_link() can link their state transitions, and hardware can advertise sample-resolution synchronised start. But that depends on having multiple compatible hardware streams with a shared synchronisation domain. It is not a general sequential-handover mechanism for a simple DAC exposing one playback substream, and it does not turn two independently configured track sessions into one sample-continuous stream.

ALSA plugins or an audio server can solve a different version of the problem by keeping one fixed-format slave stream alive and resampling or mixing the tracks into it. That can preserve audible continuity, but it changes the output policy and is no longer a direct source-native path.

For a direct PCM path, the solution has to sit above ALSA: do not end the output stream when a file ends. Prepare the next source in advance and append its first frames to the same running buffer immediately after the previous source’s final frames.

One output stream, replaceable decoders

A track and an output device have very different lifetimes. A decoder may live for four minutes. The ALSA device may stay open for an evening.

So I separated them:

Stream switcher

Only one decoder supplies samples at a time. The next one can still be opened, initialised and buffered while the current track is playing.

This gives two advantages:

  1. the next source is ready before the boundary arrives;
  2. the ALSA ring buffer survives the boundary and gives the software time to switch producers.

The second point turned out to be just as important as the first. Kalinka normally keeps about 160 ms of audio queued in ALSA. During that time the DAC continues playing without caring which decoder will provide the following frames. The software does not have to switch at an exact CPU instruction; it only has to finish before the already queued audio runs out.

Why the output node must know about the boundary

My first mental model was that the stream switcher should hide the track change. The output would see one uninterrupted sequence of frames and would not know that a new decoder had taken over.

That only works while the complete PCM format stays the same: sample rate, sample representation, channel count and layout.

Suppose track A is 16-bit stereo at 44.1 kHz and track B is 24-bit stereo at 96 kHz. If the switcher silently starts passing B’s frames into a device still configured for A, ALSA has no way to infer the change. The bytes are interpreted using the old configuration. At best the result is played at the wrong speed or with the wrong sample layout; at worst the write fails.

The hw: ALSA plugin communicates directly with the kernel driver and performs no conversion. A PCM handle has one active hardware configuration installed through snd_pcm_hw_params(). Changing that configuration means stopping or draining the current stream, applying new parameters and preparing it again.

There are several valid ways to avoid that reconfiguration, but each chooses a different output policy:

  • Resample and convert everything to one internal format. The hardware might stay at 48 kHz, 32-bit stereo for the whole session. Tracks at 44.1, 88.2 or 96 kHz are converted before reaching it. This can remain gapless to the listener, but it is no longer source-native or bit-perfect.
  • Use ALSA conversion and mixing plugins, PipeWire or another audio server. ALSA’s plug plugin can convert channels, rate and format; dmix can combine multiple streams into one fixed slave configuration. This is a perfectly reasonable desktop design, but the conversion and mixing policy is now delegated to another layer.
  • Use multiple hardware playback subdevices and a hardware mixer. Some devices expose that capability. Many simple DACs and Raspberry Pi audio boards do not. Even when it exists, independently configured hardware streams are not automatically sample-aligned at the handover, so it is not a portable route to sample-perfect switching.
  • Keep a direct, source-native path and reconfigure when the format changes. This is Kalinka’s choice. It preserves the input format where the device supports it, but it means that only equal-format transitions use the guaranteed gapless path.

This distinction matters because “gapless” and “bit-perfect” are separate requirements. Resampling can solve continuity by sacrificing source-native output. Direct output can preserve the source format, but a format change becomes an explicit event and may introduce a pause.

For that reason the track boundary cannot be hidden from the output node. The output is the component that knows the current device configuration and can decide whether the next stream is compatible.

The source-change handshake

AudioStreamSwitcher keeps a queue of input nodes and a pointer to the active one. When the current input finishes and another one is waiting, it does not immediately start reading from the next node:

if (state.state == AudioGraphNodeState::FINISHED && !inputNodes.empty()) {
  currentInputNode = nullptr;
  setState(StreamState(AudioGraphNodeState::SOURCE_CHANGED));
  return false;
}
Enter fullscreen mode Exit fullscreen mode

It removes the finished input and reports SOURCE_CHANGED. While no source is active, reads return no data:

size_t AudioStreamSwitcher::read(void *data, size_t size) {
  // ...
  if (currentInput == nullptr) {
    return 0;
  }
  return currentInput->read(data, size);
}
Enter fullscreen mode Exit fullscreen mode

The transfer remains paused until the consumer calls acceptSourceChange():

// Accept the source change. This is called when the source has changed and
// the reader should accept the new source.
// The data transfer stops until the source is accepted.
Enter fullscreen mode Exit fullscreen mode

That pause is deliberate. It gives the ALSA output node a chance to inspect the next stream before any of its frames are written using the old configuration.

It sounds dangerous to stall a live audio graph, but the hardware is still playing the frames already in its buffer. The handshake takes a tiny fraction of the available 100–160 ms, so it never becomes an audible underrun under normal conditions.

The same-format path does almost nothing

The ALSA emitter handles the source-change state:

if (inputNodeState.state != AudioGraphNodeState::STREAMING ||
    newStreamInfo.value().format != currentStreamAudioFormat || paused) {
  drainPcm();
  setState(StreamState(AudioGraphNodeState::SOURCE_CHANGED));
  return false;
}
Enter fullscreen mode Exit fullscreen mode

If the next source is already streaming, its PCM format matches the current device configuration and playback is not paused, the condition is false.

Nothing is drained. Nothing is dropped. No parameters are changed. The PCM handle stays open and the emitter continues writing to the same mmap-backed buffer. The only difference is that the next frames came from another decoder.

That is the whole fast path.

For a different format, Kalinka deliberately takes the slower path: drain pending audio, apply the new hardware parameters and continue. That transition is not guaranteed gapless. It could be made continuous by converting every track to a fixed output format, but that is not the policy I chose for Kalinka.

This is also why “gapless across any files” is too broad a claim. The precise claim is:

Kalinka adds no samples, removes no samples and does not stop the ALSA stream when two adjacent tracks have the same output PCM format.

Why avoiding reconfiguration matters in practice

Avoiding device setup is not only an optimisation. Hardware and compatibility layers can behave differently around format changes.

On my Raspberry Pi 4 with a HiFiBerry Digi2 Pro, an older kernel/firmware combination sometimes reported the device ready before the I2S path was actually producing stable output. The first part of the next track disappeared. The only workaround that helped was a configurable delay after applying the new format:

// Hack for HiFiBerry boards on Raspberry Pi
// Sleep to make sure RPi is ready to play.
// I2S sync mechanism doesn't work properly
// wich results in the first ~500 ms of the track being cut off.
Enter fullscreen mode Exit fullscreen mode

I have not reproduced that issue on current kernels, so the delay defaults to zero.

On a development laptop using pipewire-alsa, I encountered a different problem: at the time, changing the format required closing and reopening the compatibility device, although direct ALSA output did not. Kalinka has a second optional fixup for that case, the issue has since been fixed.

Neither workaround is elegant, but both reinforce the same practical point: a format-change path touches more hardware- and stack-specific behaviour than a normal write. The best boundary is the one that does not enter that path at all.

One caveat is important here. Kalinka’s default device is default, not necessarily a raw hw: endpoint, and ALSA conversion may be present depending on the user’s configuration. Kalinka’s guarantee is therefore narrow: it does not resample merely to keep the device configuration fixed. Whether the final path is bit-perfect still depends on the selected ALSA device and its configuration.

Two details that make the design actually work

Prepare the next track before EOF

Opening the next track after the current decoder reaches EOF is too late. The source may be remote, headers must be read and the decoder must fill its first buffer.

Kalinka begins this work five seconds before the current track ends:

PREFETCH_TIME_MS = 5000
Enter fullscreen mode Exit fullscreen mode

It builds the next source and decoder chain and connects it to the switcher while the current track is still playing. The buffers are bounded, so the producer blocks once they are full rather than consuming unbounded memory.

By the time the boundary arrives, the next source is usually already in the streaming state. The handshake then only selects it.

At the end of a track, the final decoder read is usually shorter than the normal block size. The graph must preserve that exact frame count so that nothing is inserted, dropped or duplicated at the boundary.

Testing a track boundary without audio hardware

Listening is useful, but it is not a strong test. A click may be hard to notice, and a few missing samples may be masked by the music. I wanted a test that could answer a simpler question: are the output bytes exactly right?

1. Capture the ALSA output

ALSA includes a file plugin. Kalinka can use it as the output device and write every PCM frame to a raw file while the slave device is null:

pcm.kalinka_capture {
    type file
    slave.pcm "null"
    file "/tmp/capture.raw"
    format raw
}
Enter fullscreen mode Exit fullscreen mode

The real graph, decoders, switcher and emitter still run. Only the physical sound card is removed.

The test uses latency_ms = 100 and period_ms = 25. At 44.1 kHz, the ALSA buffer contains 4410 frames split into periods of 1102 frames. The boundary therefore has to be completed inside a realistic playback window.

2. Create a signal with a known continuation

The source is a three-second, 441.5 Hz sine wave at 44.1 kHz, 16-bit stereo and −6 dBFS. It is cut exactly in half at frame 66,150 and encoded as two FLAC files.

Using one tone cut into two files is important. Track B must continue the same waveform, in phase, at the exact next sample. If I used two unrelated tones, a legitimate discontinuity between them could hide an implementation error.

The cut is also placed near a positive crest rather than a zero crossing, making inserted silence or duplicated samples easy to detect.

3. Compare the capture with the original PCM

The test checks:

  • total frame count;
  • byte-for-byte equality;
  • the longest run of digital silence;
  • the largest change between neighbouring samples;
  • that no drain() occurred for the equal-format transition;
  • that a 44.1-to-48 kHz transition does perform a drain and reconfiguration.

The result for the equal-format case is sample-identical: 132,300 frames, with the join at frame 66,150. No frame is inserted, removed or duplicated.

The first file is shown in blue and the second in orange below. The colour is the only visible trace of the boundary:

Captured ALSA output across the boundary, coloured by source file, at 12 ms and at sample resolution

The same join in Audacity:

The captured output in Audacity, zoomed to individual samples across the track boundary

For comparison, this is the same signal with 3 ms of silence inserted at the boundary:

The captured output next to the same boundary with 3 ms of silence inserted

The audio examples are short enough to compare directly:

The maximum step between neighbouring samples is 1031 in the clean capture and 16,384 after inserting the gap. The latter is a full-amplitude jump and corresponds to an obvious click.

Two complementary assertions verify the decision at the boundary. When the formats match, the emitter must continue without draining the PCM device. When the next track uses a different format, the test requires the reconfiguration path instead.

The test is in test_gapless_playback.py. It runs without audio hardware and can write the WAV files and plots when KALINKA_GAPLESS_ARTIFACTS points to a directory.

Reproducing the byte comparison

The result does not depend on Python or on the test implementation. Decode the two source files with the reference flac tool, concatenate them and compare that PCM with Kalinka’s capture:

$ flac --decode --force-raw-format --endian=little --sign=signed a.flac -o a.raw
$ flac --decode --force-raw-format --endian=little --sign=signed b.flac -o b.raw
$ cat a.raw b.raw > reference.raw
$ cmp reference.raw captured-joined.raw && echo identical
identical
$ sha256sum reference.raw captured-joined.raw
482bd54a14ee9ce215008b4ad57e14bcdbcc36167ec2fe6aabb73a0b6828d5d0  reference.raw
482bd54a14ee9ce215008b4ad57e14bcdbcc36167ec2fe6aabb73a0b6828d5d0  captured-joined.raw
Enter fullscreen mode Exit fullscreen mode

The inputs, raw capture and verify.sh are published with the test assets.

A null test in an audio editor says the same thing. Invert the reference, mix it with the capture, and the result is digital silence:

Null test in Audacity: the captured output summed with an inverted reference, leaving digital silence

This verifies the digital path from the decoders to the ALSA capture device. It is not a measurement of the analogue output. A physical check would require an S/PDIF loopback for bit-exact capture or an ADC for the analogue path. I have not run that experiment yet.

What the engine cannot repair

A player can avoid adding its own gap, but it cannot remove every gap already present in the files.

FLAC is convenient here because it is lossless: the decoded PCM is bit-for-bit identical to the encoded input. Two FLAC files cut from one PCM stream can therefore be joined exactly.

Lossy formats have additional complications. MP3 encoders normally introduce encoder delay and padding. Gapless-capable decoders need metadata describing how many samples to trim. minimp3 handles the Xing/Info header with the LAME extension, but files relying only on other metadata conventions may still produce a small discontinuity.

The engine also cannot know whether a second of silence at the end of a track is deliberate. Gapless playback means not adding an artificial boundary; it does not mean rewriting the album.

The useful model

The implementation became much easier once I stopped thinking of playback as “open a file, play it, close everything, repeat”.

The output stream belongs to the session. Files and decoders are temporary producers behind it. At a track boundary the producer changes, but the sink continues.

There is one important qualification: the output node must approve the new producer. If the complete PCM format matches, the correct action is almost nothing — keep writing to the same device and buffer. If the format changes, the player must choose between conversion and reconfiguration. Kalinka chooses source-native output and accepts that those transitions are not guaranteed gapless.

The resulting model is simple: the player keeps one output stream running while decoders are connected and replaced behind it. A track boundary changes the source of the next PCM frames, not the lifetime of the ALSA stream.

References

Top comments (0)