diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index bdb3d47111..ea43b19147 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -351,6 +351,40 @@ "description": "Apply compression to audio (lowers the volume of the loudest parts of the signal and raises the volume of the softest parts)", "name": "Audio Compressor" }, + "audio-stream": { + "description": "Stream audio as Opus/Ogg over HTTP (Icecast-compatible) for external applications", + "menu": { + "port": { + "label": "Port" + }, + "quality-latency": { + "label": "Quality & Latency", + "submenu": { + "bitrate": { + "label": "Bitrate" + }, + "buffer-size": { + "label": "Buffer Size" + }, + "channels": { + "label": "Channels", + "mono": "Mono", + "stereo": "Stereo" + }, + "sample-rate": { + "label": "Sample Rate" + } + } + } + }, + "name": "Audio Stream", + "prompt": { + "port": { + "label": "Enter the port for the audio stream server:\nStream URL: {{streamUrl}}", + "title": "Audio Stream Port" + } + } + }, "auth-proxy-adapter": { "description": "Support for the use of authentication proxy services", "menu": { diff --git a/src/pear-desktop.ts b/src/pear-desktop.ts index b5ace4e1fe..c07e5b7f92 100644 --- a/src/pear-desktop.ts +++ b/src/pear-desktop.ts @@ -39,3 +39,9 @@ declare module '*.css?inline' { export default css; } + +declare module '*.js?raw' { + const javascript: string; + + export default javascript; +} diff --git a/src/plugins/audio-stream/BroadcastStream.ts b/src/plugins/audio-stream/BroadcastStream.ts new file mode 100644 index 0000000000..87885c3cd0 --- /dev/null +++ b/src/plugins/audio-stream/BroadcastStream.ts @@ -0,0 +1,48 @@ +export class BroadcastStream { + private subscribers: Set> = + new Set(); + + // Get a new stream. Any priming pages (e.g. cached Ogg header pages) are + // enqueued first so the subscriber can initialise its decoder before the + // live audio pages arrive. + subscribe(primingPages: Uint8Array[] = []) { + let controller!: ReadableStreamDefaultController; + const subscribers = this.subscribers; + const stream = new ReadableStream({ + start(c) { + controller = c; + for (const page of primingPages) c.enqueue(page); + subscribers.add(c); + }, + cancel() { + subscribers.delete(controller); + }, + }); + + return stream; + } + + // Write data to all readers. + write(chunk: Uint8Array) { + for (const controller of this.subscribers) { + // Drop slow clients whose queue has backed up rather than buffering + // chunks for them unboundedly. + if ((controller.desiredSize ?? 0) <= 0) { + this.subscribers.delete(controller); + continue; + } + try { + controller.enqueue(chunk); + } catch { + this.subscribers.delete(controller); + } + } + } + + close() { + for (const controller of this.subscribers) { + controller.close(); + } + this.subscribers.clear(); + } +} diff --git a/src/plugins/audio-stream/StreamProcessor.js b/src/plugins/audio-stream/StreamProcessor.js new file mode 100644 index 0000000000..be56dcfeb6 --- /dev/null +++ b/src/plugins/audio-stream/StreamProcessor.js @@ -0,0 +1,36 @@ +/* eslint-disable no-undef, typescript/no-unsafe-assignment, typescript/no-unsafe-member-access, typescript/no-unsafe-call */ +class RecorderProcessor extends AudioWorkletProcessor { + constructor(options) { + super(); + const bufferSize = options?.processorOptions?.bufferSize || 4096; + // Prepare an interleaved stereo buffer [L,R,L,R,...] + this.buffer = new Float32Array(bufferSize * 2); + this.bufferIndex = 0; + } + + process(inputs, outputs) { + const input = inputs[0]; + if (input && input[0]) { + const left = input[0]; + const right = input[1] || left; // if mono input, duplicate for right + for (let i = 0; i < left.length; i++) { + this.buffer[this.bufferIndex++] = left[i]; + this.buffer[this.bufferIndex++] = right[i]; + if (this.bufferIndex >= this.buffer.length) { + // Buffer full: send a copy to the main thread + this.port.postMessage(new Float32Array(this.buffer)); + this.bufferIndex = 0; + } + } + } + // Optionally pass the audio through unchanged. outputs[0] can be an empty + // array (no channels connected), so guard each channel before .set(). + if (outputs[0] && inputs[0]) { + if (outputs[0][0] && inputs[0][0]) outputs[0][0].set(inputs[0][0]); + if (outputs[0][1] && inputs[0][1]) outputs[0][1].set(inputs[0][1]); + } + return true; + } +} + +registerProcessor('recorder-processor', RecorderProcessor); diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts new file mode 100644 index 0000000000..7c2f52c119 --- /dev/null +++ b/src/plugins/audio-stream/backend.ts @@ -0,0 +1,135 @@ +import { serve, type ServerType } from '@hono/node-server'; +import { Hono } from 'hono'; +import { stream } from 'hono/streaming'; + +import { registerCallback, type SongInfo } from '@/providers/song-info'; +import { createBackend } from '@/utils'; + +import { BroadcastStream } from './BroadcastStream'; +import { type AudioStreamConfig } from './config'; +import { OggOpusMuxer, OggDechainer } from './ogg-opus'; + +const VENDOR = 'Pear Desktop'; + +let config: AudioStreamConfig; +const broadcast = new BroadcastStream(); + +// Current track metadata (no ffprobe - comes straight from the player). +let currentSong: SongInfo | null = null; + +// Chained Ogg/Opus stream: one logical stream per song. Pages go straight to +// every subscriber; the muxer caches the current stream's header pages for late +// joiners. +const muxer = new OggOpusMuxer((page) => broadcast.write(page)); + +// OpusTags comments for the current track (text only). +function currentComments(): string[] { + const comments: string[] = []; + if (currentSong?.title) comments.push(`TITLE=${currentSong.title}`); + if (currentSong?.artist) comments.push(`ARTIST=${currentSong.artist}`); + if (currentSong?.album) comments.push(`ALBUM=${currentSong.album}`); + return comments.length ? comments : ['TITLE=Pear Desktop']; +} + +export const backend = createBackend< + { + app: Hono; + server?: ServerType; + }, + AudioStreamConfig +>({ + app: new Hono().get('/stream', (ctx) => { + // Per-song TEXT metadata is carried in-band via chained Ogg logical streams + // (OpusTags per track). Some clients can't follow chains - browsers + // (