All writing

~/writing/tidal-24bit-pkce

Audio
9 min read

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.

Tidal started returning 320 kbps mp4 where it used to send lossless FLAC. Same plugin, same account. The bitrate dropped 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 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 cause was a token I’d reused for months. It no longer meant what I thought.

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.

the broken assumption: quality is not per-request
# 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 when it’s issued, based on the client ID that issued it. The token isn’t 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 swapping client IDs on later requests changes nothing. A downgraded token stays downgraded until you throw it away.

The credential that did nothing

Swapping the client ID changed the request but not the response. When a credential has zero effect on the result, it’s the wrong credential. The one that mattered was already 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 part 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 dead for high resolution. Device-flow tokens are downgraded. You authenticate, you get a 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 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 attached.

I’ll stop short of a turnkey recipe, for two reasons. A copy-paste exchange is the sort of thing that gets noticed and 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, you have what you need: the upstream plugin already implements the now-dead device flow, 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 account, is mechanical from there. The non-obvious fact is that the token carries the entitlement. You have to mint a fresh one under an authorized client; fixing up the request around an old token won’t do it.

Stay inside 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.

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.

Bit depth
16-bit 24-bit
HI_RES_LOSSLESS, not LOSSLESS
Sample rate
44.1 kHz 96 kHz
over DASH
Device flow
dead
silent downgrade, two IDs 403'd

The airplane engine

Tokens sorted, I hit play on a 24-bit track and got an airplane engine. Loud, full-scale static, continuous where music should be. The token was right, the negotiated format 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. The real culprit was server-side, in the conversion rules, and it had been there 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 running a full E-AC-3 decode over data that was already FLAC inside an MPEG-DASH container. The decoder interpreted FLAC frames as E-AC-3 and emitted 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.

custom-convert.conf
# 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
custom-types.conf
# Teach the server the DASH manifest mime type so the copy rule matches.
mpd
    mpegdash application/dash+xml

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, which is how Lyrion lets you override conversion without editing shipped files. The 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 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 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 because nothing in my pipeline tracked them. A file that can break you and git has never seen 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. The DSP buffer is there for stable, glitch-free correction; the lights are cosmetic. I wasn’t going to trade audio stability for LED alignment.

The better fix moves the tap. Instead of reading pre-DSP audio, feed the visualizer the post-CamillaDSP signal, so it sees 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 the visualizer already knew how to read.

signal flow, before and after
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.

delay compensation, sample-rate-correct
# 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 changed

Once the tap was correct and stable, I went back and did the buffer work properly. I trimmed CamillaDSP to chunksize 4096, queue 2, which shaved about 550 ms while still leaving headroom for glitch-free correction. A buffer change made for the buffer’s own reasons, not to paper over a sync bug that lived elsewhere.

What I got wrong

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.” The token had frozen its entitlements at issue time and I was reusing a stale one. The static came from a forgotten conversion rule in a directory git never tracked.