<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: MadEnvel</title>
    <description>The latest articles on DEV Community by MadEnvel (@madenvel).</description>
    <link>https://dev.to/madenvel</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4038437%2Ff58da7f6-adb1-4a10-9381-608955cb7ad6.png</url>
      <title>DEV Community: MadEnvel</title>
      <link>https://dev.to/madenvel</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/madenvel"/>
    <language>en</language>
    <item>
      <title>Gapless playback with one ALSA device</title>
      <dc:creator>MadEnvel</dc:creator>
      <pubDate>Sat, 25 Jul 2026 21:56:10 +0000</pubDate>
      <link>https://dev.to/madenvel/gapless-playback-in-c-alsa-outlives-the-track-1ln1</link>
      <guid>https://dev.to/madenvel/gapless-playback-in-c-alsa-outlives-the-track-1ln1</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;But it still paused between tracks.&lt;/p&gt;

&lt;p&gt;For many albums that is only a minor annoyance. For others it changes the music. Pink Floyd’s &lt;a href="https://www.pinkfloyd.com/albums/the-dark-side-of-the-moon/" rel="noopener noreferrer"&gt;&lt;em&gt;The Dark Side of the Moon&lt;/em&gt;&lt;/a&gt; was developed and performed as one complete piece, and many of its tracks run directly into the next. Parts of &lt;em&gt;The Division Bell&lt;/em&gt; use the same kind of transitions. Jean-Michel Jarre’s &lt;a href="https://jeanmicheljarre.com/music/oxygene" rel="noopener noreferrer"&gt;&lt;em&gt;Oxygène&lt;/em&gt;&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;That was when gapless playback stopped being a nice extra for me and became something I wanted Kalinka to do properly.&lt;/p&gt;

&lt;p&gt;By &lt;em&gt;gapless&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Kalinka is an &lt;a href="https://github.com/madenvel/KalinkaPlayer" rel="noopener noreferrer"&gt;open-source music player&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;The key decision was simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Keep the PCM device open for the whole playback session, not for one track.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why separate ALSA sessions cannot be made gapless
&lt;/h2&gt;

&lt;p&gt;The first implementation anyone is likely to write looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Track A → open and configure ALSA → write PCM → drain or stop → close
                                                                    ┆
Track B → open and configure ALSA → write PCM → drain or stop → close
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part of this diagram is not &lt;code&gt;drain()&lt;/code&gt; by itself. It is the boundary between two complete PCM sessions.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If the player finishes the whole ALSA cycle at every EOF, it tells the output layer that the stream itself has ended. &lt;code&gt;snd_pcm_drain()&lt;/code&gt; is only one possible step in that cycle: it preserves the queued tail, waits for it to play, and then stops the PCM. &lt;code&gt;snd_pcm_drop()&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;start_threshold&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;ALSA does have mechanisms for synchronising several PCM streams. &lt;code&gt;snd_pcm_link()&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  One output stream, replaceable decoders
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So I separated them:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvlw7wkqnb7ygdi1o6imy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvlw7wkqnb7ygdi1o6imy.png" alt="Stream switcher" width="799" height="269"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;This gives two advantages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the next source is ready before the boundary arrives;&lt;/li&gt;
&lt;li&gt;the ALSA ring buffer survives the boundary and gives the software time to switch producers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the output node must know about the boundary
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That only works while the complete PCM format stays the same: sample rate, sample representation, channel count and layout.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;There are several valid ways to avoid that reconfiguration, but each chooses a different output policy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resample and convert everything to one internal format.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use ALSA conversion and mixing plugins, PipeWire or another audio server.&lt;/strong&gt; ALSA’s &lt;code&gt;plug&lt;/code&gt; plugin can convert channels, rate and format; &lt;code&gt;dmix&lt;/code&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use multiple hardware playback subdevices and a hardware mixer.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a direct, source-native path and reconfigure when the format changes.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The source-change handshake
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AudioStreamSwitcher&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;AudioGraphNodeState&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;FINISHED&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;inputNodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;currentInputNode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;nullptr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StreamState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AudioGraphNodeState&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;SOURCE_CHANGED&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It removes the finished input and reports &lt;code&gt;SOURCE_CHANGED&lt;/code&gt;. While no source is active, reads return no data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;AudioStreamSwitcher&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currentInput&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nb"&gt;nullptr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;currentInput&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The transfer remains paused until the consumer calls &lt;code&gt;acceptSourceChange()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Accept the source change. This is called when the source has changed and&lt;/span&gt;
&lt;span class="c1"&gt;// the reader should accept the new source.&lt;/span&gt;
&lt;span class="c1"&gt;// The data transfer stops until the source is accepted.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same-format path does almost nothing
&lt;/h2&gt;

&lt;p&gt;The ALSA emitter handles the source-change state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputNodeState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;AudioGraphNodeState&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;STREAMING&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="n"&gt;newStreamInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;format&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;currentStreamAudioFormat&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;drainPcm&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StreamState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AudioGraphNodeState&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;SOURCE_CHANGED&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the next source is already streaming, its PCM format matches the current device configuration and playback is not paused, the condition is false.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That is the whole fast path.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This is also why “gapless across any files” is too broad a claim. The precise claim is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Kalinka adds no samples, removes no samples and does not stop the ALSA stream when two adjacent tracks have the same output PCM format.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why avoiding reconfiguration matters in practice
&lt;/h2&gt;

