~/writing/tidal-24bit-pkce
Tidal stopped serving lossless, so I read the token
Upstream plugin started pulling 320 kbps instead of lossless FLAC, and swapping client IDs did nothing. The access token had frozen its entitlements at mint time, long before any request went out.
It looked like a vendor problem. Tidal started returning 320 kbps mp4 where it used to return lossless FLAC. The plugin had not changed. The account had not changed. The bitrate fell off a cliff anyway.
This is a Perl plugin for Lyrion (the server formerly called Logitech Media Server), a fork I maintain of the upstream Tidal plugin. The upstream issue tracker had the same complaint: lossless was being silently downgraded. I needed the fork to keep delivering 24-bit FLAC while I rebased it on upstream 1.8.1, so "wait for Tidal to fix it" wasn't an option. I went to find out why a request that used to work now didn't.
The answer wasn't in the request. It was sitting inside a token I'd been reusing for months, and it had quietly stopped meaning what I thought it meant.
The client ID that fixed nothing
The first theory was the obvious one. Tidal had de-authorized the old client ID and secret the plugin shipped with, so it was now authenticating as a client no longer entitled to lossless. Fine. I had a different client ID that was authorized for lossless. I swapped it in, kept the existing token, re-ran.
Still HIGH. Still 320 kbps mp4.
That made no sense under my model, where the client ID gates quality at request time. If the request now carried a lossless-authorized client ID, the response should've been lossless. It wasn't. So my model was wrong.
# This was the broken assumption: that quality is negotiated per request,
# so a fresh client ID on an old token would unlock lossless. It does not.
sub stream_url {
my ($class, $track_id, $token) = @_;
# We were sending an authorized client_id alongside an old access_token
# and still getting audioQuality => 'HIGH'. The client_id here is not
# what decides quality. The token already decided, when it was minted.
my $url = "$BASE/tracks/$track_id/playbackinfo"
. "?audioquality=HI_RES_LOSSLESS&playbackmode=STREAM&assetpresentation=FULL";
...
}What I'd missed: Tidal bakes entitlements into the access token at the moment it's issued, based on the client ID that issued it. The token isn't a bearer credential that gets re-evaluated against current entitlements on every call. It's a snapshot. A token minted by the now-dead client carries that client's limits for its entire life, and no amount of swapping client IDs on later requests changes what the token already promised. A downgraded token stays downgraded until you throw it away.
The credential that did nothing
Swapping the client ID changed the request and changed nothing in the response. When a credential you're sending has zero effect on the result, you're sending the wrong credential. The one that mattered had already been issued and frozen.
The only fix is to re-authenticate and mint a fresh token under an authorized client. There's no patching the old one.
PKCE is the only door that still opens
Minting a fresh token sounds trivial. It wasn't, and this is the second finding that cost me the most time.
The plugin authenticated with the OAuth Device Flow: you display a code, the user types it into a browser on another device, you poll for a token. As of late 2025 that flow is effectively dead for high resolution. Device-flow tokens are silently downgraded. You authenticate successfully, you get a perfectly valid token, and that token will never return HI_RES_LOSSLESS no matter what you ask for. I rotated through two newer device-flow client IDs hoping one was still privileged. Both returned 403.
The path that still yields HiRes is a browser-redirect authorization-code flow with PKCE: a genuinely different OAuth code path from the device flow, not a parameter you toggle. You send the user through a real browser authorization in their own session and exchange the returned code for a token, and that token comes back with the HiRes entitlement actually attached.
I'm going to stop short of a turnkey recipe, on purpose, for two reasons. A copy-paste exchange is exactly the sort of thing that gets noticed and quietly closed. And PKCE is a standard, well-documented OAuth extension, so there's nothing here I need to reprint. If you want to reproduce it, everything you need is already in front of you: the upstream plugin already implements the now-dead device flow, so it is a working example of how it talks to Tidal's token endpoint and where the authorization code is handled. Swapping that for the standard authorization-code-plus-PKCE flow, run against your own registered client and your own account, is mechanical from there. The one non-obvious fact, the thing this section exists to hand you, is that the token carries the entitlement: you have to mint a fresh one under an authorized client, and no amount of fixing up the request around an old token will do it.
The boundary, because it matters: your own paid account, your own registered OAuth client, your own browser. I'm describing how the auth model behaves, not handing out keys. There are no real client IDs, secrets, or tokens in this post, and there won't be.
A fresh token minted that way under an authorized client finally returned what I was after: HI_RES_LOSSLESS, 24-bit, 96 kHz, FLAC over DASH. Confirmed on the wire.
The airplane engine
Tokens sorted, I hit play on a 24-bit track and got an airplane engine. Loud, full-scale static. Not a glitch, not a dropout: continuous garbage where music should be. The token was right, the format negotiated was right, and the output was unlistenable.
Two causes, and they stacked.
The first was mine. I'd set the quality preference to LOSSLESS, which is 16-bit CD, while the negotiation was trying to pull a 24-bit DASH stream. The 24-bit pipeline isn't just "ask for more bits." It needs enableDASH=1 together with quality=HI_RES. With the quality flag set to plain LOSSLESS, the two halves of the request disagreed about what was coming back.
But fixing the flag didn't kill the static, which told me the flag wasn't the real culprit. The real culprit was server-side, in the conversion rules, and it had been sitting there for a long time.
Lyrion decides how to transcode a stream using conversion rules. For an Atmos source, an old rule of mine converted mp4eac3 to flc, decoding E-AC-3 to FLAC. That rule was now matching the DASH FLAC stream and trying to run a full E-AC-3 decode over data that was already FLAC inside an MPEG-DASH container. The decoder was interpreting FLAC frames as E-AC-3 and emitting exactly what you'd expect: noise at the volume of a jet.
The DASH FLAC path doesn't need transcoding at all. It needs a stream copy and a mime-type mapping so the server recognizes the container.
# DASH-delivered FLAC is already FLAC. Do not decode it. Copy it.
mpd flc * *
# FT:{START=--start=%t}U:{END=--end=%v}D:{RESAMPLE=--resample=%d}
[ffmpeg] -i $FILE$ -c copy -f flac -
# The wrong rule, the one that made the static: this decoded E-AC-3 over
# data that was already FLAC, and emitted full-scale noise.
# mp4eac3 flc * * -> removed# Teach the server the DASH manifest mime type so the copy rule matches.
mpd
mpegdash application/dash+xmlWorth flagging: those two files, custom-convert.conf and custom-types.conf, don't live in the plugin directory. On this install they live in /etc/squeezeboxserver. That location is deliberate, it's how Lyrion lets you override conversion without editing shipped files, but it has a consequence: a plugin upgrade never touches them, and a fresh checkout never sees them. They weren't in git. So the broken Atmos rule survived every plugin update, every rebase, every clean reinstall, sitting in a directory nothing in my workflow ever looked at. The static wasn't new. The 24-bit work just finally routed a stream through a landmine I'd buried and forgotten.
Track the files that survive your upgrades
Config that lives outside the project directory is config your version control doesn't know exists. custom-convert.conf and custom-types.conf in /etc/squeezeboxserver outlived every reinstall precisely because nothing in my pipeline tracked them. If a file can break you and git has never seen it, that's the file that will break you a year later when you've forgotten it's there.
The second act: the resync war
With 24-bit FLAC playing cleanly, I had a different problem, one I'd partly caused during the panic.
The listening chain is Tidal lossless into ALSA, into CamillaDSP doing room correction in 64-bit float, out over I2S into an ES9038 DAC with its own TCXO. There's also an LED visualizer that taps the audio and drives a strip behind the speakers. After the DSP work, the LEDs were leading the speakers by three seconds or more. The lights hit the beat, then you waited, then you heard it.
Two causes again. The first was buffering I'd added in the heat of the 24-bit debugging and never removed. CamillaDSP was running chunksize 8192 with queuelimit 4, roughly 750 ms of latency, bumped up at some point to ride out dropouts and left as cruft. The second was architectural: the visualizer's audio tap was before CamillaDSP. The lights were reacting to audio that still had the full DSP buffer ahead of it before it'd ever reach the speakers.
The lazy fix is to gut the CamillaDSP buffer so the lag goes away. I didn't do that. You don't degrade the thing that sounds right to fix a thing that only looks wrong. The DSP buffer is there for stable, glitch-free correction. The lights are cosmetic. You don't trade audio stability for LED alignment.
The disciplined fix moves the tap to the right place. Instead of reading pre-DSP audio, feed the visualizer the post-CamillaDSP signal, so it sees exactly what the speakers will play, buffer and all. I wrote a small Go daemon, about 150 lines, that reads the capture side of an ALSA Loopback device fed by CamillaDSP's output and writes a squeezelite-format shared-memory segment that the visualizer already knew how to read.
before: Tidal -> ALSA -> [tap] -> CamillaDSP -> I2S -> ES9038
\--> visualizer (3s+ early: tap is pre-DSP)
after: Tidal -> ALSA -> CamillaDSP -> Loopback -> I2S -> ES9038
\--> tap -> shm -> visualizer
(sees what the speakers play)Even with the tap in the right place there's residual latency between the Loopback capture and the DAC's actual analog output, and it depends on sample rate. As an interim measure, before I trimmed the buffer, the daemon polled /proc/asound for the device's reported delay and added a fixed sample offset, then converted the total to milliseconds at the current sample rate. That makes the compensation correct at 44.1, 48, and 96 kHz instead of being a single magic number that's only right at one rate.
# CamillaDSP queue delay (frames) reported by the kernel
$ cat /proc/asound/Loopback/pcm1p/sub0/status | grep -i delay
delay : 4096
# total_frames = kernel_delay + fixed_tap_offset
# delay_ms = total_frames / sample_rate_hz * 1000
# 4096 + 512 = 4608 frames
# @ 96000 Hz -> 48.0 ms @ 44100 Hz -> 104.5 ms
# same frame count, different milliseconds, because rate changedOnce the tap was correct and stable, I went back and did the buffer work properly, on its own terms rather than as a panic measure. I trimmed CamillaDSP to chunksize 4096, queue 2, which shaved about 550 ms while still leaving enough headroom for glitch-free correction. A buffer change made for the buffer's own reasons, not to paper over a sync bug that lived somewhere else entirely.
The same mistake, four times
Every part of this started as "the vendor downgraded me" or "the code is broken" and ended as "my model of the system was wrong." Tidal didn't break the request; the token had frozen its entitlements at issue time and I was reusing a stale one. The device flow didn't fail loudly; it downgraded silently, and only PKCE still opened the HiRes door. The static wasn't a decode bug in any normal sense; it was a forgotten conversion rule living in a directory git never tracked. And the lights weren't lagging because the DSP was too slow; they were tapping the wrong point in the chain.