~/textes/tidal-24bit-pkce
Tidal a cessé de servir du lossless, alors j'ai lu le jeton
Le plugin amont s'est mis à tirer du 320 kbps au lieu du FLAC lossless, et changer le client ID n'a rien donné. Le jeton d'accès avait figé ses droits au moment de l'émission, bien avant qu'aucune requête ne parte.
Ça avait l'air d'un problème côté fournisseur. Tidal s'est mis à retourner du mp4 320 kbps là où il retournait avant du FLAC lossless. Le plugin n'avait pas changé. Le compte n'avait pas changé. Le débit a chuté quand même.
C'est un plugin Perl pour Lyrion (le serveur autrefois appelé Logitech Media Server), un fork que je maintiens à partir du plugin Tidal amont. Le suivi de bogues amont avait la même plainte : le lossless était silencieusement déclassé. J'avais besoin que le fork continue de livrer du FLAC 24-bit pendant que je le rebasais sur l'amont 1.8.1, donc « attendre que Tidal corrige » n'était pas une option. Je suis allé comprendre pourquoi une requête qui marchait avant ne marchait plus.
La réponse n'était pas dans la requête. Elle se trouvait dans un jeton que je réutilisais depuis des mois, et il avait discrètement cessé de vouloir dire ce que je pensais.
Le client ID qui n'a rien réglé
La première théorie était l'évidente. Tidal avait désautorisé l'ancien client ID et le secret livrés avec le plugin, donc il s'authentifiait maintenant comme un client qui n'avait plus droit au lossless. Bon. J'avais un autre client ID autorisé pour le lossless. Je l'ai inséré, j'ai gardé le jeton existant, j'ai relancé.
Toujours HIGH. Toujours du mp4 320 kbps.
Ça n'avait aucun sens selon mon modèle, où le client ID décide de la qualité au moment de la requête. Si la requête portait maintenant un client ID autorisé pour le lossless, la réponse aurait dû être lossless. Elle ne l'était pas. Donc mon modèle était faux.
# 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";
...
}Ce que j'avais raté : Tidal grave les droits dans le jeton d'accès au moment où il est émis, selon le client ID qui l'a émis. Le jeton n'est pas une référence porteuse réévaluée contre les droits courants à chaque appel. C'est un instantané. Un jeton émis par le client maintenant mort porte les limites de ce client pour toute sa durée de vie, et changer de client ID sur les requêtes ultérieures ne change rien à ce que le jeton a déjà promis. Un jeton déclassé reste déclassé jusqu'à ce qu'on le jette.
La référence qui n'a rien fait
Changer le client ID a modifié la requête et n'a rien changé dans la réponse. Quand une référence que tu envoies n'a aucun effet sur le résultat, c'est que tu envoies la mauvaise référence. Celle qui comptait avait déjà été émise et figée.
Le seul correctif, c'est de se réauthentifier et d'émettre un jeton frais sous un client autorisé. On ne rapièce pas l'ancien.
PKCE est la seule porte qui s'ouvre encore
Émettre un jeton frais a l'air trivial. Ça ne l'était pas, et c'est la deuxième trouvaille qui m'a coûté le plus de temps.
Le plugin s'authentifiait avec le OAuth Device Flow : tu affiches un code, l'utilisateur le tape dans un navigateur sur un autre appareil, tu interroges pour obtenir un jeton. Fin 2025, ce flux est en pratique mort pour la haute résolution. Les jetons issus du device flow sont silencieusement déclassés. Tu t'authentifies avec succès, tu obtiens un jeton parfaitement valide, et ce jeton ne retournera jamais HI_RES_LOSSLESS peu importe ce que tu demandes. J'ai fait tourner deux client IDs device-flow plus récents en espérant que l'un soit encore privilégié. Les deux ont retourné 403.
Le chemin qui livre encore du HiRes, c'est un flux d'autorisation par redirection navigateur avec PKCE : un chemin de code OAuth vraiment différent du device flow, pas un paramètre qu'on bascule. Tu fais passer l'utilisateur par une vraie autorisation navigateur dans sa propre session et tu échanges le code retourné contre un jeton, et ce jeton revient avec le droit HiRes réellement attaché.
Je vais m'arrêter avant la recette clé en main, exprès, pour deux raisons. Un échange à copier-coller est exactement le genre de chose qui se fait remarquer et fermer discrètement. Et PKCE est une extension OAuth standard et bien documentée, donc il n'y a rien ici que j'aie besoin de réimprimer. Si tu veux le reproduire, tout ce qu'il te faut est déjà devant toi : le plugin amont implémente déjà le device flow maintenant mort, donc c'est un exemple fonctionnel de comment il parle au point de terminaison de jeton de Tidal et où le code d'autorisation est traité. Remplacer ça par le flux standard authorization-code-plus-PKCE, exécuté contre ton propre client enregistré et ton propre compte, est mécanique à partir de là. Le seul fait non évident, ce que cette section existe pour te donner, c'est que le jeton porte le droit : tu dois en émettre un frais sous un client autorisé, et aucun rafistolage de la requête autour d'un vieux jeton ne le fera.
La limite, parce qu'elle compte : ton propre compte payant, ton propre client OAuth enregistré, ton propre navigateur. Je décris comment le modèle d'authentification se comporte, je ne distribue pas de clés. Il n'y a aucun vrai client ID, secret ou jeton dans ce billet, et il n'y en aura pas.
Un jeton frais émis de cette façon sous un client autorisé a finalement retourné ce que je cherchais : HI_RES_LOSSLESS, 24-bit, 96 kHz, FLAC sur DASH. Confirmé sur le fil.
Le moteur d'avion
Les jetons réglés, j'ai lancé une piste 24-bit et j'ai eu un moteur d'avion. Fort, du statique pleine échelle. Pas un accroc, pas une coupure : de la cochonnerie continue là où il aurait dû y avoir de la musique. Le jeton était bon, le format négocié était bon, et la sortie était inécoutable.
Deux causes, et elles s'empilaient.
La première était de moi. J'avais mis la préférence de qualité à LOSSLESS, qui est du 16-bit CD, alors que la négociation essayait de tirer un flux DASH 24-bit. Le pipeline 24-bit, ce n'est pas juste « demander plus de bits ». Il faut enableDASH=1 avec quality=HI_RES. Avec le drapeau de qualité réglé sur LOSSLESS tout court, les deux moitiés de la requête n'étaient pas d'accord sur ce qui revenait.
Mais corriger le drapeau n'a pas tué le statique, ce qui m'a dit que le drapeau n'était pas le vrai coupable. Le vrai coupable était côté serveur, dans les règles de conversion, et il traînait là depuis longtemps.
Lyrion décide comment transcoder un flux à l'aide de règles de conversion. Pour une source Atmos, une vieille règle à moi convertissait mp4eac3 en flc, décodant le E-AC-3 en FLAC. Cette règle correspondait maintenant au flux DASH FLAC et essayait de lancer un décodage E-AC-3 complet sur des données qui étaient déjà du FLAC dans un conteneur MPEG-DASH. Le décodeur interprétait des trames FLAC comme du E-AC-3 et émettait exactement ce à quoi on s'attend : du bruit au volume d'un jet.
Le chemin DASH FLAC n'a pas du tout besoin de transcodage. Il a besoin d'une copie de flux et d'un mappage de type mime pour que le serveur reconnaisse le conteneur.
# 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+xmlÀ signaler : ces deux fichiers, custom-convert.conf et custom-types.conf, ne vivent pas dans le répertoire du plugin. Sur cette installation, ils vivent dans /etc/squeezeboxserver. Cet emplacement est délibéré, c'est ainsi que Lyrion te permet de surcharger la conversion sans éditer les fichiers livrés, mais ça a une conséquence : une mise à jour du plugin n'y touche jamais, et un nouveau checkout ne les voit jamais. Ils n'étaient pas dans git. Donc la règle Atmos cassée a survécu à chaque mise à jour du plugin, chaque rebase, chaque réinstallation propre, assise dans un répertoire que rien dans mon flux de travail ne regardait. Le statique n'était pas nouveau. Le travail 24-bit a juste fini par router un flux à travers une mine que j'avais enterrée et oubliée.
Suis les fichiers qui survivent à tes mises à jour
La configuration qui vit hors du répertoire du projet, c'est de la configuration dont ton contrôle de version ignore l'existence. custom-convert.conf et custom-types.conf dans /etc/squeezeboxserver ont survécu à chaque réinstallation précisément parce que rien dans mon pipeline ne les suivait. Si un fichier peut te casser et que git ne l'a jamais vu, c'est ce fichier qui te cassera un an plus tard, quand tu auras oublié qu'il est là.
Le deuxième acte : la guerre de resynchronisation
Avec le FLAC 24-bit qui jouait proprement, j'avais un autre problème, que j'avais en partie causé pendant la panique.
La chaîne d'écoute, c'est du Tidal lossless vers ALSA, vers CamillaDSP qui fait de la correction de pièce en flottant 64-bit, puis sortie en I2S vers un DAC ES9038 avec son propre TCXO. Il y a aussi un visualiseur LED qui capte l'audio et pilote un ruban derrière les haut-parleurs. Après le travail DSP, les LED précédaient les haut-parleurs de trois secondes ou plus. Les lumières frappaient le rythme, puis tu attendais, puis tu l'entendais.
Deux causes encore. La première était du buffer que j'avais ajouté dans le feu du débogage 24-bit et jamais retiré. CamillaDSP tournait avec chunksize 8192 et queuelimit 4, grosso modo 750 ms de latence, augmenté à un moment pour encaisser des coupures et laissé là comme déchet. La deuxième était architecturale : la prise audio du visualiseur était avant CamillaDSP. Les lumières réagissaient à de l'audio qui avait encore tout le buffer DSP devant lui avant même d'atteindre les haut-parleurs.
Le correctif paresseux, c'est de vider le buffer de CamillaDSP pour faire disparaître le décalage. Je ne l'ai pas fait. On ne dégrade pas ce qui sonne bien pour régler quelque chose qui n'a l'air mauvais qu'à l'œil. Le buffer DSP est là pour une correction stable et sans accroc. Les lumières sont cosmétiques. On n'échange pas la stabilité audio contre l'alignement des LED.
Le correctif discipliné déplace la prise au bon endroit. Au lieu de lire l'audio pré-DSP, on alimente le visualiseur avec le signal post-CamillaDSP, pour qu'il voie exactement ce que les haut-parleurs vont jouer, buffer compris. J'ai écrit un petit démon Go, environ 150 lignes, qui lit le côté capture d'un périphérique ALSA Loopback alimenté par la sortie de CamillaDSP et écrit un segment de mémoire partagée au format squeezelite que le visualiseur savait déjà lire.
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)Même avec la prise au bon endroit, il reste une latence résiduelle entre la capture Loopback et la sortie analogique réelle du DAC, et elle dépend de la fréquence d'échantillonnage. Comme mesure intérimaire, avant de trimer le buffer, le démon interrogeait /proc/asound pour le delay rapporté par le périphérique et ajoutait un décalage fixe en échantillons, puis convertissait le total en millisecondes à la fréquence d'échantillonnage courante. Ça rend la compensation correcte à 44.1, 48 et 96 kHz au lieu d'être un seul nombre magique correct à une seule fréquence.
# 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 changedUne fois la prise correcte et stable, je suis retourné faire le travail de buffer correctement, sur ses propres termes plutôt que comme mesure de panique. J'ai trimé CamillaDSP à chunksize 4096, queue 2, ce qui a coupé environ 550 ms tout en laissant assez de marge pour une correction sans accroc. Un changement de buffer fait pour les raisons du buffer lui-même, pas pour masquer un bogue de synchro qui vivait ailleurs entièrement.
La même erreur, quatre fois
Chaque partie de ça a commencé par « le fournisseur m'a déclassé » ou « le code est cassé » et a fini par « mon modèle du système était faux ». Tidal n'a pas cassé la requête ; le jeton avait figé ses droits à l'émission et j'en réutilisais un périmé. Le device flow n'a pas échoué bruyamment ; il déclassait en silence, et seul PKCE ouvrait encore la porte HiRes. Le statique n'était pas un bogue de décodage au sens normal ; c'était une règle de conversion oubliée qui vivait dans un répertoire que git ne suivait jamais. Et les lumières ne décalaient pas parce que le DSP était trop lent ; elles captaient au mauvais point de la chaîne.