&lt;p&gt;Avoiding device setup is not only an optimisation. Hardware and compatibility layers can behave differently around format changes.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Hack for HiFiBerry boards on Raspberry Pi&lt;/span&gt;
&lt;span class="c1"&gt;// Sleep to make sure RPi is ready to play.&lt;/span&gt;
&lt;span class="c1"&gt;// I2S sync mechanism doesn't work properly&lt;/span&gt;
&lt;span class="c1"&gt;// wich results in the first ~500 ms of the track being cut off.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I have not reproduced that issue on current kernels, so the delay defaults to zero.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://gitlab.freedesktop.org/pipewire/pipewire/-/work_items/4297" rel="noopener noreferrer"&gt;issue&lt;/a&gt; has since been fixed.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;One caveat is important here. Kalinka’s default device is &lt;code&gt;default&lt;/code&gt;, not necessarily a raw &lt;code&gt;hw:&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two details that make the design actually work
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Prepare the next track before EOF
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Kalinka begins this work five seconds before the current track ends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PREFETCH_TIME_MS = 5000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;By the time the boundary arrives, the next source is usually already in the streaming state. The handshake then only selects it.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing a track boundary without audio hardware
&lt;/h2&gt;

&lt;p&gt;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?&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Capture the ALSA output
&lt;/h3&gt;

