Merge pull request 'push-xrptryummmsr' (#76) from push-xrptryummmsr into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 29m1s

Reviewed-on: #76
This commit was merged in pull request #76.
This commit is contained in:
2026-02-27 00:02:25 +01:00
61 changed files with 4712 additions and 1745 deletions

BIN
.DS_Store vendored

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,202 @@
# WebRenderer : Streaming Audio Côté Serveur
## Problème actuel
Le webrenderer actuel délègue la lecture audio au navigateur : le serveur envoie une URL
de fichier via WebSocket (`SetUri`), et le navigateur charge cette URL dans un élément
`<audio>`. Cette approche a plusieurs limitations :
- Les URLs sont internes (IP locale + port) → inaccessibles depuis l'extérieur
- Les fichiers sur partage Samba ont des chemins locaux → jamais accessibles au navigateur
- La `base_url` doit être configurée statiquement → pas de solution propre local/externe
## Solution proposée : flux HTTP serveur
Le serveur génère un flux audio continu par instance de webrenderer, servi sur un endpoint
HTTP dédié. Le navigateur n'écoute que ce flux — une URL fixe, toujours accessible.
```
Avant : ControlPoint → SetAVTransportURI(url_interne) → WebSocket → Browser(<audio src=url_interne>)
Après : ControlPoint → SetAVTransportURI(url_interne) → Serveur(ouvre+stream) → Browser(<audio src=/api/webrenderer/{id}/stream>)
```
## Architecture cible
### Cycle de vie d'une instance
```
1. Navigateur ouvre la page
2. POST /api/webrenderer/register {instance_id, user_agent}
3. Serveur crée le device UPnP + pipeline audio
Annonce SSDP → ControlPoints découvrent le renderer
Répond : { stream_url }
4. Navigateur ouvre GET /api/webrenderer/{id}/stream (flux FLAC)
5. ControlPoint → SetAVTransportURI + Play → pipeline démarre
Navigateur écoute le flux FLAC en continu
SSE global existant → métadonnées et état vers l'interface
6. Navigateur ferme la page → flux FLAC se coupe
Serveur détecte → SSDP byebye → pipeline stoppé
Device UPnP retiré
```
Le lecteur web est complètement invisible — l'interface est pilotée par le SSE global
existant du ControlPoint. Le WebSocket est supprimé. Pas de SSE dédié au webrenderer.
### Endpoints HTTP
```
POST /api/webrenderer/register
Body: { instance_id, user_agent }
Réponse: { stream_url }
GET /api/webrenderer/{id}/stream
Content-Type: audio/flac
Cache-Control: no-store, no-transform
[Flux FLAC continu — déconnexion = fin de session]
DELETE /api/webrenderer/{id}
Désenregistrement explicite (optionnel, fallback sur coupure du flux)
```
La `stream_url` est une URL relative (`/api/webrenderer/{id}/stream`) — le navigateur
la résout lui-même, toujours correcte en local et via proxy externe, sans reconstruction
depuis les headers `X-Forwarded-*`.
### Composants nécessaires
#### 1. Pipeline audio par instance
Chaque instance possède :
- Un **`StreamingFlacSink`** — infrastructure existante dans `pmoaudio-ext`
- Un **`StreamHandle`** — exposé via l'endpoint `/stream`
- Un canal de contrôle **`PipelineControl`** — alimenté par les actions UPnP
Le pipeline est créé au `POST /register` et détruit à la coupure du flux FLAC.
#### 2. Enregistrement et création du device UPnP
```
POST /api/webrenderer/register
→ créer DeviceInstance UPnP (même factory qu'aujourd'hui)
→ annoncer via SSDP (nouveau : aujourd'hui pas de SSDP pour le webrenderer)
→ créer StreamingFlacSink + pipeline
→ enregistrer dans le RendererRegistry
→ retourner stream_url
```
L'`instance_id` vient du `localStorage` du navigateur — stable entre les reloads,
garantit que le même renderer UPnP est retrouvé à la reconnexion.
#### 3. Modification de `SetAVTransportURI`
Au lieu d'envoyer l'URL au navigateur, le handler UPnP :
1. Reçoit l'URI source (fichier cache, Samba, URL externe...)
2. Envoie `PipelineControl::LoadUri(uri)` au pipeline de l'instance
3. Le pipeline ouvre la source côté serveur et alimente le `StreamingFlacSink`
4. Le navigateur reçoit un event SSE `state_changed: Transitioning` puis `Playing`
#### 4. Gestion des transitions (gapless)
Le `StreamingFlacSink` diffuse un flux FLAC continu. À la frontière de piste, le pipeline
enchaîne les sources sans interruption du flux HTTP.
`SetNextAVTransportURI``PipelineControl::LoadNextUri(uri)` → pré-chargé dans le pipeline
→ transition seamless, le navigateur ne recharge pas l'URL.
#### 5. Métadonnées et état
Tout passe par le SSE existant — titre, artiste, artwork, position, état de lecture.
Pas de nouveau mécanisme nécessaire.
#### 6. Seek
Flux HTTP live → pas de Range requests.
Pour les fichiers (non-live) :
- `PipelineControl::Seek(position_sec)` → pipeline repart depuis la nouvelle position
- Légère interruption du flux FLAC (rebuffering navigateur ~1s) — acceptable
#### 7. Sources supportées
Le pipeline réutilise `pmoaudio-ext` et `pmoflac`. La seule source actuellement
déclarée dans `pmoaudio-ext` fonctionne à partir d'une `pmoplaylist` — c'est le
modèle à suivre pour construire dans `source_loader.rs` une source ad-hoc capable
d'ouvrir des URIs arbitraires (URL HTTP externe, fichier local/Samba) qui ne passent
pas par le cache.
### État partagé par instance
```rust
pub struct WebRendererServerState {
pub playback_state: PlaybackState,
pub current_uri: Option<String>,
pub volume: u16,
pub mute: bool,
pub stream_handle: SharedStreamHandle, // Handle vers le flux FLAC
pub pipeline_tx: mpsc::Sender<PipelineControl>, // Contrôle du pipeline
}
pub enum PipelineControl {
LoadUri(String),
LoadNextUri(String),
Play,
Pause,
Stop,
Seek(f64),
SetVolume(u16),
}
```
## Fichiers à créer / modifier
### Nouveaux fichiers
| Fichier | Rôle |
|---------|------|
| `pmowebrenderer/src/stream.rs` | Handler HTTP du flux FLAC |
| `pmowebrenderer/src/pipeline.rs` | Pipeline audio serveur par instance |
| `pmowebrenderer/src/source_loader.rs` | Ouverture des sources (cache, HTTP, fichier local) |
| `pmowebrenderer/src/register.rs` | Handler `POST /register` + `DELETE /{id}` |
### Fichiers à modifier
| Fichier | Modification |
|---------|--------------|
| `pmowebrenderer/src/state.rs` | Ajouter `stream_handle` et `pipeline_tx` |
| `pmowebrenderer/src/handlers.rs` | `set_uri_handler``PipelineControl::LoadUri` |
| `pmowebrenderer/src/websocket.rs` | **Supprimer** — remplacé par `register.rs` |
| `pmowebrenderer/src/config.rs` | Enregistrer les nouvelles routes, supprimer WS |
| `pmowebrenderer/src/messages.rs` | **Supprimer** — plus de WebSocket |
| `pmowebrenderer/src/session.rs` | Adapter : session liée au flux FLAC, pas au WS |
| `pmoapp/webapp/src/` | Remplacer WS par `POST /register` + `<audio src=stream_url>` |
## Infrastructure existante réutilisable
Tout le travail difficile est déjà fait :
- **`StreamingFlacSink`** (`pmoaudio-ext/src/sinks/streaming_flac_sink.rs`) — broadcast multi-clients, gestion backpressure, ICY metadata
- **`timed_broadcast`** (`pmoaudio-ext/src/sinks/timed_broadcast.rs`) — diffusion multi-clients avec pacing
- **`pmoflac`** — décodage/encodage FLAC temps réel
- Pattern HTTP streaming (`pmomediaserver/src/paradise_streaming.rs`) — exemple complet à suivre
## Questions ouvertes
1. **Volume côté serveur ou navigateur** ? Actuellement le navigateur gère le volume.
Côté serveur, on pourrait appliquer un gain DSP dans le pipeline.
3. **Plusieurs onglets simultanés** : chaque onglet a son propre pipeline et flux ?
Ou partage d'un flux broadcast si même contenu ? (Complexe, probablement un pipeline par instance.)
4. **Reconnexion** : si le navigateur se reconnecte (reload), le pipeline continue de
tourner ? Ou on le stoppe et recrée ? La `StreamHandle` permet plusieurs souscripteurs,
donc la reconnexion peut se faire sans interruption.
5. **Latence** : le pipeline serveur introduit une latence (buffering + encodage).
À mesurer et potentiellement configurer via `max_lead_seconds`.

30
Cargo.lock generated
View File

@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "PMOMusic"
version = "0.3.20"
version = "0.3.23"
dependencies = [
"axum 0.8.7",
"console-subscriber",
@@ -1700,6 +1700,24 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fdk-aac"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bb67e142688083cb9afb63f2424203fc98c4e7afb494bf912b60b55513b177e"
dependencies = [
"fdk-aac-sys",
]
[[package]]
name = "fdk-aac-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24516d2611506d5cb1833555adc75f6baf9fe2706b9c13e6fc33a6b22c51ca83"
dependencies = [
"cc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.5"
@@ -3863,6 +3881,7 @@ version = "0.1.0"
dependencies = [
"async-trait",
"bytes",
"futures",
"pmoaudio",
"pmoaudiocache",
"pmocache",
@@ -3871,6 +3890,7 @@ dependencies = [
"pmometadata",
"pmoplaylist",
"rand 0.8.5",
"reqwest",
"serde",
"serde_json",
"tokio",
@@ -4041,6 +4061,7 @@ version = "0.1.0"
dependencies = [
"bytes",
"claxon",
"fdk-aac",
"lewton",
"libc",
"libflac-sys",
@@ -4359,19 +4380,26 @@ dependencies = [
"async-trait",
"axum 0.8.7",
"axum-extra",
"bytes",
"futures",
"parking_lot",
"pmoaudio",
"pmoaudio-ext",
"pmoconfig",
"pmocontrol",
"pmodidl",
"pmoflac",
"pmomediarenderer",
"pmometadata",
"pmoserver",
"pmoupnp",
"pmoutils",
"reqwest",
"serde",
"serde_json",
"thiserror 2.0.17",
"tokio",
"tokio-util",
"tower-http",
"tracing",
"uuid",

View File

@@ -15,6 +15,7 @@ members = [
"pmocovers",
"pmoaudiocache",
"pmoaudio",
"pmoaudio-ext",
"pmoqobuz",
"pmoparadise",
"pmoradiofrance",

View File

@@ -1,6 +1,6 @@
[package]
name = "PMOMusic"
version = "0.3.22"
version = "0.3.23"
edition = "2024"
[dependencies]

View File

@@ -1,85 +1,25 @@
/**
* Composable pour gérer le WebRenderer navigateur.
*
* Se connecte automatiquement au WebSocket /api/webrenderer/ws au montage
* et se déconnecte proprement au démontage ou à la fermeture de la page.
* S'enregistre via POST /api/webrenderer/register au montage
* et diffuse l'audio via un élément <audio> pointant sur
* /api/webrenderer/{id}/stream (flux FLAC encodé côté serveur).
*
* Le navigateur est ainsi vu comme un renderer UPnP par le ControlPoint.
* L'audio est géré via deux éléments <audio> en ping-pong connectés à un
* AudioContext (MediaElementSourceNode) pour le contrôle du volume/mute.
* Le navigateur est vu comme un renderer UPnP par le ControlPoint.
* Le gapless est géré côté serveur : le navigateur lit un flux continu.
*
* ## Flux gapless
* 1. SetAVTransportURI(N) → slotA.src = N, slotA.load()
* 2. Play → slotA.play()
* 3. SetNextAVTransportURI(N+1) → slotB.src = N+1, slotB.load() (préchargement)
* 4. slotA "ended" → currentSlot = B, slotB.play() immédiat
* → TrackEnded envoyé au backend
* 5. Cycle recommence depuis 3 (slotA redevient le "next")
* Cycle de vie du flux audio :
* - PLAYING/TRANSITIONING → src = stream_url + play() (nouvelle connexion HTTP)
* - PAUSED/STOPPED → pause() + src = "" (déconnexion HTTP)
*/
import { ref, onMounted, onUnmounted, readonly } from "vue";
// ─── Types (miroir de messages.rs) ────────────────────────────────────────────
interface BrowserCapabilities {
instance_id: string;
user_agent: string;
supported_formats: string[];
}
interface RendererInfo {
udn: string;
friendly_name: string;
model_name: string;
description_url: string;
}
type TransportAction = "play" | "pause" | "stop" | "seek" | "set_uri" | "set_next_uri";
interface CommandParams {
uri?: string;
metadata?: string;
position?: string;
}
interface StateSyncMessage {
type: "state_sync";
current_uri?: string;
current_metadata?: string;
next_uri?: string;
next_metadata?: string;
playback_state: PlaybackState;
position?: string;
volume: number;
mute: boolean;
}
type ServerMessage =
| { type: "session_created"; token: string; renderer_info: RendererInfo }
| StateSyncMessage
| { type: "command"; action: TransportAction; params?: CommandParams }
| { type: "set_volume"; volume: number }
| { type: "set_mute"; mute: boolean }
| { type: "ping" };
type PlaybackState = "PLAYING" | "PAUSED" | "STOPPED" | "TRANSITIONING";
type ClientMessage =
| { type: "init"; capabilities: BrowserCapabilities }
| { type: "state_update"; state: PlaybackState }
| { type: "position_update"; position: string; duration: string }
| { type: "volume_update"; volume: number; mute: boolean }
| { type: "track_ended" }
| { type: "pong" };
import { sse } from "../services/pmocontrol/sse";
// ─── Identifiant stable de l'instance navigateur ─────────────────────────────
const INSTANCE_ID_KEY = "pmomusic_webrenderer_instance_id";
/**
* Génère un UUID v4. Utilise crypto.randomUUID() si disponible (HTTPS/localhost),
* sinon fallback sur crypto.getRandomValues() (disponible partout, y compris HTTP).
*/
function generateUUID(): string {
if (typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
@@ -92,10 +32,6 @@ function generateUUID(): string {
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/**
* Retourne un UUID stable pour cette instance navigateur.
* Généré une fois, persisté en localStorage, réutilisé entre les reloads.
*/
function getOrCreateInstanceId(): string {
try {
let id = localStorage.getItem(INSTANCE_ID_KEY);
@@ -105,496 +41,221 @@ function getOrCreateInstanceId(): string {
}
return id;
} catch {
// localStorage unavailable (private mode, etc.) → use a session-scoped UUID
return generateUUID();
}
}
// ─── Détection des formats supportés ─────────────────────────────────────────
// ─── Types ────────────────────────────────────────────────────────────────────
function getSupportedFormats(): string[] {
const audio = document.createElement("audio");
const formats: Array<[string, string]> = [
["mp3", "audio/mpeg"],
["flac", "audio/flac"],
["ogg", "audio/ogg; codecs=vorbis"],
["opus", "audio/ogg; codecs=opus"],
["aac", "audio/aac"],
["wav", "audio/wav"],
["m4a", 'audio/mp4; codecs="mp4a.40.2"'],
["webm", "audio/webm"],
];
return formats
.filter(([, mime]) => audio.canPlayType(mime) !== "")
.map(([fmt]) => fmt);
interface RegisterRequest {
instance_id: string;
user_agent: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function secondsToUpnpTime(s: number): string {
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = Math.floor(s % 60);
return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
}
function upnpTimeToSeconds(t: string): number {
const parts = t.split(":").map(Number);
if (parts.length !== 3) return 0;
const [h, m, s] = parts;
return (h ?? 0) * 3600 + (m ?? 0) * 60 + (s ?? 0);
}
// ─── Moteur Audio (HTMLAudioElement + MediaElementSourceNode) ─────────────────
class GaplessEngine {
/** Les deux éléments <audio> fixes (ping-pong) */
private readonly slots: [HTMLAudioElement, HTMLAudioElement];
/** Index du slot actuellement en lecture (0 ou 1) */
private currentSlot: 0 | 1 = 0;
/** URI préchargée dans le slot "next" (l'autre) */
private nextUri: string | null = null;
private volume = 1.0;
private muted = false;
/** Durée de la piste courante (secondes), lue via loadedmetadata */
private _duration = 0;
/**
* Indique qu'un play() a été reçu avant set_uri (race condition).
* setCurrent() le détectera et lancera la lecture automatiquement.
*/
private playPending = false;
onStateChange: (state: PlaybackState) => void = () => {};
onPosition: (pos: number, dur: number) => void = () => {};
onTrackEnded: () => void = () => {};
private positionInterval: ReturnType<typeof setInterval> | null = null;
constructor() {
this.slots = [
this.makeAudioElement(),
this.makeAudioElement(),
];
}
// ── Volume / Mute ────────────────────────────────────────────────────────
setVolume(v: number) {
this.volume = v;
for (const el of this.slots) {
el.volume = this.muted ? 0 : v;
}
}
setMute(m: boolean) {
this.muted = m;
for (const el of this.slots) {
el.volume = m ? 0 : this.volume;
}
}
// ── Transport ────────────────────────────────────────────────────────────
/** Charge la piste courante (sans la jouer). */
setCurrent(uri: string): void {
this.nextUri = null;
this.onStateChange("TRANSITIONING");
this._loadCurrent(uri);
// Si play() est arrivé avant set_uri (race condition), lancer la lecture maintenant
if (this.playPending) {
this.playPending = false;
this.play().catch((e) =>
console.error("[GaplessEngine] deferred play() failed:", e),
);
}
}
/**
* Restaure l'état audio après un reload sans notifier le serveur de TRANSITIONING.
* Le serveur connaît déjà l'état ; on recharge juste l'audio localement.
* Si shouldPlay=true mais que l'autoplay est bloqué, on notifie PAUSED
* pour que l'interface puisse proposer un bouton Play fonctionnel.
*/
async syncRestore(currentUri: string, nextUri: string | undefined, shouldPlay: boolean): Promise<void> {
this._loadCurrent(currentUri);
if (nextUri) {
this.setNext(nextUri);
}
if (shouldPlay) {
try {
await this.play();
// play() a réussi : onStateChange("PLAYING") a déjà été appelé dans play()
} catch {
// Autoplay bloqué par le navigateur : signaler PAUSED au serveur
// L'audio est chargé, un clic Play suffira à démarrer
this.onStateChange("PAUSED");
}
}
}
private _loadCurrent(uri: string): void {
const el = this.slots[this.currentSlot];
// Retirer l'écouteur "ended" de l'autre slot si présent
const otherSlot = (1 - this.currentSlot) as 0 | 1;
this.slots[otherSlot].onended = null;
this.slots[otherSlot].pause();
el.onended = null;
el.pause();
el.src = uri;
el.load();
// Récupérer la durée dès que les métadonnées sont disponibles
el.onloadedmetadata = () => {
this._duration = el.duration || 0;
};
}
/** Précharge la piste suivante dans l'autre slot. */
setNext(uri: string): void {
this.nextUri = uri;
const nextSlot = (1 - this.currentSlot) as 0 | 1;
const el = this.slots[nextSlot];
el.src = uri;
el.preload = "auto";
el.load();
}
async play(): Promise<void> {
const el = this.slots[this.currentSlot];
// Si pas de source : play() est arrivé avant set_uri (race condition).
// On mémorise et setCurrent() déclenchera la lecture dès qu'il sera appelé.
if (!el.src || el.src === window.location.href) {
this.playPending = true;
return;
}
this.playPending = false;
el.onended = () => this.onCurrentEnded();
// Si l'élément n'a pas encore de données, attendre canplay.
if (el.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
await new Promise<void>((resolve) => {
const onCanPlay = () => {
el.removeEventListener("canplay", onCanPlay);
resolve();
};
el.addEventListener("canplay", onCanPlay);
});
}
try {
await el.play();
} catch (e) {
console.warn("[GaplessEngine] play() failed (autoplay blocked?):", e);
throw e;
}
this.startPositionTimer();
this.onStateChange("PLAYING");
}
pause(): void {
const el = this.slots[this.currentSlot];
el.pause();
this.stopPositionTimer();
this.onStateChange("PAUSED");
this.sendPosition();
}
stop(): void {
this.playPending = false;
const el = this.slots[this.currentSlot];
el.onended = null;
el.pause();
el.currentTime = 0;
this.nextUri = null;
this.stopPositionTimer();
this.onStateChange("STOPPED");
}
seek(toSeconds: number): void {
const el = this.slots[this.currentSlot];
el.currentTime = toSeconds;
}
destroy(): void {
this.stopPositionTimer();
for (const el of this.slots) {
el.onended = null;
el.pause();
el.src = "";
}
}
// ── Privé ────────────────────────────────────────────────────────────────
private makeAudioElement(): HTMLAudioElement {
const el = document.createElement("audio");
el.preload = "auto";
return el;
}
private onCurrentEnded(): void {
const nextSlot = (1 - this.currentSlot) as 0 | 1;
if (this.nextUri !== null) {
// Le slot suivant est préchargé, on bascule
this.currentSlot = nextSlot;
this.nextUri = null;
this._duration = 0;
const nextEl = this.slots[this.currentSlot];
nextEl.onended = () => this.onCurrentEnded();
// Récupérer la durée de la nouvelle piste courante
if (nextEl.duration && isFinite(nextEl.duration)) {
this._duration = nextEl.duration;
} else {
nextEl.onloadedmetadata = () => {
this._duration = nextEl.duration || 0;
};
}
// Informer le backend (qui fera le swap current←next côté serveur)
this.onTrackEnded();
// Démarrer immédiatement (le préchargement a eu lieu)
nextEl.play().catch((e) =>
console.error("[GaplessEngine] next.play() failed:", e),
);
// L'état reste PLAYING
} else {
// Pas de suivant : fin de lecture
this.stopPositionTimer();
this.onStateChange("STOPPED");
this.onTrackEnded();
}
}
private startPositionTimer(): void {
if (this.positionInterval !== null) return;
this.positionInterval = setInterval(() => this.sendPosition(), 1000);
}
private stopPositionTimer(): void {
if (this.positionInterval !== null) {
clearInterval(this.positionInterval);
this.positionInterval = null;
}
}
private sendPosition(): void {
const el = this.slots[this.currentSlot];
const pos = el.currentTime || 0;
const dur = (el.duration && isFinite(el.duration)) ? el.duration : this._duration;
this.onPosition(pos, dur);
}
interface RegisterResponse {
stream_url: string;
udn: string;
}
// ─── Composable ───────────────────────────────────────────────────────────────
export function useWebRenderer() {
const connected = ref(false);
const rendererInfo = ref<RendererInfo | null>(null);
const streamUrl = ref<string | null>(null);
/** UDN du device UPnP créé côté serveur pour ce navigateur */
const rendererUdn = ref<string | null>(null);
let ws: WebSocket | null = null;
let engine: GaplessEngine | null = null;
let audioEl: HTMLAudioElement | null = null;
let instanceId: string | null = null;
let currentStreamUrl: string | null = null;
let onConnectedCallback: (() => void) | null = null;
let sseUnsubscribe: (() => void) | null = null;
let pendingCanPlay: (() => void) | null = null;
// ── Envoi d'un message au backend ────────────────────────────────────────
// ── Connexion / déconnexion du flux audio ─────────────────────────────────
function send(msg: ClientMessage) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
function startStream(): void {
console.log("[WebRenderer] startStream called, audioEl=", !!audioEl, "currentStreamUrl=", currentStreamUrl);
if (!audioEl || !currentStreamUrl) return;
const el = audioEl;
// Si le stream est en erreur (networkState=3), réinitialiser avant de réessayer
if (el.networkState === 3 /* NETWORK_NO_SOURCE */ && !pendingCanPlay) {
console.log("[WebRenderer] startStream: networkState=3, resetting before retry");
el.removeAttribute("src");
el.load();
}
}
// ── Initialisation du moteur ──────────────────────────────────────────────
function initEngine(): GaplessEngine {
const e = new GaplessEngine();
e.onStateChange = (state) => {
send({ type: "state_update", state });
};
e.onPosition = (pos, dur) => {
send({
type: "position_update",
position: secondsToUpnpTime(pos),
duration: secondsToUpnpTime(dur),
});
};
e.onTrackEnded = () => {
send({ type: "track_ended" });
};
return e;
}
// ── Exécution des commandes UPnP ──────────────────────────────────────────
async function execCommand(action: TransportAction, params?: CommandParams) {
if (!engine) return;
switch (action) {
case "set_uri":
if (params?.uri) {
engine.setCurrent(params.uri);
}
break;
case "set_next_uri":
if (params?.uri) {
engine.setNext(params.uri);
}
break;
case "play":
await engine.play().catch((e) =>
console.warn("[WebRenderer] play() failed:", e),
);
break;
case "pause":
engine.pause();
break;
case "stop":
engine.stop();
break;
case "seek":
if (params?.position) {
engine.seek(upnpTimeToSeconds(params.position));
}
break;
}
}
// ── Gestion des messages entrants ─────────────────────────────────────────
function handleMessage(event: MessageEvent) {
let msg: ServerMessage;
try {
msg = JSON.parse(event.data as string) as ServerMessage;
} catch {
console.warn("[WebRenderer] Message non-JSON reçu :", event.data);
// Si le stream est déjà chargé/en cours, ne pas réouvrir la connexion HTTP.
// (évite que PLAYING après TRANSITIONING ne crée un nouveau pipe)
if (el.hasAttribute("src") && (el.readyState > 0 || pendingCanPlay)) {
console.log("[WebRenderer] startStream: stream already open, ignoring (readyState=", el.readyState, ")");
return;
}
switch (msg.type) {
case "session_created":
rendererInfo.value = msg.renderer_info;
connected.value = true;
onConnectedCallback?.();
break;
case "state_sync":
if (engine && msg.current_uri) {
engine.setVolume(msg.volume / 100);
engine.setMute(msg.mute);
const shouldPlay = msg.playback_state === "PLAYING" || msg.playback_state === "TRANSITIONING";
// syncRestore recharge l'audio sans notifier le serveur de l'état
// (le serveur connaît déjà l'état ; on évite un aller-retour TRANSITIONING)
engine.syncRestore(msg.current_uri, msg.next_uri, shouldPlay);
}
break;
case "command":
void execCommand(msg.action, msg.params);
break;
case "set_volume":
engine?.setVolume(msg.volume / 100);
break;
case "set_mute":
engine?.setMute(msg.mute);
break;
case "ping":
send({ type: "pong" });
break;
// Sur Safari, play() échoue avec NotSupportedError si appelé avant que
// l'élément audio ait reçu assez de données (readyState < HAVE_FUTURE_DATA).
// On attend canplay avant d'appeler play().
if (pendingCanPlay) {
el.removeEventListener("canplay", pendingCanPlay);
}
}
el.src = currentStreamUrl;
console.log("[WebRenderer] src set, readyState=", el.readyState, "networkState=", el.networkState);
// ── Connexion ─────────────────────────────────────────────────────────────
el.addEventListener("error", () => {
console.error("[WebRenderer] event:error code=", el.error?.code, el.error?.message,
"readyState=", el.readyState, "networkState=", el.networkState);
// Nettoyer pendingCanPlay pour permettre un retry au prochain PLAYING/TRANSITIONING
if (pendingCanPlay) {
el.removeEventListener("canplay", pendingCanPlay);
pendingCanPlay = null;
}
}, { once: true });
el.addEventListener("loadstart", () => console.debug("[WebRenderer] event:loadstart readyState=", el.readyState), { once: true });
el.addEventListener("loadedmetadata", () => console.debug("[WebRenderer] event:loadedmetadata readyState=", el.readyState), { once: true });
el.addEventListener("loadeddata", () => console.debug("[WebRenderer] event:loadeddata readyState=", el.readyState), { once: true });
el.addEventListener("progress", () => console.debug("[WebRenderer] event:progress readyState=", el.readyState), { once: true });
el.addEventListener("stalled", () => console.warn("[WebRenderer] event:stalled readyState=", el.readyState, "networkState=", el.networkState));
el.addEventListener("waiting", () => console.warn("[WebRenderer] event:waiting readyState=", el.readyState));
el.addEventListener("suspend", () => console.debug("[WebRenderer] event:suspend readyState=", el.readyState, "networkState=", el.networkState), { once: true });
el.addEventListener("abort", () => console.warn("[WebRenderer] event:abort"), { once: true });
el.addEventListener("emptied", () => console.warn("[WebRenderer] event:emptied"), { once: true });
function connect() {
if (ws) return;
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const url = `${protocol}//${location.host}/api/webrenderer/ws`;
ws = new WebSocket(url);
ws.onopen = () => {
send({
type: "init",
capabilities: {
instance_id: getOrCreateInstanceId(),
user_agent: navigator.userAgent,
supported_formats: getSupportedFormats(),
},
const onCanPlay = () => {
console.log("[WebRenderer] event:canplay readyState=", el.readyState, "calling play()");
pendingCanPlay = null;
el.removeEventListener("canplay", onCanPlay);
el.play().then(() => {
console.log("[WebRenderer] play() resolved OK");
}).catch((e: unknown) => {
console.warn("[WebRenderer] play() rejected:", e);
});
};
ws.onmessage = handleMessage;
ws.onclose = () => {
connected.value = false;
rendererInfo.value = null;
ws = null;
};
ws.onerror = (err) => {
console.error("[WebRenderer] Erreur WebSocket :", err);
};
pendingCanPlay = onCanPlay;
el.addEventListener("canplay", onCanPlay);
}
// ── Déconnexion ───────────────────────────────────────────────────────────
function disconnect() {
engine?.destroy();
if (ws) {
ws.close(1000, "Page unloaded");
ws = null;
function stopStream(): void {
console.log("[WebRenderer] stopStream called, readyState=", audioEl?.readyState);
if (!audioEl) return;
if (pendingCanPlay) {
audioEl.removeEventListener("canplay", pendingCanPlay);
pendingCanPlay = null;
}
connected.value = false;
audioEl.pause();
audioEl.removeAttribute("src"); // ferme la connexion HTTP (src="" résolu comme URL de page sur Safari)
audioEl.load(); // force le reset de l'état réseau
}
// ── Enregistrement ────────────────────────────────────────────────────────
async function register(): Promise<void> {
instanceId = getOrCreateInstanceId();
const body: RegisterRequest = {
instance_id: instanceId,
user_agent: navigator.userAgent,
};
try {
const resp = await fetch("/api/webrenderer/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
console.error("[WebRenderer] register failed:", resp.status);
return;
}
const data = (await resp.json()) as RegisterResponse;
streamUrl.value = data.stream_url;
currentStreamUrl = data.stream_url;
rendererUdn.value = data.udn;
connected.value = true;
onConnectedCallback?.();
// S'abonner aux événements SSE du renderer pour piloter la lecture
sse.connect();
const udn = data.udn;
sseUnsubscribe?.();
sseUnsubscribe = sse.onRendererEvent((event) => {
if (event.renderer_id !== udn) return;
if (event.type !== "state_changed") return;
const state = event.state;
console.log("[WebRenderer] SSE state_changed →", state, "| event.renderer_id=", event.renderer_id, "udn=", udn, "| audioEl.src=", audioEl?.src, "readyState=", audioEl?.readyState, "networkState=", audioEl?.networkState, "pendingCanPlay=", !!pendingCanPlay);
if (state === "PLAYING" || state === "TRANSITIONING") {
startStream();
} else if (state === "PAUSED" || state === "STOPPED") {
stopStream();
}
});
} catch (e) {
console.error("[WebRenderer] register error:", e);
}
}
// ── Désenregistrement ─────────────────────────────────────────────────────
async function unregister(): Promise<void> {
if (!instanceId) return;
try {
await fetch(`/api/webrenderer/${instanceId}`, { method: "DELETE" });
} catch {
// Ignoré lors du déchargement de page
}
instanceId = null;
}
// ── Volume / Mute ─────────────────────────────────────────────────────────
function setVolume(v: number): void {
if (audioEl) audioEl.volume = v;
}
function setMute(m: boolean): void {
if (audioEl) audioEl.muted = m;
}
// ── Cycle de vie ──────────────────────────────────────────────────────────
onMounted(() => {
engine = initEngine();
connect();
window.addEventListener("beforeunload", disconnect);
audioEl = document.createElement("audio");
audioEl.preload = "auto";
document.body.appendChild(audioEl);
void register();
window.addEventListener("beforeunload", () => void unregister());
});
onUnmounted(() => {
disconnect();
engine = null;
window.removeEventListener("beforeunload", disconnect);
sseUnsubscribe?.();
sseUnsubscribe = null;
stopStream();
void unregister();
if (audioEl) {
audioEl.remove();
audioEl = null;
}
connected.value = false;
streamUrl.value = null;
rendererUdn.value = null;
window.removeEventListener("beforeunload", () => void unregister());
});
// ── API publique ──────────────────────────────────────────────────────────
return {
/** true quand la session WebRenderer est établie */
/** true quand l'instance est enregistrée sur le serveur */
connected: readonly(connected),
/** Infos du renderer UPnP créé pour ce navigateur */
rendererInfo: readonly(rendererInfo),
/** Callback appelé quand la session est créée (pour rafraîchir la liste des renderers) */
/** URL du flux FLAC servi par le serveur */
streamUrl: readonly(streamUrl),
/** UDN du device UPnP créé pour ce navigateur (null avant enregistrement) */
rendererUdn: readonly(rendererUdn),
/** Callback appelé quand l'enregistrement est confirmé */
onConnected(fn: () => void) {
onConnectedCallback = fn;
},
setVolume,
setMute,
};
}

View File

@@ -106,7 +106,7 @@ function handleRendererSelect(rendererId: string) {
// Filtre la liste des renderers pour exclure les WebRenderers d'autres navigateurs.
// Seul le WebRenderer créé par ce navigateur (identifié par son UDN) reste visible.
function filterRenderers(renderers: typeof allRenderers.value) {
const myUdn = webRenderer.rendererInfo.value?.udn ?? null;
const myUdn = webRenderer.rendererUdn.value;
return renderers.filter((r) => {
if (r.model_name !== "WebRenderer") return true; // renderer classique : toujours visible
if (myUdn === null) return false; // pas encore de session : masquer tous les WebRenderers
@@ -140,7 +140,7 @@ watch(
// Watch l'UDN du WebRenderer local : quand il s'établit, resync pour faire apparaître notre onglet
watch(
() => webRenderer.rendererInfo.value?.udn,
() => webRenderer.rendererUdn.value,
() => {
syncWithRenderers(filterRenderers(allRenderers.value));
},

View File

@@ -30,10 +30,12 @@ rand = "0.8"
bytes = { version = "1.0", optional = true }
serde = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
reqwest = { workspace = true, features = ["stream"], optional = true }
futures = { version = "0.3", optional = true }
[features]
default = []
cache-sink = ["dep:pmoaudiocache", "dep:pmoflac", "dep:pmometadata", "dep:serde_json"]
playlist = ["cache-sink", "dep:pmoplaylist", "dep:pmocache"]
http-stream = ["dep:pmoflac", "dep:pmometadata", "dep:bytes", "dep:serde"]
http-stream = ["dep:pmoflac", "dep:pmometadata", "dep:bytes", "dep:serde", "dep:reqwest", "dep:futures"]
all = ["cache-sink", "playlist", "http-stream"]

View File

@@ -28,7 +28,7 @@ pub mod sinks;
#[cfg(any(feature = "cache-sink", feature = "http-stream"))]
pub mod nodes;
#[cfg(feature = "playlist")]
#[cfg(any(feature = "playlist", feature = "http-stream"))]
pub mod sources;
// Re-exports pour faciliter l'utilisation
@@ -39,4 +39,7 @@ pub use sinks::*;
pub use nodes::*;
#[cfg(feature = "playlist")]
pub use sources::*;
pub use sources::PlaylistSource;
#[cfg(feature = "http-stream")]
pub use sources::UriSource;

View File

@@ -5,7 +5,7 @@
//! - Drops frames that are late (audio_ts < elapsed)
//! - Paces broadcast to match audio playback rate
use std::time::Instant;
use std::time::{Duration, Instant};
use tracing::trace;
/// Error returned when a frame should be skipped (too late)
@@ -13,7 +13,6 @@ use tracing::trace;
pub struct SkipFrame;
/// Manages broadcast pacing with TopZeroSync detection
#[allow(dead_code)]
pub struct BroadcastPacer {
/// Start time (reset on TopZeroSync)
start_time: Instant,
@@ -21,8 +20,6 @@ pub struct BroadcastPacer {
max_lead_time: f64,
/// Label for logging (e.g., "FLAC" or "OGG")
label: String,
/// Pending reset flag - will reset timer on next chunk
pending_reset: bool,
}
impl BroadcastPacer {
@@ -37,24 +34,37 @@ impl BroadcastPacer {
start_time: Instant::now(),
max_lead_time: max_lead_time.max(0.0),
label: label.into(),
pending_reset: false,
}
}
/// Check timing and apply pacing - NO-OP VERSION
/// Reset the pacer clock (call when audio timestamp resets to 0).
pub fn reset(&mut self) {
self.start_time = Instant::now();
trace!("{} broadcaster: pacer reset", self.label);
}
/// Check timing and apply pacing.
///
/// Pacing is now handled entirely by the expiration-based system in
/// TimedBroadcast. This method is kept for backward compatibility
/// but always returns Ok(()).
///
/// # Returns
///
/// - Always returns `Ok(())`
/// If the audio is ahead of real time by more than `max_lead_time`, sleeps
/// until the lead is within bounds. Returns `Err(SkipFrame)` if the chunk
/// is already late (audio_ts < elapsed - 1s grace).
pub async fn check_and_pace(&mut self, audio_timestamp: f64) -> Result<(), SkipFrame> {
trace!(
"{} broadcaster: check_and_pace called with audio_ts={:.3}s (no-op - pacing handled by TimedBroadcast)",
self.label, audio_timestamp
);
if self.max_lead_time <= 0.0 {
return Ok(());
}
let elapsed = self.start_time.elapsed().as_secs_f64();
let lead = audio_timestamp - elapsed;
if lead > self.max_lead_time {
let sleep_secs = lead - self.max_lead_time;
trace!(
"{} broadcaster: audio ahead by {:.3}s, sleeping {:.3}s",
self.label, lead, sleep_secs
);
tokio::time::sleep(Duration::from_secs_f64(sleep_secs)).await;
}
Ok(())
}
}

View File

@@ -0,0 +1,367 @@
//! DirectFlacSink — nœud puits FLAC pour un seul client HTTP.
//!
//! Encode l'audio en FLAC (format fixe : 96 kHz / stéréo / 24 bits).
//!
//! # Cycle de vie
//!
//! - **Play** : le navigateur appelle `GET /stream`. `connect()` crée un nouveau
//! canal PCM + pipe duplex + encodeur FLAC, installe le sender dans le sink,
//! et notifie le sink via `client_notify`. Le flux reste ouvert : les morceaux
//! s'enchaînent en gapless.
//! - **Stop** : le navigateur ferme la connexion. Le pipe se rompt, l'encodeur
//! s'arrête. Le sink voit `pcm_tx.send()` échouer, passe le sender à `None`,
//! et **bloque** sur `client_notify` jusqu'au prochain Play.
//! Cela bloque la source et préserve la backpressure.
//! - **Play suivant** : `connect()` → nouveau pipe → `client_notify.notify_one()`
//! → le sink se débloque et reprend la consommation des segments.
//!
//! # Architecture
//!
//! ```text
//! AudioSegment I24 @ 96 kHz
//! ↓ NodeLogic::process() [bloque si pas de client]
//! chunk_to_pcm_bytes() → PCM 24-bit LE
//! ↓ Arc<Mutex<Option<mpsc::Sender<PcmChunk>>>>
//! ByteStreamReader (AsyncRead)
//! ↓ encode_flac_stream()
//! ↓ tokio::io::copy()
//! ↓ tokio::io::duplex pipe (256 KB)
//! ↓ DirectFlacStream (AsyncRead) → Body HTTP
//! ```
use std::io;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use async_trait::async_trait;
use pmoaudio::{
pipeline::{AudioPipelineNode, Node, NodeLogic, PipelineHandle, StopReason},
AudioError, AudioSegment, SyncMarker, TypeRequirement, TypedAudioNode, _AudioSegment,
};
use pmoflac::{EncoderOptions, PcmFormat};
use tokio::io::{AsyncRead, ReadBuf};
use tokio::sync::{mpsc, watch, Mutex};
use tokio_util::sync::CancellationToken;
use tracing::debug;
use crate::sinks::byte_stream_reader::{ByteStreamReader, PcmChunk};
use crate::sinks::chunk_to_pcm::chunk_to_pcm_bytes;
/// Format de sortie fixe du sink.
pub const DIRECT_FLAC_SAMPLE_RATE: u32 = 96_000;
pub const DIRECT_FLAC_CHANNELS: u8 = 2;
pub const DIRECT_FLAC_BITS_PER_SAMPLE: u8 = 24;
/// Capacité du pipe duplex (~256 KB ≈ 0.35s à 96 kHz/24 bits/stéréo).
const PIPE_CAPACITY: usize = 256 * 1024;
// ─── Shared state ─────────────────────────────────────────────────────────────
type SharedPcmTx = Arc<Mutex<Option<mpsc::Sender<PcmChunk>>>>;
// ─── Handle public ────────────────────────────────────────────────────────────
/// Handle vers le sink, cloneable, reconnectable à chaque Play.
#[derive(Clone)]
pub struct DirectFlacHandle {
pcm_tx: SharedPcmTx,
/// Compteur de connexions : incrémenté à chaque connect().
/// Utiliser un watch channel pour éviter les notifications perdues (vs Notify).
client_connect_tx: Arc<watch::Sender<u64>>,
/// Notifie le sink interne qu'un client vient de se connecter (edge-triggered,
/// usage interne uniquement — le sink tourne dans le même contexte que connect()).
client_notify_internal: Arc<tokio::sync::Notify>,
/// Signale que le premier byte FLAC a été lu par le client HTTP.
/// `false` au démarrage / après connect(), `true` dès le premier poll_read non-vide.
first_byte_tx: Arc<watch::Sender<bool>>,
encoder_options: EncoderOptions,
}
impl DirectFlacHandle {
/// Crée un nouveau pipe + encodeur FLAC et retourne le flux côté lecture.
/// Débloque le sink s'il attendait un client.
pub async fn connect(&self) -> DirectFlacStream {
let connect_count_before = *self.client_connect_tx.borrow();
debug!("DirectFlacHandle::connect() called, connect_count={}", connect_count_before);
let (pcm_tx, pcm_rx) = mpsc::channel::<PcmChunk>(8);
let current_ts = Arc::new(tokio::sync::RwLock::new(0.0f64));
let current_dur = Arc::new(tokio::sync::RwLock::new(0.0f64));
let pcm_reader = ByteStreamReader::new(pcm_rx, current_ts, current_dur);
let (pipe_writer, pipe_reader) = tokio::io::duplex(PIPE_CAPACITY);
// Réinitialiser le signal "premier byte" AVANT de notifier le sink,
// pour éviter qu'une notification précédente ne se propage.
let _ = self.first_byte_tx.send(false);
debug!("DirectFlacHandle::connect() first_byte reset to false");
// Installer le nouveau sender (remplace l'éventuel ancien)
*self.pcm_tx.lock().await = Some(pcm_tx);
debug!("DirectFlacHandle::connect() pcm_tx installed");
// Incrémenter le compteur de connexions (mémorisé dans watch — pas de perte)
let new_count = connect_count_before.wrapping_add(1);
let _ = self.client_connect_tx.send(new_count);
debug!("DirectFlacHandle::connect() client_connect_count -> {}", new_count);
// Débloquer le sink interne (même contexte async → pas de race)
self.client_notify_internal.notify_one();
// Lancer l'encodeur en background
let options = self.encoder_options.clone();
tokio::spawn(async move {
debug!("DirectFlacHandle: encoder task started");
if let Err(e) = run_encoder(pcm_reader, pipe_writer, options).await {
debug!("DirectFlacStream encoder stopped: {}", e);
}
debug!("DirectFlacHandle: encoder task ended");
});
debug!("DirectFlacHandle::connect() returning DirectFlacStream");
DirectFlacStream {
inner: pipe_reader,
first_byte_tx: Some(self.first_byte_tx.clone()),
}
}
/// Retourne un receiver qui passe à `true` quand le premier byte FLAC
/// a été effectivement lu par le client HTTP.
pub fn first_byte_ready(&self) -> watch::Receiver<bool> {
self.first_byte_tx.subscribe()
}
/// Attend qu'un client HTTP se connecte (i.e. que `connect()` soit appelé).
/// Utilisé par `stream_source` pour retarder l'ouverture de la source
/// jusqu'à ce que le navigateur soit prêt à recevoir des données.
///
/// Mémorise la valeur du compteur au moment de l'appel et attend qu'elle
/// augmente — ce qui garantit qu'on attend bien UNE NOUVELLE connexion,
/// même si `connect()` a déjà été appelé lors d'une lecture précédente.
pub async fn wait_for_client(&self) {
let seen = *self.client_connect_tx.borrow();
debug!("DirectFlacHandle::wait_for_client() called, seen connect_count={}", seen);
// subscribe() retourne un receiver dont la valeur courante est marquée "changed"
// donc wait_for() retourne immédiatement si la condition est déjà vraie.
let mut rx = self.client_connect_tx.subscribe();
let result = rx.wait_for(|v| {
debug!("DirectFlacHandle::wait_for_client() checking v={} > seen={}: {}", v, seen, *v > seen);
*v > seen
}).await;
debug!("DirectFlacHandle::wait_for_client() unblocked, result ok={}", result.is_ok());
}
}
// ─── Stream public ────────────────────────────────────────────────────────────
/// Flux FLAC exposé au handler HTTP.
///
/// Transmet les bytes du pipe directement au client HTTP.
/// Intercepte le premier `poll_read` non-vide pour signaler via `first_byte_tx`
/// que des données FLAC ont effectivement été transmises au client.
pub struct DirectFlacStream {
inner: tokio::io::DuplexStream,
/// Présent jusqu'au premier byte reçu, puis consommé (set à None).
first_byte_tx: Option<Arc<watch::Sender<bool>>>,
}
impl AsyncRead for DirectFlacStream {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let filled_before = buf.filled().len();
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
if let Poll::Ready(Ok(())) = &result {
let filled_after = buf.filled().len();
if filled_after > filled_before {
if let Some(tx) = self.first_byte_tx.take() {
debug!("DirectFlacStream: first {} bytes sent to HTTP client", filled_after - filled_before);
let _ = tx.send(true);
}
}
}
result
}
}
// ─── Logique du nœud ─────────────────────────────────────────────────────────
struct DirectFlacSinkLogic {
pcm_tx: SharedPcmTx,
client_notify: Arc<tokio::sync::Notify>,
}
#[async_trait]
impl NodeLogic for DirectFlacSinkLogic {
async fn process(
&mut self,
input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
_output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
let mut input = input.ok_or_else(|| {
AudioError::ProcessingError("DirectFlacSink requires an input".into())
})?;
loop {
tokio::select! {
_ = stop_token.cancelled() => {
debug!("DirectFlacSink: cancelled");
break;
}
segment = input.recv() => {
match segment {
None => {
debug!("DirectFlacSink: input channel closed");
break;
}
Some(seg) => match &seg.segment {
_AudioSegment::Chunk(chunk) => {
// Attendre un client si nécessaire (backpressure quand pas de Play)
loop {
let tx_opt = self.pcm_tx.lock().await.clone();
if tx_opt.is_some() {
break;
}
// Pas de client : bloquer jusqu'à connect() ou stop
debug!("DirectFlacSink: no pcm_tx, waiting for client_notify...");
tokio::select! {
_ = stop_token.cancelled() => {
debug!("DirectFlacSink: cancelled while waiting for client");
return Ok(());
}
_ = self.client_notify.notified() => {
debug!("DirectFlacSink: client_notify received, rechecking pcm_tx");
}
}
}
let tx = self.pcm_tx.lock().await.clone().unwrap();
let pcm_bytes = chunk_to_pcm_bytes(chunk, DIRECT_FLAC_BITS_PER_SAMPLE)?;
let duration_sec = chunk.len() as f64 / DIRECT_FLAC_SAMPLE_RATE as f64;
let pcm_chunk = PcmChunk {
bytes: pcm_bytes,
timestamp_sec: seg.timestamp_sec,
duration_sec,
};
if tx.send(pcm_chunk).await.is_err() {
debug!("DirectFlacSink: pcm_tx send failed (client disconnected), clearing pcm_tx");
*self.pcm_tx.lock().await = None;
}
}
_AudioSegment::Sync(marker) => match marker.as_ref() {
SyncMarker::EndOfStream => {
debug!("DirectFlacSink: EndOfStream");
}
_ => {}
},
},
}
}
}
}
Ok(())
}
async fn cleanup(&mut self, _reason: StopReason) -> Result<(), AudioError> {
Ok(())
}
}
// ─── Encodeur FLAC ────────────────────────────────────────────────────────────
async fn run_encoder(
pcm_reader: ByteStreamReader,
mut pipe_writer: tokio::io::DuplexStream,
options: EncoderOptions,
) -> Result<(), AudioError> {
let format = PcmFormat {
sample_rate: DIRECT_FLAC_SAMPLE_RATE,
channels: DIRECT_FLAC_CHANNELS,
bits_per_sample: DIRECT_FLAC_BITS_PER_SAMPLE,
};
let mut flac_stream = pmoflac::encode_flac_stream(pcm_reader, format, options)
.await
.map_err(|e| AudioError::ProcessingError(format!("FLAC encoder init: {}", e)))?;
tokio::io::copy(&mut flac_stream, &mut pipe_writer)
.await
.map_err(|e| AudioError::IoError(format!("FLAC pipe copy: {}", e)))?;
flac_stream
.wait()
.await
.map_err(|e| AudioError::ProcessingError(format!("FLAC encoder wait: {}", e)))?;
Ok(())
}
// ─── Nœud public ─────────────────────────────────────────────────────────────
pub struct DirectFlacSink {
inner: Node<DirectFlacSinkLogic>,
}
impl DirectFlacSink {
pub fn new(encoder_options: EncoderOptions) -> (Self, DirectFlacHandle) {
let pcm_tx: SharedPcmTx = Arc::new(Mutex::new(None));
let client_notify_internal = Arc::new(tokio::sync::Notify::new());
let (client_connect_tx, _) = watch::channel(0u64);
let client_connect_tx = Arc::new(client_connect_tx);
let (first_byte_tx, _) = watch::channel(false);
let first_byte_tx = Arc::new(first_byte_tx);
let logic = DirectFlacSinkLogic {
pcm_tx: pcm_tx.clone(),
client_notify: client_notify_internal.clone(),
};
let sink = Self {
inner: Node::new_with_input(logic, 16),
};
let handle = DirectFlacHandle {
pcm_tx,
client_connect_tx,
client_notify_internal,
first_byte_tx,
encoder_options,
};
(sink, handle)
}
}
#[async_trait]
impl AudioPipelineNode for DirectFlacSink {
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
self.inner.get_tx()
}
fn register(&mut self, _child: Box<dyn AudioPipelineNode>) {
panic!("DirectFlacSink is a terminal sink and cannot have children");
}
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await
}
fn start(self: Box<Self>) -> PipelineHandle {
Box::new(self.inner).start()
}
}
impl TypedAudioNode for DirectFlacSink {
fn input_type(&self) -> Option<TypeRequirement> {
Some(TypeRequirement::any_integer())
}
fn output_type(&self) -> Option<TypeRequirement> {
None
}
}

View File

@@ -0,0 +1,574 @@
//! DirectOggFlacSink — nœud puits OGG-FLAC pour un seul client HTTP.
//!
//! Combine la logique de backpressure/reconnexion de `DirectFlacSink`
//! avec l'encodage OGG-FLAC de `StreamingOggFlacSink`.
//!
//! # Cycle de vie
//!
//! - **Play** : le navigateur appelle `GET /stream`. `connect()` crée un nouveau
//! canal PCM + pipe duplex + encodeur FLAC + wrapper OGG, installe le sender
//! dans le sink, et notifie le sink via `client_notify`. Le flux reste ouvert :
//! les morceaux s'enchaînent en gapless.
//! - **Stop** : le navigateur ferme la connexion. Le pipe se rompt, l'encodeur
//! s'arrête. Le sink voit `pcm_tx.send()` échouer, passe le sender à `None`,
//! et **bloque** sur `client_notify` jusqu'au prochain Play.
//! - **Play suivant** : `connect()` → nouveau pipe → `client_notify.notify_one()`
//! → le sink se débloque et reprend la consommation des segments.
//!
//! # Architecture
//!
//! ```text
//! AudioSegment I24 @ 96 kHz
//! ↓ NodeLogic::process() [bloque si pas de client]
//! chunk_to_pcm_bytes() → PCM 24-bit LE
//! ↓ Arc<Mutex<Option<mpsc::Sender<PcmChunk>>>>
//! ByteStreamReader (AsyncRead)
//! ↓ encode_flac_stream()
//! ↓ broadcast_ogg_flac_stream() → wrapping OGG pages
//! ↓ tokio::io::duplex pipe (256 KB)
//! ↓ DirectOggFlacStream (AsyncRead) → Body HTTP
//! ```
use std::io;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use async_trait::async_trait;
use bytes::Bytes;
use pmoaudio::{
pipeline::{AudioPipelineNode, Node, NodeLogic, PipelineHandle, StopReason},
AudioError, AudioSegment, SyncMarker, TypeRequirement, TypedAudioNode, _AudioSegment,
};
use pmoflac::{EncoderOptions, PcmFormat};
use tokio::io::{AsyncRead, ReadBuf};
use tokio::sync::{mpsc, watch, Mutex};
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
use crate::sinks::byte_stream_reader::{ByteStreamReader, PcmChunk};
use crate::sinks::chunk_to_pcm::chunk_to_pcm_bytes;
use crate::sinks::flac_frame_utils::{extract_sample_rate_from_streaminfo, read_flac_header};
/// Format de sortie fixe du sink.
pub const DIRECT_OGG_FLAC_SAMPLE_RATE: u32 = 96_000;
pub const DIRECT_OGG_FLAC_CHANNELS: u8 = 2;
pub const DIRECT_OGG_FLAC_BITS_PER_SAMPLE: u8 = 24;
/// Capacité du pipe duplex (~256 KB).
const PIPE_CAPACITY: usize = 256 * 1024;
// ─── Shared state ─────────────────────────────────────────────────────────────
type SharedPcmTx = Arc<Mutex<Option<mpsc::Sender<PcmChunk>>>>;
// ─── Handle public ────────────────────────────────────────────────────────────
/// Handle vers le sink, cloneable, reconnectable à chaque Play.
#[derive(Clone)]
pub struct DirectOggFlacHandle {
pcm_tx: SharedPcmTx,
client_connect_tx: Arc<watch::Sender<u64>>,
client_notify_internal: Arc<tokio::sync::Notify>,
first_byte_tx: Arc<watch::Sender<bool>>,
encoder_options: EncoderOptions,
/// Position de lecture courante (mise à jour par ByteStreamReader).
current_timestamp: Arc<tokio::sync::RwLock<f64>>,
}
impl DirectOggFlacHandle {
/// Crée un nouveau pipe OGG-FLAC et retourne le flux côté lecture.
/// Débloque le sink s'il attendait un client.
pub async fn connect(&self) -> DirectOggFlacStream {
let connect_count_before = *self.client_connect_tx.borrow();
debug!("DirectOggFlacHandle::connect() called, connect_count={}", connect_count_before);
let (pcm_tx, pcm_rx) = mpsc::channel::<PcmChunk>(8);
// Réinitialiser le timestamp à 0 pour la nouvelle connexion
*self.current_timestamp.write().await = 0.0;
let current_dur = Arc::new(tokio::sync::RwLock::new(0.0f64));
// Partager current_timestamp avec ByteStreamReader : il sera mis à jour
// avec le timestamp absolu du segment audio (position dans le fichier source).
let pcm_reader = ByteStreamReader::new(pcm_rx, self.current_timestamp.clone(), current_dur);
let (pipe_writer, pipe_reader) = tokio::io::duplex(PIPE_CAPACITY);
let _ = self.first_byte_tx.send(false);
debug!("DirectOggFlacHandle::connect() first_byte reset to false");
*self.pcm_tx.lock().await = Some(pcm_tx);
debug!("DirectOggFlacHandle::connect() pcm_tx installed");
let new_count = connect_count_before.wrapping_add(1);
let _ = self.client_connect_tx.send(new_count);
debug!("DirectOggFlacHandle::connect() client_connect_count -> {}", new_count);
self.client_notify_internal.notify_one();
let options = self.encoder_options.clone();
let current_timestamp = self.current_timestamp.clone();
tokio::spawn(async move {
debug!("DirectOggFlacHandle: encoder+ogg task started");
if let Err(e) = run_ogg_encoder(pcm_reader, pipe_writer, options, current_timestamp).await {
debug!("DirectOggFlacStream encoder stopped: {}", e);
}
debug!("DirectOggFlacHandle: encoder+ogg task ended");
});
debug!("DirectOggFlacHandle::connect() returning DirectOggFlacStream");
DirectOggFlacStream {
inner: pipe_reader,
first_byte_tx: Some(self.first_byte_tx.clone()),
}
}
pub fn first_byte_ready(&self) -> watch::Receiver<bool> {
self.first_byte_tx.subscribe()
}
/// Retourne la position de lecture courante en secondes.
pub async fn current_position_sec(&self) -> f64 {
*self.current_timestamp.read().await
}
pub async fn wait_for_client(&self) {
let seen = *self.client_connect_tx.borrow();
debug!("DirectOggFlacHandle::wait_for_client() called, seen connect_count={}", seen);
let mut rx = self.client_connect_tx.subscribe();
let _ = rx.wait_for(|v| *v > seen).await;
debug!("DirectOggFlacHandle::wait_for_client() unblocked");
}
}
// ─── Stream public ────────────────────────────────────────────────────────────
pub struct DirectOggFlacStream {
inner: tokio::io::DuplexStream,
first_byte_tx: Option<Arc<watch::Sender<bool>>>,
}
impl AsyncRead for DirectOggFlacStream {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let filled_before = buf.filled().len();
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
if let Poll::Ready(Ok(())) = &result {
let filled_after = buf.filled().len();
if filled_after > filled_before {
if let Some(tx) = self.first_byte_tx.take() {
debug!("DirectOggFlacStream: first {} bytes sent to HTTP client", filled_after - filled_before);
let _ = tx.send(true);
}
}
}
result
}
}
// ─── Logique du nœud ─────────────────────────────────────────────────────────
struct DirectOggFlacSinkLogic {
pcm_tx: SharedPcmTx,
client_notify: Arc<tokio::sync::Notify>,
}
#[async_trait]
impl NodeLogic for DirectOggFlacSinkLogic {
async fn process(
&mut self,
input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
_output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
let mut input = input.ok_or_else(|| {
AudioError::ProcessingError("DirectOggFlacSink requires an input".into())
})?;
loop {
tokio::select! {
_ = stop_token.cancelled() => {
debug!("DirectOggFlacSink: cancelled");
break;
}
segment = input.recv() => {
match segment {
None => {
debug!("DirectOggFlacSink: input channel closed");
break;
}
Some(seg) => match &seg.segment {
_AudioSegment::Chunk(chunk) => {
// Attendre un client si nécessaire (backpressure quand pas de Play)
loop {
let tx_opt = self.pcm_tx.lock().await.clone();
if tx_opt.is_some() {
break;
}
debug!("DirectOggFlacSink: no pcm_tx, waiting for client_notify...");
tokio::select! {
_ = stop_token.cancelled() => {
debug!("DirectOggFlacSink: cancelled while waiting for client");
return Ok(());
}
_ = self.client_notify.notified() => {
debug!("DirectOggFlacSink: client_notify received, rechecking pcm_tx");
}
}
}
let tx = self.pcm_tx.lock().await.clone().unwrap();
let pcm_bytes = chunk_to_pcm_bytes(chunk, DIRECT_OGG_FLAC_BITS_PER_SAMPLE)?;
let duration_sec = chunk.len() as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64;
let pcm_chunk = PcmChunk {
bytes: pcm_bytes,
timestamp_sec: seg.timestamp_sec,
duration_sec,
};
if tx.send(pcm_chunk).await.is_err() {
warn!(
ts = seg.timestamp_sec,
"DirectOggFlacSink: chunk dropped (client disconnected at {:.3}s), waiting for reconnect",
seg.timestamp_sec,
);
*self.pcm_tx.lock().await = None;
}
}
_AudioSegment::Sync(marker) => match marker.as_ref() {
SyncMarker::EndOfStream => {
debug!("DirectOggFlacSink: EndOfStream");
}
_ => {}
},
},
}
}
}
}
Ok(())
}
async fn cleanup(&mut self, _reason: StopReason) -> Result<(), AudioError> {
Ok(())
}
}
// ─── Encodeur FLAC + wrapper OGG ─────────────────────────────────────────────
async fn run_ogg_encoder(
pcm_reader: ByteStreamReader,
mut pipe_writer: tokio::io::DuplexStream,
options: EncoderOptions,
_current_timestamp: Arc<tokio::sync::RwLock<f64>>,
) -> Result<(), AudioError> {
let format = PcmFormat {
sample_rate: DIRECT_OGG_FLAC_SAMPLE_RATE,
channels: DIRECT_OGG_FLAC_CHANNELS,
bits_per_sample: DIRECT_OGG_FLAC_BITS_PER_SAMPLE,
};
let mut flac_stream = pmoflac::encode_flac_stream(pcm_reader, format, options)
.await
.map_err(|e| AudioError::ProcessingError(format!("FLAC encoder init: {}", e)))?;
// Lire le header FLAC et construire les pages OGG d'en-tête
let flac_header = read_flac_header(&mut flac_stream).await?;
let sample_rate = extract_sample_rate_from_streaminfo(&flac_header)?;
let stream_serial: u32 = rand::random();
let mut ogg = OggPageWriter::new(stream_serial);
// Page BOS (identification OGG-FLAC)
let ogg_flac_id = create_ogg_flac_identification(&flac_header)?;
let bos_page = Bytes::from(ogg.create_page(&ogg_flac_id, true, false, false));
// Page Vorbis Comment
let vorbis_comment = create_empty_vorbis_comment();
let comment_page = Bytes::from(ogg.create_page(&vorbis_comment, false, false, false));
pipe_writer.write_all(&bos_page).await
.map_err(|e| AudioError::IoError(format!("OGG BOS write: {}", e)))?;
pipe_writer.write_all(&comment_page).await
.map_err(|e| AudioError::IoError(format!("OGG comment write: {}", e)))?;
// Lire les frames FLAC et les encapsuler dans des pages OGG
let sample_rate_f64 = sample_rate as f64;
let mut encoded_samples = 0u64;
let mut read_buffer = vec![0u8; 16384];
let mut accumulator: Vec<u8> = Vec::with_capacity(32768);
use tokio::io::{AsyncReadExt, AsyncWriteExt};
loop {
match flac_stream.read(&mut read_buffer).await {
Ok(0) => {
// EOF : page EOS finale
let eos_page = Bytes::from(ogg.create_page(&accumulator, false, true, false));
let _ = pipe_writer.write_all(&eos_page).await;
break;
}
Ok(n) => {
accumulator.extend_from_slice(&read_buffer[..n]);
loop {
if accumulator.len() < 4 {
break;
}
// Trouver les positions de sync FLAC
let mut sync_data: Vec<(usize, u32)> = Vec::new();
for i in 0..accumulator.len() - 1 {
let b1 = accumulator[i];
let b2 = accumulator[i + 1];
if b1 == 0xFF && b2 >= 0xF8 && b2 <= 0xFE {
use crate::sinks::flac_frame_utils::{validate_frame_header_crc, parse_flac_block_size};
if validate_frame_header_crc(&accumulator, i) {
if let Some(samples) = parse_flac_block_size(&accumulator, i) {
sync_data.push((i, samples));
}
}
}
}
if sync_data.len() < 2 {
break;
}
let first_start = sync_data[0].0;
let first_samples = sync_data[0].1;
let second_start = sync_data[1].0;
if first_start != 0 {
accumulator.drain(0..first_start);
continue;
}
let frame: Vec<u8> = accumulator.drain(0..second_start).collect();
encoded_samples = encoded_samples.saturating_add(first_samples as u64);
ogg.add_samples(first_samples as u64);
let ogg_page = Bytes::from(ogg.create_page(&frame, false, false, false));
if pipe_writer.write_all(&ogg_page).await.is_err() {
// Client déconnecté — le pipe HTTP s'est rompu
warn!(
samples = encoded_samples,
"DirectOggFlacSink: OGG pipe broken after {} samples ({:.3}s), client disconnected",
encoded_samples,
encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64,
);
return Ok(());
}
}
}
Err(e) => {
return Err(AudioError::ProcessingError(format!("FLAC read: {}", e)));
}
}
}
flac_stream.wait().await
.map_err(|e| AudioError::ProcessingError(format!("FLAC encoder wait: {}", e)))?;
Ok(())
}
// ─── OGG helpers (copiés de streaming_ogg_flac_sink) ─────────────────────────
struct OggPageWriter {
stream_serial: u32,
page_sequence: u32,
granule_position: u64,
}
impl OggPageWriter {
fn new(stream_serial: u32) -> Self {
Self { stream_serial, page_sequence: 0, granule_position: 0 }
}
fn add_samples(&mut self, samples: u64) {
self.granule_position += samples;
}
fn create_page(&mut self, packet_data: &[u8], is_bos: bool, is_eos: bool, is_continuation: bool) -> Vec<u8> {
use std::io::Write;
let mut segments = Vec::new();
let mut remaining = packet_data.len();
while remaining > 0 {
let seg = remaining.min(255);
segments.push(seg as u8);
remaining -= seg;
}
if !packet_data.is_empty() && packet_data.len() % 255 == 0 && !is_continuation {
segments.push(0);
}
let segment_count = segments.len();
let total_size = 27 + segment_count + packet_data.len();
let mut page = Vec::with_capacity(total_size);
page.write_all(b"OggS").unwrap();
page.write_all(&[0]).unwrap();
let mut header_type = 0u8;
if is_continuation { header_type |= 0x01; }
if is_bos { header_type |= 0x02; }
if is_eos { header_type |= 0x04; }
page.write_all(&[header_type]).unwrap();
page.write_all(&self.granule_position.to_le_bytes()).unwrap();
page.write_all(&self.stream_serial.to_le_bytes()).unwrap();
page.write_all(&self.page_sequence.to_le_bytes()).unwrap();
self.page_sequence += 1;
let crc_offset = page.len();
page.write_all(&[0, 0, 0, 0]).unwrap();
page.write_all(&[segment_count as u8]).unwrap();
page.write_all(&segments).unwrap();
page.write_all(packet_data).unwrap();
let crc = calculate_ogg_crc(&page);
page[crc_offset..crc_offset + 4].copy_from_slice(&crc.to_le_bytes());
page
}
}
fn calculate_ogg_crc(data: &[u8]) -> u32 {
const CRC_TABLE: [u32; 256] = generate_crc_table();
let mut crc: u32 = 0;
for &byte in data {
crc = (crc << 8) ^ CRC_TABLE[((crc >> 24) ^ (byte as u32)) as usize];
}
crc
}
const fn generate_crc_table() -> [u32; 256] {
let mut table = [0u32; 256];
let mut i = 0usize;
while i < 256 {
let mut r = (i as u32) << 24;
let mut j = 0;
while j < 8 {
if (r & 0x80000000) != 0 { r = (r << 1) ^ 0x04c11db7; } else { r <<= 1; }
j += 1;
}
table[i] = r;
i += 1;
}
table
}
fn create_ogg_flac_identification(flac_header: &[u8]) -> Result<Vec<u8>, AudioError> {
if flac_header.len() < 8 || &flac_header[0..4] != b"fLaC" {
return Err(AudioError::ProcessingError("Invalid FLAC header".into()));
}
let first_block_type = flac_header[4] & 0x7F;
if first_block_type != 0 {
return Err(AudioError::ProcessingError("First FLAC block is not STREAMINFO".into()));
}
let block_length = u32::from_be_bytes([0, flac_header[5], flac_header[6], flac_header[7]]) as usize;
let streaminfo_size = 4 + block_length;
if flac_header.len() < 4 + streaminfo_size {
return Err(AudioError::ProcessingError("FLAC header truncated".into()));
}
let streaminfo = &flac_header[4..4 + streaminfo_size];
let mut packet = Vec::new();
packet.push(0x7F);
packet.extend_from_slice(b"FLAC");
packet.push(0x01);
packet.push(0x00);
packet.extend_from_slice(&1u16.to_be_bytes());
packet.extend_from_slice(b"fLaC");
packet.extend_from_slice(streaminfo);
Ok(packet)
}
fn create_empty_vorbis_comment() -> Vec<u8> {
let vendor = "pmoaudio DirectOggFlacSink";
let vendor_bytes = vendor.as_bytes();
let mut vorbis_data = Vec::new();
vorbis_data.extend_from_slice(&(vendor_bytes.len() as u32).to_le_bytes());
vorbis_data.extend_from_slice(vendor_bytes);
vorbis_data.extend_from_slice(&0u32.to_le_bytes());
let mut block = Vec::new();
block.push(0x84); // last-block + VORBIS_COMMENT type
let length = vorbis_data.len() as u32;
block.push((length >> 16) as u8);
block.push((length >> 8) as u8);
block.push(length as u8);
block.extend_from_slice(&vorbis_data);
block
}
// ─── Nœud public ─────────────────────────────────────────────────────────────
pub struct DirectOggFlacSink {
inner: Node<DirectOggFlacSinkLogic>,
}
impl DirectOggFlacSink {
pub fn new(encoder_options: EncoderOptions) -> (Self, DirectOggFlacHandle) {
let pcm_tx: SharedPcmTx = Arc::new(Mutex::new(None));
let client_notify_internal = Arc::new(tokio::sync::Notify::new());
let (client_connect_tx, _) = watch::channel(0u64);
let client_connect_tx = Arc::new(client_connect_tx);
let (first_byte_tx, _) = watch::channel(false);
let first_byte_tx = Arc::new(first_byte_tx);
let current_timestamp = Arc::new(tokio::sync::RwLock::new(0.0f64));
let logic = DirectOggFlacSinkLogic {
pcm_tx: pcm_tx.clone(),
client_notify: client_notify_internal.clone(),
};
let sink = Self {
inner: Node::new_with_input(logic, 16),
};
let handle = DirectOggFlacHandle {
pcm_tx,
client_connect_tx,
client_notify_internal,
first_byte_tx,
encoder_options,
current_timestamp,
};
(sink, handle)
}
}
#[async_trait]
impl AudioPipelineNode for DirectOggFlacSink {
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
self.inner.get_tx()
}
fn register(&mut self, _child: Box<dyn AudioPipelineNode>) {
panic!("DirectOggFlacSink is a terminal sink and cannot have children");
}
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await
}
fn start(self: Box<Self>) -> PipelineHandle {
Box::new(self.inner).start()
}
}
impl TypedAudioNode for DirectOggFlacSink {
fn input_type(&self) -> Option<TypeRequirement> {
Some(TypeRequirement::any_integer())
}
fn output_type(&self) -> Option<TypeRequirement> {
None
}
}

View File

@@ -23,6 +23,24 @@ mod flac_frame_utils;
#[cfg(feature = "http-stream")]
mod timed_broadcast;
#[cfg(feature = "http-stream")]
mod direct_flac_sink;
#[cfg(feature = "http-stream")]
pub use direct_flac_sink::{
DirectFlacHandle, DirectFlacSink, DirectFlacStream,
DIRECT_FLAC_BITS_PER_SAMPLE, DIRECT_FLAC_CHANNELS, DIRECT_FLAC_SAMPLE_RATE,
};
#[cfg(feature = "http-stream")]
mod direct_ogg_flac_sink;
#[cfg(feature = "http-stream")]
pub use direct_ogg_flac_sink::{
DirectOggFlacHandle, DirectOggFlacSink, DirectOggFlacStream,
DIRECT_OGG_FLAC_BITS_PER_SAMPLE, DIRECT_OGG_FLAC_CHANNELS, DIRECT_OGG_FLAC_SAMPLE_RATE,
};
#[cfg(feature = "http-stream")]
mod streaming_flac_sink;

View File

@@ -215,7 +215,13 @@ impl AsyncRead for SharedClientStream {
self.state = StreamState::Streaming;
continue;
} else {
self.state = StreamState::Streaming;
// Header not yet available (encoder not yet started): wait and retry
let waker = cx.waker().clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(10)).await;
waker.wake();
});
return Poll::Pending;
}
}

View File

@@ -3,8 +3,19 @@
//! Ce module contient des sources audio qui dépendent d'autres crates
//! du projet PMO (pmoplaylist, pmoaudiocache, etc.)
// Helpers partagés (conversion PCM → AudioSegment)
// Disponibles dès que l'une des deux features qui en dépend est activée
#[cfg(any(feature = "playlist", feature = "http-stream"))]
pub(crate) mod pcm_decode;
#[cfg(feature = "playlist")]
mod playlist_source;
#[cfg(feature = "playlist")]
pub use playlist_source::PlaylistSource;
#[cfg(feature = "http-stream")]
mod uri_source;
#[cfg(feature = "http-stream")]
pub use uri_source::UriSource;

View File

@@ -0,0 +1,145 @@
//! Helpers partagés pour le décodage PCM → AudioSegment
//!
//! Utilisés par `PlaylistSource` et `UriSource`.
//!
//! Les fonctions `bytes_to_segment` et `validate_stream` ont été extraites
//! de `playlist_source.rs` sans modification pour éviter toute duplication.
use std::sync::Arc;
use pmoaudio::{AudioChunk, AudioChunkData, AudioSegment, I24, _AudioSegment, nodes::AudioError};
use pmoflac::StreamInfo;
pub(crate) fn validate_stream(info: &StreamInfo) -> Result<(), AudioError> {
if !(1..=2).contains(&info.channels) {
return Err(AudioError::ProcessingError(format!(
"Unsupported channel count: {}",
info.channels
)));
}
match info.bits_per_sample {
8 | 16 | 24 | 32 => Ok(()),
other => Err(AudioError::ProcessingError(format!(
"Unsupported bit depth: {}",
other
))),
}
}
/// Convertit des bytes PCM en AudioSegment avec le type approprié
pub(crate) fn bytes_to_segment(
chunk_bytes: &[u8],
info: &StreamInfo,
frames: usize,
order: u64,
timestamp_sec: f64,
) -> Result<Arc<AudioSegment>, AudioError> {
let bytes_per_sample = info.bytes_per_sample();
let channels = info.channels as usize;
let frame_bytes = bytes_per_sample * channels;
// Créer le chunk du bon type selon la profondeur de bit
let chunk = match info.bits_per_sample {
16 => {
// Type I16
let mut stereo = Vec::with_capacity(frames);
for frame_idx in 0..frames {
let base = frame_idx * frame_bytes;
let l = i16::from_le_bytes(
chunk_bytes[base..base + bytes_per_sample]
.try_into()
.unwrap(),
);
let r = if channels == 1 {
l
} else {
i16::from_le_bytes(
chunk_bytes[base + bytes_per_sample..base + 2 * bytes_per_sample]
.try_into()
.unwrap(),
)
};
stereo.push([l, r]);
}
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
AudioChunk::I16(chunk_data)
}
24 => {
// Type I24
let mut stereo = Vec::with_capacity(frames);
for frame_idx in 0..frames {
let base = frame_idx * frame_bytes;
let l_i32 = {
let mut buf = [0u8; 4];
buf[..3].copy_from_slice(&chunk_bytes[base..base + 3]);
// Sign extend
if chunk_bytes[base + 2] & 0x80 != 0 {
buf[3] = 0xFF;
}
i32::from_le_bytes(buf)
};
let l = I24::new(l_i32).ok_or_else(|| {
AudioError::ProcessingError(format!("Invalid I24 value: {}", l_i32))
})?;
let r = if channels == 1 {
l
} else {
let r_i32 = {
let mut buf = [0u8; 4];
buf[..3].copy_from_slice(
&chunk_bytes[base + bytes_per_sample..base + bytes_per_sample + 3],
);
// Sign extend
if chunk_bytes[base + bytes_per_sample + 2] & 0x80 != 0 {
buf[3] = 0xFF;
}
i32::from_le_bytes(buf)
};
I24::new(r_i32).ok_or_else(|| {
AudioError::ProcessingError(format!("Invalid I24 value: {}", r_i32))
})?
};
stereo.push([l, r]);
}
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
AudioChunk::I24(chunk_data)
}
32 => {
// Type I32
let mut stereo = Vec::with_capacity(frames);
for frame_idx in 0..frames {
let base = frame_idx * frame_bytes;
let l = i32::from_le_bytes(
chunk_bytes[base..base + bytes_per_sample]
.try_into()
.unwrap(),
);
let r = if channels == 1 {
l
} else {
i32::from_le_bytes(
chunk_bytes[base + bytes_per_sample..base + 2 * bytes_per_sample]
.try_into()
.unwrap(),
)
};
stereo.push([l, r]);
}
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
AudioChunk::I32(chunk_data)
}
_ => {
return Err(AudioError::ProcessingError(format!(
"Unsupported bit depth: {}",
info.bits_per_sample
)))
}
};
Ok(Arc::new(AudioSegment {
order,
timestamp_sec,
segment: _AudioSegment::Chunk(Arc::new(chunk)),
}))
}

View File

@@ -113,11 +113,13 @@ use pmoaudio::{
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
type_constraints::TypeRequirement,
AudioChunk, AudioChunkData, AudioSegment, I24,
AudioSegment,
};
use pmoaudiocache::Cache as AudioCache;
use pmoflac::{decode_audio_stream, StreamInfo};
use pmoplaylist::{PlaylistRole, ReadHandle};
use super::pcm_decode::{bytes_to_segment, validate_stream};
use std::{path::PathBuf, sync::Arc, time::Duration};
use tokio::{fs::File, io::AsyncReadExt, sync::mpsc};
use tokio_util::sync::CancellationToken;
@@ -563,139 +565,6 @@ async fn decode_and_emit_track(
Ok(())
}
fn validate_stream(info: &StreamInfo) -> Result<(), AudioError> {
if !(1..=2).contains(&info.channels) {
return Err(AudioError::ProcessingError(format!(
"Unsupported channel count: {}",
info.channels
)));
}
match info.bits_per_sample {
8 | 16 | 24 | 32 => Ok(()),
other => Err(AudioError::ProcessingError(format!(
"Unsupported bit depth: {}",
other
))),
}
}
/// Convertit des bytes PCM en AudioSegment avec le type approprié
fn bytes_to_segment(
chunk_bytes: &[u8],
info: &StreamInfo,
frames: usize,
order: u64,
timestamp_sec: f64,
) -> Result<Arc<AudioSegment>, AudioError> {
let bytes_per_sample = info.bytes_per_sample();
let channels = info.channels as usize;
let frame_bytes = bytes_per_sample * channels;
// Créer le chunk du bon type selon la profondeur de bit
let chunk = match info.bits_per_sample {
16 => {
// Type I16
let mut stereo = Vec::with_capacity(frames);
for frame_idx in 0..frames {
let base = frame_idx * frame_bytes;
let l = i16::from_le_bytes(
chunk_bytes[base..base + bytes_per_sample]
.try_into()
.unwrap(),
);
let r = if channels == 1 {
l
} else {
i16::from_le_bytes(
chunk_bytes[base + bytes_per_sample..base + 2 * bytes_per_sample]
.try_into()
.unwrap(),
)
};
stereo.push([l, r]);
}
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
AudioChunk::I16(chunk_data)
}
24 => {
// Type I24
let mut stereo = Vec::with_capacity(frames);
for frame_idx in 0..frames {
let base = frame_idx * frame_bytes;
let l_i32 = {
let mut buf = [0u8; 4];
buf[..3].copy_from_slice(&chunk_bytes[base..base + 3]);
// Sign extend
if chunk_bytes[base + 2] & 0x80 != 0 {
buf[3] = 0xFF;
}
i32::from_le_bytes(buf)
};
let l = I24::new(l_i32).ok_or_else(|| {
AudioError::ProcessingError(format!("Invalid I24 value: {}", l_i32))
})?;
let r = if channels == 1 {
l
} else {
let r_i32 = {
let mut buf = [0u8; 4];
buf[..3].copy_from_slice(
&chunk_bytes[base + bytes_per_sample..base + bytes_per_sample + 3],
);
// Sign extend
if chunk_bytes[base + bytes_per_sample + 2] & 0x80 != 0 {
buf[3] = 0xFF;
}
i32::from_le_bytes(buf)
};
I24::new(r_i32).ok_or_else(|| {
AudioError::ProcessingError(format!("Invalid I24 value: {}", r_i32))
})?
};
stereo.push([l, r]);
}
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
AudioChunk::I24(chunk_data)
}
32 => {
// Type I32
let mut stereo = Vec::with_capacity(frames);
for frame_idx in 0..frames {
let base = frame_idx * frame_bytes;
let l = i32::from_le_bytes(
chunk_bytes[base..base + bytes_per_sample]
.try_into()
.unwrap(),
);
let r = if channels == 1 {
l
} else {
i32::from_le_bytes(
chunk_bytes[base + bytes_per_sample..base + 2 * bytes_per_sample]
.try_into()
.unwrap(),
)
};
stereo.push([l, r]);
}
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
AudioChunk::I32(chunk_data)
}
_ => {
return Err(AudioError::ProcessingError(format!(
"Unsupported bit depth: {}",
info.bits_per_sample
)))
}
};
Ok(Arc::new(AudioSegment {
order,
timestamp_sec,
segment: pmoaudio::_AudioSegment::Chunk(Arc::new(chunk)),
}))
}
// ═══════════════════════════════════════════════════════════════════════════
// WRAPPER PlaylistSource - Délègue à Node<PlaylistSourceLogic>
@@ -795,6 +664,7 @@ impl TypedAudioNode for PlaylistSource {
#[cfg(test)]
mod tests {
use super::*;
use pmoaudio::AudioChunk;
// ═══════════════════════════════════════════════════════════════════════════
// Tests unitaires pour les fonctions helper

View File

@@ -0,0 +1,254 @@
//! UriSource - Source audio depuis une URI arbitraire
//!
//! Ouvre une URI (fichier local ou HTTP/HTTPS), décode l'audio (tout format
//! supporté par `pmoflac` : FLAC, MP3, OGG, WAV, AIFF) et émet des `AudioSegment`
//! vers un sender tokio.
//!
//! # Usage
//!
//! ```rust,no_run
//! use pmoaudio_ext::sources::UriSource;
//! use tokio_util::sync::CancellationToken;
//! use tokio::sync::mpsc;
//! use std::sync::Arc;
//! use pmoaudio::AudioSegment;
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! let (tx, mut rx) = mpsc::channel::<Arc<AudioSegment>>(64);
//! let stop = CancellationToken::new();
//!
//! let source = UriSource::open("/music/track.flac", 0.0, stop.clone()).await?;
//! println!("Duration: {:?}", source.duration_sec());
//!
//! let eof = source.emit_to_channel(&tx, &stop).await?;
//! # Ok(())
//! # }
//! ```
//!
//! # Seek
//!
//! Implémenté par skip des frames initiales. Pour les formats sans seek natif
//! (MP3, stream HTTP), tout le contenu est lu mais les frames avant `seek_sec`
//! ne sont pas émises.
use std::sync::Arc;
use pmoaudio::{AudioSegment, nodes::AudioError};
use pmoflac::{StreamInfo, decode_audio_stream};
use tokio::io::AsyncReadExt;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info};
use super::pcm_decode::{bytes_to_segment, validate_stream};
const CHUNK_FRAMES: usize = 2048; // ~46ms @ 44.1kHz
/// Source audio ouverte depuis une URI, prête à émettre des segments.
pub struct UriSource {
reader: Box<dyn tokio::io::AsyncRead + Send + Unpin>,
stream_info: StreamInfo,
frames_to_skip: u64,
}
impl UriSource {
/// Ouvre une URI et prépare la source.
///
/// - Chemin absolu ou `file://...` → fichier local
/// - `http://...` / `https://...` → streaming HTTP
pub async fn open(
uri: &str,
seek_sec: f64,
stop_token: CancellationToken,
) -> Result<Self, AudioError> {
if uri.starts_with("http://") || uri.starts_with("https://") {
Self::open_http(uri, seek_sec, &stop_token).await
} else {
let path = uri.strip_prefix("file://").unwrap_or(uri);
Self::open_file(path, seek_sec).await
}
}
/// Durée totale en secondes, si connue.
pub fn duration_sec(&self) -> Option<f64> {
let info = &self.stream_info;
info.total_samples
.filter(|&s| s > 0)
.map(|s| s as f64 / info.sample_rate as f64)
}
/// Nombre total de samples à 96 kHz après resampling, si connu.
/// Utilisé pour renseigner STREAMINFO.total_samples dans le FLAC de sortie.
pub fn total_samples_at(&self, output_sample_rate: u32) -> Option<u64> {
let info = &self.stream_info;
info.total_samples.filter(|&s| s > 0).map(|s| {
// Convertir le nombre de samples source vers le sample rate de sortie
let ratio = output_sample_rate as f64 / info.sample_rate as f64;
(s as f64 * ratio).round() as u64
})
}
/// Émet les chunks audio vers `tx`.
///
/// Retourne `Ok(true)` si EOF naturel, `Ok(false)` si annulé ou receiver fermé.
pub async fn emit_to_channel(
mut self,
tx: &mpsc::Sender<Arc<AudioSegment>>,
stop_token: &CancellationToken,
) -> Result<bool, AudioError> {
let info = self.stream_info.clone();
let bytes_per_sample = info.bytes_per_sample();
let frame_bytes = bytes_per_sample * info.channels as usize;
let chunk_byte_len = CHUNK_FRAMES * frame_bytes;
let mut pending = Vec::new();
let mut read_buf = vec![0u8; frame_bytes * 512.max(CHUNK_FRAMES)];
let mut chunk_index = 0u64;
let mut total_frames = 0u64;
loop {
tokio::select! {
_ = stop_token.cancelled() => {
debug!("UriSource: cancelled");
return Ok(false);
}
read_result = self.reader.read(&mut read_buf) => {
let read = read_result
.map_err(|e| AudioError::IoError(e.to_string()))?;
if read == 0 {
break; // EOF
}
pending.extend_from_slice(&read_buf[..read]);
while pending.len() >= chunk_byte_len {
let chunk_bytes = pending.drain(..chunk_byte_len).collect::<Vec<_>>();
let frames = CHUNK_FRAMES;
// Seek : ignorer les frames avant la position demandée
if total_frames + frames as u64 <= self.frames_to_skip {
total_frames += frames as u64;
chunk_index += 1;
continue;
}
let timestamp_sec = total_frames as f64 / info.sample_rate as f64;
let segment = bytes_to_segment(&chunk_bytes, &info, frames, chunk_index, timestamp_sec)?;
if tx.send(segment).await.is_err() {
debug!("UriSource: receiver dropped");
return Ok(false);
}
total_frames += frames as u64;
chunk_index += 1;
}
}
}
}
// Émettre le reste (< un chunk complet)
if !pending.is_empty() {
let frames = pending.len() / frame_bytes;
if frames > 0 && total_frames >= self.frames_to_skip {
let timestamp_sec = total_frames as f64 / info.sample_rate as f64;
if let Ok(seg) = bytes_to_segment(
&pending[..frames * frame_bytes],
&info,
frames,
chunk_index,
timestamp_sec,
) {
let _ = tx.send(seg).await;
}
}
}
info!(
"UriSource: EOF after {} frames ({:.1}s)",
total_frames,
total_frames as f64 / info.sample_rate.max(1) as f64
);
Ok(true)
}
// ── Constructeurs internes ────────────────────────────────────────────────
async fn open_file(path: &str, seek_sec: f64) -> Result<Self, AudioError> {
let file = tokio::fs::File::open(path)
.await
.map_err(|e| AudioError::IoError(format!("Cannot open {:?}: {}", path, e)))?;
let stream = decode_audio_stream(file)
.await
.map_err(|e| AudioError::ProcessingError(format!("Decode error: {}", e)))?;
let stream_info = stream.info().clone();
validate_stream(&stream_info)?;
let frames_to_skip = (seek_sec * stream_info.sample_rate as f64) as u64;
info!(
"UriSource: opened file {} Hz {} ch {} bps {:.1}s",
stream_info.sample_rate,
stream_info.channels,
stream_info.bits_per_sample,
stream_info.total_samples
.map(|s| s as f64 / stream_info.sample_rate as f64)
.unwrap_or(0.0),
);
let (_, reader) = stream.into_reader();
Ok(Self { reader: Box::new(reader), stream_info, frames_to_skip })
}
async fn open_http(
url: &str,
seek_sec: f64,
stop_token: &CancellationToken,
) -> Result<Self, AudioError> {
let response = tokio::select! {
_ = stop_token.cancelled() => {
return Err(AudioError::IoError("Cancelled before HTTP connect".into()));
}
result = reqwest::get(url) => {
result.map_err(|e| AudioError::IoError(format!("HTTP error: {}", e)))?
}
};
if !response.status().is_success() {
return Err(AudioError::IoError(format!(
"HTTP {} for {}",
response.status(),
url
)));
}
use futures::TryStreamExt;
use tokio_util::io::StreamReader;
let byte_stream = response
.bytes_stream()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e));
let reader = StreamReader::new(byte_stream);
let stream = decode_audio_stream(reader)
.await
.map_err(|e| AudioError::ProcessingError(format!("Decode error: {}", e)))?;
let stream_info = stream.info().clone();
validate_stream(&stream_info)?;
let frames_to_skip = (seek_sec * stream_info.sample_rate as f64) as u64;
info!(
"UriSource: opened HTTP {} Hz {} ch {} bps",
stream_info.sample_rate, stream_info.channels, stream_info.bits_per_sample,
);
let (_, reader) = stream.into_reader();
Ok(Self { reader: Box::new(reader), stream_info, frames_to_skip })
}
}

View File

@@ -70,8 +70,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let sink = FlacFileSink::new(output_path);
// Construire la chaîne: source → converter → sink
converter.register(Box::new(sink));
source.register(converter);
converter.register(sink.boxed());
source.register(converter.boxed());
// Créer un token d'arrêt pour contrôle manuel si besoin
let stop_token = CancellationToken::new();

View File

@@ -38,7 +38,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let sink = FlacFileSink::new(output_path);
// Enregistrer le sink comme enfant de la source
source.register(Box::new(sink));
source.register(sink.boxed());
// Créer un token d'arrêt pour contrôle manuel si besoin
let stop_token = CancellationToken::new();

View File

@@ -34,7 +34,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let sink = AudioSink::new();
// Connecter la source au sink
source.register(Box::new(sink));
source.register(sink.boxed());
println!("Démarrage de la lecture...");
println!("Appuyez sur Ctrl+C pour arrêter");

View File

@@ -35,22 +35,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Lecture de: {}", file_path);
println!("Sample rate cible: {} Hz", target_sample_rate);
// Créer la source audio
let mut source = FileSource::new(file_path).await?;
// Créer le nœud de resampling
let mut resampler = ResamplingNode::new(target_sample_rate);
// Créer le nœud de conversion vers I24
let mut converter = ToI24Node::new();
// Créer le sink audio avec volume à 80%
let sink = AudioSink::with_volume(0.8);
// Construire le pipeline: Source → Resampler → Converter → Sink
source.register(Box::new(resampler));
resampler.register(Box::new(converter));
converter.register(Box::new(sink));
let sink = AudioSink::new();
let mut converter = ToI24Node::new();
converter.register(sink.boxed());
let mut resampler = ResamplingNode::new(target_sample_rate);
resampler.register(converter.boxed());
let mut source = FileSource::new(file_path);
source.register(resampler.boxed());
println!(
"Pipeline créé: FileSource → Resampling({} Hz) → ToI24 → AudioSink",

View File

@@ -126,6 +126,7 @@ pub use nodes::{
resampling_node::ResamplingNode,
timer_buffer_node::TimerBufferNode,
timer_node::TimerNode,
position_tracker_node::{PositionHandle, PositionTrackerNode},
AudioError, AudioNode, TypedAudioNode,
};

View File

@@ -547,19 +547,23 @@ impl AudioSink {
}
}
pub fn make() -> Box<dyn AudioPipelineNode> {
Self::new().boxed()
}
/// Crée un nouveau AudioSink avec une taille de channel personnalisée
pub fn with_channel_size(channel_size: usize) -> Self {
pub fn with_channel_size(channel_size: usize) -> Box<dyn AudioPipelineNode> {
Self {
inner: Node::new_with_input(AudioSinkLogic::new(), channel_size),
}
}.boxed()
}
/// Crée un AudioSink avec null output (pour tests sans carte audio)
/// Consomme les segments audio sans les jouer
pub fn with_null_output() -> Self {
pub fn with_null_output() -> Box<dyn AudioPipelineNode> {
Self {
inner: Node::new_with_input(AudioSinkLogic::with_null_output(), DEFAULT_CHANNEL_SIZE),
}
}.boxed()
}
}

View File

@@ -22,6 +22,7 @@ use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
/// Logique de conversion générique
///
/// Cette struct contient la logique pure de conversion d'un type vers un autre.
@@ -112,102 +113,162 @@ where
// ═══════════════════════════════════════════════════════════════════════════
/// Node de conversion vers I16 (16-bit signed integer)
pub struct ToI16Node;
pub struct ToI16Node(Node<ConverterLogic<fn(&AudioChunk) -> AudioChunk>>);
impl ToI16Node {
pub fn new() -> Box<dyn AudioPipelineNode> {
Self::with_channel_size(16)
pub fn new() -> Self {
Self(Node::new_with_input(ConverterLogic::new(|chunk: &AudioChunk| chunk.to_i16()), 16))
}
pub fn make() -> Box<dyn AudioPipelineNode> {
Self::new().boxed()
}
pub fn with_channel_size(channel_size: usize) -> Box<dyn AudioPipelineNode> {
let logic = ConverterLogic::new(|chunk: &AudioChunk| chunk.to_i16());
Box::new(Node::new_with_input(logic, channel_size))
Self(Node::new_with_input(ConverterLogic::new(|chunk: &AudioChunk| chunk.to_i16()), channel_size)).boxed()
}
}
impl Default for ToI16Node {
fn default() -> Self {
Self
Self::new()
}
}
#[async_trait::async_trait]
impl AudioPipelineNode for ToI16Node {
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> { self.0.get_tx() }
fn register(&mut self, child: Box<dyn AudioPipelineNode>) { self.0.register(child) }
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.0).run(stop_token).await
}
}
/// Node de conversion vers I24 (24-bit signed integer)
pub struct ToI24Node;
pub struct ToI24Node(Node<ConverterLogic<fn(&AudioChunk) -> AudioChunk>>);
impl ToI24Node {
pub fn new() -> Box<dyn AudioPipelineNode> {
Self::with_channel_size(16)
pub fn new() -> Self {
Self(Node::new_with_input(ConverterLogic::new(|chunk: &AudioChunk| chunk.to_i24()), 16))
}
pub fn make() -> Box<dyn AudioPipelineNode> {
Self::new().boxed()
}
pub fn with_channel_size(channel_size: usize) -> Box<dyn AudioPipelineNode> {
let logic = ConverterLogic::new(|chunk: &AudioChunk| chunk.to_i24());
Box::new(Node::new_with_input(logic, channel_size))
Self(Node::new_with_input(ConverterLogic::new(|chunk: &AudioChunk| chunk.to_i24()), channel_size)).boxed()
}
}
impl Default for ToI24Node {
fn default() -> Self {
Self
Self::new()
}
}
#[async_trait::async_trait]
impl AudioPipelineNode for ToI24Node {
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> { self.0.get_tx() }
fn register(&mut self, child: Box<dyn AudioPipelineNode>) { self.0.register(child) }
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.0).run(stop_token).await
}
}
/// Node de conversion vers I32 (32-bit signed integer)
pub struct ToI32Node;
pub struct ToI32Node(Node<ConverterLogic<fn(&AudioChunk) -> AudioChunk>>);
impl ToI32Node {
pub fn new() -> Box<dyn AudioPipelineNode> {
Self::with_channel_size(16)
pub fn new() -> Self {
Self(Node::new_with_input(ConverterLogic::new(|chunk: &AudioChunk| chunk.to_i32()), 16))
}
pub fn make() -> Box<dyn AudioPipelineNode> {
Self::new().boxed()
}
pub fn with_channel_size(channel_size: usize) -> Box<dyn AudioPipelineNode> {
let logic = ConverterLogic::new(|chunk: &AudioChunk| chunk.to_i32());
Box::new(Node::new_with_input(logic, channel_size))
Self(Node::new_with_input(ConverterLogic::new(|chunk: &AudioChunk| chunk.to_i32()), channel_size)).boxed()
}
}
impl Default for ToI32Node {
fn default() -> Self {
Self
Self::new()
}
}
#[async_trait::async_trait]
impl AudioPipelineNode for ToI32Node {
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> { self.0.get_tx() }
fn register(&mut self, child: Box<dyn AudioPipelineNode>) { self.0.register(child) }
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.0).run(stop_token).await
}
}
/// Node de conversion vers F32 (32-bit floating point)
pub struct ToF32Node;
pub struct ToF32Node(Node<ConverterLogic<fn(&AudioChunk) -> AudioChunk>>);
impl ToF32Node {
pub fn new() -> Box<dyn AudioPipelineNode> {
Self::with_channel_size(16)
pub fn new() -> Self {
Self(Node::new_with_input(ConverterLogic::new(|chunk: &AudioChunk| chunk.to_f32()), 16))
}
pub fn make() -> Box<dyn AudioPipelineNode> {
Self::new().boxed()
}
pub fn with_channel_size(channel_size: usize) -> Box<dyn AudioPipelineNode> {
let logic = ConverterLogic::new(|chunk: &AudioChunk| chunk.to_f32());
Box::new(Node::new_with_input(logic, channel_size))
Self(Node::new_with_input(ConverterLogic::new(|chunk: &AudioChunk| chunk.to_f32()), channel_size)).boxed()
}
}
impl Default for ToF32Node {
fn default() -> Self {
Self
Self::new()
}
}
#[async_trait::async_trait]
impl AudioPipelineNode for ToF32Node {
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> { self.0.get_tx() }
fn register(&mut self, child: Box<dyn AudioPipelineNode>) { self.0.register(child) }
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.0).run(stop_token).await
}
}
/// Node de conversion vers F64 (64-bit floating point)
pub struct ToF64Node;
pub struct ToF64Node(Node<ConverterLogic<fn(&AudioChunk) -> AudioChunk>>);
impl ToF64Node {
pub fn new() -> Box<dyn AudioPipelineNode> {
Self::with_channel_size(16)
pub fn new() -> Self {
Self(Node::new_with_input(ConverterLogic::new(|chunk: &AudioChunk| chunk.to_f64()), 16))
}
pub fn make() -> Box<dyn AudioPipelineNode> {
Self::new().boxed()
}
pub fn with_channel_size(channel_size: usize) -> Box<dyn AudioPipelineNode> {
let logic = ConverterLogic::new(|chunk: &AudioChunk| chunk.to_f64());
Box::new(Node::new_with_input(logic, channel_size))
Self(Node::new_with_input(ConverterLogic::new(|chunk: &AudioChunk| chunk.to_f64()), channel_size)).boxed()
}
}
impl Default for ToF64Node {
fn default() -> Self {
Self
Self::new()
}
}
#[async_trait::async_trait]
impl AudioPipelineNode for ToF64Node {
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> { self.0.get_tx() }
fn register(&mut self, child: Box<dyn AudioPipelineNode>) { self.0.register(child) }
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.0).run(stop_token).await
}
}

View File

@@ -224,18 +224,21 @@ impl FileSource {
///
/// * `path` - chemin du fichier audio à lire
pub fn new<P: Into<PathBuf>>(path: P) -> Self {
Self::with_chunk_size(path, 0) // 0 = auto-calculer
let logic = FileSourceLogic::new(path, 0);
Self { inner: Node::new_source(logic) }
}
pub fn make<P: Into<PathBuf>>(path: P) -> Box<dyn AudioPipelineNode> {
Self::new(path).boxed()
}
/// Crée une nouvelle source de fichier avec une taille de chunk spécifique.
///
/// * `path` - chemin du fichier audio à lire
/// * `chunk_frames` - nombre d'échantillons par canal par chunk (0 = auto)
pub fn with_chunk_size<P: Into<PathBuf>>(path: P, chunk_frames: usize) -> Self {
pub fn with_chunk_size<P: Into<PathBuf>>(path: P, chunk_frames: usize) -> Box<dyn AudioPipelineNode> {
let logic = FileSourceLogic::new(path, chunk_frames);
Self {
inner: Node::new_source(logic),
}
Self { inner: Node::new_source(logic) }.boxed()
}
}

View File

@@ -305,7 +305,12 @@ impl FlacFileSink {
/// * `base_path` - Chemin de base pour les fichiers FLAC. Si des TrackBoundary sont reçus,
/// des fichiers seront créés avec des suffixes (_01, _02, etc.)
pub fn new<P: Into<PathBuf>>(base_path: P) -> Self {
Self::with_channel_size(base_path, DEFAULT_CHANNEL_SIZE)
let logic = FlacFileSinkLogic::new(base_path, EncoderOptions::default(), 8);
Self { inner: Node::new_with_input(logic, DEFAULT_CHANNEL_SIZE) }
}
pub fn make<P: Into<PathBuf>>(base_path: P) -> Box<dyn AudioPipelineNode> {
Self::new(base_path).boxed()
}
/// Crée un sink FLAC avec une taille de buffer MPSC personnalisée.
@@ -314,8 +319,9 @@ impl FlacFileSink {
///
/// * `base_path` - Chemin de base pour les fichiers FLAC
/// * `channel_size` - Taille du buffer MPSC (nombre de segments en attente avant backpressure)
pub fn with_channel_size<P: Into<PathBuf>>(base_path: P, channel_size: usize) -> Self {
Self::with_config(base_path, channel_size, EncoderOptions::default())
pub fn with_channel_size<P: Into<PathBuf>>(base_path: P, channel_size: usize) -> Box<dyn AudioPipelineNode> {
let logic = FlacFileSinkLogic::new(base_path, EncoderOptions::default(), 8);
Self { inner: Node::new_with_input(logic, channel_size) }.boxed()
}
/// Crée un sink FLAC avec une configuration complète.
@@ -329,11 +335,11 @@ impl FlacFileSink {
base_path: P,
channel_size: usize,
encoder_options: EncoderOptions,
) -> Self {
) -> Box<dyn AudioPipelineNode> {
let logic = FlacFileSinkLogic::new(base_path, encoder_options, 8);
Self {
inner: Node::new_with_input(logic, channel_size),
}
}.boxed()
}
}

View File

@@ -297,7 +297,12 @@ impl HttpSource {
/// let source = HttpSource::new("http://example.com/music.flac");
/// ```
pub fn new<S: Into<String>>(url: S) -> Self {
Self::with_chunk_size(url, 0)
let logic = HttpSourceLogic::new(url.into(), 0);
Self { inner: Node::new_source(logic) }
}
pub fn make<S: Into<String>>(url: S) -> Box<dyn AudioPipelineNode> {
Self::new(url).boxed()
}
/// Crée une nouvelle source HTTP avec une taille de chunk spécifique.
@@ -315,11 +320,9 @@ impl HttpSource {
/// // Utiliser des chunks de 2048 frames
/// let source = HttpSource::with_chunk_size("http://example.com/music.mp3", 2048);
/// ```
pub fn with_chunk_size<S: Into<String>>(url: S, chunk_frames: usize) -> Self {
pub fn with_chunk_size<S: Into<String>>(url: S, chunk_frames: usize) -> Box<dyn AudioPipelineNode> {
let logic = HttpSourceLogic::new(url.into(), chunk_frames);
Self {
inner: Node::new_source(logic),
}
Self { inner: Node::new_source(logic) }.boxed()
}
pub fn get_url(&self) -> String {

View File

@@ -27,6 +27,7 @@ pub mod http_source;
pub mod resampling_node;
pub mod timer_buffer_node;
pub mod timer_node;
pub mod position_tracker_node;
// Modules temporairement désactivés
/*

View File

@@ -0,0 +1,127 @@
//! PositionTrackerNode — nœud transparent de suivi de position.
//!
//! Laisse passer tous les segments sans modification, et maintient
//! un compteur de position courant basé sur le `timestamp_sec` des chunks.
//!
//! Placé après un `TimerBufferNode`, il reflète la position de l'audio
//! effectivement sorti du buffer, pas celui encore en attente.
use crate::{
nodes::AudioError,
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
type_constraints::TypeRequirement,
AudioSegment, _AudioSegment,
};
use std::sync::{Arc, atomic::{AtomicU64, Ordering}};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
// ─── Handle public ────────────────────────────────────────────────────────────
/// Handle partageable pour lire la position courante.
#[derive(Clone)]
pub struct PositionHandle {
/// Position encodée en microsecondes dans un AtomicU64 (pas de lock nécessaire).
position_us: Arc<AtomicU64>,
}
impl PositionHandle {
/// Retourne la position courante en secondes.
pub fn current_position_sec(&self) -> f64 {
self.position_us.load(Ordering::Relaxed) as f64 / 1_000_000.0
}
}
// ─── Logique du nœud ─────────────────────────────────────────────────────────
struct PositionTrackerLogic {
position_us: Arc<AtomicU64>,
}
#[async_trait::async_trait]
impl NodeLogic for PositionTrackerLogic {
async fn process(
&mut self,
input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
let mut input = input.ok_or_else(|| {
AudioError::ProcessingError("PositionTrackerNode requires an input".into())
})?;
loop {
tokio::select! {
_ = stop_token.cancelled() => break,
segment = input.recv() => {
match segment {
None => break,
Some(seg) => {
// Mettre à jour la position sur les chunks audio uniquement
if let _AudioSegment::Chunk(_) = &seg.segment {
let us = (seg.timestamp_sec * 1_000_000.0) as u64;
self.position_us.store(us, Ordering::Relaxed);
}
// Passer le segment sans modification
send_to_children("PositionTrackerNode", &output, seg).await?;
}
}
}
}
}
Ok(())
}
async fn cleanup(
&mut self,
_reason: crate::pipeline::StopReason,
) -> Result<(), AudioError> {
Ok(())
}
}
// ─── Nœud public ─────────────────────────────────────────────────────────────
pub struct PositionTrackerNode {
inner: Node<PositionTrackerLogic>,
}
impl PositionTrackerNode {
pub fn new() -> (Self, PositionHandle) {
let position_us = Arc::new(AtomicU64::new(0));
let logic = PositionTrackerLogic { position_us: position_us.clone() };
let handle = PositionHandle { position_us };
let node = Self { inner: Node::new_with_input(logic, 16) };
(node, handle)
}
}
#[async_trait::async_trait]
impl AudioPipelineNode for PositionTrackerNode {
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
self.inner.get_tx()
}
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
self.inner.register(child);
}
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await
}
fn start(self: Box<Self>) -> crate::pipeline::PipelineHandle {
Box::new(self.inner).start()
}
}
impl crate::TypedAudioNode for PositionTrackerNode {
fn input_type(&self) -> Option<TypeRequirement> {
None // Accepte tout
}
fn output_type(&self) -> Option<TypeRequirement> {
None // Passe tout
}
}

View File

@@ -333,8 +333,13 @@ impl ResamplingNode {
/// Crée un nouveau node de resampling
///
/// * `target_sample_rate` - Sample rate de sortie en Hz (ex: 48000)
pub fn new(target_sample_rate: u32) -> Box<dyn AudioPipelineNode> {
Self::with_channel_size(target_sample_rate, 16)
pub fn new(target_sample_rate: u32) -> Self {
let logic = ResamplingLogic::new(target_sample_rate);
Self { inner: Node::new_with_input(logic, 16) }
}
pub fn make(target_sample_rate: u32) -> Box<dyn AudioPipelineNode> {
Self::new(target_sample_rate).boxed()
}
/// Crée un nouveau node de resampling avec taille de canal personnalisée
@@ -346,9 +351,7 @@ impl ResamplingNode {
channel_size: usize,
) -> Box<dyn AudioPipelineNode> {
let logic = ResamplingLogic::new(target_sample_rate);
Box::new(Self {
inner: Node::new_with_input(logic, channel_size),
})
Self { inner: Node::new_with_input(logic, channel_size) }.boxed()
}
}

View File

@@ -135,6 +135,14 @@ impl TimerBufferNodeLogic {
self.buffer.len()
);
if self.buffer.is_empty() && self.buffered_time_sec < self.capacity_sec * 0.1 {
tracing::warn!(
"TimerBufferNode: buffer underrun at ts={:.3}s (capacity={:.1}s) — source too slow or stalled",
segment.timestamp_sec,
self.capacity_sec,
);
}
send_to_children(std::any::type_name::<Self>(), output, segment).await?;
}
Ok(())
@@ -305,7 +313,12 @@ impl TimerBufferNode {
/// let buffer = TimerBufferNode::new(3.0);
/// ```
pub fn new(capacity_sec: f64) -> Self {
Self::with_channel_size(capacity_sec, DEFAULT_CHANNEL_SIZE)
let logic = TimerBufferNodeLogic::new(capacity_sec);
Self { inner: Node::new_with_input(logic, DEFAULT_CHANNEL_SIZE) }
}
pub fn make(capacity_sec: f64) -> Box<dyn AudioPipelineNode> {
Self::new(capacity_sec).boxed()
}
/// Crée un TimerBufferNode avec une taille de buffer MPSC personnalisée
@@ -314,11 +327,9 @@ impl TimerBufferNode {
///
/// * `capacity_sec` - Capacité du buffer en secondes
/// * `channel_size` - Taille du buffer MPSC (nombre de segments en attente)
pub fn with_channel_size(capacity_sec: f64, channel_size: usize) -> Self {
pub fn with_channel_size(capacity_sec: f64, channel_size: usize) -> Box<dyn AudioPipelineNode> {
let logic = TimerBufferNodeLogic::new(capacity_sec);
Self {
inner: Node::new_with_input(logic, channel_size),
}
Self { inner: Node::new_with_input(logic, channel_size) }.boxed()
}
}

View File

@@ -308,7 +308,12 @@ impl TimerNode {
/// let timer = TimerNode::new(3.0);
/// ```
pub fn new(max_lead_time_sec: f64) -> Self {
Self::with_channel_size(max_lead_time_sec, DEFAULT_CHANNEL_SIZE)
let logic = TimerNodeLogic::new(max_lead_time_sec);
Self { inner: Node::new_with_input(logic, DEFAULT_CHANNEL_SIZE) }
}
pub fn make(max_lead_time_sec: f64) -> Box<dyn AudioPipelineNode> {
Self::new(max_lead_time_sec).boxed()
}
/// Crée un TimerNode avec une taille de buffer MPSC personnalisée
@@ -317,11 +322,9 @@ impl TimerNode {
///
/// * `max_lead_time_sec` - Avance maximale en secondes
/// * `channel_size` - Taille du buffer MPSC (nombre de segments en attente)
pub fn with_channel_size(max_lead_time_sec: f64, channel_size: usize) -> Self {
pub fn with_channel_size(max_lead_time_sec: f64, channel_size: usize) -> Box<dyn AudioPipelineNode> {
let logic = TimerNodeLogic::new(max_lead_time_sec);
Self {
inner: Node::new_with_input(logic, channel_size),
}
Self { inner: Node::new_with_input(logic, channel_size) }.boxed()
}
}

View File

@@ -82,6 +82,14 @@ pub trait AudioPipelineNode: Send + 'static {
/// Le parent extrait le tx via `child.get_tx()` avant de stocker le child.
fn register(&mut self, child: Box<dyn AudioPipelineNode>);
/// Encapsule ce nœud dans un `Box<dyn AudioPipelineNode>` pour l'utiliser dans un pipeline.
fn boxed(self) -> Box<dyn AudioPipelineNode>
where
Self: Sized + 'static,
{
Box::new(self)
}
/// Lance le nœud et tous ses enfants
///
/// # Arguments

View File

@@ -125,6 +125,7 @@ fn codec_to_string(codec: AudioCodec) -> String {
AudioCodec::OggOpus => "ogg_opus",
AudioCodec::Wav => "wav",
AudioCodec::Aiff => "aiff",
AudioCodec::Aac => "aac",
}
.to_string()
}

View File

@@ -550,8 +550,8 @@ impl MusicRenderer {
.map(|prev| !playback_state_equal(prev, &raw_state))
.unwrap_or(true);
// Emit event only for non-transient states to reduce noise
if changed && !matches!(raw_state, PlaybackState::Transitioning) {
// Emit event for all state changes including Transitioning
if changed {
let state_clone = raw_state.clone();
drop(watched);
self.emit_event(RendererEvent::StateChanged {

View File

@@ -19,6 +19,7 @@ libflac-sys = { version = "0.3.3", default-features = false, features = ["build-
lofty = "0.22"
minimp3 = "0.5"
opus = "0.3"
fdk-aac = "0.8"
pmometadata = { path = "../pmometadata" }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros", "sync", "io-util", "fs"] }

177
pmoflac/src/aac.rs Normal file
View File

@@ -0,0 +1,177 @@
//! AAC Decoder Module (ADTS streaming)
//!
//! Provides asynchronous streaming AAC/ADTS decoding via libfdk-aac (statically linked).
//! Decodes AAC audio streams into PCM data (16-bit little-endian interleaved).
//!
//! Designed for live radio streams (e.g. Radio France icecast AAC 192kbps).
//! No seek required — pure linear streaming.
//!
//! ## Architecture
//!
//! ```text
//! AAC Input → [Ingest Task] → [Decode Task (blocking)] → [Writer Task] → PCM Output (AsyncRead)
//! ```
use fdk_aac::dec::{Decoder, DecoderError, Transport};
use tokio::{
io::AsyncRead,
sync::{mpsc, oneshot},
};
use crate::{
common::ChannelReader,
decoder_common::{
spawn_ingest_task, spawn_writer_task, DecodedStream, DecoderError as PmoDecoderError,
CHANNEL_CAPACITY, DUPLEX_BUFFER_SIZE,
},
pcm::StreamInfo,
stream::ManagedAsyncReader,
};
/// Errors that can occur while decoding AAC data.
pub type AacError = PmoDecoderError;
/// Async decoded AAC stream.
pub type AacDecodedStream = DecodedStream<AacError>;
/// Decodes an AAC/ADTS stream into PCM audio data (16-bit little-endian interleaved).
///
/// The input must be a raw ADTS stream (as produced by Radio France icecast).
/// No seek is required — the decoder processes frames linearly.
pub async fn decode_aac_stream<R>(reader: R) -> Result<AacDecodedStream, AacError>
where
R: AsyncRead + Unpin + Send + 'static,
{
let (ingest_tx, ingest_rx) = mpsc::channel(CHANNEL_CAPACITY);
spawn_ingest_task(reader, ingest_tx);
let (pcm_tx, pcm_rx) = mpsc::channel(CHANNEL_CAPACITY);
let (pcm_reader, pcm_writer) = tokio::io::duplex(DUPLEX_BUFFER_SIZE);
let (info_tx, info_rx) = oneshot::channel::<Result<StreamInfo, AacError>>();
let blocking_handle = tokio::task::spawn_blocking(move || -> Result<(), AacError> {
let mut channel_reader = ChannelReader::<AacError>::new(ingest_rx);
let mut decoder = Decoder::new(Transport::Adts);
let mut info_tx = Some(info_tx);
// Buffer de lecture — on lit des chunks et on les pousse au décodeur
let mut read_buf = vec![0u8; 8192];
// Buffer de sortie PCM — fdk-aac écrit des frames entières
let mut pcm_out = vec![0i16; 8192];
use std::io::Read;
loop {
// Lire des bytes depuis le stream ADTS
let n = match channel_reader.read(&mut read_buf) {
Ok(0) => break, // EOF
Ok(n) => n,
Err(e) => {
let err = AacError::Io {
kind: e.kind(),
message: e.to_string(),
};
if let Some(tx) = info_tx.take() {
let _ = tx.send(Err(err.clone()));
}
return Err(err);
}
};
// Pousser les bytes au décodeur fdk-aac
let filled = match decoder.fill(&read_buf[..n]) {
Ok(filled) => filled,
Err(e) => {
let err = AacError::Decode(format!("fdk-aac fill error: {:?}", e));
if let Some(tx) = info_tx.take() {
let _ = tx.send(Err(err.clone()));
}
return Err(err);
}
};
// Si fill n'a pas consommé tous les bytes, on les remet devant
// (fdk-aac peut ne pas consommer tout en une passe)
// Note: fdk-aac retourne le nombre de bytes non-consommés — on les ignore
// car le décodeur conserve son état interne entre appels.
let _ = filled;
// Décoder les frames disponibles
loop {
// Adapter la taille du buffer PCM si nécessaire
let stream_info = decoder.stream_info();
let frame_size = if stream_info.frameSize > 0 && stream_info.numChannels > 0 {
(stream_info.frameSize * stream_info.numChannels) as usize
} else {
2048 // taille par défaut avant la première frame
};
if pcm_out.len() < frame_size {
pcm_out.resize(frame_size, 0i16);
}
match decoder.decode_frame(&mut pcm_out) {
Ok(()) => {
let info_ref = decoder.stream_info();
// Envoyer les infos au premier décodage réussi
if let Some(tx) = info_tx.take() {
let info = StreamInfo {
sample_rate: info_ref.sampleRate as u32,
channels: info_ref.numChannels as u8,
bits_per_sample: 16,
total_samples: None,
max_block_size: 0,
min_block_size: 0,
};
if tx.send(Ok(info)).is_err() {
return Ok(());
}
}
// Convertir i16 → bytes little-endian
let samples = &pcm_out[..frame_size];
let mut bytes = Vec::with_capacity(samples.len() * 2);
for s in samples {
bytes.extend_from_slice(&s.to_le_bytes());
}
if pcm_tx.blocking_send(Ok(bytes)).is_err() {
return Ok(());
}
}
Err(DecoderError::NOT_ENOUGH_BITS) => {
// Pas assez de données — besoin de plus d'input
break;
}
Err(DecoderError::TRANSPORT_SYNC_ERROR) => {
// Erreur de sync ADTS transitoire — continuer
break;
}
Err(e) => {
let err = AacError::Decode(format!("fdk-aac decode error: {:?}", e));
if let Some(tx) = info_tx.take() {
let _ = tx.send(Err(err.clone()));
}
return Err(err);
}
}
}
}
if let Some(tx) = info_tx.take() {
let err = AacError::Decode("AAC stream contained no decodable frames".into());
let _ = tx.send(Err(err.clone()));
return Err(err);
}
Ok(())
});
let writer_handle = spawn_writer_task(pcm_rx, pcm_writer, blocking_handle, "aac-decode");
let info = info_rx.await.map_err(|_| AacError::ChannelClosed)??;
let reader = ManagedAsyncReader::new("aac-decode-writer", pcm_reader, writer_handle);
Ok(DecodedStream::new(info, reader))
}

View File

@@ -7,6 +7,7 @@ use std::{
use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf};
use crate::{
aac::{decode_aac_stream, AacDecodedStream, AacError},
decode_aiff_stream, decode_flac_stream, decode_mp3_stream, decode_ogg_opus_stream,
decode_ogg_vorbis_stream, decode_wav_stream, pcm::StreamInfo, prefixed_reader::PrefixedReader,
AiffDecodedStream, AiffError, FlacDecodedStream, FlacError, Mp3DecodedStream, Mp3Error,
@@ -34,6 +35,8 @@ pub enum DecodeAudioError {
Wav(WavError),
#[error("AIFF decode error: {0}")]
Aiff(AiffError),
#[error("AAC decode error: {0}")]
Aac(AacError),
}
pub async fn decode_audio_stream<R>(reader: R) -> Result<DecodedAudioStream, DecodeAudioError>
@@ -94,6 +97,12 @@ where
.map_err(DecodeAudioError::Aiff)?;
DecodedAudioStream::Aiff(stream)
}
DetectedFormat::Aac => {
let stream = decode_aac_stream(prefixed)
.await
.map_err(DecodeAudioError::Aac)?;
DecodedAudioStream::Aac(stream)
}
};
Ok(stream)
@@ -106,6 +115,7 @@ pub enum DecodedAudioStream {
OggOpus(OggOpusDecodedStream),
Wav(WavDecodedStream),
Aiff(AiffDecodedStream),
Aac(AacDecodedStream),
}
impl DecodedAudioStream {
@@ -117,6 +127,7 @@ impl DecodedAudioStream {
DecodedAudioStream::OggOpus(inner) => inner.info(),
DecodedAudioStream::Wav(inner) => inner.info(),
DecodedAudioStream::Aiff(inner) => inner.info(),
DecodedAudioStream::Aac(inner) => inner.info(),
}
}
@@ -132,6 +143,7 @@ impl DecodedAudioStream {
}
DecodedAudioStream::Wav(inner) => inner.wait().await.map_err(DecodeAudioError::Wav),
DecodedAudioStream::Aiff(inner) => inner.wait().await.map_err(DecodeAudioError::Aiff),
DecodedAudioStream::Aac(inner) => inner.wait().await.map_err(DecodeAudioError::Aac),
}
}
@@ -161,6 +173,10 @@ impl DecodedAudioStream {
let (info, reader) = inner.into_parts();
(info, DecodedReader::Aiff(reader))
}
DecodedAudioStream::Aac(inner) => {
let (info, reader) = inner.into_parts();
(info, DecodedReader::Aac(reader))
}
}
}
}
@@ -178,6 +194,7 @@ impl AsyncRead for DecodedAudioStream {
DecodedAudioStream::OggOpus(inner) => Pin::new(inner).poll_read(cx, buf),
DecodedAudioStream::Wav(inner) => Pin::new(inner).poll_read(cx, buf),
DecodedAudioStream::Aiff(inner) => Pin::new(inner).poll_read(cx, buf),
DecodedAudioStream::Aac(inner) => Pin::new(inner).poll_read(cx, buf),
}
}
}
@@ -189,6 +206,7 @@ pub enum DecodedReader {
OggOpus(crate::stream::ManagedAsyncReader<OggOpusError>),
Wav(crate::stream::ManagedAsyncReader<WavError>),
Aiff(crate::stream::ManagedAsyncReader<AiffError>),
Aac(crate::stream::ManagedAsyncReader<AacError>),
}
impl DecodedReader {
@@ -200,6 +218,7 @@ impl DecodedReader {
DecodedReader::OggOpus(inner) => inner.wait().await.map_err(DecodeAudioError::Opus),
DecodedReader::Wav(inner) => inner.wait().await.map_err(DecodeAudioError::Wav),
DecodedReader::Aiff(inner) => inner.wait().await.map_err(DecodeAudioError::Aiff),
DecodedReader::Aac(inner) => inner.wait().await.map_err(DecodeAudioError::Aac),
}
}
}
@@ -217,6 +236,7 @@ impl AsyncRead for DecodedReader {
DecodedReader::OggOpus(inner) => Pin::new(inner).poll_read(cx, buf),
DecodedReader::Wav(inner) => Pin::new(inner).poll_read(cx, buf),
DecodedReader::Aiff(inner) => Pin::new(inner).poll_read(cx, buf),
DecodedReader::Aac(inner) => Pin::new(inner).poll_read(cx, buf),
}
}
}
@@ -242,12 +262,29 @@ fn detect_format(bytes: &[u8]) -> Option<DetectedFormat> {
if let Some(fmt) = detect_ogg(bytes) {
return Some(fmt);
}
// Tester AAC avant MP3 : ADTS (0xFF 0xF0/0xF8) est un sous-ensemble du syncword MP3
// (0xFF 0xEx), donc la détection MP3 absorberait les flux AAC si elle passait en premier.
if is_adts_aac(bytes) {
return Some(DetectedFormat::Aac);
}
if is_mp3(bytes) {
return Some(DetectedFormat::Mp3);
}
None
}
/// Détecte un flux AAC ADTS : syncword 0xFFF (12 bits) + layer = 00.
/// Structure du 2e octet : 1111 VLLL → V=version, L=layer(00 pour AAC), P=protection
/// MP3 a layer != 00 (01=III, 10=II, 11=I), AAC ADTS a layer == 00.
fn is_adts_aac(bytes: &[u8]) -> bool {
if bytes.len() < 2 {
return false;
}
// syncword = 0xFFF (12 bits) : byte0=0xFF, byte1[7:4]=0xF
// layer = byte1[2:1] == 0b00 (distingue AAC de MP3)
bytes[0] == 0xFF && (bytes[1] & 0xF6) == 0xF0
}
fn detect_ogg(bytes: &[u8]) -> Option<DetectedFormat> {
if bytes.len() < 27 || &bytes[..4] != b"OggS" {
return None;
@@ -294,4 +331,5 @@ enum DetectedFormat {
OggOpus,
Wav,
Aiff,
Aac,
}

View File

@@ -95,6 +95,7 @@
//! }
//! ```
pub mod aac;
pub mod aiff;
pub mod autodetect;
mod common;
@@ -114,6 +115,7 @@ pub mod transcode;
mod util;
pub mod wav;
pub use aac::{decode_aac_stream, AacDecodedStream, AacError};
pub use aiff::{decode_aiff_stream, AiffDecodedStream, AiffError};
pub use autodetect::{
decode_audio_stream, is_flac_magic_header, DecodeAudioError, DecodedAudioStream, DecodedReader,

View File

@@ -31,6 +31,7 @@ pub enum AudioCodec {
OggOpus,
Wav,
Aiff,
Aac,
}
/// Options controlling how the transcoder operates.
@@ -195,6 +196,9 @@ where
DecodedAudioStream::Aiff(stream) => {
transcode_from_decoded(AudioCodec::Aiff, stream, options.encoder_options).await
}
DecodedAudioStream::Aac(stream) => {
transcode_from_decoded(AudioCodec::Aac, stream, options.encoder_options).await
}
}
}

View File

@@ -0,0 +1,43 @@
use pmoflac::decode_aac_stream;
use tokio::io::AsyncReadExt;
#[tokio::test]
async fn test_decode_adts_file() {
let file = tokio::fs::File::open("/tmp/test_adts.aac")
.await
.expect("test ADTS file not found — run: ffmpeg -i tests/SBRtestStereoHiBr.mp4 -vn -acodec copy -f adts /tmp/test_adts.aac");
let mut stream = decode_aac_stream(file)
.await
.expect("decode_aac_stream failed");
let info = stream.info().clone();
println!("StreamInfo: {} Hz, {} ch, {} bps", info.sample_rate, info.channels, info.bits_per_sample);
assert!(info.sample_rate > 0, "sample_rate should be > 0");
assert!(info.channels == 1 || info.channels == 2, "channels should be 1 or 2");
assert_eq!(info.bits_per_sample, 16);
let mut pcm = Vec::new();
stream.read_to_end(&mut pcm).await.expect("read_to_end failed");
println!("Decoded {} PCM bytes ({} samples)", pcm.len(), pcm.len() / 2);
assert!(pcm.len() > 0, "should have decoded some PCM data");
}
#[tokio::test]
async fn test_autodetect_adts() {
use pmoflac::decode_audio_stream;
let file = tokio::fs::File::open("/tmp/test_adts.aac")
.await
.expect("test ADTS file not found");
let stream = decode_audio_stream(file)
.await
.expect("decode_audio_stream failed");
let info = stream.info().clone();
println!("Autodetect: {} Hz, {} ch, {} bps", info.sample_rate, info.channels, info.bits_per_sample);
assert!(info.sample_rate > 0);
}

View File

@@ -117,7 +117,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let sink = FlacFileSink::new(&base_path);
// Construire la chaîne: source → sink
source.register(Box::new(sink));
source.register(sink.boxed());
// Créer un token d'arrêt
let stop_token = CancellationToken::new();

View File

@@ -217,15 +217,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let audio_sink = if use_null_audio {
AudioSink::with_null_output()
} else {
AudioSink::new()
AudioSink::make()
};
tracing::debug!("AudioSink created");
// Connecter timer → audio (AVANT de mettre timer dans une Box)
timer.register(Box::new(audio_sink));
timer.register(audio_sink);
// Connecter playlist → timer
playlist_source.register(Box::new(timer));
playlist_source.register(timer.boxed());
tracing::info!("Playback pipeline connected: PlaylistSource → TimerNode → AudioSink");
// ═══════════════════════════════════════════════════════════════════════════

View File

@@ -69,8 +69,7 @@ impl RadioFranceSource {
let cover_cache = pmoupnp::cache_registry::get_cover_cache()
.ok_or_else(|| crate::error::Error::Other("Cover cache not initialized".to_string()))?;
// TODO: Récupérer server_base_url depuis config
let server_base_url = "http://localhost:8080".to_string();
let server_base_url = config.get_base_url();
let metadata_cache = Arc::new(MetadataCache::new(
client,

View File

@@ -192,6 +192,20 @@ pub fn get_server() -> Option<Arc<RwLock<Server>>> {
/// println!("Stream available at: {}", stream_url);
/// }
/// ```
/// Récupère l'URL de base effective pour une requête donnée.
///
/// Utilise les headers `X-Forwarded-*` si présents (accès via reverse proxy),
/// sinon fallback sur l'URL de base configurée (accès local direct).
pub fn get_request_base_url(headers: &axum::http::HeaderMap) -> Option<String> {
GLOBAL_SERVER.get().map(|server| {
if let Ok(srv) = server.try_read() {
srv.request_base_url(headers)
} else {
futures::executor::block_on(async { server.read().await.request_base_url(headers) })
}
})
}
pub fn get_server_base_url() -> Option<String> {
GLOBAL_SERVER.get().map(|server| {
// Utiliser try_read() pour éviter de bloquer

View File

@@ -615,6 +615,30 @@ impl Server {
self.join_handle.take()
}
/// Retourne l'URL de base vue par le client pour une requête donnée.
///
/// Priorité : headers `X-Forwarded-Proto` + `X-Forwarded-Host` (présents
/// quand la requête passe par un reverse proxy comme NPM), sinon fallback
/// sur `self.base_url()`. Cela permet de générer des URLs correctes aussi
/// bien en accès local direct qu'en accès public via proxy.
pub fn request_base_url(&self, headers: &axum::http::HeaderMap) -> String {
let proto = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.map(str::trim);
let host = headers
.get("x-forwarded-host")
.or_else(|| headers.get("host"))
.and_then(|v| v.to_str().ok())
.map(str::trim);
match (proto, host) {
(Some(proto), Some(host)) => format!("{}://{}", proto, host),
_ => self.base_url(),
}
}
/// Retourne l'URL de base complète du serveur (schéma + hôte + port).
///
/// La valeur configurable peut omettre le schéma ou le port ; cette méthode

View File

@@ -1,24 +1,33 @@
[package]
name = "pmowebrenderer"
version = "0.1.0"
edition = "2021"
[package]
name = "pmowebrenderer"
version = "0.1.0"
edition = "2021"
[dependencies]
pmoupnp = { path = "../pmoupnp" }
pmomediarenderer = { path = "../pmomediarenderer" }
pmoserver = { path = "../pmoserver", optional = true }
pmocontrol = { path = "../pmocontrol", optional = true }
pmoconfig = { path = "../pmoconfig" }
[dependencies]
pmoupnp = { path = "../pmoupnp" }
pmomediarenderer = { path = "../pmomediarenderer" }
pmoserver = { path = "../pmoserver", optional = true }
pmocontrol = { path = "../pmocontrol", optional = true }
pmoconfig = { path = "../pmoconfig" }
# Audio pipeline (toujours actif — c'est le cœur du renderer)
pmoaudio-ext = { path = "../pmoaudio-ext", features = ["http-stream"] }
pmoaudio = { path = "../pmoaudio" }
pmoflac = { path = "../pmoflac" }
pmometadata = { path = "../pmometadata" }
# Async runtime
tokio = { workspace = true, features = ["full"] }
tokio-util = { workspace = true }
async-trait = { workspace = true }
# WebSocket
axum = { workspace = true, features = ["ws"] }
# HTTP
axum = { workspace = true }
axum-extra = { version = "0.9", features = ["typed-header"] }
tower-http = { version = "0.6", features = ["fs", "trace"] }
futures = "0.3"
reqwest = { workspace = true, features = ["stream"] }
bytes = "1.0"
# Serialization
serde = { workspace = true }

View File

@@ -2,21 +2,24 @@
#[cfg(feature = "pmoserver")]
use std::sync::Arc;
#[cfg(feature = "pmoserver")]
use std::time::Duration;
#[cfg(feature = "pmoserver")]
use async_trait::async_trait;
#[cfg(feature = "pmoserver")]
use axum::{Router, routing::{delete, get, post}};
#[cfg(feature = "pmoserver")]
use pmocontrol::ControlPoint;
#[cfg(feature = "pmoserver")]
use crate::error::WebRendererError;
#[cfg(feature = "pmoserver")]
use crate::session::SessionManager;
use crate::register::{register_handler, unregister_handler};
#[cfg(feature = "pmoserver")]
use crate::websocket::{websocket_handler, WebSocketState};
use crate::registry::RendererRegistry;
#[cfg(feature = "pmoserver")]
use crate::stream::stream_handler;
/// Trait pour étendre pmoserver::Server avec les routes WebRenderer
#[cfg(feature = "pmoserver")]
@@ -35,17 +38,27 @@ impl WebRendererExt for pmoserver::Server {
&mut self,
control_point: Arc<ControlPoint>,
) -> Result<(), WebRendererError> {
let session_manager = Arc::new(SessionManager::new(Duration::from_secs(30 * 60)));
let registry = Arc::new(RendererRegistry::new(control_point));
let ws_state = WebSocketState {
session_manager,
control_point,
};
// POST /api/webrenderer/register
self.add_post_handler_with_state(
"/api/webrenderer/register",
register_handler,
registry.clone(),
)
.await;
self.add_any_handler_with_state("/api/webrenderer/ws", websocket_handler, ws_state)
.await;
// GET /api/webrenderer/{id}/stream + DELETE /api/webrenderer/{id}
let dynamic_router = Router::new()
.route("/{id}/stream", get(stream_handler))
.route("/{id}", delete(unregister_handler))
.with_state(registry.clone());
self.add_router("/api/webrenderer", dynamic_router).await;
tracing::info!("WebRenderer WebSocket endpoint registered at /api/webrenderer/ws");
tracing::info!("WebRenderer server-side streaming endpoints registered");
tracing::info!(" POST /api/webrenderer/register");
tracing::info!(" GET /api/webrenderer/{{id}}/stream");
tracing::info!(" DELETE /api/webrenderer/{{id}}");
Ok(())
}
}

View File

@@ -1,7 +1,7 @@
//! Action handlers SOAP → WebSocket pour le WebRenderer
//! Action handlers SOAP → Pipeline pour le WebRenderer serveur
//!
//! Chaque handler bridge une action UPnP vers une commande WebSocket
//! envoyée au navigateur, ou lit l'état partagé pour les requêtes GET.
//! Chaque handler bridge une action UPnP vers une commande `PipelineControl`
//! envoyée au pipeline audio serveur, ou lit l'état partagé pour les requêtes GET.
use std::sync::Arc;
@@ -10,106 +10,89 @@ use pmoupnp::actions::{ActionData, ActionError, ActionHandler, get_value};
use pmoupnp::{get, set};
use pmoutils::ToXmlElement;
use crate::messages::{CommandParams, PlaybackState, ServerMessage, TransportAction};
use crate::state::{SharedSender, SharedState};
use crate::messages::PlaybackState;
use crate::pipeline::{PipelineControl, PipelineHandle, seconds_to_upnp_time, upnp_time_to_seconds};
use crate::state::SharedState;
type ActionFuture =
std::pin::Pin<Box<dyn std::future::Future<Output = Result<ActionData, ActionError>> + Send>>;
// ─── AVTransport Handlers ───────────────────────────────────────────────────
pub fn play_handler(ws: SharedSender, state: SharedState) -> ActionHandler {
pub fn play_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let ws = ws.clone();
let pipeline = pipeline.clone();
let state = state.clone();
Box::pin(async move {
ws.send(ServerMessage::Command {
action: TransportAction::Play,
params: None,
});
state.write().playback_state = PlaybackState::Playing;
// Ne pas écrire Playing ici : c'est stream_source qui le fera
// une fois que les premiers bytes FLAC ont été produits.
// Écrire Transitioning pour signaler que la lecture va démarrer.
state.write().playback_state = PlaybackState::Transitioning;
pipeline.send(PipelineControl::Play).await;
Ok(data)
})
})
}
pub fn stop_handler(ws: SharedSender, state: SharedState) -> ActionHandler {
pub fn stop_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let ws = ws.clone();
let pipeline = pipeline.clone();
let state = state.clone();
Box::pin(async move {
ws.send(ServerMessage::Command {
action: TransportAction::Stop,
params: None,
});
pipeline.send(PipelineControl::Stop).await;
state.write().playback_state = PlaybackState::Stopped;
Ok(data)
})
})
}
pub fn pause_handler(ws: SharedSender, state: SharedState) -> ActionHandler {
pub fn pause_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let ws = ws.clone();
let pipeline = pipeline.clone();
let state = state.clone();
Box::pin(async move {
ws.send(ServerMessage::Command {
action: TransportAction::Pause,
params: None,
});
pipeline.send(PipelineControl::Pause).await;
state.write().playback_state = PlaybackState::Paused;
Ok(data)
})
})
}
pub fn next_handler(ws: SharedSender) -> ActionHandler {
pub fn next_handler(pipeline: PipelineHandle) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let ws = ws.clone();
let pipeline = pipeline.clone();
Box::pin(async move {
ws.send(ServerMessage::Command {
action: TransportAction::Play,
params: None,
});
pipeline.send(PipelineControl::Play).await;
Ok(data)
})
})
}
pub fn previous_handler(ws: SharedSender) -> ActionHandler {
pub fn previous_handler(pipeline: PipelineHandle) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let ws = ws.clone();
let pipeline = pipeline.clone();
Box::pin(async move {
ws.send(ServerMessage::Command {
action: TransportAction::Play,
params: None,
});
pipeline.send(PipelineControl::Play).await;
Ok(data)
})
})
}
pub fn seek_handler(ws: SharedSender) -> ActionHandler {
pub fn seek_handler(pipeline: PipelineHandle) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let ws = ws.clone();
let pipeline = pipeline.clone();
Box::pin(async move {
let target: String = get!(&data, "Target", String);
ws.send(ServerMessage::Command {
action: TransportAction::Seek,
params: Some(CommandParams {
uri: None,
metadata: None,
position: Some(target),
}),
});
let pos_sec = upnp_time_to_seconds(&target);
pipeline.send(PipelineControl::Seek(pos_sec)).await;
Ok(data)
})
})
}
pub fn set_uri_handler(ws: SharedSender, state: SharedState) -> ActionHandler {
pub fn set_uri_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let ws = ws.clone();
let pipeline = pipeline.clone();
let state = state.clone();
Box::pin(async move {
let uri: String = get!(&data, "CurrentURI", String);
@@ -119,14 +102,10 @@ pub fn set_uri_handler(ws: SharedSender, state: SharedState) -> ActionHandler {
.map(|didl| didl.to_xml())
})
.unwrap_or_default();
ws.send(ServerMessage::Command {
action: TransportAction::SetUri,
params: Some(CommandParams {
uri: Some(uri.clone()),
metadata: Some(metadata.clone()),
position: None,
}),
});
// Envoyer l'URI au pipeline serveur (remplace l'envoi WebSocket)
pipeline.send(PipelineControl::LoadUri(uri.clone())).await;
{
let mut s = state.write();
s.current_uri = Some(uri);
@@ -138,9 +117,9 @@ pub fn set_uri_handler(ws: SharedSender, state: SharedState) -> ActionHandler {
})
}
pub fn set_next_uri_handler(ws: SharedSender, state: SharedState) -> ActionHandler {
pub fn set_next_uri_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let ws = ws.clone();
let pipeline = pipeline.clone();
let state = state.clone();
Box::pin(async move {
let uri: String = get!(&data, "NextURI", String);
@@ -150,14 +129,9 @@ pub fn set_next_uri_handler(ws: SharedSender, state: SharedState) -> ActionHandl
.map(|didl| didl.to_xml())
})
.unwrap_or_default();
ws.send(ServerMessage::Command {
action: TransportAction::SetNextUri,
params: Some(CommandParams {
uri: Some(uri.clone()),
metadata: Some(metadata.clone()),
position: None,
}),
});
pipeline.send(PipelineControl::LoadNextUri(uri.clone())).await;
{
let mut s = state.write();
s.next_uri = Some(uri);
@@ -215,18 +189,14 @@ pub fn get_transport_info_handler(state: SharedState) -> ActionHandler {
Box::pin(async move {
let mut data = data;
let s = state.read();
tracing::info!("[WebRenderer] GetTransportInfo handler called, state={:?}", s.playback_state);
tracing::info!("[WebRenderer] GetTransportInfo: state={:?}", s.playback_state);
let transport_state = match s.playback_state {
PlaybackState::Stopped => "STOPPED",
PlaybackState::Playing => "PLAYING",
PlaybackState::Paused => "PAUSED_PLAYBACK",
PlaybackState::Transitioning => "TRANSITIONING",
};
set!(
&mut data,
"CurrentTransportState",
transport_state.to_string()
);
set!(&mut data, "CurrentTransportState", transport_state.to_string());
set!(&mut data, "CurrentTransportStatus", "OK".to_string());
set!(&mut data, "CurrentSpeed", "1".to_string());
Ok(data)
@@ -245,26 +215,10 @@ pub fn get_media_info_handler(state: SharedState) -> ActionHandler {
"NrTracks",
if s.current_uri.is_some() { 1u32 } else { 0u32 }
);
set!(
&mut data,
"CurrentURI",
s.current_uri.clone().unwrap_or_default()
);
set!(
&mut data,
"CurrentURIMetaData",
s.current_metadata.clone().unwrap_or_default()
);
set!(
&mut data,
"NextURI",
s.next_uri.clone().unwrap_or_default()
);
set!(
&mut data,
"NextURIMetaData",
s.next_metadata.clone().unwrap_or_default()
);
set!(&mut data, "CurrentURI", s.current_uri.clone().unwrap_or_default());
set!(&mut data, "CurrentURIMetaData", s.current_metadata.clone().unwrap_or_default());
set!(&mut data, "NextURI", s.next_uri.clone().unwrap_or_default());
set!(&mut data, "NextURIMetaData", s.next_metadata.clone().unwrap_or_default());
Ok(data)
})
})
@@ -272,13 +226,13 @@ pub fn get_media_info_handler(state: SharedState) -> ActionHandler {
// ─── RenderingControl Handlers ──────────────────────────────────────────────
pub fn set_volume_handler(ws: SharedSender, state: SharedState) -> ActionHandler {
pub fn set_volume_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let ws = ws.clone();
let pipeline = pipeline.clone();
let state = state.clone();
Box::pin(async move {
let volume: u16 = get!(&data, "DesiredVolume", u16);
ws.send(ServerMessage::SetVolume { volume });
pipeline.send(PipelineControl::SetVolume(volume)).await;
state.write().volume = volume;
Ok(data)
})
@@ -297,13 +251,13 @@ pub fn get_volume_handler(state: SharedState) -> ActionHandler {
})
}
pub fn set_mute_handler(ws: SharedSender, state: SharedState) -> ActionHandler {
pub fn set_mute_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let ws = ws.clone();
let pipeline = pipeline.clone();
let state = state.clone();
Box::pin(async move {
let mute: bool = get!(&data, "DesiredMute", bool);
ws.send(ServerMessage::SetMute { mute });
pipeline.send(PipelineControl::SetMute(mute)).await;
state.write().mute = mute;
Ok(data)
})
@@ -329,10 +283,11 @@ pub fn get_protocol_info_handler() -> ActionHandler {
Box::pin(async move {
let mut data = data;
set!(&mut data, "Source", String::new());
// Serveur-side streaming : on produit du FLAC uniquement
set!(
&mut data,
"Sink",
"http-get:*:audio/mpeg:*,http-get:*:audio/mp4:*,http-get:*:audio/ogg:*,http-get:*:audio/flac:*,http-get:*:audio/wav:*,http-get:*:audio/x-flac:*,http-get:*:audio/aac:*,http-get:*:audio/webm:*".to_string()
"http-get:*:audio/flac:*,http-get:*:audio/x-flac:*".to_string()
);
Ok(data)
})

View File

@@ -1,25 +1,29 @@
//! PMO Web Renderer - Transforme un navigateur en MediaRenderer UPnP privé
//! PMO Web Renderer Transforme un navigateur en MediaRenderer UPnP privé
//!
//! Architecture serveur-side streaming :
//! - Le serveur ouvre la source audio (fichier/HTTP) et l'encode en FLAC
//! - Le navigateur lit un flux FLAC via GET /api/webrenderer/{id}/stream
//! - Les commandes UPnP sont relayées vers le pipeline audio via PipelineControl
mod error;
mod handlers;
mod messages;
mod pipeline;
mod register;
mod registry;
mod renderer;
mod session;
mod state;
mod websocket;
mod stream;
#[cfg(feature = "pmoserver")]
mod config;
pub use error::WebRendererError;
pub use messages::{
BrowserCapabilities, ClientMessage, CommandParams, PlaybackState, RendererInfo, ServerMessage,
TrackMetadata, TransportAction,
};
pub use messages::PlaybackState;
pub use pipeline::{PipelineControl, PipelineHandle};
pub use registry::{RendererRegistry, WebRendererInstance};
pub use renderer::{FactoryError, WebRendererFactory};
pub use session::{SessionManager, WebRendererSession};
pub use state::{RendererState, SharedState};
pub use websocket::{websocket_handler, WebSocketState};
#[cfg(feature = "pmoserver")]
pub use config::WebRendererExt;

View File

@@ -0,0 +1,448 @@
//! Pipeline audio serveur par instance WebRenderer
//!
//! Chaque instance WebRenderer possède un pipeline indépendant :
//! - Un `StreamingFlacSink` qui encode et diffuse le flux FLAC aux clients HTTP
//! - Un canal de contrôle `PipelineControl` alimenté par les handlers UPnP
//! - Une task background qui orchestre sources et sink
use std::sync::Arc;
use pmoaudio::{AudioSegment, PositionTrackerNode, ResamplingNode, ToI24Node};
use pmometadata::{MemoryTrackMetadata, TrackMetadata};
use pmoaudio_ext::sinks::{
OggFlacStreamHandle, StreamingOggFlacSink,
DIRECT_OGG_FLAC_BITS_PER_SAMPLE, DIRECT_OGG_FLAC_SAMPLE_RATE,
};
use pmoaudio_ext::UriSource;
use pmoflac::EncoderOptions;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
use crate::messages::PlaybackState;
use crate::state::SharedState;
// ─── Commandes de contrôle ───────────────────────────────────────────────────
/// Commandes envoyées au pipeline audio de l'instance
#[derive(Debug)]
pub enum PipelineControl {
LoadUri(String),
LoadNextUri(String),
Play,
Pause,
Stop,
Seek(f64),
SetVolume(u16),
SetMute(bool),
/// Notification interne : la source courante s'est terminée (EOF ou erreur)
SourceEnded,
}
// ─── Handle vers le pipeline ─────────────────────────────────────────────────
/// Handle partageable vers le pipeline audio d'une instance
#[derive(Clone)]
pub struct PipelineHandle {
pub control_tx: mpsc::Sender<PipelineControl>,
pub stop_token: CancellationToken,
}
impl PipelineHandle {
pub async fn send(&self, cmd: PipelineControl) {
let _ = self.control_tx.send(cmd).await;
}
}
// ─── Pipeline instancié ──────────────────────────────────────────────────────
/// Pipeline audio complet pour une instance WebRenderer.
///
/// Créé au `POST /register`. Le flux OGG-FLAC est accessible via `flac_handle`
/// (multi-clients, chaque `subscribe()` retourne un nouveau flux depuis le point courant).
pub struct InstancePipeline {
/// Handle vers le sink OGG-FLAC — clonable, chaque subscribe() donne un flux live.
pub flac_handle: OggFlacStreamHandle,
pub pipeline_handle: PipelineHandle,
}
impl InstancePipeline {
/// Crée et démarre le pipeline en background.
/// Retourne immédiatement avec les handles nécessaires.
pub fn start(
state: SharedState,
#[cfg(feature = "pmoserver")]
control_point: Arc<pmocontrol::ControlPoint>,
udn: String,
) -> Self {
let stop_token = CancellationToken::new();
let (control_tx, control_rx) = mpsc::channel::<PipelineControl>(32);
// Chaîne de traitement : ResamplingNode(96kHz) → ToI24Node → DirectFlacSink
// La backpressure remonte naturellement depuis le pipe duplex jusqu'à la source.
use pmoaudio::pipeline::AudioPipelineNode;
let (sink, flac_handle) = StreamingOggFlacSink::new(
EncoderOptions::default(),
DIRECT_OGG_FLAC_BITS_PER_SAMPLE,
);
// Nœud de suivi de position : lit le timestamp des chunks sortant vers le sink
let (mut position_tracker, position_handle) = PositionTrackerNode::new();
position_tracker.register(sink.boxed());
// Nœud de conversion de profondeur : tout type entier → I24
let mut to_i24 = ToI24Node::new();
to_i24.register(position_tracker.boxed());
// Nœud de rééchantillonnage : n'importe quel sample rate → 96 kHz
let mut resampler = ResamplingNode::new(DIRECT_OGG_FLAC_SAMPLE_RATE);
resampler.register(to_i24.boxed());
// Le tx d'entrée du resampler est le point d'entrée du pipeline
let segment_tx = resampler.get_tx().expect("ResamplingNode doit avoir un sender");
let pipeline_handle = PipelineHandle {
control_tx,
stop_token: stop_token.clone(),
};
// Lancer la chaîne resampler → to_i24 → sink en background
let sink_stop = stop_token.clone();
tokio::spawn(async move {
if let Err(e) = resampler.boxed().run(sink_stop).await {
warn!("Audio pipeline error: {:?}", e);
}
debug!("Sink task terminated");
});
// Task de mise à jour de la position (toutes les secondes)
{
let state_pos = state.clone();
let pos_stop = stop_token.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = pos_stop.cancelled() => break,
_ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {
let pos = position_handle.current_position_sec();
if pos > 0.0 {
state_pos.write().position = Some(seconds_to_upnp_time(pos));
}
}
}
}
});
}
// Task pipeline : reçoit les commandes UPnP et pilote les sources
let stop_token_clone = stop_token.clone();
let control_tx_clone = pipeline_handle.control_tx.clone();
tokio::spawn(async move {
run_pipeline(
segment_tx,
control_rx,
control_tx_clone,
stop_token_clone,
state,
udn,
#[cfg(feature = "pmoserver")]
control_point,
)
.await;
debug!("Pipeline task terminated");
});
Self {
flac_handle,
pipeline_handle,
}
}
}
// ─── Task principale du pipeline ─────────────────────────────────────────────
async fn run_pipeline(
segment_tx: mpsc::Sender<Arc<AudioSegment>>,
mut control_rx: mpsc::Receiver<PipelineControl>,
control_tx: mpsc::Sender<PipelineControl>,
stop_token: CancellationToken,
state: SharedState,
udn: String,
#[cfg(feature = "pmoserver")]
control_point: Arc<pmocontrol::ControlPoint>,
) {
let mut current_source_stop: Option<CancellationToken> = None;
let mut current_uri: Option<String> = None;
loop {
tokio::select! {
_ = stop_token.cancelled() => {
info!(udn = %udn, "Pipeline stopping by cancellation");
if let Some(src_stop) = current_source_stop.take() {
src_stop.cancel();
}
break;
}
cmd = control_rx.recv() => {
match cmd {
None => {
info!(udn = %udn, "Pipeline control channel closed");
break;
}
Some(PipelineControl::SourceEnded) => {
// La source s'est terminée (EOF ou erreur) : libérer le slot
debug!(udn = %udn, "Pipeline: SourceEnded — source slot freed");
current_source_stop = None;
}
Some(PipelineControl::LoadUri(uri)) => {
info!(udn = %udn, uri = %uri, "Pipeline: LoadUri");
if let Some(src_stop) = current_source_stop.take() {
src_stop.cancel();
}
current_uri = Some(uri.clone());
{
let mut s = state.write();
s.playback_state = PlaybackState::Transitioning;
s.current_uri = Some(uri.clone());
s.position = None;
}
let src_stop = stop_token.child_token();
current_source_stop = Some(src_stop.clone());
let tx = segment_tx.clone();
let notify = control_tx.clone();
let st = state.clone();
let udn_c = udn.clone();
#[cfg(feature = "pmoserver")]
let cp = control_point.clone();
tokio::spawn(async move {
stream_source(
uri, 0.0, tx, src_stop, st, udn_c,
#[cfg(feature = "pmoserver")]
cp,
).await;
let _ = notify.send(PipelineControl::SourceEnded).await;
});
}
Some(PipelineControl::LoadNextUri(uri)) => {
debug!(udn = %udn, uri = %uri, "Pipeline: LoadNextUri");
state.write().next_uri = Some(uri);
}
Some(PipelineControl::Play) => {
// Redémarrer la source si elle n'est pas en cours
if current_source_stop.is_none() {
if let Some(uri) = current_uri.clone() {
info!(udn = %udn, uri = %uri, "Pipeline: Play — restarting source");
let src_stop = stop_token.child_token();
current_source_stop = Some(src_stop.clone());
let tx = segment_tx.clone();
let notify = control_tx.clone();
let st = state.clone();
let udn_c = udn.clone();
#[cfg(feature = "pmoserver")]
let cp = control_point.clone();
tokio::spawn(async move {
stream_source(
uri, 0.0, tx, src_stop, st, udn_c,
#[cfg(feature = "pmoserver")]
cp,
).await;
let _ = notify.send(PipelineControl::SourceEnded).await;
});
} else {
debug!(udn = %udn, "Pipeline: Play — no URI loaded, ignoring");
}
} else {
debug!(udn = %udn, "Pipeline: Play — source already running");
}
}
Some(PipelineControl::Pause) => {
debug!(udn = %udn, "Pipeline: Pause (not supported on live stream)");
}
Some(PipelineControl::Stop) => {
info!(udn = %udn, "Pipeline: Stop");
if let Some(src_stop) = current_source_stop.take() {
src_stop.cancel();
}
// Conserver current_uri pour permettre un Play ultérieur
let mut s = state.write();
s.playback_state = PlaybackState::Stopped;
s.position = None;
}
Some(PipelineControl::Seek(pos_sec)) => {
info!(udn = %udn, pos = pos_sec, "Pipeline: Seek");
if let Some(uri) = current_uri.clone() {
if let Some(src_stop) = current_source_stop.take() {
src_stop.cancel();
}
let src_stop = stop_token.child_token();
current_source_stop = Some(src_stop.clone());
let tx = segment_tx.clone();
let notify = control_tx.clone();
let st = state.clone();
let udn_c = udn.clone();
#[cfg(feature = "pmoserver")]
let cp = control_point.clone();
tokio::spawn(async move {
stream_source(
uri, pos_sec, tx, src_stop, st, udn_c,
#[cfg(feature = "pmoserver")]
cp,
).await;
let _ = notify.send(PipelineControl::SourceEnded).await;
});
}
}
Some(PipelineControl::SetVolume(vol)) => {
state.write().volume = vol;
}
Some(PipelineControl::SetMute(mute)) => {
state.write().mute = mute;
}
}
}
}
}
}
// ─── Task source ─────────────────────────────────────────────────────────────
async fn stream_source(
uri: String,
seek_sec: f64,
tx: mpsc::Sender<Arc<AudioSegment>>,
stop_token: CancellationToken,
state: SharedState,
udn: String,
#[cfg(feature = "pmoserver")]
control_point: Arc<pmocontrol::ControlPoint>,
) {
info!(udn = %udn, uri = %uri, seek = seek_sec, "Source task: opening URI");
match UriSource::open(&uri, seek_sec, stop_token.clone()).await {
Ok(source) => {
debug!(udn = %udn, "Source task: URI opened, duration={:?}", source.duration_sec());
if let Some(dur) = source.duration_sec() {
state.write().duration = Some(seconds_to_upnp_time(dur));
}
// TrackBoundary avec métadonnées minimales
let boundary = {
let mut meta = MemoryTrackMetadata::new();
let _ = meta.set_title(Some(uri.clone())).await;
let meta_arc = Arc::new(tokio::sync::RwLock::new(meta));
AudioSegment::new_track_boundary(0, seek_sec, meta_arc)
};
let _ = tx.send(boundary).await;
// L'URI est ouverte, le flux va commencer à couler.
// Le sink bloquera naturellement si aucun client HTTP n'est connecté.
{
let mut s = state.write();
s.playback_state = PlaybackState::Playing;
if seek_sec > 0.0 {
s.position = Some(seconds_to_upnp_time(seek_sec));
}
}
match source.emit_to_channel(&tx, &stop_token).await {
Ok(true) => {
debug!(udn = %udn, "Source task: EOF → TrackEnded");
handle_track_ended(
state, udn, tx, stop_token,
#[cfg(feature = "pmoserver")]
control_point,
).await;
}
Ok(false) => {
debug!(udn = %udn, "Source task: cancelled");
}
Err(e) => {
warn!(udn = %udn, error = %e, "Source task error");
state.write().playback_state = PlaybackState::Stopped;
}
}
}
Err(e) => {
warn!(udn = %udn, error = %e, "Source task: failed to open URI");
state.write().playback_state = PlaybackState::Stopped;
}
}
}
// ─── TrackEnded : avancer current←next ───────────────────────────────────────
async fn handle_track_ended(
state: SharedState,
udn: String,
tx: mpsc::Sender<Arc<AudioSegment>>,
stop_token: CancellationToken,
#[cfg(feature = "pmoserver")]
control_point: Arc<pmocontrol::ControlPoint>,
) {
let next_uri = {
let mut s = state.write();
let uri = s.next_uri.take();
let meta = s.next_metadata.take();
s.current_uri = uri.clone();
s.current_metadata = meta;
s.next_uri = None;
s.next_metadata = None;
s.position = None;
s.duration = None;
if uri.is_some() {
s.playback_state = PlaybackState::Playing;
} else {
s.playback_state = PlaybackState::Stopped;
}
uri
};
#[cfg(feature = "pmoserver")]
if next_uri.is_some() {
let cp = control_point.clone();
let udn_clone = udn.clone();
tokio::spawn(async move {
cp.advance_queue_and_prefetch(&pmocontrol::DeviceId(udn_clone));
});
}
if let Some(uri) = next_uri {
info!(udn = %udn, uri = %uri, "TrackEnded: starting next track");
Box::pin(stream_source(
uri, 0.0, tx, stop_token, state, udn,
#[cfg(feature = "pmoserver")]
control_point,
)).await;
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
pub fn seconds_to_upnp_time(s: f64) -> String {
let s = s as u64;
let h = s / 3600;
let m = (s % 3600) / 60;
let sec = s % 60;
format!("{}:{:02}:{:02}", h, m, sec)
}
pub fn upnp_time_to_seconds(t: &str) -> f64 {
let parts: Vec<f64> = t.split(':').filter_map(|p| p.parse().ok()).collect();
match parts.as_slice() {
[h, m, s] => h * 3600.0 + m * 60.0 + s,
[m, s] => m * 60.0 + s,
[s] => *s,
_ => 0.0,
}
}

View File

@@ -0,0 +1,72 @@
//! Handlers HTTP pour l'enregistrement/désenregistrement des instances WebRenderer.
//!
//! - POST /api/webrenderer/register → crée ou reconnecte une instance
//! - DELETE /api/webrenderer/{id} → désenregistrement explicite
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::registry::RendererRegistry;
#[derive(Debug, Deserialize)]
pub struct RegisterRequest {
pub instance_id: String,
pub user_agent: String,
}
#[derive(Debug, Serialize)]
pub struct RegisterResponse {
pub stream_url: String,
pub udn: String,
}
/// POST /api/webrenderer/register
pub async fn register_handler(
State(registry): State<Arc<RendererRegistry>>,
Json(req): Json<RegisterRequest>,
) -> impl IntoResponse {
tracing::info!(
instance_id = %req.instance_id,
user_agent = %req.user_agent,
"WebRenderer: register request"
);
match registry
.register_or_reconnect(&req.instance_id, &req.user_agent)
.await
{
Ok((stream_url, udn)) => {
tracing::info!(
instance_id = %req.instance_id,
stream_url = %stream_url,
udn = %udn,
"WebRenderer: registered"
);
(StatusCode::OK, Json(RegisterResponse { stream_url, udn })).into_response()
}
Err(e) => {
tracing::error!(
instance_id = %req.instance_id,
error = %e,
"WebRenderer: registration failed"
);
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
}
}
}
/// DELETE /api/webrenderer/{id}
pub async fn unregister_handler(
State(registry): State<Arc<RendererRegistry>>,
Path(instance_id): Path<String>,
) -> impl IntoResponse {
tracing::info!(instance_id = %instance_id, "WebRenderer: explicit unregister");
registry.schedule_unregister(&instance_id);
StatusCode::NO_CONTENT
}

View File

@@ -0,0 +1,346 @@
//! Registre des instances WebRenderer actives.
//!
//! Remplace `SessionManager` et `websocket.rs`. La session est maintenant liée
//! au flux FLAC HTTP, pas à une connexion WebSocket.
use parking_lot::RwLock;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::SystemTime;
use pmoupnp::devices::DeviceInstance;
use crate::error::WebRendererError;
use crate::pipeline::{InstancePipeline, PipelineHandle};
use crate::renderer::WebRendererFactory;
use crate::state::{RendererState, SharedState};
use crate::messages::PlaybackState;
#[cfg(feature = "pmoserver")]
use pmocontrol::{ControlPoint, DeviceId};
#[cfg(feature = "pmoserver")]
use pmocontrol::model::{RendererCapabilities, RendererProtocol};
#[cfg(feature = "pmoserver")]
use pmoupnp::UpnpTypedInstance;
/// Une instance WebRenderer côté serveur
pub struct WebRendererInstance {
pub instance_id: String,
pub udn: String,
pub device_instance: Arc<DeviceInstance>,
pub state: SharedState,
/// Handle vers le sink OGG-FLAC — clonable, chaque subscribe() donne un flux live.
pub flac_handle: pmoaudio_ext::sinks::OggFlacStreamHandle,
pub pipeline: PipelineHandle,
pub created_at: SystemTime,
}
/// Registre global des instances WebRenderer
pub struct RendererRegistry {
/// Map instance_id → instance
instances: RwLock<HashMap<String, Arc<WebRendererInstance>>>,
/// Map udn → instance (pour retrouver depuis les handlers UPnP)
by_udn: RwLock<HashMap<String, Arc<WebRendererInstance>>>,
/// Tokens d'annulation des unregister différés (instance_id → token)
pending_unregister: RwLock<HashMap<String, tokio_util::sync::CancellationToken>>,
#[cfg(feature = "pmoserver")]
control_point: Arc<ControlPoint>,
}
impl RendererRegistry {
#[cfg(feature = "pmoserver")]
pub fn new(control_point: Arc<ControlPoint>) -> Self {
Self {
instances: RwLock::new(HashMap::new()),
by_udn: RwLock::new(HashMap::new()),
pending_unregister: RwLock::new(HashMap::new()),
control_point,
}
}
#[cfg(not(feature = "pmoserver"))]
pub fn new() -> Self {
Self {
instances: RwLock::new(HashMap::new()),
by_udn: RwLock::new(HashMap::new()),
pending_unregister: RwLock::new(HashMap::new()),
}
}
/// Enregistre ou reconnecte une instance.
/// Retourne `(stream_url, udn)`.
pub async fn register_or_reconnect(
&self,
instance_id: &str,
user_agent: &str,
) -> Result<(String, String), WebRendererError> {
// Annuler tout unregister différé pour cet instance_id
if let Some(cancel) = self.pending_unregister.write().remove(instance_id) {
tracing::info!(instance_id = %instance_id, "WebRenderer: cancelled pending unregister (page reload)");
cancel.cancel();
}
// Reconnexion : l'instance existe déjà (ou vient d'être conservée)
{
let instances = self.instances.read();
if let Some(existing) = instances.get(instance_id) {
tracing::info!(instance_id = %instance_id, "WebRenderer: reconnecting existing instance");
#[cfg(feature = "pmoserver")]
self.register_with_control_point(&existing.device_instance)?;
let stream_url = format!("/api/webrenderer/{}/stream", instance_id);
return Ok((stream_url, existing.udn.clone()));
}
}
// Première connexion : créer device UPnP + pipeline
let instance = self.create_instance(instance_id, user_agent).await?;
let instance = Arc::new(instance);
let stream_url = format!("/api/webrenderer/{}/stream", instance_id);
let udn = instance.udn.clone();
{
let mut instances = self.instances.write();
instances.insert(instance_id.to_string(), instance.clone());
}
{
let mut by_udn = self.by_udn.write();
by_udn.insert(instance.udn.clone(), instance.clone());
}
tracing::info!(
instance_id = %instance_id,
udn = %udn,
"WebRenderer: new instance registered"
);
Ok((stream_url, udn))
}
/// Retourne le OggFlacStreamHandle pour l'endpoint /stream (clonable).
pub fn get_flac_handle(
&self,
instance_id: &str,
) -> Option<pmoaudio_ext::sinks::OggFlacStreamHandle> {
self.instances
.read()
.get(instance_id)
.map(|i| i.flac_handle.clone())
}
/// Retourne le PipelineHandle par UDN (pour les handlers UPnP)
pub fn get_pipeline_by_udn(&self, udn: &str) -> Option<PipelineHandle> {
self.by_udn
.read()
.get(udn)
.map(|i| i.pipeline.clone())
}
/// Retourne le SharedState par UDN
pub fn get_state_by_udn(&self, udn: &str) -> Option<SharedState> {
self.by_udn
.read()
.get(udn)
.map(|i| i.state.clone())
}
/// Retourne le DeviceInstance par UDN
pub fn get_device_by_udn(&self, udn: &str) -> Option<Arc<DeviceInstance>> {
self.by_udn
.read()
.get(udn)
.map(|i| i.device_instance.clone())
}
pub fn schedule_unregister(self: &Arc<Self>, instance_id: &str) {
use tokio_util::sync::CancellationToken;
// Ne pas détruire immédiatement : attendre 5s au cas où la page se recharge
let cancel = CancellationToken::new();
self.pending_unregister.write().insert(instance_id.to_string(), cancel.clone());
let instance_id_owned = instance_id.to_string();
let registry = Arc::clone(self);
tracing::info!(instance_id = %instance_id, "WebRenderer: unregister scheduled (5s grace period)");
tokio::spawn(async move {
tokio::select! {
_ = cancel.cancelled() => {
tracing::info!(instance_id = %instance_id_owned, "WebRenderer: deferred unregister cancelled (page reload)");
}
_ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {
registry.pending_unregister.write().remove(&instance_id_owned);
let instance = registry.instances.write().remove(&instance_id_owned);
if let Some(instance) = instance {
registry.by_udn.write().remove(&instance.udn);
instance.pipeline.stop_token.cancel();
#[cfg(feature = "pmoserver")]
if let Ok(mut reg) = registry.control_point.registry().write() {
reg.device_says_byebye(&instance.udn);
}
tracing::info!(
instance_id = %instance_id_owned,
udn = %instance.udn,
"WebRenderer: instance unregistered"
);
}
}
}
});
}
// ── Création d'instance ────────────────────────────────────────────────────
async fn create_instance(
&self,
instance_id: &str,
user_agent: &str,
) -> Result<WebRendererInstance, WebRendererError> {
// UDN stable dérivé de l'instance_id
let candidate_udn = instance_id.to_ascii_lowercase();
let full_udn = format!("uuid:{}", candidate_udn);
// Persister l'UDN dans la config (pour que device_instance.rs le retrouve)
if let Err(e) = pmoconfig::get_config().set_device_udn(
"MediaRenderer",
instance_id,
candidate_udn.clone(),
) {
tracing::warn!("WebRenderer: failed to persist UDN: {:?}", e);
}
let state: SharedState = Arc::new(parking_lot::RwLock::new(RendererState::default()));
#[cfg(feature = "pmoserver")]
let (device_instance, pipeline) = {
use pmoupnp::UpnpServerExt;
let server_arc = pmoserver::get_server()
.ok_or(WebRendererError::ServerNotAvailable)?;
// Créer le pipeline d'abord pour avoir le PipelineHandle
let ip = InstancePipeline::start(
state.clone(),
self.control_point.clone(),
full_udn.clone(),
);
let pipeline = ip.pipeline_handle.clone();
// Vérifier si un device avec ce même UDN existe déjà
let existing_di = {
let server = server_arc.read().await;
server.get_device(&candidate_udn)
};
let di = if let Some(di) = existing_di {
tracing::info!(udn = %candidate_udn, "WebRenderer: reusing device from registry");
di
} else {
// Créer le device UPnP
tracing::info!(udn = %candidate_udn, "WebRenderer: creating new device");
let device = WebRendererFactory::create_device_with_pipeline(
instance_id,
user_agent,
pipeline.clone(),
state.clone(),
)
.map_err(|e| WebRendererError::DeviceCreationError(e.to_string()))?;
let mut server = server_arc.write().await;
server
.register_device(Arc::new(device))
.await
.map_err(|e| WebRendererError::RegistrationError(e.to_string()))?
};
self.register_with_control_point(&di)?;
(di, ip)
};
#[cfg(not(feature = "pmoserver"))]
let (device_instance, pipeline) = {
use pmoupnp::UpnpModel;
let ip = InstancePipeline::start(state.clone(), full_udn.clone());
let pipeline = ip.pipeline_handle.clone();
let device = WebRendererFactory::create_device_with_pipeline(
instance_id,
user_agent,
pipeline.clone(),
state.clone(),
)
.map_err(|e| WebRendererError::DeviceCreationError(e.to_string()))?;
(Arc::new(device).create_instance(), ip)
};
Ok(WebRendererInstance {
instance_id: instance_id.to_string(),
udn: full_udn,
device_instance,
state,
flac_handle: pipeline.flac_handle.clone(),
pipeline: pipeline.pipeline_handle,
created_at: SystemTime::now(),
})
}
/// Enregistre le device dans le ControlPoint
#[cfg(feature = "pmoserver")]
fn register_with_control_point(
&self,
di: &Arc<DeviceInstance>,
) -> Result<(), WebRendererError> {
let base_url = di.base_url().to_string();
let udn = di.udn().to_ascii_lowercase();
let udn_with_prefix = format!("uuid:{}", udn);
let device_route = di.route();
let model = di.get_model();
let avtransport_control_url = Some(format!(
"{}{}/service/AVTransport/control",
base_url, device_route
));
let rendering_control_url = Some(format!(
"{}{}/service/RenderingControl/control",
base_url, device_route
));
let connection_manager_url = Some(format!(
"{}{}/service/ConnectionManager/control",
base_url, device_route
));
let renderer_info = pmocontrol::RendererInfo::make(
DeviceId(udn_with_prefix.clone()),
udn_with_prefix.clone(),
model.friendly_name().to_string(),
model.model_name().to_string(),
"PMOMusic".to_string(),
RendererProtocol::UpnpAvOnly,
RendererCapabilities {
has_avtransport: true,
has_avtransport_set_next: true,
has_rendering_control: true,
has_connection_manager: true,
..Default::default()
},
format!("{}{}", base_url, di.description_route()),
"PMOMusic WebRenderer/2.0".to_string(),
Some("urn:schemas-upnp-org:service:AVTransport:1".to_string()),
avtransport_control_url,
Some("urn:schemas-upnp-org:service:RenderingControl:1".to_string()),
rendering_control_url,
Some("urn:schemas-upnp-org:service:ConnectionManager:1".to_string()),
connection_manager_url,
None, None, None, None, None, None, None, None, None, None, None, None, None, None, None,
);
if let Ok(mut registry) = self.control_point.registry().write() {
registry.push_renderer(&renderer_info, 86400);
}
tracing::info!(udn = %udn, "WebRenderer: registered with ControlPoint");
Ok(())
}
}

View File

@@ -5,15 +5,13 @@
use std::sync::Arc;
use thiserror::Error;
use tokio::sync::mpsc;
use pmoupnp::actions::{Action, Argument};
use pmoupnp::devices::Device;
use pmoupnp::services::Service;
use crate::handlers;
use crate::messages::ServerMessage;
use crate::state::{SharedSender, SharedState};
use crate::pipeline::PipelineHandle;
use crate::state::SharedState;
// ─── Réimport des variables statiques de pmomediarenderer ───────────────────
// Variables AVTransport
@@ -65,7 +63,7 @@ fn extract_browser_name(ua: &str) -> &str {
}
}
/// Factory pour créer des Device UPnP WebRenderer avec des handlers WebSocket
/// Factory pour créer des Device UPnP WebRenderer avec un pipeline audio serveur
pub struct WebRendererFactory;
impl WebRendererFactory {
@@ -73,26 +71,23 @@ impl WebRendererFactory {
///
/// `device_name` sert de clé pour retrouver l'UDN persistant dans la config.
/// `browser_ua` est le User-Agent complet (pour déterminer le nom affiché).
///
/// Retourne le Device et le `SharedSender` associé. Le `SharedSender` peut être
/// mis à jour à chaque reconnexion WebSocket via `shared_sender.set(new_tx)`.
pub fn create_device_with_name(
pub fn create_device_with_pipeline(
device_name: &str,
browser_ua: &str,
ws_sender: mpsc::UnboundedSender<ServerMessage>,
pipeline: PipelineHandle,
state: SharedState,
) -> Result<(Device, SharedSender), FactoryError> {
let shared_sender = SharedSender::new(ws_sender);
let avtransport = Self::build_avtransport(shared_sender.clone(), state.clone())?;
let renderingcontrol = Self::build_renderingcontrol(shared_sender.clone(), state.clone())?;
) -> Result<Device, FactoryError> {
let avtransport = Self::build_avtransport(pipeline.clone(), state.clone())?;
let renderingcontrol = Self::build_renderingcontrol(pipeline.clone(), state.clone())?;
let connectionmanager = Self::build_connectionmanager()?;
let short_name = extract_browser_name(browser_ua);
let device = Device::new(
let mut device = Device::new(
device_name.to_string(),
"MediaRenderer".to_string(),
format!("Web Audio {}", short_name),
);
device.set_model_name("WebRenderer".to_string());
device
.add_service(Arc::new(avtransport))
.map_err(|e| FactoryError::ServiceError(format!("{:?}", e)))?;
@@ -103,12 +98,12 @@ impl WebRendererFactory {
.add_service(Arc::new(connectionmanager))
.map_err(|e| FactoryError::ServiceError(format!("{:?}", e)))?;
Ok((device, shared_sender))
Ok(device)
}
/// Construit le service AVTransport avec les handlers WebSocket
/// Construit le service AVTransport avec les handlers pipeline
fn build_avtransport(
ws: SharedSender,
pipeline: PipelineHandle,
state: SharedState,
) -> Result<Service, FactoryError> {
let mut svc = Service::new("AVTransport".to_string());
@@ -158,7 +153,7 @@ impl WebRendererFactory {
Arc::clone(&TRANSPORTPLAYSPEED),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
play.set_handler(handlers::play_handler(ws.clone(), state.clone()));
play.set_handler(handlers::play_handler(pipeline.clone(), state.clone()));
add_action(&mut svc, Arc::new(play))?;
// Stop
@@ -168,7 +163,7 @@ impl WebRendererFactory {
Arc::clone(&AVT_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
stop.set_handler(handlers::stop_handler(ws.clone(), state.clone()));
stop.set_handler(handlers::stop_handler(pipeline.clone(), state.clone()));
add_action(&mut svc, Arc::new(stop))?;
// Pause
@@ -179,7 +174,7 @@ impl WebRendererFactory {
Arc::clone(&AVT_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
pause.set_handler(handlers::pause_handler(ws.clone(), state.clone()));
pause.set_handler(handlers::pause_handler(pipeline.clone(), state.clone()));
add_action(&mut svc, Arc::new(pause))?;
// Next
@@ -189,7 +184,7 @@ impl WebRendererFactory {
Arc::clone(&AVT_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
next.set_handler(handlers::next_handler(ws.clone()));
next.set_handler(handlers::next_handler(pipeline.clone()));
add_action(&mut svc, Arc::new(next))?;
// Previous
@@ -200,7 +195,7 @@ impl WebRendererFactory {
Arc::clone(&AVT_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
previous.set_handler(handlers::previous_handler(ws.clone()));
previous.set_handler(handlers::previous_handler(pipeline.clone()));
add_action(&mut svc, Arc::new(previous))?;
// Seek
@@ -220,7 +215,7 @@ impl WebRendererFactory {
Arc::clone(&SEEKMODE),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
seek.set_handler(handlers::seek_handler(ws.clone()));
seek.set_handler(handlers::seek_handler(pipeline.clone()));
add_action(&mut svc, Arc::new(seek))?;
// SetAVTransportURI
@@ -243,7 +238,7 @@ impl WebRendererFactory {
Arc::clone(&AVTRANSPORTURIMETADATA),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
set_uri.set_handler(handlers::set_uri_handler(ws.clone(), state.clone()));
set_uri.set_handler(handlers::set_uri_handler(pipeline.clone(), state.clone()));
add_action(&mut svc, Arc::new(set_uri))?;
// SetNextAVTransportURI
@@ -266,7 +261,7 @@ impl WebRendererFactory {
Arc::clone(&AVTRANSPORTNEXTURIMETADATA),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
set_next_uri.set_handler(handlers::set_next_uri_handler(ws.clone(), state.clone()));
set_next_uri.set_handler(handlers::set_next_uri_handler(pipeline.clone(), state.clone()));
add_action(&mut svc, Arc::new(set_next_uri))?;
// GetPositionInfo
@@ -422,9 +417,9 @@ impl WebRendererFactory {
Ok(svc)
}
/// Construit le service RenderingControl avec les handlers WebSocket
/// Construit le service RenderingControl avec les handlers pipeline
fn build_renderingcontrol(
ws: SharedSender,
pipeline: PipelineHandle,
state: SharedState,
) -> Result<Service, FactoryError> {
let mut svc = Service::new("RenderingControl".to_string());
@@ -458,7 +453,7 @@ impl WebRendererFactory {
Arc::clone(&VOLUME),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
set_vol.set_handler(handlers::set_volume_handler(ws.clone(), state.clone()));
set_vol.set_handler(handlers::set_volume_handler(pipeline.clone(), state.clone()));
svc.add_action(Arc::new(set_vol))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
@@ -507,7 +502,7 @@ impl WebRendererFactory {
Arc::clone(&MUTE),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
set_mute.set_handler(handlers::set_mute_handler(ws.clone(), state.clone()));
set_mute.set_handler(handlers::set_mute_handler(pipeline.clone(), state.clone()));
svc.add_action(Arc::new(set_mute))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;

View File

@@ -1,133 +0,0 @@
//! Gestionnaire de sessions WebRenderer
use parking_lot::RwLock;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use pmoupnp::devices::DeviceInstance;
use crate::state::{SharedSender, SharedState};
/// Session WebSocket liée à un MediaRenderer privé
pub struct WebRendererSession {
pub token: String,
pub udn: String,
pub device_instance: Arc<DeviceInstance>,
/// Sender partagé : mis à jour à chaque reconnexion WebSocket.
pub shared_sender: SharedSender,
pub state: SharedState,
pub created_at: SystemTime,
pub last_activity: Arc<RwLock<SystemTime>>,
}
/// Gestionnaire global des sessions
#[derive(Clone)]
pub struct SessionManager {
sessions: Arc<RwLock<HashMap<String, Arc<WebRendererSession>>>>,
/// Map UDN → SharedSender, persiste même après suppression de la session.
/// Permet de retrouver et mettre à jour le sender à la reconnexion.
senders: Arc<RwLock<HashMap<String, SharedSender>>>,
/// Map UDN → SharedState, persiste même après suppression de la session.
/// Permet de réutiliser l'état partagé avec les handlers du device existant.
states: Arc<RwLock<HashMap<String, SharedState>>>,
timeout_duration: Duration,
}
impl SessionManager {
pub fn new(timeout_duration: Duration) -> Self {
let manager = Self {
sessions: Arc::new(RwLock::new(HashMap::new())),
senders: Arc::new(RwLock::new(HashMap::new())),
states: Arc::new(RwLock::new(HashMap::new())),
timeout_duration,
};
manager.spawn_cleanup_task();
manager
}
pub fn add_session(&self, session: Arc<WebRendererSession>) {
let token = session.token.clone();
let udn = session.udn.clone();
let sender = session.shared_sender.clone();
let state = session.state.clone();
self.sessions.write().insert(token.clone(), session);
self.senders.write().insert(udn.clone(), sender);
self.states.write().insert(udn, state);
tracing::info!(token = %token, "WebRenderer session added");
}
pub fn get_session(&self, token: &str) -> Option<Arc<WebRendererSession>> {
let sessions = self.sessions.read();
if let Some(session) = sessions.get(token) {
*session.last_activity.write() = SystemTime::now();
Some(session.clone())
} else {
None
}
}
/// Retrouve une session par UDN du device (indépendant du token WebSocket).
pub fn get_session_by_udn(&self, udn: &str) -> Option<Arc<WebRendererSession>> {
let sessions = self.sessions.read();
sessions.values().find(|s| s.udn == udn).cloned()
}
/// Retrouve le SharedSender par UDN (persiste même après suppression de session).
pub fn get_sender_by_udn(&self, udn: &str) -> Option<SharedSender> {
self.senders.read().get(udn).cloned()
}
/// Retrouve le SharedState par UDN (persiste même après suppression de session).
pub fn get_state_by_udn(&self, udn: &str) -> Option<SharedState> {
self.states.read().get(udn).cloned()
}
pub fn remove_session(&self, token: &str) -> Option<Arc<WebRendererSession>> {
let session = self.sessions.write().remove(token);
if let Some(ref s) = session {
tracing::info!(token = %s.token, udn = %s.udn, "WebRenderer session removed");
}
session
}
pub fn list_sessions(&self) -> Vec<Arc<WebRendererSession>> {
self.sessions.read().values().cloned().collect()
}
fn spawn_cleanup_task(&self) {
let sessions = self.sessions.clone();
let timeout = self.timeout_duration;
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(60));
loop {
interval.tick().await;
let now = SystemTime::now();
let mut to_remove = Vec::new();
{
let sessions_guard = sessions.read();
for (token, session) in sessions_guard.iter() {
let last = session.last_activity.read();
if let Ok(elapsed) = now.duration_since(*last) {
if elapsed > timeout {
to_remove.push(token.clone());
}
}
}
}
if !to_remove.is_empty() {
let mut sessions_guard = sessions.write();
for token in to_remove {
sessions_guard.remove(&token);
tracing::info!(token = %token, "Session expired and removed");
}
}
}
});
}
}

View File

@@ -1,10 +1,9 @@
//! État partagé du renderer (backend ↔ navigateur)
//! État partagé du renderer (backend ↔ pipeline)
use parking_lot::RwLock;
use std::sync::Arc;
use tokio::sync::mpsc;
use crate::messages::{PlaybackState, ServerMessage};
use crate::messages::PlaybackState;
/// État temps-réel du renderer (partagé backend ↔ navigateur)
#[derive(Debug, Clone)]
@@ -38,34 +37,3 @@ impl Default for RendererState {
/// Alias pour l'état partagé
pub type SharedState = Arc<RwLock<RendererState>>;
/// Sender WebSocket partagé et remplaçable entre les reconnexions.
///
/// Les handlers UPnP capturent ce `Arc` à la création du device. À chaque
/// reconnexion WebSocket (reload de page), on remplace le sender interne via
/// `set()`, sans avoir à recréer le device ni ses handlers.
#[derive(Clone)]
pub struct SharedSender(Arc<RwLock<Option<mpsc::UnboundedSender<ServerMessage>>>>);
impl SharedSender {
pub fn new(sender: mpsc::UnboundedSender<ServerMessage>) -> Self {
Self(Arc::new(RwLock::new(Some(sender))))
}
/// Envoie un message au navigateur. Ignore silencieusement si déconnecté.
pub fn send(&self, msg: ServerMessage) {
if let Some(tx) = self.0.read().as_ref() {
let _ = tx.send(msg);
}
}
/// Remplace le sender (appelé à la reconnexion WebSocket).
pub fn set(&self, sender: mpsc::UnboundedSender<ServerMessage>) {
*self.0.write() = Some(sender);
}
/// Retire le sender (appelé à la déconnexion).
pub fn clear(&self) {
*self.0.write() = None;
}
}

View File

@@ -0,0 +1,63 @@
//! Handler HTTP GET /api/webrenderer/{id}/stream
//!
//! Sert le flux FLAC d'une instance WebRenderer via DirectFlacSink.
//!
//! Reconnectable : appelé à chaque Play, crée un nouveau pipe + encodeur.
use axum::{
body::Body,
extract::{Path, State},
http::{
StatusCode,
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
},
response::{IntoResponse, Response},
};
use std::sync::Arc;
use tokio_util::io::ReaderStream;
use tracing::info;
use crate::registry::RendererRegistry;
/// GET /api/webrenderer/{id}/stream
///
/// Crée un nouveau pipe FLAC à chaque connexion (chaque Play).
/// Le flux reste ouvert jusqu'à ce que le client se déconnecte (Stop).
/// Les morceaux s'enchaînent en gapless dans le même flux.
///
/// Safari envoie un Range header (bytes=0-) et exige Accept-Ranges: bytes.
/// On répond 206 Partial Content si un Range header est présent, sinon 200.
pub async fn stream_handler(
State(registry): State<Arc<RendererRegistry>>,
Path(instance_id): Path<String>,
) -> impl IntoResponse {
info!(instance_id = %instance_id, "FLAC stream client connecting");
let handle = match registry.get_flac_handle(&instance_id) {
Some(h) => h,
None => {
return (
StatusCode::NOT_FOUND,
format!("No WebRenderer instance for id={}", instance_id),
)
.into_response()
}
};
let stream = handle.subscribe();
info!(instance_id = %instance_id, "FLAC stream started");
// Toujours 200 OK pour un flux live de taille inconnue.
// Les navigateurs (Safari inclus) acceptent 200 pour l'audio streaming.
// Un Content-Range invalide (bytes 0-*/*) ferait rejeter le flux.
Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, "audio/ogg; codecs=flac")
.header(CACHE_CONTROL, "no-store, no-transform")
.header(CONNECTION, "keep-alive")
.header("X-Content-Type-Options", "nosniff")
.body(Body::from_stream(ReaderStream::new(stream)))
.unwrap()
.into_response()
}

View File

@@ -1,646 +0,0 @@
//! Handler WebSocket pour les connexions navigateur → WebRenderer
use std::sync::Arc;
use std::time::SystemTime;
use axum::extract::ws::{Message, WebSocket};
use axum::extract::{State, WebSocketUpgrade};
use axum::response::IntoResponse;
use futures::{SinkExt, StreamExt};
use parking_lot::RwLock;
use tokio::sync::mpsc;
use uuid::Uuid;
use pmoupnp::devices::DeviceInstance;
use pmoupnp::variable_types::StateValue;
#[cfg(not(feature = "pmoserver"))]
use pmoupnp::UpnpModel;
use pmoupnp::UpnpTypedInstance;
use crate::messages::*;
use crate::renderer::WebRendererFactory;
use crate::session::{SessionManager, WebRendererSession};
use crate::state::{RendererState, SharedState};
#[cfg(feature = "pmoserver")]
use pmocontrol::model::{RendererCapabilities, RendererProtocol};
#[cfg(feature = "pmoserver")]
use pmocontrol::{ControlPoint, DeviceId};
/// État partagé du serveur WebSocket
#[derive(Clone)]
pub struct WebSocketState {
pub session_manager: Arc<SessionManager>,
#[cfg(feature = "pmoserver")]
pub control_point: Arc<ControlPoint>,
}
/// Handler pour la connexion WebSocket
pub async fn websocket_handler(
ws: WebSocketUpgrade,
State(state): State<WebSocketState>,
) -> impl IntoResponse {
tracing::info!("WebRenderer WebSocket upgrade request received");
ws.on_upgrade(move |socket: WebSocket| handle_socket(socket, state))
}
/// Gestion de la connexion WebSocket
async fn handle_socket(socket: WebSocket, state: WebSocketState) {
tracing::info!("WebRenderer WebSocket connection established");
let (mut sink, mut stream) = socket.split();
// Canal pour envoyer des messages au navigateur
let (tx, mut rx) = mpsc::unbounded_channel::<ServerMessage>();
// Task pour envoyer les messages du canal vers le WebSocket
let send_task = tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if let Ok(json) = serde_json::to_string(&msg) {
if sink.send(Message::Text(json.into())).await.is_err() {
break;
}
}
}
});
let mut session_token: Option<String> = None;
#[allow(unused_variables, unused_assignments, unused_mut)]
let mut device_udn: Option<String> = None;
// Boucle de réception des messages du navigateur
while let Some(msg_result) = stream.next().await {
match msg_result {
Ok(Message::Text(text)) => {
tracing::info!("WebRenderer received text message: {}", &text);
match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Init { capabilities }) => {
tracing::info!("WebRenderer Init received, creating renderer...");
// Créer ou reconnecter le renderer UPnP pour ce navigateur.
match create_renderer_for_browser(&capabilities, tx.clone(), &state).await {
Ok(session) => {
tracing::info!("WebRenderer create_renderer_for_browser OK");
let token = session.token.clone();
let udn = session.udn.clone();
let model = session.device_instance.get_model();
// Envoyer la confirmation au navigateur
let _ = tx.send(ServerMessage::SessionCreated {
token: token.clone(),
renderer_info: RendererInfo {
udn: udn.clone(),
friendly_name: model.friendly_name().to_string(),
model_name: model.model_name().to_string(),
description_url: format!(
"{}{}",
session.device_instance.base_url(),
session.device_instance.description_route()
),
},
});
// Si une URI est déjà chargée (reconnexion en cours de lecture),
// envoyer l'état complet pour que le navigateur puisse reprendre.
{
let s = session.state.read();
if s.current_uri.is_some() {
let _ = tx.send(ServerMessage::StateSync {
current_uri: s.current_uri.clone(),
current_metadata: s.current_metadata.clone(),
next_uri: s.next_uri.clone(),
next_metadata: s.next_metadata.clone(),
playback_state: s.playback_state.clone(),
position: s.position.clone(),
volume: s.volume,
mute: s.mute,
});
tracing::info!(
udn = %udn,
state = ?s.playback_state,
"WebRenderer: sent StateSync to reconnected browser"
);
}
}
session_token = Some(token.clone());
#[cfg(feature = "pmoserver")]
{ device_udn = Some(udn.clone()); }
state.session_manager.add_session(session);
tracing::info!(
token = %token,
udn = %udn,
"WebRenderer initialized for browser: {}",
capabilities.user_agent
);
}
Err(e) => {
tracing::error!("Failed to create WebRenderer: {:?}", e);
break;
}
}
}
Ok(ClientMessage::StateUpdate { state: new_state }) => {
if let Some(ref token) = session_token {
if let Some(session) = state.session_manager.get_session(token) {
{
session.state.write().playback_state = new_state.clone();
}
update_transport_state_var(&session.device_instance, &new_state)
.await;
}
}
}
Ok(ClientMessage::PositionUpdate { position, duration }) => {
if let Some(ref token) = session_token {
if let Some(session) = state.session_manager.get_session(token) {
{
let mut s = session.state.write();
s.position = Some(position.clone());
s.duration = Some(duration.clone());
}
update_position_vars(
&session.device_instance,
&position,
&duration,
)
.await;
}
}
}
Ok(ClientMessage::MetadataUpdate { metadata }) => {
if let Some(ref token) = session_token {
if let Some(session) = state.session_manager.get_session(token) {
let didl = build_didl_from_metadata(&metadata);
{
session.state.write().current_metadata = Some(didl.clone());
}
update_metadata_var(&session.device_instance, &didl).await;
}
}
}
Ok(ClientMessage::VolumeUpdate { volume, mute }) => {
if let Some(ref token) = session_token {
if let Some(session) = state.session_manager.get_session(token) {
{
let mut s = session.state.write();
s.volume = volume;
s.mute = mute;
}
update_volume_vars(&session.device_instance, volume, mute).await;
}
}
}
Ok(ClientMessage::TrackEnded) => {
if let Some(ref token) = session_token {
if let Some(session) = state.session_manager.get_session(token) {
let (next_uri, next_metadata, had_next) = {
let mut s = session.state.write();
let uri = s.next_uri.take();
let meta = s.next_metadata.take();
let had_next = uri.is_some();
s.current_uri = uri.clone();
s.current_metadata = meta.clone();
s.next_uri = None;
s.next_metadata = None;
s.position = None;
s.duration = None;
// Si on avait une piste suivante (gapless), on reste en Playing.
// Sinon, on reste en Stopped pour que le watcher déclenche l'auto-advance.
if had_next {
s.playback_state = PlaybackState::Playing;
}
// Si had_next == false, le navigateur a déjà envoyé state_update:STOPPED,
// donc s.playback_state est déjà Stopped. On le laisse tel quel.
(uri, meta, had_next)
};
let new_state = if had_next {
PlaybackState::Playing
} else {
PlaybackState::Stopped
};
update_transport_state_var(
&session.device_instance,
&new_state,
)
.await;
// Mettre à jour AVTransportURI pour que le ControlPoint
// voie la nouvelle piste courante et envoie SetNextAVTransportURI
update_uri_vars(
&session.device_instance,
next_uri.as_deref().unwrap_or(""),
next_metadata.as_deref().unwrap_or(""),
"", // next_uri vide : le ControlPoint le remplira
"",
)
.await;
// Si c'était une transition gapless (on avait une piste suivante),
// avancer l'index de la queue dans le ControlPoint et prefetch la piste N+2.
// Si pas de piste suivante, le watcher verra STOPPED et déclenchera l'auto-advance.
#[cfg(feature = "pmoserver")]
if had_next {
let udn = session.udn.clone();
let cp = state.control_point.clone();
tokio::spawn(async move {
cp.advance_queue_and_prefetch(
&pmocontrol::DeviceId(udn),
);
});
}
tracing::debug!(
uri = ?next_uri,
had_next,
"WebRenderer TrackEnded: advanced to next track"
);
}
}
}
Ok(ClientMessage::Pong) => {}
Err(e) => {
tracing::warn!(error = %e, "Failed to parse client message");
}
}
}
Ok(Message::Binary(b)) => {
tracing::warn!("WebRenderer received binary message ({} bytes)", b.len());
}
Ok(Message::Close(_)) => {
tracing::info!("WebRenderer WebSocket closed by client");
break;
}
Err(e) => {
tracing::error!("WebSocket error: {}", e);
break;
}
_ => {}
}
}
// Cleanup à la déconnexion
tracing::info!("WebRenderer WebSocket handler exiting (session_token={:?})", session_token);
if let Some(token) = session_token {
state.session_manager.remove_session(&token);
}
// Marquer le renderer comme offline dans le ControlPoint
#[cfg(feature = "pmoserver")]
if let Some(ref udn) = device_udn {
if let Ok(mut registry) = state.control_point.registry().write() {
registry.device_says_byebye(udn);
}
tracing::info!(udn = %udn, "WebRenderer disconnected and marked offline");
}
send_task.abort();
}
/// Crée ou reconnecte un DeviceInstance UPnP pour un navigateur.
///
/// - Première connexion : crée le device, l'enregistre, crée la session.
/// - Reconnexion (reload) : retrouve la session existante par UDN, met à jour le
/// `SharedSender` avec le nouveau tx WebSocket (les handlers continuent de fonctionner),
/// et crée une nouvelle session avec un nouveau token.
async fn create_renderer_for_browser(
capabilities: &BrowserCapabilities,
ws_sender: mpsc::UnboundedSender<ServerMessage>,
ws_state: &WebSocketState,
) -> Result<Arc<WebRendererSession>, crate::error::WebRendererError> {
let token = Uuid::new_v4().to_string();
// Persister l'UDN dérivé de l'instance_id dans la config pour que device_instance.rs
// le retrouve de façon déterministe. La clé ("MediaRenderer", instance_id) est unique
// par onglet/navigateur et stable entre les reloads.
let instance_udn = capabilities.instance_id.clone();
if let Err(e) = pmoconfig::get_config().set_device_udn(
"MediaRenderer",
&instance_udn,
instance_udn.clone(),
) {
tracing::warn!("WebRenderer: failed to persist UDN in config: {:?}", e);
}
// UDN normalisé tel que stocké dans le DEVICE_REGISTRY (sans préfixe "uuid:")
let candidate_udn = instance_udn.to_ascii_lowercase();
// UDN avec préfixe "uuid:" pour le ControlPoint et la session
let full_udn = format!("uuid:{}", candidate_udn);
// ── Reconnexion : session existante par UDN ───────────────────────────────
// Si une session avec ce même UDN existe encore dans le SessionManager, on
// met à jour son SharedSender (les handlers UPnP enverront vers le nouveau WS).
if let Some(existing_session) = ws_state.session_manager.get_session_by_udn(&full_udn) {
tracing::info!(udn = %full_udn, "WebRenderer: reconnecting via existing session");
existing_session.shared_sender.set(ws_sender.clone());
#[cfg(feature = "pmoserver")]
register_with_control_point(&existing_session.device_instance, ws_state)?;
// Nouvelle session avec nouveau token, mais même device/state/sender partagés
let session = Arc::new(WebRendererSession {
token,
udn: full_udn,
device_instance: existing_session.device_instance.clone(),
shared_sender: existing_session.shared_sender.clone(),
state: existing_session.state.clone(),
created_at: existing_session.created_at,
last_activity: existing_session.last_activity.clone(),
});
return Ok(session);
}
// ── Première connexion : création complète ────────────────────────────────
// Enregistrer le device via UpnpServerExt (gère base_url, register_urls, DEVICE_REGISTRY)
// Retourne (DeviceInstance, SharedSender effectif, SharedState effective pour cette session)
#[cfg(feature = "pmoserver")]
let (di, shared_sender, shared_state) = {
use pmoupnp::UpnpServerExt;
tracing::info!("WebRenderer: candidate UDN = {}", candidate_udn);
// Vérifier si un device avec ce même UDN est déjà dans le DEVICE_REGISTRY
// (cas où la session a expiré mais le device est encore enregistré).
let server_arc =
pmoserver::get_server().ok_or(crate::error::WebRendererError::ServerNotAvailable)?;
let existing_di = {
let server = server_arc.read().await;
server.get_device(&candidate_udn)
};
if let Some(di) = existing_di {
tracing::info!(udn = %candidate_udn, "WebRenderer: reusing device from registry (session expired)");
// Mettre à jour le SharedSender de ce device (session supprimée mais device toujours dans registry).
// Le SharedSender et le SharedState sont ceux capturés dans les handlers du di existant.
let effective_sender = if let Some(existing_sender) = ws_state.session_manager.get_sender_by_udn(&full_udn) {
existing_sender.set(ws_sender);
tracing::info!(udn = %full_udn, "WebRenderer: updated SharedSender for reused device");
existing_sender
} else {
// Fallback : ne devrait pas arriver mais on crée un sender neuf
tracing::warn!(udn = %full_udn, "WebRenderer: no SharedSender found for reused device");
let new_state: SharedState = Arc::new(RwLock::new(RendererState::default()));
let (_, new_sender) = WebRendererFactory::create_device_with_name(
&instance_udn, &capabilities.user_agent, ws_sender, new_state.clone(),
).map_err(|e| crate::error::WebRendererError::DeviceCreationError(e.to_string()))?;
new_sender
};
let effective_state = ws_state.session_manager.get_state_by_udn(&full_udn)
.unwrap_or_else(|| Arc::new(RwLock::new(RendererState::default())));
register_with_control_point(&di, ws_state)?;
(di, effective_sender, effective_state)
} else {
// Véritablement première connexion : créer device + state + sender
let new_state: SharedState = Arc::new(RwLock::new(RendererState::default()));
tracing::info!("WebRenderer: creating device model...");
let (device, new_sender) = WebRendererFactory::create_device_with_name(
&instance_udn,
&capabilities.user_agent,
ws_sender,
new_state.clone(),
)
.map_err(|e| crate::error::WebRendererError::DeviceCreationError(e.to_string()))?;
tracing::info!("WebRenderer: device model created");
let device = Arc::new(device);
tracing::info!("WebRenderer: registering new device...");
let di = {
let mut server = server_arc.write().await;
server
.register_device(device)
.await
.map_err(|e| crate::error::WebRendererError::RegistrationError(e.to_string()))?
};
tracing::info!("WebRenderer: device registered");
register_with_control_point(&di, ws_state)?;
(di, new_sender, new_state)
}
};
#[cfg(not(feature = "pmoserver"))]
let (di, shared_sender, shared_state) = {
let new_state: SharedState = Arc::new(RwLock::new(RendererState::default()));
let (device, new_sender) = WebRendererFactory::create_device_with_name(
&instance_udn,
&capabilities.user_agent,
ws_sender,
new_state.clone(),
)
.map_err(|e| crate::error::WebRendererError::DeviceCreationError(e.to_string()))?;
(Arc::new(device).create_instance(), new_sender, new_state)
};
let session = Arc::new(WebRendererSession {
token,
udn: full_udn,
device_instance: di,
shared_sender,
state: shared_state,
created_at: SystemTime::now(),
last_activity: Arc::new(RwLock::new(SystemTime::now())),
});
Ok(session)
}
/// Enregistre le DeviceInstance auprès du ControlPoint comme un renderer
#[cfg(feature = "pmoserver")]
fn register_with_control_point(
di: &Arc<DeviceInstance>,
ws_state: &WebSocketState,
) -> Result<(), crate::error::WebRendererError> {
let base_url = di.base_url().to_string();
let udn = di.udn().to_ascii_lowercase();
// Préfixer avec "uuid:" pour correspondre au format SSDP et éviter les doublons
let udn_with_prefix = format!("uuid:{}", udn);
let device_route = di.route();
let model = di.get_model();
let avtransport_control_url = Some(format!(
"{}{}/service/AVTransport/control",
base_url, device_route
));
let rendering_control_url = Some(format!(
"{}{}/service/RenderingControl/control",
base_url, device_route
));
let connection_manager_url = Some(format!(
"{}{}/service/ConnectionManager/control",
base_url, device_route
));
let renderer_info = pmocontrol::RendererInfo::make(
DeviceId(udn_with_prefix.clone()),
udn_with_prefix.clone(),
model.friendly_name().to_string(),
model.model_name().to_string(),
"PMOMusic".to_string(),
RendererProtocol::UpnpAvOnly,
RendererCapabilities {
has_avtransport: true,
has_avtransport_set_next: true,
has_rendering_control: true,
has_connection_manager: true,
..Default::default()
},
format!("{}{}", base_url, di.description_route()),
"PMOMusic WebRenderer/1.0".to_string(),
Some("urn:schemas-upnp-org:service:AVTransport:1".to_string()),
avtransport_control_url,
Some("urn:schemas-upnp-org:service:RenderingControl:1".to_string()),
rendering_control_url,
Some("urn:schemas-upnp-org:service:ConnectionManager:1".to_string()),
connection_manager_url,
None, // oh_playlist_service_type
None, // oh_playlist_control_url
None, // oh_playlist_event_sub_url
None, // oh_info_service_type
None, // oh_info_control_url
None, // oh_info_event_sub_url
None, // oh_time_service_type
None, // oh_time_control_url
None, // oh_time_event_sub_url
None, // oh_volume_service_type
None, // oh_volume_control_url
None, // oh_radio_service_type
None, // oh_radio_control_url
None, // oh_product_service_type
None, // oh_product_control_url
);
if let Ok(mut registry) = ws_state.control_point.registry().write() {
// max_age élevé car pas de SSDP — cleanup à la déconnexion WS
registry.push_renderer(&renderer_info, 86400);
}
tracing::info!(udn = %udn, "WebRenderer registered with ControlPoint");
Ok(())
}
// ─── Mise à jour des StateVarInstance UPnP ──────────────────────────────────
async fn update_transport_state_var(di: &DeviceInstance, state: &PlaybackState) {
let upnp_state = match state {
PlaybackState::Stopped => "STOPPED",
PlaybackState::Playing => "PLAYING",
PlaybackState::Paused => "PAUSED_PLAYBACK",
PlaybackState::Transitioning => "TRANSITIONING",
};
if let Some(service) = di.get_service("AVTransport") {
if let Some(var) = service.get_variable("TransportState") {
let _ = var
.set_value(StateValue::String(upnp_state.to_string()))
.await;
}
}
}
async fn update_position_vars(di: &DeviceInstance, position: &str, duration: &str) {
if let Some(service) = di.get_service("AVTransport") {
if let Some(var) = service.get_variable("RelativeTimePosition") {
let _ = var
.set_value(StateValue::String(position.to_string()))
.await;
}
if let Some(var) = service.get_variable("AbsoluteTimePosition") {
let _ = var
.set_value(StateValue::String(position.to_string()))
.await;
}
if let Some(var) = service.get_variable("CurrentTrackDuration") {
let _ = var
.set_value(StateValue::String(duration.to_string()))
.await;
}
}
}
async fn update_uri_vars(
di: &DeviceInstance,
current_uri: &str,
current_metadata: &str,
next_uri: &str,
next_metadata: &str,
) {
if let Some(service) = di.get_service("AVTransport") {
if let Some(var) = service.get_variable("AVTransportURI") {
let _ = var
.set_value(StateValue::String(current_uri.to_string()))
.await;
}
if let Some(var) = service.get_variable("AVTransportURIMetaData") {
let _ = var
.set_value(StateValue::String(current_metadata.to_string()))
.await;
}
if let Some(var) = service.get_variable("AVTransportNextURI") {
let _ = var
.set_value(StateValue::String(next_uri.to_string()))
.await;
}
if let Some(var) = service.get_variable("AVTransportNextURIMetaData") {
let _ = var
.set_value(StateValue::String(next_metadata.to_string()))
.await;
}
}
}
async fn update_metadata_var(di: &DeviceInstance, didl: &str) {
if let Some(service) = di.get_service("AVTransport") {
if let Some(var) = service.get_variable("CurrentTrackMetaData") {
let _ = var.set_value(StateValue::String(didl.to_string())).await;
}
}
}
async fn update_volume_vars(di: &DeviceInstance, volume: u16, mute: bool) {
if let Some(service) = di.get_service("RenderingControl") {
if let Some(var) = service.get_variable("Volume") {
let _ = var.set_value(StateValue::UI2(volume)).await;
}
if let Some(var) = service.get_variable("Mute") {
let _ = var.set_value(StateValue::Boolean(mute)).await;
}
}
}
fn build_didl_from_metadata(metadata: &TrackMetadata) -> String {
use pmodidl::{DIDLLite, Item, Resource};
use pmoutils::ToXmlElement;
let item = Item {
id: "0".to_string(),
parent_id: "-1".to_string(),
restricted: Some("1".to_string()),
title: metadata
.title
.clone()
.unwrap_or_else(|| "Unknown".to_string()),
creator: None,
class: "object.item.audioItem.musicTrack".to_string(),
artist: metadata.artist.clone(),
album: metadata.album.clone(),
genre: None,
album_art: metadata.album_art_uri.clone(),
album_art_pk: None,
date: None,
original_track_number: None,
resources: vec![Resource {
protocol_info: "http-get:*:audio/*:*".to_string(),
duration: metadata.duration.clone(),
url: "".to_string(),
bits_per_sample: None,
sample_frequency: None,
nr_audio_channels: None,
}],
descriptions: vec![],
};
let didl = DIDLLite {
items: vec![item],
..Default::default()
};
didl.to_xml()
}

View File

@@ -1 +1 @@
0.3.22
0.3.23