&lt;p&gt;ALSA includes a &lt;code&gt;file&lt;/code&gt; plugin. Kalinka can use it as the output device and write every PCM frame to a raw file while the slave device is &lt;code&gt;null&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pcm.kalinka_capture {
    type file
    slave.pcm "null"
    file "/tmp/capture.raw"
    format raw
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real graph, decoders, switcher and emitter still run. Only the physical sound card is removed.&lt;/p&gt;

&lt;p&gt;The test uses &lt;code&gt;latency_ms = 100&lt;/code&gt; and &lt;code&gt;period_ms = 25&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Create a signal with a known continuation
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  3. Compare the capture with the original PCM
&lt;/h3&gt;

&lt;p&gt;The test checks:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The first file is shown in blue and the second in orange below. The colour is the only visible trace of the boundary:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F08m6qfkz9pz1dncmwf9x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F08m6qfkz9pz1dncmwf9x.png" alt="Captured ALSA output across the boundary, coloured by source file, at 12 ms and at sample resolution" width="800" height="282"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same join in Audacity:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmmzvmumrqv7m88au3v20.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmmzvmumrqv7m88au3v20.png" alt="The captured output in Audacity, zoomed to individual samples across the track boundary" width="799" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For comparison, this is the same signal with 3 ms of silence inserted at the boundary:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxu4rac6o0p319wyaefxk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxu4rac6o0p319wyaefxk.png" alt="The captured output next to the same boundary with 3 ms of silence inserted" width="799" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The audio examples are short enough to compare directly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/madenvel/KalinkaPlayer/releases/download/gapless-assets-v1/captured-joined.wav" rel="noopener noreferrer"&gt;captured-joined.wav&lt;/a&gt; — the two tracks played back to back;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/madenvel/KalinkaPlayer/releases/download/gapless-assets-v1/captured-with-gap.wav" rel="noopener noreferrer"&gt;captured-with-gap.wav&lt;/a&gt; — the same output with 3 ms of silence inserted;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/madenvel/KalinkaPlayer/releases/download/gapless-assets-v1/reference-joined.wav" rel="noopener noreferrer"&gt;reference-joined.wav&lt;/a&gt; — the original continuous tone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The test is in &lt;a href="https://github.com/madenvel/KalinkaPlayer/blob/main/packages/kalinka-server/tests/test_gapless_playback.py" rel="noopener noreferrer"&gt;&lt;code&gt;test_gapless_playback.py&lt;/code&gt;&lt;/a&gt;. It runs without audio hardware and can write the WAV files and plots when &lt;code&gt;KALINKA_GAPLESS_ARTIFACTS&lt;/code&gt; points to a directory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reproducing the byte comparison
&lt;/h3&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ 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 &amp;gt; reference.raw
$ cmp reference.raw captured-joined.raw &amp;amp;&amp;amp; echo identical
identical
$ sha256sum reference.raw captured-joined.raw
482bd54a14ee9ce215008b4ad57e14bcdbcc36167ec2fe6aabb73a0b6828d5d0  reference.raw
482bd54a14ee9ce215008b4ad57e14bcdbcc36167ec2fe6aabb73a0b6828d5d0  captured-joined.raw
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The inputs, raw capture and &lt;code&gt;verify.sh&lt;/code&gt; are &lt;a href="https://github.com/madenvel/KalinkaPlayer/releases/tag/gapless-assets-v1" rel="noopener noreferrer"&gt;published with the test assets&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F464pa2z3p7usduv0a9ls.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F464pa2z3p7usduv0a9ls.png" alt="Null test in Audacity: the captured output summed with an inverted reference, leaving digital silence" width="800" height="641"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the engine cannot repair
&lt;/h2&gt;

&lt;p&gt;A player can avoid adding its own gap, but it cannot remove every gap already present in the files.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The useful model
&lt;/h2&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.alsa-project.org/alsa-doc/alsa-lib/group___p_c_m.html" rel="noopener noreferrer"&gt;ALSA PCM interface: &lt;code&gt;snd_pcm_drain()&lt;/code&gt;, &lt;code&gt;snd_pcm_drop()&lt;/code&gt; and hardware parameters&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.alsa-project.org/alsa-doc/alsa-lib/pcm_plugins.html" rel="noopener noreferrer"&gt;ALSA PCM plugins: raw &lt;code&gt;hw&lt;/code&gt;, automatic &lt;code&gt;plug&lt;/code&gt;, rate conversion, &lt;code&gt;dmix&lt;/code&gt; and file capture&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://xiph.org/flac/features.html" rel="noopener noreferrer"&gt;FLAC features and lossless PCM guarantees&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cpp</category>
      <category>linux</category>
      <category>opensource</category>
      <category>raspberrypi</category>
    </item>
    <item>
      <title>The circular_buffer trap: how a 'faster' data structure spiked my CPU from 2% to 25%</title>
      <dc:creator>MadEnvel</dc:creator>
      <pubDate>Tue, 21 Jul 2026 22:35:35 +0000</pubDate>
      <link>https://dev.to/madenvel/the-circularbuffer-trap-how-a-faster-data-structure-spiked-my-cpu-from-2-to-25-1h2n</link>
      <guid>https://dev.to/madenvel/the-circularbuffer-trap-how-a-faster-data-structure-spiked-my-cpu-from-2-to-25-1h2n</guid>
      <description>&lt;p&gt;There's a special kind of bug that only shows up &lt;em&gt;after&lt;/em&gt; you've made your code "better." You swap in the data structure everyone agrees is more efficient, the microbenchmark cheers, you commit — and then the real system quietly starts burning CPU. This is the story of how I replaced &lt;code&gt;std::deque&lt;/code&gt; with &lt;code&gt;boost::circular_buffer&lt;/code&gt; in my audio player, watched playback CPU jump from 1–3% to 20–30%, and ended up reverting the whole thing a week later.&lt;/p&gt;

&lt;p&gt;The punchline up front: for a byte-streaming FIFO, &lt;code&gt;std::deque&lt;/code&gt; beat &lt;code&gt;boost::circular_buffer&lt;/code&gt; by at least 2×, and the "obvious" reasons circular buffers are supposed to be faster never applied.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/madenvel/KalinkaPlayer" rel="noopener noreferrer"&gt;Kalinka Player&lt;/a&gt; is a music player that runs on a Raspberry Pi. At its heart is a bounded producer/consumer buffer: a network/decoder thread &lt;strong&gt;writes&lt;/strong&gt; audio bytes to the back, and the audio output thread &lt;strong&gt;reads&lt;/strong&gt; and removes them from the front. The element type is &lt;code&gt;uint8_t&lt;/code&gt;, capacities are big (the HTTP buffer is ~768 KB, FLAC ~1.5 MB), and data moves in large chunks (~16 KB, matching &lt;code&gt;CURL_MAX_WRITE_SIZE&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The original buffer was backed by &lt;code&gt;std::deque&amp;lt;T&amp;gt;&lt;/code&gt;. The two hot paths look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;lock_guard&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;mutex&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// append to back&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;lock_guard&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;mutex&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;copy_n&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;             &lt;span class="c1"&gt;// copy from front&lt;/span&gt;
  &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;erase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// remove from front&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at that &lt;code&gt;read()&lt;/code&gt;. A ring buffer is &lt;em&gt;literally the textbook data structure&lt;/em&gt; for this access pattern. &lt;code&gt;deque&lt;/code&gt; has to manage a map of fixed-size blocks and, so my reasoning went, all that front-erasing must be doing real work. A &lt;code&gt;circular_buffer&lt;/code&gt; is a single fixed allocation with two indices — no allocation churn, perfect for a bounded FIFO. It felt like free performance.&lt;/p&gt;

&lt;p&gt;I even wrote a synthetic benchmark to confirm it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The synthetic benchmark that lied to me
&lt;/h2&gt;

&lt;p&gt;The microbenchmark pushed fixed-size records onto the back of a bounded queue and drained them from the front — exactly the producer/consumer shape, no overwrite. &lt;code&gt;circular_buffer&lt;/code&gt; won comfortably:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bounded FIFO, 32-byte records, 20M ops (higher is better):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;capacity&lt;/th&gt;
&lt;th&gt;boost::circular_buffer&lt;/th&gt;
&lt;th&gt;std::deque&lt;/th&gt;
&lt;th&gt;winner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;845 Mops/s&lt;/td&gt;
&lt;td&gt;304 Mops/s&lt;/td&gt;
&lt;td&gt;circular &lt;strong&gt;2.78×&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;813 Mops/s&lt;/td&gt;
&lt;td&gt;295 Mops/s&lt;/td&gt;
&lt;td&gt;circular &lt;strong&gt;2.75×&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;65536&lt;/td&gt;
&lt;td&gt;614 Mops/s&lt;/td&gt;
&lt;td&gt;280 Mops/s&lt;/td&gt;
&lt;td&gt;circular &lt;strong&gt;2.19×&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Case closed, right? &lt;code&gt;circular_buffer&lt;/code&gt; is 2–3× faster, it's the &lt;em&gt;correct&lt;/em&gt; data structure for a ring, and the benchmark agrees. On &lt;strong&gt;2024-09-11&lt;/strong&gt; I committed:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;bad7d04&lt;/code&gt; — Switch to boost::circular_buffer, give DequeBuffer a generic name&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Reality disagrees
&lt;/h2&gt;

&lt;p&gt;The unit tests passed. Playback worked. And then I looked at &lt;code&gt;top&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The KalinkaPlayer process, which used to sit at a comfortable &lt;strong&gt;1–3% CPU&lt;/strong&gt; during playback, was now churning at &lt;strong&gt;20–30%&lt;/strong&gt; — on a Raspberry Pi, where that actually matters for heat and battery. Nothing else had changed. The only difference was the buffer.&lt;/p&gt;

&lt;p&gt;My first instinct was that I'd used the container naively. And I had: I'd left the &lt;code&gt;read()&lt;/code&gt; path calling the generic &lt;code&gt;erase(begin, begin + n)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;erase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;sizeToCopy&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;boost::circular_buffer&lt;/code&gt; has a &lt;em&gt;dedicated&lt;/em&gt; front-removal method, &lt;code&gt;erase_begin(n)&lt;/code&gt;, that just advances the internal start index instead of going through the general erase machinery. So on &lt;strong&gt;2024-09-18&lt;/strong&gt; I fixed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- data.erase(data.begin(), data.begin() + sizeToCopy);
&lt;/span&gt;&lt;span class="gi"&gt;+ data.erase_begin(sizeToCopy);
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;402cdd1&lt;/code&gt; — Improve CPU usage by changing erase with more efficient erase_begin call on circular buffer — time down from 2ms to 0.02ms&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's a &lt;strong&gt;100× improvement&lt;/strong&gt; on that single call — 2 ms down to 0.02 ms. Huge! Surely &lt;em&gt;that&lt;/em&gt; was the CPU regression solved.&lt;/p&gt;

&lt;p&gt;It helped. It was not enough. CPU was better but still nowhere near the old &lt;code&gt;deque&lt;/code&gt; baseline. So I added a performance monitor (&lt;code&gt;60976db&lt;/code&gt;) to actually measure the hot paths instead of guessing — and the numbers were damning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring the real workload
&lt;/h2&gt;

&lt;p&gt;Synthetic record-pushing is not what the buffer actually does. The real buffer moves &lt;strong&gt;bulk byte ranges&lt;/strong&gt;: &lt;code&gt;insert(end, src, src+n)&lt;/code&gt; and &lt;code&gt;copy_n(begin, n, dst)&lt;/code&gt; over ~16 KB chunks of &lt;code&gt;uint8_t&lt;/code&gt;. So I rebuilt the benchmark around the &lt;em&gt;actual&lt;/em&gt; &lt;code&gt;Buffer&amp;lt;uint8_t&amp;gt;&lt;/code&gt; read/write hot paths, with the correct &lt;code&gt;erase_begin&lt;/code&gt; for the ring, and verified both containers produced byte-identical output before trusting any number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Buffer&amp;lt;uint8_t&amp;gt;&lt;/code&gt;, capacity 768 KB, 16 KB chunks, 4 GB streamed (higher is better):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Scenario A — fill to capacity, then drain to empty:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;container&lt;/th&gt;
&lt;th&gt;write MB/s&lt;/th&gt;
&lt;th&gt;read MB/s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;std::deque&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6387&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2798&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;boost::circular_buffer&lt;/td&gt;
&lt;td&gt;1655&lt;/td&gt;
&lt;td&gt;1785&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;circular vs deque&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;0.26×&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;0.64×&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Scenario B — steady interleave, buffer kept near full:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;container&lt;/th&gt;
&lt;th&gt;write MB/s&lt;/th&gt;
&lt;th&gt;read MB/s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;std::deque&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;33420&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4046&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;boost::circular_buffer&lt;/td&gt;
&lt;td&gt;3671&lt;/td&gt;
&lt;td&gt;2268&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;circular vs deque&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;0.11×&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;0.56×&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The "faster" data structure was &lt;strong&gt;1.6× to 9× slower&lt;/strong&gt; on the workload that mattered. Even &lt;em&gt;with&lt;/em&gt; the &lt;code&gt;erase_begin&lt;/code&gt; fix in place. On &lt;strong&gt;2024-09-18&lt;/strong&gt;, the same day, I gave up:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;5b281fc&lt;/code&gt; — Switch back to std::deque as buffer implementation as it is at least 2x faster than circular buffer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Total lifespan of the circular_buffer experiment: &lt;strong&gt;one week&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the ring loses: the memmove fast path
&lt;/h2&gt;

&lt;p&gt;Here's the mechanism, and it's the whole story.&lt;/p&gt;

&lt;p&gt;For a trivially-copyable type like &lt;code&gt;uint8_t&lt;/code&gt;, the standard library reduces bulk copies (&lt;code&gt;std::copy&lt;/code&gt;, &lt;code&gt;std::copy_n&lt;/code&gt;, and &lt;code&gt;deque&lt;/code&gt;'s range &lt;code&gt;insert&lt;/code&gt;) to a single vectorized &lt;strong&gt;&lt;code&gt;memmove&lt;/code&gt;&lt;/strong&gt; — but &lt;em&gt;only when it can prove the iterators are contiguous&lt;/em&gt;. Raw pointers qualify. &lt;code&gt;std::deque&lt;/code&gt;'s block iterators qualify (the library special-cases them, copying block-by-block via &lt;code&gt;memmove&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;boost::circular_buffer&lt;/code&gt;'s iterator is &lt;strong&gt;not&lt;/strong&gt; contiguous — its storage wraps, so every &lt;code&gt;++&lt;/code&gt; does modular index arithmetic. The library can't take the &lt;code&gt;memmove&lt;/code&gt; path, so both hot ops collapse to a scalar, per-element, unvectorizable loop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;write:&lt;/strong&gt; &lt;code&gt;deque::insert(end, src, src+n)&lt;/code&gt; → &lt;code&gt;memmove&lt;/code&gt; into the tail block. That's the 33 GB/s in Scenario B — essentially raw &lt;code&gt;memcpy&lt;/code&gt;. The ring can only copy one byte at a time through its wrapping iterator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;read:&lt;/strong&gt; &lt;code&gt;copy_n(begin, n, dst)&lt;/code&gt; → &lt;code&gt;memmove&lt;/code&gt; out of the front blocks for deque; byte-at-a-time for the ring.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That single fact — &lt;em&gt;no &lt;code&gt;memmove&lt;/code&gt; for a wrapping buffer&lt;/em&gt; — accounts for the entire gap, on both reads and writes.&lt;/p&gt;

&lt;p&gt;And every intuition that sent me down this path turned out to be irrelevant here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"No allocation churn."&lt;/strong&gt; True, but at 16 KB chunks &lt;code&gt;deque&lt;/code&gt; mostly reuses its already-allocated blocks — writes hit an existing tail block and &lt;code&gt;memmove&lt;/code&gt; into it. There's almost no allocation to avoid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Better locality — one contiguous buffer."&lt;/strong&gt; Also true, and it genuinely favors the ring... but the win is buried under per-element iterator overhead that dwarfs any cache benefit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"erase_begin is the fix."&lt;/strong&gt; It was a real 100× win on that &lt;em&gt;one call&lt;/em&gt;, and still the ring lost overall — because the &lt;code&gt;copy&lt;/code&gt; and &lt;code&gt;insert&lt;/code&gt; around it were the actual bottleneck, not the erase.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The synthetic benchmark wasn't &lt;em&gt;wrong&lt;/em&gt; — it faithfully measured element-by-element pushes of 32-byte records, and for &lt;em&gt;that&lt;/em&gt; workload the ring really is faster. It just measured a workload my program never runs. My program moves big contiguous runs of bytes, and that's precisely where &lt;code&gt;memmove&lt;/code&gt; makes &lt;code&gt;deque&lt;/code&gt; untouchable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Benchmark the real operation, not the shape of the problem.&lt;/strong&gt; "Bounded FIFO" made me benchmark pushes and pops of single records. The real workload was bulk &lt;code&gt;memcpy&lt;/code&gt; of byte ranges. Same data structure, completely different performance regime.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trivially-copyable + contiguous = &lt;code&gt;memmove&lt;/code&gt;, and it's enormous.&lt;/strong&gt; If your hot path is bulk-copying POD, the single most important question is whether the library can lower it to &lt;code&gt;memmove&lt;/code&gt;. A container that breaks contiguity (a wrapping ring, a linked structure) forfeits that, and no amount of micro-optimizing the &lt;em&gt;other&lt;/em&gt; calls buys it back.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;"The textbook data structure for X" is an argument, not a measurement.&lt;/strong&gt; A ring buffer is the canonical structure for a bounded FIFO. It was still the wrong choice here. The canonical answer optimizes for allocation and wraparound; my bottleneck was neither.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Watch the system, not just the unit test.&lt;/strong&gt; The tests were green the whole time. The regression only showed up as CPU on a running Pi. A cheap always-on performance counter (&lt;code&gt;60976db&lt;/code&gt;) turned "it feels sluggish" into "here are the microseconds," and that's what ended the debate.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;std::deque&lt;/code&gt; has quietly backed KalinkaPlayer's audio buffer ever since. The best data structure was the one I already had.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The benchmarks in this article are reproducible. The full single-file C++ harnesses — one reproducing the &lt;code&gt;Buffer&amp;lt;uint8_t&amp;gt;&lt;/code&gt; read/write hot paths across both containers (verifying byte-identical output before timing), and one for the synthetic record-push benchmark — are available as a &lt;a href="https://gist.github.com/madenvel/19915557bd2329654b543c73d87c679c" rel="noopener noreferrer"&gt;GitHub Gist&lt;/a&gt;. Build with &lt;code&gt;g++ -O2 -std=c++17&lt;/code&gt; (Boost's &lt;code&gt;circular_buffer&lt;/code&gt; is header-only) and run; they print the tables above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cpp</category>
      <category>performance</category>
      <category>datastructures</category>
      <category>programming</category>
    </item>
    <item>
      <title>"Something melancholic for tonight": Building semantic music search that runs on a Raspberry Pi</title>
      <dc:creator>MadEnvel</dc:creator>
      <pubDate>Mon, 20 Jul 2026 16:22:30 +0000</pubDate>
      <link>https://dev.to/madenvel/something-melancholic-for-tonight-building-semantic-music-search-that-runs-on-a-raspberry-pi-3kko</link>
      <guid>https://dev.to/madenvel/something-melancholic-for-tonight-building-semantic-music-search-that-runs-on-a-raspberry-pi-3kko</guid>
      <description>&lt;p&gt;I wanted to type &lt;strong&gt;"something melancholic for tonight"&lt;/strong&gt; and get a playlist from my own FLAC collection — matched by how the music &lt;em&gt;sounds&lt;/em&gt;, not by tags. And I wanted it to run entirely locally, on the same Raspberry Pi 4 with 4 GB of RAM that plays the music. No cloud, no external APIs.&lt;/p&gt;

&lt;p&gt;This post is about getting there: two failed approaches, a benchmark that told me exactly what my model couldn't do, a tiny 66k-parameter network that fixed it, and the memory tricks needed to make everything fit next to a running audio server.&lt;/p&gt;

&lt;p&gt;Some context first. For the last three years I've been building &lt;a href="https://kalinkaplayer.com" rel="noopener noreferrer"&gt;Kalinka&lt;/a&gt; (&lt;a href="//[github.com/madenvel/KalinkaPlayer]"&gt;GitHub&lt;/a&gt;), an open-source Hi-Fi music streamer: a server that runs on a Raspberry Pi connected to a DAC and plays audio bit-perfect and gapless through ALSA, with desktop/mobile apps acting as remote controls. It grew out of frustration with my receiver's built-in streaming (every 192 kHz track started with an audible stutter — I only noticed after two years, when I heard the same song open cleanly on YouTube), and with the alternatives: Roon wanted a subscription, Volumio put Qobuz behind an extra paywall on top of the Qobuz subscription I already paid for. So I did the reasonable thing and wrote my own.&lt;/p&gt;

&lt;p&gt;The C++ audio graph, gapless playback against CDN links that expire mid-queue, and the real-time client sync each deserve their own write-up. Today: search.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with tags
&lt;/h2&gt;

&lt;p&gt;By the time Kalinka's local library became central, I had a collection big enough to hit a familiar wall: lots of music, no way to pick something &lt;em&gt;for right now&lt;/em&gt;. Genre, year, album — none of it describes how a track sounds. Text search assumes you already know what you're looking for. I wanted the opposite: describe a mood, get tracks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 1: pre-trained taggers (it went badly)
&lt;/h2&gt;

&lt;p&gt;The obvious approach: auto-tag the whole library, then search the tags. The &lt;a href="https://essentia.upf.edu/" rel="noopener noreferrer"&gt;Essentia&lt;/a&gt; project ships pre-trained TensorFlow models for exactly this, so I wired up three:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Predicts&lt;/th&gt;
&lt;th&gt;Model (backbone → head)&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Genre&lt;/td&gt;
&lt;td&gt;EffNet-Discogs → Discogs-400&lt;/td&gt;
&lt;td&gt;top-N genre labels + scores&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mood&lt;/td&gt;
&lt;td&gt;VGGish → MIREX mood&lt;/td&gt;
&lt;td&gt;one of 5 mood clusters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Danceability&lt;/td&gt;
&lt;td&gt;VGGish → danceability&lt;/td&gt;
&lt;td&gt;a single float&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It worked… disappointingly. The genre classifier was noisy — a suspicious number of tracks came back as &lt;code&gt;electronic---ambient&lt;/code&gt; regardless of what they actually were — and produced usable genres for only about a third of my library. The mood model collapsed the entire collection into three or four clusters. The published numbers explain why: PR-AUC below 0.2. Multi-label music tagging is just a hard problem.&lt;/p&gt;

&lt;p&gt;And even ignoring quality, the stack was too heavy. TensorFlow plus its dependency tree is not something you want to install on a Raspberry Pi.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 2: betting on CLAP
&lt;/h2&gt;

&lt;p&gt;In parallel I was already computing an embedding for every track with &lt;a href="https://huggingface.co/docs/transformers/en/model_doc/clap" rel="noopener noreferrer"&gt;CLAP&lt;/a&gt; (Contrastive Language-Audio Pretraining). CLAP's core idea is what I actually needed: audio and text map into the &lt;em&gt;same&lt;/em&gt; vector space, so a text query becomes an embedding and search becomes nearest-neighbour lookup — no intermediate tags at all.&lt;/p&gt;

&lt;p&gt;It quickly became clear this was the real solution and the tagging pipeline had been a temporary crutch.&lt;/p&gt;

&lt;p&gt;Making CLAP behave on a Pi took a few iterations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dropped PyTorch + &lt;code&gt;laion-clap&lt;/code&gt; for ONNX Runtime&lt;/strong&gt; — far easier to distribute, much lighter at startup.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Swapped the general audio model for an &lt;a href="https://arxiv.org/abs/2202.00874" rel="noopener noreferrer"&gt;HTSAT&lt;/a&gt;-based CLAP trained specifically on music.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Since the model only "hears" ~10 seconds at a time, I &lt;strong&gt;sample several fragments per track and average the embeddings.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Measure before you optimize
&lt;/h2&gt;

&lt;p&gt;"Vibe" is too subjective to develop against, so before tuning anything I built a proper benchmark on &lt;a href="https://mtg.github.io/mtg-jamendo-dataset/" rel="noopener noreferrer"&gt;MTG-Jamendo&lt;/a&gt;, where tracks are labelled by humans. The results were clarifying - and a little sobering:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query category&lt;/th&gt;
&lt;th&gt;P@10&lt;/th&gt;
&lt;th&gt;Lift over random&lt;/th&gt;
&lt;th&gt;MRR&lt;/th&gt;
&lt;th&gt;Hit-rate@10&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Genre&lt;/td&gt;
&lt;td&gt;0.296&lt;/td&gt;
&lt;td&gt;+0.212&lt;/td&gt;
&lt;td&gt;0.670&lt;/td&gt;
&lt;td&gt;0.880&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instrument&lt;/td&gt;
&lt;td&gt;0.273&lt;/td&gt;
&lt;td&gt;+0.157&lt;/td&gt;
&lt;td&gt;0.476&lt;/td&gt;
&lt;td&gt;0.933&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mood / theme&lt;/td&gt;
&lt;td&gt;0.140&lt;/td&gt;
&lt;td&gt;+0.050&lt;/td&gt;
&lt;td&gt;0.315&lt;/td&gt;
&lt;td&gt;0.733&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Overall&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.247&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.153&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.520&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.855&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;CLAP is genuinely good at concrete sonic properties. Instrument queries shine — &lt;code&gt;piano&lt;/code&gt; hits P@10 = 0.80, meaning eight of the top ten results are relevant. Guitar, strings, violin all do well; among genres, hip-hop and jazz stand out.&lt;/p&gt;

&lt;p&gt;Abstract emotional concepts are another story. &lt;code&gt;uplifting&lt;/code&gt;, &lt;code&gt;inspiring&lt;/code&gt;, &lt;code&gt;love&lt;/code&gt; — effectively zero. Worse than picking tracks at random:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Strong (P@10)&lt;/th&gt;
&lt;th&gt;Weak (P@10)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Instruments&lt;/td&gt;
&lt;td&gt;piano 0.80, guitar 0.50, acoustic guitar / strings / cello 0.40&lt;/td&gt;
&lt;td&gt;synthesizer 0.30, voice 0.10, "electronic production" 0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Genres&lt;/td&gt;
&lt;td&gt;hip-hop 0.80, jazz / rock / electronic 0.60&lt;/td&gt;
&lt;td&gt;pop 0.20, techno / world 0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mood / theme&lt;/td&gt;
&lt;td&gt;epic 0.50, film / meditative 0.30&lt;/td&gt;
&lt;td&gt;uplifting / inspiring / love 0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This turns out to be a well-known property of CLAP. But reproducing it on my own benchmark was oddly satisfying: instead of a vague feeling that mood search was weak, I had a number — and a direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing mood: a small model on top of CLAP
&lt;/h2&gt;

&lt;p&gt;My hypothesis: if CLAP embeddings describe the &lt;em&gt;sound&lt;/em&gt; well, maybe a small extra model can learn to extract the &lt;em&gt;emotional&lt;/em&gt; component from them.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://cvml.unige.ch/databases/DEAM/" rel="noopener noreferrer"&gt;DEAM&lt;/a&gt; dataset fit perfectly — ~1,800 tracks hand-annotated on two axes: &lt;strong&gt;valence&lt;/strong&gt; (how positive the music feels) and &lt;strong&gt;arousal&lt;/strong&gt; (how energetic it is). I computed CLAP embeddings for DEAM and trained a two-layer MLP with just &lt;strong&gt;66k parameters&lt;/strong&gt; to map an embedding to a &lt;code&gt;(valence, arousal)&lt;/code&gt; point.&lt;/p&gt;

&lt;p&gt;(Training ran on my Lenovo ThinkBook 13x, which got hot enough to burn my fingers twice. The eventual fix was parking it under the air conditioner. A cooling pad is probably in my future.)&lt;/p&gt;

&lt;h3&gt;
  
  
  The part where I got a negative R²
&lt;/h3&gt;

&lt;p&gt;My first attempt was a textbook lesson in &lt;strong&gt;train/serve skew&lt;/strong&gt;. I had computed DEAM's embeddings with the &lt;em&gt;general-audio&lt;/em&gt; CLAP model — while production had already moved to the &lt;em&gt;music-trained&lt;/em&gt; one. On real production embeddings the results were catastrophic: &lt;strong&gt;R² = −0.42&lt;/strong&gt;. The model performed worse than a baseline that assigns every track the same average value.&lt;/p&gt;

&lt;p&gt;After finding the mismatch and retraining on the exact embeddings used in production:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Axis&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;R²&lt;/th&gt;
&lt;th&gt;CCC&lt;/th&gt;
&lt;th&gt;RMSE&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Valence&lt;/td&gt;
&lt;td&gt;MLP head&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.594&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.761&lt;/td&gt;
&lt;td&gt;0.755&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arousal&lt;/td&gt;
&lt;td&gt;MLP head&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.711&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.827&lt;/td&gt;
&lt;td&gt;0.683&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Valence&lt;/td&gt;
&lt;td&gt;ridge baseline&lt;/td&gt;
&lt;td&gt;0.480&lt;/td&gt;
&lt;td&gt;0.626&lt;/td&gt;
&lt;td&gt;0.855&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arousal&lt;/td&gt;
&lt;td&gt;ridge baseline&lt;/td&gt;
&lt;td&gt;0.631&lt;/td&gt;
&lt;td&gt;0.742&lt;/td&gt;
&lt;td&gt;0.773&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I plug this mood signal into ranking through a confidence gate, so it only influences queries that are actually about emotion — it stays out of the way when you search for &lt;code&gt;piano&lt;/code&gt; or &lt;code&gt;hip-hop&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Re-running the benchmark: &lt;strong&gt;mood search improved by ~80%&lt;/strong&gt; (lift over random went from +0.05 to +0.09), and P@10 for &lt;code&gt;uplifting&lt;/code&gt; went from 0.00 to 0.30. Genre and instrument search degraded by an amount small enough to ignore. A trade I'll take:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query category&lt;/th&gt;
&lt;th&gt;P@10&lt;/th&gt;
&lt;th&gt;Random baseline&lt;/th&gt;
&lt;th&gt;vs random&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Genre&lt;/td&gt;
&lt;td&gt;0.296&lt;/td&gt;
&lt;td&gt;0.084&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.5×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instrument&lt;/td&gt;
&lt;td&gt;0.273&lt;/td&gt;
&lt;td&gt;0.116&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.4×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mood / theme&lt;/td&gt;
&lt;td&gt;0.140&lt;/td&gt;
&lt;td&gt;0.090&lt;/td&gt;
&lt;td&gt;1.6×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Overall&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.247&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.094&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.6×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;th&gt;P@10&lt;/th&gt;
&lt;th&gt;Random baseline&lt;/th&gt;
&lt;th&gt;vs random&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;hip-hop&lt;/td&gt;
&lt;td&gt;0.80&lt;/td&gt;
&lt;td&gt;0.063&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12.7×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;trance&lt;/td&gt;
&lt;td&gt;0.40&lt;/td&gt;
&lt;td&gt;0.032&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12.5×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jazz&lt;/td&gt;
&lt;td&gt;0.60&lt;/td&gt;
&lt;td&gt;0.067&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.0×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rock&lt;/td&gt;
&lt;td&gt;0.60&lt;/td&gt;
&lt;td&gt;0.083&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.2×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;piano&lt;/td&gt;
&lt;td&gt;0.80&lt;/td&gt;
&lt;td&gt;0.277&lt;/td&gt;
&lt;td&gt;2.9×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Making it fit in 4 GB
&lt;/h2&gt;

&lt;p&gt;On the Pi, the bottleneck was memory, not compute. Indexing several tracks in a row kept growing RSS until the OOM killer took the process down; forcing Python's garbage collector after every iteration stopped the bleed. Beyond that, two optimizations did the heavy lifting:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Quantized stored embeddings to &lt;code&gt;int8&lt;/code&gt;.&lt;/strong&gt; Vectors shrank 4×, and ~99% of top-10 results stayed identical. Practically free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split CLAP in two.&lt;/strong&gt; The text encoder (needed for every user query) stays resident. The much heavier audio encoder is only needed for indexing — so it unloads automatically once indexing finishes and comes back when new tracks arrive.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Deleting the scaffolding
&lt;/h2&gt;

&lt;p&gt;At this point CLAP embeddings fully covered semantic search, and the little valence/arousal head covered mood — both jobs the Essentia taggers were originally there for, done better and cheaper. Meanwhile the taggers had turned into pure liability: &lt;code&gt;essentia-tensorflow&lt;/code&gt; ships ARM wheels only for Python 3.11, which had silently pinned my entire environment to 3.11 right when I needed 3.13.&lt;/p&gt;

&lt;p&gt;So the whole auto-tagging pipeline went in the bin — TensorFlow (a multi-hundred-megabyte dependency), the Python version pin, the &lt;code&gt;tags_predicted&lt;/code&gt; column, and all the tag-based ranking logic. What replaced genre and danceability detection? Nothing separate — CLAP already does it. What replaced the mood model? The one I trained myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shipped
&lt;/h2&gt;

&lt;p&gt;Kalinka's smart search today is exactly two components: CLAP audio embeddings and a 66k-parameter valence/arousal model, both running locally through ONNX Runtime. No TensorFlow, no external taggers, no cloud.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc4roe5ft0wzbl94d9bh4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc4roe5ft0wzbl94d9bh4.png" alt="Kalinka search results for " width="800" height="492"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A nice side effect: a Russian song and a Pink Floyd track can land next to each other in the results if they share a mood. The system ranks by how music sounds, not what it's called. And all of it fits on a Raspberry Pi 4 with 4 GB of RAM.&lt;/p&gt;

&lt;p&gt;(For completeness: semantic search is one layer of a larger query system — SQLite FTS for full-text, streaming-service search, and semantic catalog matching, with &lt;a href="https://rapidfuzz.com/" rel="noopener noreferrer"&gt;RapidFuzz&lt;/a&gt;-based ranking and smart-search results surfacing only when there's no exact match. One search box, several engines behind it.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Recent work went into first-run setup and easier installation, plus a second free streaming source (Jamendo) with semantic search built the same fully-local way. The model still can't search by origin or era — &lt;code&gt;italian 80s&lt;/code&gt; won't find my Toto Cutugno records — so there's plenty left to explore.&lt;/p&gt;

&lt;p&gt;Kalinka is open source, and I'd genuinely welcome testers and contributors — not just code: trying it on different hardware, bug reports, UI feedback, docs, new plugins. AI-assisted PRs are fine by me; what matters is that the author understands the solution, can explain and maintain it, and the code is tested and fits the architecture.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Kalinka Player GitHub:&lt;/strong&gt; &lt;a href="https://github.com/madenvel/KalinkaPlayer" rel="noopener noreferrer"&gt;Kalinka Player&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Kalinka Player Client:&lt;/strong&gt; &lt;a href="https://github.com/madenvel/KalinkaAI" rel="noopener noreferrer"&gt;Kalinka AI App&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Site:&lt;/strong&gt; &lt;a href="https://kalinkaplayer.com" rel="noopener noreferrer"&gt;https://kalinkaplayer.com&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>raspberrypi</category>
      <category>machinelearning</category>
      <category>showdev</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
