From f8e60453728dbaaf1da9b4989c52d735daff9bb0 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Thu, 18 Dec 2025 01:54:37 +0700 Subject: [PATCH 01/39] new plugin: audio-stream --- src/plugins/audio-stream/backend.ts | 417 +++++++++++++ src/plugins/audio-stream/config.ts | 24 + src/plugins/audio-stream/index.ts | 20 + src/plugins/audio-stream/menu.ts | 127 ++++ src/plugins/audio-stream/renderer.ts | 427 +++++++++++++ src/plugins/audio-stream/test-gui.html | 816 +++++++++++++++++++++++++ 6 files changed, 1831 insertions(+) create mode 100644 src/plugins/audio-stream/backend.ts create mode 100644 src/plugins/audio-stream/config.ts create mode 100644 src/plugins/audio-stream/index.ts create mode 100644 src/plugins/audio-stream/menu.ts create mode 100644 src/plugins/audio-stream/renderer.ts create mode 100644 src/plugins/audio-stream/test-gui.html diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts new file mode 100644 index 0000000000..0888364c01 --- /dev/null +++ b/src/plugins/audio-stream/backend.ts @@ -0,0 +1,417 @@ +import { createServer, IncomingMessage, ServerResponse } from 'node:http'; +import { ipcMain } from 'electron'; +import { createBackend } from '@/utils'; +import { LoggerPrefix } from '@/utils'; + +import type { AudioStreamConfig } from './config'; +import type { BackendContext } from '@/types/contexts'; + +type ClientInfo = { + response: ServerResponse; + ip: string; + lastActivity: number; +}; + +type BackendType = { + server?: ReturnType; + clients: Map; + context?: BackendContext; + oldConfig?: AudioStreamConfig; + audioConfig?: { + sampleRate: number; + bitDepth: number; + channels: number; + }; + pcmBuffer: Array<{ + metadata: { + timestamp: number; + sampleRate: number; + bitDepth: number; + channels: number; + }; + data: Buffer; + }>; + maxBufferSize: number; + startServer: (config: AudioStreamConfig) => void; + stopServer: () => void; +}; + +export const backend = createBackend({ + clients: new Map(), + audioConfig: undefined, + pcmBuffer: [], + maxBufferSize: 10, // Keep last 10 chunks for new clients + + async start(ctx: BackendContext) { + this.context = ctx; + const config = await ctx.getConfig(); + this.oldConfig = config; + + // Listen for audio configuration + ctx.ipc.on('audio-stream:config', (config: { sampleRate: number; bitDepth: number; channels: number }) => { + const oldConfig = this.audioConfig; + this.audioConfig = config; + console.log( + LoggerPrefix, + `[Audio Stream] Received audio config:`, + config, + ); + + // If config changed and we have clients, broadcast the new config to all existing clients + if (oldConfig && this.clients.size > 0) { + const configJson = JSON.stringify({ + type: 'config', + sampleRate: config.sampleRate, + bitDepth: config.bitDepth, + channels: config.channels, + }); + const configBuffer = Buffer.from(configJson, 'utf-8'); + const configLength = Buffer.allocUnsafe(4); + configLength.writeUInt32BE(configBuffer.length, 0); + + this.clients.forEach((client, clientId) => { + try { + if (client.response.writable && !client.response.destroyed) { + client.response.write(configLength); + client.response.write(configBuffer); + console.log( + LoggerPrefix, + `[Audio Stream] Sent updated config to client ${client.ip}`, + ); + } + } catch (error) { + console.error( + LoggerPrefix, + `[Audio Stream] Error sending updated config to client ${client.ip}:`, + error, + ); + } + }); + } + }); + + // Listen for PCM audio data from renderer + ctx.ipc.on('audio-stream:pcm-data', (data: { metadata: any; data: string }) => { + if (!this.audioConfig) return; + + try { + // Decode base64 to buffer + const pcmBuffer = Buffer.from(data.data, 'base64'); + + const chunk = { + metadata: { + timestamp: data.metadata.timestamp || Date.now(), + sampleRate: this.audioConfig.sampleRate, + bitDepth: this.audioConfig.bitDepth, + channels: this.audioConfig.channels, + }, + data: pcmBuffer, + }; + + // Add to buffer for new clients + this.pcmBuffer.push(chunk); + if (this.pcmBuffer.length > this.maxBufferSize) { + this.pcmBuffer.shift(); + } + + // Send to all connected clients + const clientsToRemove: string[] = []; + + // Pre-compute metadata to avoid repeated JSON.stringify + const metadataJson = JSON.stringify(chunk.metadata); + const metadataBuffer = Buffer.from(metadataJson, 'utf-8'); + const metadataLength = Buffer.allocUnsafe(4); + metadataLength.writeUInt32BE(metadataBuffer.length, 0); + + // Combine all data into single buffer for efficient write (reduces syscalls) + const combinedBuffer = Buffer.concat([metadataLength, metadataBuffer, pcmBuffer]); + + this.clients.forEach((client, clientId) => { + try { + // Check if response is writable to prevent blocking + if (client.response.writable && !client.response.destroyed) { + // Single write call is more efficient than multiple writes + const canWrite = client.response.write(combinedBuffer); + client.lastActivity = Date.now(); + + // Handle backpressure - if write returns false, buffer is full + // Don't remove client, just skip this write to prevent blocking + if (!canWrite) { + // Set up drain handler if not already set + if (!client.response.listenerCount('drain')) { + client.response.once('drain', () => { + // Buffer drained, can continue writing + }); + } + } + } else { + // Response is not writable, mark for removal + clientsToRemove.push(clientId); + } + } catch (error) { + console.error( + LoggerPrefix, + `[Audio Stream] Error sending PCM data to client ${client.ip}:`, + error, + ); + clientsToRemove.push(clientId); + } + }); + + // Remove failed clients + clientsToRemove.forEach((clientId) => { + const client = this.clients.get(clientId); + if (client) { + try { + client.response.end(); + } catch (error) { + // Ignore errors when closing + } + } + this.clients.delete(clientId); + }); + } catch (error) { + console.error( + LoggerPrefix, + '[Audio Stream] Error processing PCM data:', + error, + ); + } + }); + + if (config.enabled) { + this.startServer(config); + } + }, + + stop() { + // Remove IPC listeners + ipcMain.removeAllListeners('audio-stream:config'); + ipcMain.removeAllListeners('audio-stream:pcm-data'); + + this.stopServer(); + }, + + async onConfigChange(config: AudioStreamConfig) { + const wasEnabled = this.oldConfig?.enabled ?? false; + const portChanged = this.oldConfig?.port !== config.port; + const hostnameChanged = this.oldConfig?.hostname !== config.hostname; + + // If port or hostname changed and server is enabled, restart it + if (config.enabled && (portChanged || hostnameChanged || !wasEnabled)) { + this.stopServer(); + this.startServer(config); + } else if (!config.enabled && wasEnabled) { + // If disabled, stop the server + this.stopServer(); + } + + this.oldConfig = config; + }, + + startServer(config: AudioStreamConfig) { + if (this.server) { + this.stopServer(); + } + + const httpServer = createServer((req: IncomingMessage, res: ServerResponse) => { + // Handle CORS + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + res.writeHead(200); + res.end(); + return; + } + + if (req.method !== 'GET' || req.url !== '/stream') { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + return; + } + + const clientIp = req.socket.remoteAddress || 'unknown'; + const clientId = `${clientIp}-${Date.now()}`; + + // Optimize socket for low latency + const socket = req.socket; + if (socket) { + socket.setNoDelay(true); // Disable Nagle's algorithm for lower latency + socket.setKeepAlive(true, 60000); // Keep connection alive + // Increase socket buffer sizes for better throughput (if available) + if ('setReceiveBufferSize' in socket && typeof socket.setReceiveBufferSize === 'function') { + try { + (socket as any).setReceiveBufferSize(1024 * 1024); // 1MB receive buffer + } catch (e) { + // Ignore if not supported + } + } + if ('setSendBufferSize' in socket && typeof socket.setSendBufferSize === 'function') { + try { + (socket as any).setSendBufferSize(1024 * 1024); // 1MB send buffer + } catch (e) { + // Ignore if not supported + } + } + } + + // Set up streaming response + res.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Transfer-Encoding': 'chunked', + 'X-Accel-Buffering': 'no', // Disable buffering for nginx (if used) + }); + + const clientInfo: ClientInfo = { + response: res, + ip: clientIp, + lastActivity: Date.now(), + }; + + this.clients.set(clientId, clientInfo); + + console.log( + LoggerPrefix, + `[Audio Stream] Client connected from ${clientIp}. Total clients: ${this.clients.size}`, + ); + + // Send audio configuration first + if (this.audioConfig) { + const configJson = JSON.stringify({ + type: 'config', + sampleRate: this.audioConfig.sampleRate, + bitDepth: this.audioConfig.bitDepth, + channels: this.audioConfig.channels, + }); + const configBuffer = Buffer.from(configJson, 'utf-8'); + const configLength = Buffer.allocUnsafe(4); + configLength.writeUInt32BE(configBuffer.length, 0); + + try { + res.write(configLength); + res.write(configBuffer); + } catch (error) { + console.error( + LoggerPrefix, + `[Audio Stream] Error sending config to client ${clientIp}:`, + error, + ); + } + } + + // Send buffered chunks to new client + this.pcmBuffer.forEach((chunk) => { + try { + const metadataJson = JSON.stringify(chunk.metadata); + const metadataBuffer = Buffer.from(metadataJson, 'utf-8'); + const metadataLength = Buffer.allocUnsafe(4); + metadataLength.writeUInt32BE(metadataBuffer.length, 0); + + res.write(metadataLength); + res.write(metadataBuffer); + res.write(chunk.data); + } catch (error) { + console.error( + LoggerPrefix, + `[Audio Stream] Error sending buffered chunk to client ${clientIp}:`, + error, + ); + } + }); + + // Handle client disconnect + req.on('close', () => { + this.clients.delete(clientId); + console.log( + LoggerPrefix, + `[Audio Stream] Client disconnected from ${clientIp}. Total clients: ${this.clients.size}`, + ); + }); + + req.on('error', (error: NodeJS.ErrnoException) => { + // Ignore ECONNRESET and EPIPE errors (common when client disconnects) + if (error.code !== 'ECONNRESET' && error.code !== 'EPIPE') { + console.error( + LoggerPrefix, + `[Audio Stream] Error from client ${clientIp}:`, + error, + ); + } + this.clients.delete(clientId); + }); + + res.on('error', (error: NodeJS.ErrnoException) => { + // Ignore ECONNRESET and EPIPE errors + if (error.code !== 'ECONNRESET' && error.code !== 'EPIPE') { + console.error( + LoggerPrefix, + `[Audio Stream] Response error from client ${clientIp}:`, + error, + ); + } + this.clients.delete(clientId); + }); + }); + + httpServer.listen(config.port, config.hostname, () => { + console.log( + LoggerPrefix, + `[Audio Stream] PCM streaming server listening on http://${config.hostname}:${config.port}/stream`, + ); + }); + + httpServer.on('error', (error: NodeJS.ErrnoException) => { + console.error( + LoggerPrefix, + `[Audio Stream] Server error on ${config.hostname}:${config.port}:`, + error.message, + ); + // If port is in use, log a helpful message + if (error.code === 'EADDRINUSE') { + console.error( + LoggerPrefix, + `[Audio Stream] Port ${config.port} is already in use. Please choose a different port.`, + ); + } + }); + + this.server = httpServer; + }, + + stopServer() { + // Close all client connections + if (this.clients.size > 0) { + this.clients.forEach((client) => { + try { + client.response.end(); + } catch (error) { + // Ignore errors when closing + } + }); + this.clients.clear(); + } + + if (this.server) { + this.server.close((error) => { + if (error) { + console.error( + LoggerPrefix, + '[Audio Stream] Error closing server:', + error, + ); + } else { + console.log(LoggerPrefix, '[Audio Stream] HTTP server stopped'); + } + }); + this.server = undefined; + } + + // Clear buffers + this.pcmBuffer = []; + this.audioConfig = undefined; + }, +}); diff --git a/src/plugins/audio-stream/config.ts b/src/plugins/audio-stream/config.ts new file mode 100644 index 0000000000..a4f319a07d --- /dev/null +++ b/src/plugins/audio-stream/config.ts @@ -0,0 +1,24 @@ +export interface AudioStreamConfig { + enabled: boolean; + port: number; + hostname: string; + // Audio quality settings for PCM streaming + sampleRate: number; // Audio sample rate (e.g., 44100, 48000, 96000) + bitDepth: number; // Bit depth (16 or 32) + channels: number; // Number of channels (1 = mono, 2 = stereo) + bufferSize: number; // Audio buffer size (1024, 2048, 4096, 8192) - affects latency +} + +export const defaultAudioStreamConfig: AudioStreamConfig = { + enabled: false, + port: 8765, + hostname: '0.0.0.0', + // High quality audio settings for local network + // Using 48kHz/16-bit for stability - can increase to 96kHz/32-bit once working + sampleRate: 48000, // 48kHz - high quality and widely supported + bitDepth: 16, // 16-bit - reliable and well-tested + channels: 2, // Stereo + bufferSize: 2048, // Low latency buffer size +}; + + diff --git a/src/plugins/audio-stream/index.ts b/src/plugins/audio-stream/index.ts new file mode 100644 index 0000000000..ca213c11e0 --- /dev/null +++ b/src/plugins/audio-stream/index.ts @@ -0,0 +1,20 @@ +import { t } from '@/i18n'; +import { createPlugin } from '@/utils'; +import { backend } from './backend'; +import { renderer } from './renderer'; +import { onMenu } from './menu'; +import { defaultAudioStreamConfig } from './config'; +import type { AudioStreamConfig } from './config'; + +export default createPlugin({ + name: () => t('plugins.audio-stream.name'), + description: () => t('plugins.audio-stream.description'), + restartNeeded: false, + config: defaultAudioStreamConfig as AudioStreamConfig, + backend, + renderer, + menu: onMenu, +}); + + + diff --git a/src/plugins/audio-stream/menu.ts b/src/plugins/audio-stream/menu.ts new file mode 100644 index 0000000000..264e56946a --- /dev/null +++ b/src/plugins/audio-stream/menu.ts @@ -0,0 +1,127 @@ +import prompt from 'custom-electron-prompt'; + +import { t } from '@/i18n'; +import promptOptions from '@/providers/prompt-options'; + +import { + type AudioStreamConfig, + defaultAudioStreamConfig, +} from './config'; + +import type { MenuContext } from '@/types/contexts'; +import type { MenuTemplate } from '@/menu'; + +// Quality and latency presets +const SAMPLE_RATES = [44100, 48000, 96000]; +const BIT_DEPTHS = [16, 32]; +const CHANNELS = [1, 2]; +const BUFFER_SIZES = [1024, 2048, 4096, 8192]; + +export const onMenu = async ({ + getConfig, + setConfig, + window, +}: MenuContext): Promise => { + const config = await getConfig(); + + return [ + { + label: t('plugins.audio-stream.menu.port.label'), + type: 'normal', + async click() { + const config = await getConfig(); + + const currentPort = config.port || defaultAudioStreamConfig.port; + const streamUrl = `http://localhost:${currentPort}/stream`; + + const newPort = + (await prompt( + { + title: t('plugins.audio-stream.prompt.port.title'), + label: t('plugins.audio-stream.prompt.port.label', { + streamUrl, + }), + value: config.port, + type: 'counter', + counterOptions: { minimum: 1, maximum: 65535 }, + width: 450, + ...promptOptions(), + }, + window, + )) ?? + config.port ?? + defaultAudioStreamConfig.port; + + if (newPort !== config.port) { + await setConfig({ ...config, port: newPort }); + } + }, + }, + { + label: t('plugins.audio-stream.menu.quality-latency.label'), + type: 'submenu', + submenu: [ + { + label: t('plugins.audio-stream.menu.quality-latency.submenu.sample-rate.label'), + type: 'submenu', + submenu: SAMPLE_RATES.map((sampleRate) => ({ + label: `${sampleRate} Hz`, + type: 'radio' as const, + checked: config.sampleRate === sampleRate, + async click() { + const currentConfig = await getConfig(); + if (currentConfig.sampleRate !== sampleRate) { + await setConfig({ ...currentConfig, sampleRate }); + } + }, + })), + }, + { + label: t('plugins.audio-stream.menu.quality-latency.submenu.bit-depth.label'), + type: 'submenu', + submenu: BIT_DEPTHS.map((bitDepth) => ({ + label: `${bitDepth}-bit`, + type: 'radio' as const, + checked: config.bitDepth === bitDepth, + async click() { + const currentConfig = await getConfig(); + if (currentConfig.bitDepth !== bitDepth) { + await setConfig({ ...currentConfig, bitDepth }); + } + }, + })), + }, + { + label: t('plugins.audio-stream.menu.quality-latency.submenu.channels.label'), + type: 'submenu', + submenu: CHANNELS.map((channels) => ({ + label: channels === 1 ? t('plugins.audio-stream.menu.quality-latency.submenu.channels.mono') : t('plugins.audio-stream.menu.quality-latency.submenu.channels.stereo'), + type: 'radio' as const, + checked: config.channels === channels, + async click() { + const currentConfig = await getConfig(); + if (currentConfig.channels !== channels) { + await setConfig({ ...currentConfig, channels }); + } + }, + })), + }, + { + label: t('plugins.audio-stream.menu.quality-latency.submenu.buffer-size.label'), + type: 'submenu', + submenu: BUFFER_SIZES.map((bufferSize) => ({ + label: `${bufferSize} samples`, + type: 'radio' as const, + checked: config.bufferSize === bufferSize, + async click() { + const currentConfig = await getConfig(); + if (currentConfig.bufferSize !== bufferSize) { + await setConfig({ ...currentConfig, bufferSize }); + } + }, + })), + }, + ], + }, + ]; +}; diff --git a/src/plugins/audio-stream/renderer.ts b/src/plugins/audio-stream/renderer.ts new file mode 100644 index 0000000000..ebd8dce07b --- /dev/null +++ b/src/plugins/audio-stream/renderer.ts @@ -0,0 +1,427 @@ +import { createRenderer } from '@/utils'; +import type { MusicPlayer } from '@/types/music-player'; +import type { RendererContext } from '@/types/contexts'; +import type { AudioStreamConfig } from './config'; + +type ProcessingQueueItem = { + buffer: Int16Array | Int32Array; + metadata: { + timestamp: number; + sampleRate: number; + bitDepth: number; + channels: number; + }; +}; + +type RendererProperties = { + audioContext?: AudioContext; + audioSource?: AudioNode; + scriptProcessor?: ScriptProcessorNode; + config?: AudioStreamConfig; + context?: RendererContext; + isStreaming: boolean; + batchBuffer: Int16Array | Int32Array | null; + batchCount: number; + processingQueue: ProcessingQueueItem[]; + isProcessing: boolean; + startStreaming: (audioContext: AudioContext, audioSource: AudioNode) => void; +}; + +export const renderer = createRenderer({ + isStreaming: false, + batchBuffer: null, + batchCount: 0, + processingQueue: [], + isProcessing: false, + + async onPlayerApiReady(_: MusicPlayer, context: RendererContext) { + this.context = context; + this.config = await context.getConfig(); + + if (!this.config.enabled) { + return; + } + + // Wait for audio to be ready + document.addEventListener( + 'peard:audio-can-play', + (e) => { + this.startStreaming(e.detail.audioContext, e.detail.audioSource); + }, + { once: true, passive: true }, + ); + }, + + startStreaming( + audioContext: AudioContext, + audioSource: AudioNode, + ) { + if (this.isStreaming || !this.context) { + return; + } + + this.audioContext = audioContext; + this.audioSource = audioSource; + + // Get fresh config to ensure we have the latest values + const config = this.config!; + // Use the actual AudioContext sample rate, not config (audio might be resampled) + const sampleRate = audioContext.sampleRate; + const bitDepth = config.bitDepth || 16; + // Use actual number of channels from the audio source + const channels = config.channels || 2; + // Use buffer size from config + const bufferSize = config.bufferSize || 2048; + + // Send audio configuration to backend + this.context.ipc.send('audio-stream:config', { + sampleRate, + bitDepth, + channels, + }); + + // Create ScriptProcessorNode for PCM capture + // NOTE: ScriptProcessorNode is deprecated and can cause timing issues/crackling. + // For best results, consider migrating to AudioWorkletNode in the future. + // Use buffer size from config for latency control + const scriptProcessor = audioContext.createScriptProcessor(bufferSize, channels, channels); + + // No batching - send immediately to minimize latency + // Base64 encoding is deferred to async queue to prevent blocking + this.batchBuffer = null; + this.batchCount = 0; + + // Reset processing queue + this.processingQueue = []; + this.isProcessing = false; + + // Optimized base64 conversion - process in chunks to avoid stack overflow + const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; // 32KB chunks + let binary = ''; + + // Process in chunks to avoid call stack overflow with spread operator + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length)); + // Use apply with Array.from for better performance and stack safety + binary += String.fromCharCode.apply(null, Array.from(chunk)); + } + return btoa(binary); + }; + + // Process queue with limited items per tick to prevent blocking + // Increased queue size to handle bursts better + const MAX_QUEUE_SIZE = 16; // Increased from 8 to handle bursts + const ITEMS_PER_TICK = 2; // Process 2 items per tick for better throughput + + const processQueue = () => { + if (this.isProcessing || this.processingQueue.length === 0 || !this.context) { + return; + } + + this.isProcessing = true; + + // Process multiple items per tick to keep up with audio callback rate + let itemsProcessed = 0; + const processBatch = () => { + while (itemsProcessed < ITEMS_PER_TICK && this.processingQueue.length > 0 && this.context) { + const item = this.processingQueue.shift(); + if (!item) break; + + try { + // Convert to regular ArrayBuffer (handle SharedArrayBuffer case) + let buffer: ArrayBuffer; + if (item.buffer.buffer instanceof SharedArrayBuffer) { + buffer = new ArrayBuffer(item.buffer.buffer.byteLength); + new Uint8Array(buffer).set(new Uint8Array(item.buffer.buffer)); + } else { + buffer = item.buffer.buffer; + } + + // Base64 encoding happens outside audio callback + const pcmDataBase64 = arrayBufferToBase64(buffer); + + // Send PCM data to backend + this.context!.ipc.send('audio-stream:pcm-data', { + metadata: item.metadata, + data: pcmDataBase64, + }); + + itemsProcessed++; + } catch (error) { + console.error('[Audio Stream] Error processing queue item:', error); + itemsProcessed++; + } + } + + this.isProcessing = false; + + // Schedule next batch if queue still has items + if (this.processingQueue.length > 0) { + // Use immediate microtask for low latency when queue is small + // Use setTimeout(0) for larger queues to prevent blocking + if (this.processingQueue.length > 8) { + setTimeout(processQueue, 0); + } else { + queueMicrotask(processQueue); + } + } + }; + + // Start processing immediately + processBatch(); + }; + + scriptProcessor.onaudioprocess = (event) => { + if (!this.isStreaming) { + return; + } + + const inputBuffer = event.inputBuffer; + const numberOfChannels = inputBuffer.numberOfChannels; + const length = inputBuffer.length; + + // Convert Float32Array to PCM - optimized conversion + let pcmArray: Int16Array | Int32Array; + + if (bitDepth === 32) { + // 32-bit PCM: convert float32 (-1.0 to 1.0) to int32 (-2147483648 to 2147483647) + const pcm32 = new Int32Array(length * numberOfChannels); + const MAX_INT32 = 2147483647; + + // Optimized loop - process channels interleaved + for (let channel = 0; channel < numberOfChannels; channel++) { + const channelData = inputBuffer.getChannelData(channel); + for (let i = 0; i < length; i++) { + const sample = channelData[i]; + // Clamp to [-1, 1] range and convert to int32 + // Use MAX_INT32 for scaling (2147483647), clamp result to valid int32 range + const clamped = sample < -1 ? -1 : sample > 1 ? 1 : sample; + const scaled = Math.round(clamped * MAX_INT32); + // Clamp to int32 range + pcm32[i * numberOfChannels + channel] = scaled < -2147483648 + ? -2147483648 + : scaled > MAX_INT32 + ? MAX_INT32 + : scaled; + } + } + pcmArray = pcm32; + } else { + // 16-bit PCM: highly optimized conversion + const pcm16 = new Int16Array(length * numberOfChannels); + const MAX_INT16 = 0x7FFF; + + // Optimize for common case (stereo) + if (numberOfChannels === 2) { + const leftChannel = inputBuffer.getChannelData(0); + const rightChannel = inputBuffer.getChannelData(1); + + for (let i = 0; i < length; i++) { + // Clamp and convert with minimal branching + const left = leftChannel[i]; + const right = rightChannel[i]; + + // Fast clamp: Math.max(-1, Math.min(1, sample)) + const leftClamped = left < -1 ? -1 : left > 1 ? 1 : left; + const rightClamped = right < -1 ? -1 : right > 1 ? 1 : right; + + // Convert to int16 (interleaved: L, R, L, R, ...) + pcm16[i * 2] = Math.round(leftClamped * MAX_INT16); + pcm16[i * 2 + 1] = Math.round(rightClamped * MAX_INT16); + } + } else { + // Generic case for mono or other channel counts + for (let channel = 0; channel < numberOfChannels; channel++) { + const channelData = inputBuffer.getChannelData(channel); + for (let i = 0; i < length; i++) { + const sample = channelData[i]; + const clamped = sample < -1 ? -1 : sample > 1 ? 1 : sample; + pcm16[i * numberOfChannels + channel] = Math.round(clamped * MAX_INT16); + } + } + } + pcmArray = pcm16; + } + + // Queue immediately for async processing (don't block audio callback) + // No batching to minimize latency - each buffer is sent immediately + if (this.context) { + // Drop oldest items if queue is too long to prevent buildup and stuttering + // More aggressive dropping to prevent queue buildup + while (this.processingQueue.length >= MAX_QUEUE_SIZE) { + // Remove oldest items (FIFO) until we have room + this.processingQueue.shift(); + } + + this.processingQueue.push({ + buffer: pcmArray, + metadata: { + timestamp: Date.now(), + sampleRate, + bitDepth, + channels: numberOfChannels, + }, + }); + + // Trigger async processing if not already running + // Use queueMicrotask for immediate processing with minimal delay + if (!this.isProcessing) { + queueMicrotask(processQueue); + } + } + }; + + // Connect audio source to script processor, then to destination + audioSource.connect(scriptProcessor); + scriptProcessor.connect(audioContext.destination); + + this.scriptProcessor = scriptProcessor; + this.isStreaming = true; + + console.log( + '[Audio Stream] Started PCM streaming:', + `${sampleRate}Hz, ${bitDepth}-bit, ${channels} channel(s)`, + ); + }, + + stop() { + this.isStreaming = false; + + // Clear processing queue to prevent sending stale data + this.processingQueue = []; + this.isProcessing = false; + + // Flush any remaining batched data + if (this.batchBuffer && this.batchBuffer.length > 0 && this.context) { + try { + // Optimized base64 conversion + const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; // 32KB chunks + let binary = ''; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + binary += String.fromCharCode.apply(null, Array.from(chunk)); + } + return btoa(binary); + }; + + let buffer: ArrayBuffer; + if (this.batchBuffer.buffer instanceof SharedArrayBuffer) { + buffer = new ArrayBuffer(this.batchBuffer.buffer.byteLength); + new Uint8Array(buffer).set(new Uint8Array(this.batchBuffer.buffer)); + } else { + buffer = this.batchBuffer.buffer; + } + const pcmDataBase64 = arrayBufferToBase64(buffer); + this.context.ipc.send('audio-stream:pcm-data', { + metadata: { + timestamp: Date.now(), + sampleRate: this.config?.sampleRate || 48000, + bitDepth: this.config?.bitDepth || 16, + channels: 2, + }, + data: pcmDataBase64, + }); + } catch (error) { + // Ignore flush errors + } + this.batchBuffer = null; + this.batchCount = 0; + } + + if (this.scriptProcessor) { + try { + this.scriptProcessor.disconnect(); + } catch (error) { + // Ignore disconnect errors + } + this.scriptProcessor = undefined; + } + + this.audioContext = undefined; + this.audioSource = undefined; + }, + + async onConfigChange(config: AudioStreamConfig) { + const wasEnabled = this.config?.enabled; + const oldBitDepth = this.config?.bitDepth; + const oldChannels = this.config?.channels; + const oldBufferSize = this.config?.bufferSize; + + // Check if quality/latency settings changed + const qualityChanged = + oldBitDepth !== config.bitDepth || + oldChannels !== config.channels || + oldBufferSize !== config.bufferSize; + + this.config = config; + + if (config.enabled && !wasEnabled) { + // Wait for audio to be ready if not already streaming + if (!this.isStreaming && this.audioContext && this.audioSource) { + // Already have audio context, start immediately + this.startStreaming(this.audioContext, this.audioSource); + } else if (!this.isStreaming) { + // Wait for audio to be ready + document.addEventListener( + 'peard:audio-can-play', + (e) => { + this.startStreaming(e.detail.audioContext, e.detail.audioSource); + }, + { once: true, passive: true }, + ); + } + } else if (!config.enabled && wasEnabled) { + // Stop streaming + this.isStreaming = false; + + if (this.scriptProcessor) { + try { + this.scriptProcessor.disconnect(); + } catch (error) { + // Ignore disconnect errors + } + this.scriptProcessor = undefined; + } + + this.audioContext = undefined; + this.audioSource = undefined; + } else if (config.enabled && wasEnabled && qualityChanged && this.isStreaming) { + // Quality/latency settings changed while streaming - restart with new settings + if (this.audioContext && this.audioSource) { + // Stop current streaming + this.isStreaming = false; + + // Clear processing queue to prevent sending stale data + this.processingQueue = []; + this.isProcessing = false; + + // Store references before cleanup + const audioContext = this.audioContext; + const audioSource = this.audioSource; + + if (this.scriptProcessor) { + try { + this.scriptProcessor.disconnect(); + } catch (error) { + // Ignore disconnect errors + } + this.scriptProcessor = undefined; + } + + // Use requestAnimationFrame to ensure cleanup is complete before restarting + requestAnimationFrame(() => { + // Double-check we're not streaming and have valid references + if (audioContext && audioSource && !this.isStreaming && this.context) { + // Restart with new settings - this will send new config to backend + this.startStreaming(audioContext, audioSource); + } + }); + } + } + }, +}); + diff --git a/src/plugins/audio-stream/test-gui.html b/src/plugins/audio-stream/test-gui.html new file mode 100644 index 0000000000..5f1817a370 --- /dev/null +++ b/src/plugins/audio-stream/test-gui.html @@ -0,0 +1,816 @@ + + + + + + PCM Audio Stream Test GUI + + + +
+

🎵 PCM Audio Stream Test

+

Test the audio streaming plugin with real-time PCM playback

+ +
+
+ + Disconnected +
+ +
+ + +
+ +
+ + +
+
+ + + +
+
📝 Log
+
+
+
+ + + + + From f44a1f0a3e856a606909c7043919bba784e4d773 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 11:46:26 +0700 Subject: [PATCH 02/39] fix audio-stream --- src/i18n/resources/en.json | 34 +++++++++++++++++++++++++++++++ src/plugins/audio-stream/index.ts | 2 -- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index 74a4dc8a87..160a6d7182 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -341,6 +341,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 PCM audio data over HTTP for external processing or recording", + "menu": { + "port": { + "label": "Port" + }, + "quality-latency": { + "label": "Quality & Latency", + "submenu": { + "bit-depth": { + "label": "Bit Depth" + }, + "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/plugins/audio-stream/index.ts b/src/plugins/audio-stream/index.ts index ca213c11e0..c120df7a3d 100644 --- a/src/plugins/audio-stream/index.ts +++ b/src/plugins/audio-stream/index.ts @@ -16,5 +16,3 @@ export default createPlugin({ menu: onMenu, }); - - From 1e8b093c0da3360315963c71683cbc499179ea3b Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:00:12 +0700 Subject: [PATCH 03/39] fix audio-stream --- src/i18n/resources/en.json | 2 +- src/plugins/audio-stream/index.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index 160a6d7182..f55cb8975b 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -342,7 +342,7 @@ "name": "Audio Compressor" }, "audio-stream": { - "description": "Stream PCM audio data over HTTP for external processing or recording", + "description": "Stream audio as PCM data over HTTP for external applications", "menu": { "port": { "label": "Port" diff --git a/src/plugins/audio-stream/index.ts b/src/plugins/audio-stream/index.ts index c120df7a3d..ca213c11e0 100644 --- a/src/plugins/audio-stream/index.ts +++ b/src/plugins/audio-stream/index.ts @@ -16,3 +16,5 @@ export default createPlugin({ menu: onMenu, }); + + From 70673db8647b40abff9a4125e6105e628ea1cf30 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:10:17 +0700 Subject: [PATCH 04/39] fix audio-stream --- src/plugins/audio-stream/backend.ts | 36 ++++++++------- src/plugins/audio-stream/index.ts | 8 ++-- src/plugins/audio-stream/menu.ts | 8 ++-- src/plugins/audio-stream/renderer.ts | 67 +++++++++++++++------------- 4 files changed, 64 insertions(+), 55 deletions(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index 0888364c01..80ddf770fe 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -1,11 +1,13 @@ import { createServer, IncomingMessage, ServerResponse } from 'node:http'; + import { ipcMain } from 'electron'; -import { createBackend } from '@/utils'; -import { LoggerPrefix } from '@/utils'; -import type { AudioStreamConfig } from './config'; +import { createBackend, LoggerPrefix } from '@/utils'; + import type { BackendContext } from '@/types/contexts'; +import type { AudioStreamConfig } from './config'; + type ClientInfo = { response: ServerResponse; ip: string; @@ -56,7 +58,7 @@ export const backend = createBackend({ `[Audio Stream] Received audio config:`, config, ); - + // If config changed and we have clients, broadcast the new config to all existing clients if (oldConfig && this.clients.size > 0) { const configJson = JSON.stringify({ @@ -68,7 +70,7 @@ export const backend = createBackend({ const configBuffer = Buffer.from(configJson, 'utf-8'); const configLength = Buffer.allocUnsafe(4); configLength.writeUInt32BE(configBuffer.length, 0); - + this.clients.forEach((client, clientId) => { try { if (client.response.writable && !client.response.destroyed) { @@ -97,7 +99,7 @@ export const backend = createBackend({ try { // Decode base64 to buffer const pcmBuffer = Buffer.from(data.data, 'base64'); - + const chunk = { metadata: { timestamp: data.metadata.timestamp || Date.now(), @@ -116,7 +118,7 @@ export const backend = createBackend({ // Send to all connected clients const clientsToRemove: string[] = []; - + // Pre-compute metadata to avoid repeated JSON.stringify const metadataJson = JSON.stringify(chunk.metadata); const metadataBuffer = Buffer.from(metadataJson, 'utf-8'); @@ -133,7 +135,7 @@ export const backend = createBackend({ // Single write call is more efficient than multiple writes const canWrite = client.response.write(combinedBuffer); client.lastActivity = Date.now(); - + // Handle backpressure - if write returns false, buffer is full // Don't remove client, just skip this write to prevent blocking if (!canWrite) { @@ -164,7 +166,7 @@ export const backend = createBackend({ if (client) { try { client.response.end(); - } catch (error) { + } catch { // Ignore errors when closing } } @@ -188,15 +190,15 @@ export const backend = createBackend({ // Remove IPC listeners ipcMain.removeAllListeners('audio-stream:config'); ipcMain.removeAllListeners('audio-stream:pcm-data'); - + this.stopServer(); }, - async onConfigChange(config: AudioStreamConfig) { + onConfigChange(config: AudioStreamConfig) { const wasEnabled = this.oldConfig?.enabled ?? false; const portChanged = this.oldConfig?.port !== config.port; const hostnameChanged = this.oldConfig?.hostname !== config.hostname; - + // If port or hostname changed and server is enabled, restart it if (config.enabled && (portChanged || hostnameChanged || !wasEnabled)) { this.stopServer(); @@ -205,7 +207,7 @@ export const backend = createBackend({ // If disabled, stop the server this.stopServer(); } - + this.oldConfig = config; }, @@ -244,14 +246,14 @@ export const backend = createBackend({ if ('setReceiveBufferSize' in socket && typeof socket.setReceiveBufferSize === 'function') { try { (socket as any).setReceiveBufferSize(1024 * 1024); // 1MB receive buffer - } catch (e) { + } catch { // Ignore if not supported } } if ('setSendBufferSize' in socket && typeof socket.setSendBufferSize === 'function') { try { (socket as any).setSendBufferSize(1024 * 1024); // 1MB send buffer - } catch (e) { + } catch { // Ignore if not supported } } @@ -290,7 +292,7 @@ export const backend = createBackend({ const configBuffer = Buffer.from(configJson, 'utf-8'); const configLength = Buffer.allocUnsafe(4); configLength.writeUInt32BE(configBuffer.length, 0); - + try { res.write(configLength); res.write(configBuffer); @@ -310,7 +312,7 @@ export const backend = createBackend({ const metadataBuffer = Buffer.from(metadataJson, 'utf-8'); const metadataLength = Buffer.allocUnsafe(4); metadataLength.writeUInt32BE(metadataBuffer.length, 0); - + res.write(metadataLength); res.write(metadataBuffer); res.write(chunk.data); diff --git a/src/plugins/audio-stream/index.ts b/src/plugins/audio-stream/index.ts index ca213c11e0..c1c08ec977 100644 --- a/src/plugins/audio-stream/index.ts +++ b/src/plugins/audio-stream/index.ts @@ -1,9 +1,11 @@ import { t } from '@/i18n'; import { createPlugin } from '@/utils'; + +import { defaultAudioStreamConfig } from './config'; import { backend } from './backend'; -import { renderer } from './renderer'; import { onMenu } from './menu'; -import { defaultAudioStreamConfig } from './config'; +import { renderer } from './renderer'; + import type { AudioStreamConfig } from './config'; export default createPlugin({ @@ -16,5 +18,3 @@ export default createPlugin({ menu: onMenu, }); - - diff --git a/src/plugins/audio-stream/menu.ts b/src/plugins/audio-stream/menu.ts index 264e56946a..8be990eb6b 100644 --- a/src/plugins/audio-stream/menu.ts +++ b/src/plugins/audio-stream/menu.ts @@ -3,14 +3,14 @@ import prompt from 'custom-electron-prompt'; import { t } from '@/i18n'; import promptOptions from '@/providers/prompt-options'; +import type { MenuContext } from '@/types/contexts'; +import type { MenuTemplate } from '@/menu'; + import { type AudioStreamConfig, defaultAudioStreamConfig, } from './config'; -import type { MenuContext } from '@/types/contexts'; -import type { MenuTemplate } from '@/menu'; - // Quality and latency presets const SAMPLE_RATES = [44100, 48000, 96000]; const BIT_DEPTHS = [16, 32]; @@ -33,7 +33,7 @@ export const onMenu = async ({ const currentPort = config.port || defaultAudioStreamConfig.port; const streamUrl = `http://localhost:${currentPort}/stream`; - + const newPort = (await prompt( { diff --git a/src/plugins/audio-stream/renderer.ts b/src/plugins/audio-stream/renderer.ts index ebd8dce07b..fece07797e 100644 --- a/src/plugins/audio-stream/renderer.ts +++ b/src/plugins/audio-stream/renderer.ts @@ -1,6 +1,8 @@ import { createRenderer } from '@/utils'; -import type { MusicPlayer } from '@/types/music-player'; + import type { RendererContext } from '@/types/contexts'; +import type { MusicPlayer } from '@/types/music-player'; + import type { AudioStreamConfig } from './config'; type ProcessingQueueItem = { @@ -84,13 +86,17 @@ export const renderer = createRenderer({ // NOTE: ScriptProcessorNode is deprecated and can cause timing issues/crackling. // For best results, consider migrating to AudioWorkletNode in the future. // Use buffer size from config for latency control - const scriptProcessor = audioContext.createScriptProcessor(bufferSize, channels, channels); - + const scriptProcessor = audioContext.createScriptProcessor( + bufferSize, + channels, + channels, + ); + // No batching - send immediately to minimize latency // Base64 encoding is deferred to async queue to prevent blocking this.batchBuffer = null; this.batchCount = 0; - + // Reset processing queue this.processingQueue = []; this.isProcessing = false; @@ -100,7 +106,7 @@ export const renderer = createRenderer({ const bytes = new Uint8Array(buffer); const chunkSize = 0x8000; // 32KB chunks let binary = ''; - + // Process in chunks to avoid call stack overflow with spread operator for (let i = 0; i < bytes.length; i += chunkSize) { const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length)); @@ -114,14 +120,14 @@ export const renderer = createRenderer({ // Increased queue size to handle bursts better const MAX_QUEUE_SIZE = 16; // Increased from 8 to handle bursts const ITEMS_PER_TICK = 2; // Process 2 items per tick for better throughput - + const processQueue = () => { if (this.isProcessing || this.processingQueue.length === 0 || !this.context) { return; } this.isProcessing = true; - + // Process multiple items per tick to keep up with audio callback rate let itemsProcessed = 0; const processBatch = () => { @@ -138,7 +144,7 @@ export const renderer = createRenderer({ } else { buffer = item.buffer.buffer; } - + // Base64 encoding happens outside audio callback const pcmDataBase64 = arrayBufferToBase64(buffer); @@ -154,9 +160,9 @@ export const renderer = createRenderer({ itemsProcessed++; } } - + this.isProcessing = false; - + // Schedule next batch if queue still has items if (this.processingQueue.length > 0) { // Use immediate microtask for low latency when queue is small @@ -168,7 +174,7 @@ export const renderer = createRenderer({ } } }; - + // Start processing immediately processBatch(); }; @@ -189,7 +195,7 @@ export const renderer = createRenderer({ // 32-bit PCM: convert float32 (-1.0 to 1.0) to int32 (-2147483648 to 2147483647) const pcm32 = new Int32Array(length * numberOfChannels); const MAX_INT32 = 2147483647; - + // Optimized loop - process channels interleaved for (let channel = 0; channel < numberOfChannels; channel++) { const channelData = inputBuffer.getChannelData(channel); @@ -200,19 +206,20 @@ export const renderer = createRenderer({ const clamped = sample < -1 ? -1 : sample > 1 ? 1 : sample; const scaled = Math.round(clamped * MAX_INT32); // Clamp to int32 range - pcm32[i * numberOfChannels + channel] = scaled < -2147483648 - ? -2147483648 - : scaled > MAX_INT32 - ? MAX_INT32 - : scaled; + pcm32[i * numberOfChannels + channel] = + scaled < -2147483648 + ? -2147483648 + : scaled > MAX_INT32 + ? MAX_INT32 + : scaled; } } pcmArray = pcm32; } else { // 16-bit PCM: highly optimized conversion const pcm16 = new Int16Array(length * numberOfChannels); - const MAX_INT16 = 0x7FFF; - + const MAX_INT16 = 0x7fff; + // Optimize for common case (stereo) if (numberOfChannels === 2) { const leftChannel = inputBuffer.getChannelData(0); @@ -222,7 +229,7 @@ export const renderer = createRenderer({ // Clamp and convert with minimal branching const left = leftChannel[i]; const right = rightChannel[i]; - + // Fast clamp: Math.max(-1, Math.min(1, sample)) const leftClamped = left < -1 ? -1 : left > 1 ? 1 : left; const rightClamped = right < -1 ? -1 : right > 1 ? 1 : right; @@ -254,7 +261,7 @@ export const renderer = createRenderer({ // Remove oldest items (FIFO) until we have room this.processingQueue.shift(); } - + this.processingQueue.push({ buffer: pcmArray, metadata: { @@ -325,7 +332,7 @@ export const renderer = createRenderer({ }, data: pcmDataBase64, }); - } catch (error) { + } catch { // Ignore flush errors } this.batchBuffer = null; @@ -335,7 +342,7 @@ export const renderer = createRenderer({ if (this.scriptProcessor) { try { this.scriptProcessor.disconnect(); - } catch (error) { + } catch { // Ignore disconnect errors } this.scriptProcessor = undefined; @@ -345,18 +352,18 @@ export const renderer = createRenderer({ this.audioSource = undefined; }, - async onConfigChange(config: AudioStreamConfig) { + onConfigChange(config: AudioStreamConfig) { const wasEnabled = this.config?.enabled; const oldBitDepth = this.config?.bitDepth; const oldChannels = this.config?.channels; const oldBufferSize = this.config?.bufferSize; - + // Check if quality/latency settings changed - const qualityChanged = + const qualityChanged = oldBitDepth !== config.bitDepth || oldChannels !== config.channels || oldBufferSize !== config.bufferSize; - + this.config = config; if (config.enabled && !wasEnabled) { @@ -381,7 +388,7 @@ export const renderer = createRenderer({ if (this.scriptProcessor) { try { this.scriptProcessor.disconnect(); - } catch (error) { + } catch { // Ignore disconnect errors } this.scriptProcessor = undefined; @@ -394,7 +401,7 @@ export const renderer = createRenderer({ if (this.audioContext && this.audioSource) { // Stop current streaming this.isStreaming = false; - + // Clear processing queue to prevent sending stale data this.processingQueue = []; this.isProcessing = false; @@ -411,7 +418,7 @@ export const renderer = createRenderer({ } this.scriptProcessor = undefined; } - + // Use requestAnimationFrame to ensure cleanup is complete before restarting requestAnimationFrame(() => { // Double-check we're not streaming and have valid references From f27372a40f47ba0a245c5a562f17e6ffd8802930 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:14:22 +0700 Subject: [PATCH 05/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index 80ddf770fe..319ca60b87 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -1,4 +1,4 @@ -import { createServer, IncomingMessage, ServerResponse } from 'node:http'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { ipcMain } from 'electron'; From fab4b6ba9e780702c3caf09b030ffc3e03d1623b Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:14:29 +0700 Subject: [PATCH 06/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index 319ca60b87..e6e74ca6db 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -50,7 +50,9 @@ export const backend = createBackend({ this.oldConfig = config; // Listen for audio configuration - ctx.ipc.on('audio-stream:config', (config: { sampleRate: number; bitDepth: number; channels: number }) => { + ctx.ipc.on( + 'audio-stream:config', + (config: { sampleRate: number; bitDepth: number; channels: number }) => { const oldConfig = this.audioConfig; this.audioConfig = config; console.log( From 8b36e8b0bb8758d5c4e3385fd512bf9884b0f9d2 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:14:36 +0700 Subject: [PATCH 07/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index e6e74ca6db..e45d2f54f8 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -53,7 +53,7 @@ export const backend = createBackend({ ctx.ipc.on( 'audio-stream:config', (config: { sampleRate: number; bitDepth: number; channels: number }) => { - const oldConfig = this.audioConfig; + const oldConfig = this.audioConfig; this.audioConfig = config; console.log( LoggerPrefix, From d19c8999d05280ff35a8ec3a83b3fa7f64564aa8 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:14:42 +0700 Subject: [PATCH 08/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index e45d2f54f8..4702ed4eda 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -54,7 +54,7 @@ export const backend = createBackend({ 'audio-stream:config', (config: { sampleRate: number; bitDepth: number; channels: number }) => { const oldConfig = this.audioConfig; - this.audioConfig = config; + this.audioConfig = config; console.log( LoggerPrefix, `[Audio Stream] Received audio config:`, From ec309307dfc2c680a8208a70270bb8c42c4dcaf6 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:14:48 +0700 Subject: [PATCH 09/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index 4702ed4eda..1348cf2c21 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -55,7 +55,7 @@ export const backend = createBackend({ (config: { sampleRate: number; bitDepth: number; channels: number }) => { const oldConfig = this.audioConfig; this.audioConfig = config; - console.log( + console.log( LoggerPrefix, `[Audio Stream] Received audio config:`, config, From e67931169f32fe89fa5a49a73208b40a97493003 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:14:56 +0700 Subject: [PATCH 10/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index 1348cf2c21..6acc7c4970 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -76,7 +76,7 @@ export const backend = createBackend({ this.clients.forEach((client, clientId) => { try { if (client.response.writable && !client.response.destroyed) { - client.response.write(configLength); + client.response.write(configLength); client.response.write(configBuffer); console.log( LoggerPrefix, From 16da921da5cf6dae35eef1b0e04942ffcd5279f5 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:15:09 +0700 Subject: [PATCH 11/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index 6acc7c4970..e1561aac5f 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -56,7 +56,7 @@ export const backend = createBackend({ const oldConfig = this.audioConfig; this.audioConfig = config; console.log( - LoggerPrefix, + LoggerPrefix, `[Audio Stream] Received audio config:`, config, ); From 8b4448a06ae50581de3af92dc2660f42c32fc437 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:15:15 +0700 Subject: [PATCH 12/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index e1561aac5f..bf39763df2 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -78,7 +78,7 @@ export const backend = createBackend({ if (client.response.writable && !client.response.destroyed) { client.response.write(configLength); client.response.write(configBuffer); - console.log( + console.log( LoggerPrefix, `[Audio Stream] Sent updated config to client ${client.ip}`, ); From 87146da7996950ac7badda32a519368f56ade016 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:15:21 +0700 Subject: [PATCH 13/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index bf39763df2..0bb80b39e5 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -80,7 +80,7 @@ export const backend = createBackend({ client.response.write(configBuffer); console.log( LoggerPrefix, - `[Audio Stream] Sent updated config to client ${client.ip}`, + `[Audio Stream] Sent updated config to client ${client.ip}`, ); } } catch (error) { From bc9d0996fa72500eefe3763535171530872ba162 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:15:27 +0700 Subject: [PATCH 14/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index 0bb80b39e5..da43e036af 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -79,7 +79,7 @@ export const backend = createBackend({ client.response.write(configLength); client.response.write(configBuffer); console.log( - LoggerPrefix, + LoggerPrefix, `[Audio Stream] Sent updated config to client ${client.ip}`, ); } From e4c748ec2e1d5afc955000970dc0c92d92274569 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:15:38 +0700 Subject: [PATCH 15/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index da43e036af..f72f58da8c 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -77,7 +77,7 @@ export const backend = createBackend({ try { if (client.response.writable && !client.response.destroyed) { client.response.write(configLength); - client.response.write(configBuffer); + client.response.write(configBuffer); console.log( LoggerPrefix, `[Audio Stream] Sent updated config to client ${client.ip}`, From 792b440b0e96ed7597c4138c313777c3cf28db36 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:15:51 +0700 Subject: [PATCH 16/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index f72f58da8c..31eb176139 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -1,4 +1,8 @@ -import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from 'node:http'; import { ipcMain } from 'electron'; From d1d891bf8a622579fb7057ce2bab535afe883881 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:16:01 +0700 Subject: [PATCH 17/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index 31eb176139..b4e5330e83 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -85,7 +85,7 @@ export const backend = createBackend({ console.log( LoggerPrefix, `[Audio Stream] Sent updated config to client ${client.ip}`, - ); + ); } } catch (error) { console.error( From 7a1fbea598eca74fe61aa86041414b13112fd850 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:16:10 +0700 Subject: [PATCH 18/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index b4e5330e83..33d5793443 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -88,7 +88,7 @@ export const backend = createBackend({ ); } } catch (error) { - console.error( + console.error( LoggerPrefix, `[Audio Stream] Error sending updated config to client ${client.ip}:`, error, From 024508b5282a13299cfd3ac62f501158029214b5 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:16:21 +0700 Subject: [PATCH 19/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index 33d5793443..f3a8979a43 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -87,7 +87,7 @@ export const backend = createBackend({ `[Audio Stream] Sent updated config to client ${client.ip}`, ); } - } catch (error) { + } catch (error) { console.error( LoggerPrefix, `[Audio Stream] Error sending updated config to client ${client.ip}:`, From 0af3e8edb7f887c06ea6e095442f94c964df5e78 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:17:08 +0700 Subject: [PATCH 20/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index f3a8979a43..9a3ce20789 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -61,7 +61,7 @@ export const backend = createBackend({ this.audioConfig = config; console.log( LoggerPrefix, - `[Audio Stream] Received audio config:`, + `[Audio Stream] Received audio config:`, config, ); From 6c112beea2f338c10713afadee516a5881376037 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:17:19 +0700 Subject: [PATCH 21/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index 9a3ce20789..46bcc21779 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -91,7 +91,7 @@ export const backend = createBackend({ console.error( LoggerPrefix, `[Audio Stream] Error sending updated config to client ${client.ip}:`, - error, + error, ); } }); From 5ca2a7fdbb9aa8437a8e594336fdc4053f77ea42 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:17:36 +0700 Subject: [PATCH 22/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index 46bcc21779..6ff7f03dc1 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -116,7 +116,7 @@ export const backend = createBackend({ data: pcmBuffer, }; - // Add to buffer for new clients + // Add to buffer for new clients this.pcmBuffer.push(chunk); if (this.pcmBuffer.length > this.maxBufferSize) { this.pcmBuffer.shift(); From 738872dfae0aeac878ad7ca67ed61bd8b92e0bb6 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:17:46 +0700 Subject: [PATCH 23/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index 6ff7f03dc1..c305a7b5c7 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -114,7 +114,7 @@ export const backend = createBackend({ channels: this.audioConfig.channels, }, data: pcmBuffer, - }; + }; // Add to buffer for new clients this.pcmBuffer.push(chunk); From 13f818a9bd477fbae663583121bf26eda68aa48a Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:18:48 +0700 Subject: [PATCH 24/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index c305a7b5c7..d750467ff1 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -68,7 +68,7 @@ export const backend = createBackend({ // If config changed and we have clients, broadcast the new config to all existing clients if (oldConfig && this.clients.size > 0) { const configJson = JSON.stringify({ - type: 'config', + type: 'config', sampleRate: config.sampleRate, bitDepth: config.bitDepth, channels: config.channels, From d995d9ff2850ea95a3319409cd378d8d180f329d Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:19:05 +0700 Subject: [PATCH 25/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index d750467ff1..4d841a8cde 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -71,7 +71,7 @@ export const backend = createBackend({ type: 'config', sampleRate: config.sampleRate, bitDepth: config.bitDepth, - channels: config.channels, + channels: config.channels, }); const configBuffer = Buffer.from(configJson, 'utf-8'); const configLength = Buffer.allocUnsafe(4); From 8fa70f8f360cd9e88372b751a3238414e8228be3 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:19:20 +0700 Subject: [PATCH 26/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index 4d841a8cde..6735eb1242 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -78,7 +78,7 @@ export const backend = createBackend({ configLength.writeUInt32BE(configBuffer.length, 0); this.clients.forEach((client, clientId) => { - try { + try { if (client.response.writable && !client.response.destroyed) { client.response.write(configLength); client.response.write(configBuffer); From 59490ed638e745b56132fbfae43a2b9a0870a086 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:19:39 +0700 Subject: [PATCH 27/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index 6735eb1242..ae3fe3dc6a 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -62,7 +62,7 @@ export const backend = createBackend({ console.log( LoggerPrefix, `[Audio Stream] Received audio config:`, - config, + config, ); // If config changed and we have clients, broadcast the new config to all existing clients From d87c843b068edd2aabda75c686b7b10fe21f2c07 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:19:51 +0700 Subject: [PATCH 28/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index ae3fe3dc6a..579cbb3245 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -74,7 +74,7 @@ export const backend = createBackend({ channels: config.channels, }); const configBuffer = Buffer.from(configJson, 'utf-8'); - const configLength = Buffer.allocUnsafe(4); + const configLength = Buffer.allocUnsafe(4); configLength.writeUInt32BE(configBuffer.length, 0); this.clients.forEach((client, clientId) => { From 668ac39ba9c6409ed122eec7ec800f82cca43e46 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:20:36 +0700 Subject: [PATCH 29/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index 579cbb3245..7f473989c0 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -65,7 +65,7 @@ export const backend = createBackend({ config, ); - // If config changed and we have clients, broadcast the new config to all existing clients + // If config changed and we have clients, broadcast the new config to all existing clients if (oldConfig && this.clients.size > 0) { const configJson = JSON.stringify({ type: 'config', From 80e17d9e5a36516d5270c6da0702df137e94677f Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:36:35 +0700 Subject: [PATCH 30/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index 7f473989c0..b68778771f 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -67,7 +67,7 @@ export const backend = createBackend({ // If config changed and we have clients, broadcast the new config to all existing clients if (oldConfig && this.clients.size > 0) { - const configJson = JSON.stringify({ + const configJson = JSON.stringify({ type: 'config', sampleRate: config.sampleRate, bitDepth: config.bitDepth, From d443777e01884146418acea1ec746c87edfffcd5 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Mon, 29 Dec 2025 21:15:41 +0700 Subject: [PATCH 31/39] Migrate audio processing to AudioWorkletNode Refactor audio processing to use AudioWorkletNode for improved performance and stability. Removed deprecated ScriptProcessorNode and optimized PCM data handling. --- src/plugins/audio-stream/renderer.ts | 253 ++++++++++++--------------- 1 file changed, 109 insertions(+), 144 deletions(-) diff --git a/src/plugins/audio-stream/renderer.ts b/src/plugins/audio-stream/renderer.ts index fece07797e..b75dd54b8a 100644 --- a/src/plugins/audio-stream/renderer.ts +++ b/src/plugins/audio-stream/renderer.ts @@ -72,8 +72,6 @@ export const renderer = createRenderer({ const bitDepth = config.bitDepth || 16; // Use actual number of channels from the audio source const channels = config.channels || 2; - // Use buffer size from config - const bufferSize = config.bufferSize || 2048; // Send audio configuration to backend this.context.ipc.send('audio-stream:config', { @@ -82,39 +80,16 @@ export const renderer = createRenderer({ channels, }); - // Create ScriptProcessorNode for PCM capture - // NOTE: ScriptProcessorNode is deprecated and can cause timing issues/crackling. - // For best results, consider migrating to AudioWorkletNode in the future. - // Use buffer size from config for latency control - const scriptProcessor = audioContext.createScriptProcessor( - bufferSize, - channels, - channels, - ); - - // No batching - send immediately to minimize latency - // Base64 encoding is deferred to async queue to prevent blocking + // Prefer AudioWorkletNode for stable timing and higher-quality capture. + // We create a small inline AudioWorkletProcessor via a Blob so bundling + // doesn't need extra files. The worklet interleaves and converts to + // Int16 and posts transferable ArrayBuffers to the main thread. this.batchBuffer = null; this.batchCount = 0; - - // Reset processing queue this.processingQueue = []; this.isProcessing = false; - // Optimized base64 conversion - process in chunks to avoid stack overflow - const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { - const bytes = new Uint8Array(buffer); - const chunkSize = 0x8000; // 32KB chunks - let binary = ''; - - // Process in chunks to avoid call stack overflow with spread operator - for (let i = 0; i < bytes.length; i += chunkSize) { - const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length)); - // Use apply with Array.from for better performance and stack safety - binary += String.fromCharCode.apply(null, Array.from(chunk)); - } - return btoa(binary); - }; + // Process queue with limited items per tick to prevent blocking // Increased queue size to handle bursts better @@ -145,13 +120,11 @@ export const renderer = createRenderer({ buffer = item.buffer.buffer; } - // Base64 encoding happens outside audio callback - const pcmDataBase64 = arrayBufferToBase64(buffer); - - // Send PCM data to backend - this.context!.ipc.send('audio-stream:pcm-data', { + // Send binary PCM to backend (avoid base64 to reduce CPU/GC) + const uint8 = new Uint8Array(buffer); + this.context!.ipc.send('audio-stream:pcm-binary', { metadata: item.metadata, - data: pcmDataBase64, + data: uint8, }); itemsProcessed++; @@ -179,112 +152,114 @@ export const renderer = createRenderer({ processBatch(); }; - scriptProcessor.onaudioprocess = (event) => { - if (!this.isStreaming) { - return; + // Create AudioWorklet module inline (avoids bundling extra files). + const workletCode = `class PCMProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.port.onmessage = (e) => { + if (e.data && e.data.sampleRate) { + this._sampleRate = e.data.sampleRate; } - - const inputBuffer = event.inputBuffer; - const numberOfChannels = inputBuffer.numberOfChannels; - const length = inputBuffer.length; - - // Convert Float32Array to PCM - optimized conversion - let pcmArray: Int16Array | Int32Array; - - if (bitDepth === 32) { - // 32-bit PCM: convert float32 (-1.0 to 1.0) to int32 (-2147483648 to 2147483647) - const pcm32 = new Int32Array(length * numberOfChannels); - const MAX_INT32 = 2147483647; - - // Optimized loop - process channels interleaved - for (let channel = 0; channel < numberOfChannels; channel++) { - const channelData = inputBuffer.getChannelData(channel); - for (let i = 0; i < length; i++) { - const sample = channelData[i]; - // Clamp to [-1, 1] range and convert to int32 - // Use MAX_INT32 for scaling (2147483647), clamp result to valid int32 range - const clamped = sample < -1 ? -1 : sample > 1 ? 1 : sample; - const scaled = Math.round(clamped * MAX_INT32); - // Clamp to int32 range - pcm32[i * numberOfChannels + channel] = - scaled < -2147483648 - ? -2147483648 - : scaled > MAX_INT32 - ? MAX_INT32 - : scaled; - } + }; + } + + process(inputs) { + try { + const input = inputs[0]; + if (!input || input.length === 0) return true; + + const channels = input.length; + const frames = input[0].length; + const interleaved = new Int16Array(frames * channels); + const MAX_INT16 = 0x7fff; + + if (channels === 2) { + const left = input[0]; + const right = input[1]; + for (let i = 0; i < frames; i++) { + const l = left[i] < -1 ? -1 : left[i] > 1 ? 1 : left[i]; + const r = right[i] < -1 ? -1 : right[i] > 1 ? 1 : right[i]; + interleaved[i * 2] = Math.round(l * MAX_INT16); + interleaved[i * 2 + 1] = Math.round(r * MAX_INT16); } - pcmArray = pcm32; } else { - // 16-bit PCM: highly optimized conversion - const pcm16 = new Int16Array(length * numberOfChannels); - const MAX_INT16 = 0x7fff; - - // Optimize for common case (stereo) - if (numberOfChannels === 2) { - const leftChannel = inputBuffer.getChannelData(0); - const rightChannel = inputBuffer.getChannelData(1); - - for (let i = 0; i < length; i++) { - // Clamp and convert with minimal branching - const left = leftChannel[i]; - const right = rightChannel[i]; - - // Fast clamp: Math.max(-1, Math.min(1, sample)) - const leftClamped = left < -1 ? -1 : left > 1 ? 1 : left; - const rightClamped = right < -1 ? -1 : right > 1 ? 1 : right; - - // Convert to int16 (interleaved: L, R, L, R, ...) - pcm16[i * 2] = Math.round(leftClamped * MAX_INT16); - pcm16[i * 2 + 1] = Math.round(rightClamped * MAX_INT16); - } - } else { - // Generic case for mono or other channel counts - for (let channel = 0; channel < numberOfChannels; channel++) { - const channelData = inputBuffer.getChannelData(channel); - for (let i = 0; i < length; i++) { - const sample = channelData[i]; - const clamped = sample < -1 ? -1 : sample > 1 ? 1 : sample; - pcm16[i * numberOfChannels + channel] = Math.round(clamped * MAX_INT16); - } + for (let ch = 0; ch < channels; ch++) { + const data = input[ch]; + for (let i = 0; i < frames; i++) { + const s = data[i] < -1 ? -1 : data[i] > 1 ? 1 : data[i]; + interleaved[i * channels + ch] = Math.round(s * MAX_INT16); } } - pcmArray = pcm16; } - // Queue immediately for async processing (don't block audio callback) - // No batching to minimize latency - each buffer is sent immediately - if (this.context) { - // Drop oldest items if queue is too long to prevent buildup and stuttering - // More aggressive dropping to prevent queue buildup - while (this.processingQueue.length >= MAX_QUEUE_SIZE) { - // Remove oldest items (FIFO) until we have room - this.processingQueue.shift(); + this.port.postMessage({ + buffer: interleaved.buffer, + metadata: { + timestamp: Date.now(), + sampleRate: this._sampleRate || 48000, + bitDepth: 16, + channels: channels, } - - this.processingQueue.push({ - buffer: pcmArray, - metadata: { - timestamp: Date.now(), - sampleRate, - bitDepth, - channels: numberOfChannels, - }, + }, [interleaved.buffer]); + } catch (err) { + // keep audio thread alive + } + return true; + } +} +registerProcessor('pcm-processor', PCMProcessor); +`; + + const blob = new Blob([workletCode], { type: 'application/javascript' }); + const blobUrl = URL.createObjectURL(blob); + + try { + audioContext.audioWorklet.addModule(blobUrl).then(() => { + const workletNode = new AudioWorkletNode(audioContext, 'pcm-processor', { + numberOfInputs: 1, + numberOfOutputs: 0, + channelCount: channels, }); - // Trigger async processing if not already running - // Use queueMicrotask for immediate processing with minimal delay - if (!this.isProcessing) { - queueMicrotask(processQueue); - } - } - }; + workletNode.port.postMessage({ sampleRate }); + + workletNode.port.onmessage = (event) => { + if (!this.isStreaming || !this.context) return; + + try { + const ab = event.data.buffer as ArrayBuffer; + const metadata = event.data.metadata || {}; + + while (this.processingQueue.length >= MAX_QUEUE_SIZE) { + this.processingQueue.shift(); + } - // Connect audio source to script processor, then to destination - audioSource.connect(scriptProcessor); - scriptProcessor.connect(audioContext.destination); + this.processingQueue.push({ + buffer: new Int16Array(ab), + metadata: { + timestamp: metadata.timestamp || Date.now(), + sampleRate: metadata.sampleRate || sampleRate, + bitDepth: metadata.bitDepth || 16, + channels: metadata.channels || channels, + }, + }); + + if (!this.isProcessing) queueMicrotask(processQueue); + } catch (err) { + console.error('[Audio Stream] Worklet message error:', err); + } + }; + + audioSource.connect(workletNode); - this.scriptProcessor = scriptProcessor; + this.scriptProcessor = undefined; + this.isStreaming = true; + }).catch((err) => { + console.error('[Audio Stream] Failed to add audio worklet module:', err); + }); + } catch (err) { + console.error('[Audio Stream] AudioWorklet setup failed:', err); + } this.isStreaming = true; console.log( @@ -303,17 +278,7 @@ export const renderer = createRenderer({ // Flush any remaining batched data if (this.batchBuffer && this.batchBuffer.length > 0 && this.context) { try { - // Optimized base64 conversion - const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { - const bytes = new Uint8Array(buffer); - const chunkSize = 0x8000; // 32KB chunks - let binary = ''; - for (let i = 0; i < bytes.length; i += chunkSize) { - const chunk = bytes.subarray(i, i + chunkSize); - binary += String.fromCharCode.apply(null, Array.from(chunk)); - } - return btoa(binary); - }; + let buffer: ArrayBuffer; if (this.batchBuffer.buffer instanceof SharedArrayBuffer) { @@ -322,15 +287,15 @@ export const renderer = createRenderer({ } else { buffer = this.batchBuffer.buffer; } - const pcmDataBase64 = arrayBufferToBase64(buffer); - this.context.ipc.send('audio-stream:pcm-data', { + const uint8 = new Uint8Array(buffer); + this.context.ipc.send('audio-stream:pcm-binary', { metadata: { timestamp: Date.now(), sampleRate: this.config?.sampleRate || 48000, bitDepth: this.config?.bitDepth || 16, channels: 2, }, - data: pcmDataBase64, + data: uint8, }); } catch { // Ignore flush errors From 01842c860dd80558a8a6293f10b27e4ec8c9c885 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Mon, 29 Dec 2025 21:16:04 +0700 Subject: [PATCH 32/39] Implement binary PCM data handling in audio stream Added support for handling binary PCM data from clients. --- src/plugins/audio-stream/backend.ts | 83 ++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index b68778771f..b60ace6bef 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -77,7 +77,7 @@ export const backend = createBackend({ const configLength = Buffer.allocUnsafe(4); configLength.writeUInt32BE(configBuffer.length, 0); - this.clients.forEach((client, clientId) => { + this.clients.forEach((client) => { try { if (client.response.writable && !client.response.destroyed) { client.response.write(configLength); @@ -187,6 +187,86 @@ export const backend = createBackend({ } }); + // New: Listen for binary PCM data (ArrayBuffer / Uint8Array / Buffer) + ctx.ipc.on('audio-stream:pcm-binary', (data: { metadata: any; data: any }) => { + if (!this.audioConfig) return; + + try { + let pcmBuffer: Buffer; + + // Accept Node Buffer, Uint8Array, or plain ArrayBuffer + if (Buffer.isBuffer(data.data)) { + pcmBuffer = data.data as Buffer; + } else if (data.data instanceof Uint8Array) { + pcmBuffer = Buffer.from(data.data.buffer, data.data.byteOffset, data.data.byteLength); + } else if (data.data && data.data instanceof ArrayBuffer) { + pcmBuffer = Buffer.from(data.data); + } else { + // Fallback: try to create buffer from whatever was sent + pcmBuffer = Buffer.from(data.data); + } + + const chunk = { + metadata: { + timestamp: data.metadata?.timestamp || Date.now(), + sampleRate: this.audioConfig.sampleRate, + bitDepth: this.audioConfig.bitDepth, + channels: this.audioConfig.channels, + }, + data: pcmBuffer, + }; + + this.pcmBuffer.push(chunk); + if (this.pcmBuffer.length > this.maxBufferSize) this.pcmBuffer.shift(); + + const clientsToRemove: string[] = []; + + const metadataJson = JSON.stringify(chunk.metadata); + const metadataBuffer = Buffer.from(metadataJson, 'utf-8'); + const metadataLength = Buffer.allocUnsafe(4); + metadataLength.writeUInt32BE(metadataBuffer.length, 0); + + const combinedBuffer = Buffer.concat([metadataLength, metadataBuffer, pcmBuffer]); + + this.clients.forEach((client, clientId) => { + try { + if (client.response.writable && !client.response.destroyed) { + const canWrite = client.response.write(combinedBuffer); + client.lastActivity = Date.now(); + if (!canWrite) { + if (!client.response.listenerCount('drain')) { + client.response.once('drain', () => {}); + } + } + } else { + clientsToRemove.push(clientId); + } + } catch (error) { + console.error( + LoggerPrefix, + `[Audio Stream] Error sending PCM data to client ${client.ip}:`, + error, + ); + clientsToRemove.push(clientId); + } + }); + + clientsToRemove.forEach((clientId) => { + const client = this.clients.get(clientId); + if (client) { + try { client.response.end(); } catch {} + } + this.clients.delete(clientId); + }); + } catch (error) { + console.error( + LoggerPrefix, + '[Audio Stream] Error processing PCM binary data:', + error, + ); + } + }); + if (config.enabled) { this.startServer(config); } @@ -196,6 +276,7 @@ export const backend = createBackend({ // Remove IPC listeners ipcMain.removeAllListeners('audio-stream:config'); ipcMain.removeAllListeners('audio-stream:pcm-data'); + ipcMain.removeAllListeners('audio-stream:pcm-binary'); this.stopServer(); }, From 02a74dee3b5ac19ab0903f9851867419d0af66c8 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Mon, 29 Dec 2025 21:25:37 +0700 Subject: [PATCH 33/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index b60ace6bef..2099183ece 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -61,7 +61,7 @@ export const backend = createBackend({ this.audioConfig = config; console.log( LoggerPrefix, - `[Audio Stream] Received audio config:`, + '[Audio Stream] Received audio config:', config, ); From 5e85f8b2e22bfbda76a9e1140734df582f4c83d7 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Mon, 29 Dec 2025 21:25:52 +0700 Subject: [PATCH 34/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index 2099183ece..aa531628fe 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -63,7 +63,7 @@ export const backend = createBackend({ LoggerPrefix, '[Audio Stream] Received audio config:', config, - ); + ); // If config changed and we have clients, broadcast the new config to all existing clients if (oldConfig && this.clients.size > 0) { From d6aa17dbf41514bdf6add5ad7f5e8b54979bb7d3 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Mon, 29 Dec 2025 21:27:34 +0700 Subject: [PATCH 35/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index aa531628fe..b170ef2351 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -109,7 +109,7 @@ export const backend = createBackend({ const chunk = { metadata: { timestamp: data.metadata.timestamp || Date.now(), - sampleRate: this.audioConfig.sampleRate, + sampleRate: this.audioConfig.sampleRate, bitDepth: this.audioConfig.bitDepth, channels: this.audioConfig.channels, }, From ddefbb69465837599ca2a578e5323ce9575fe3a7 Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Mon, 29 Dec 2025 21:42:24 +0700 Subject: [PATCH 36/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index b170ef2351..7a8d2687d2 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -117,7 +117,7 @@ export const backend = createBackend({ }; // Add to buffer for new clients - this.pcmBuffer.push(chunk); + this.pcmBuffer.push(chunk); if (this.pcmBuffer.length > this.maxBufferSize) { this.pcmBuffer.shift(); } From 278bcaff3679765548f1e4368a7a2d4dc3473ebc Mon Sep 17 00:00:00 2001 From: Arif Cendekiawan <67047376+dnecra@users.noreply.github.com> Date: Mon, 29 Dec 2025 21:42:38 +0700 Subject: [PATCH 37/39] Update src/plugins/audio-stream/backend.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/plugins/audio-stream/backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index 7a8d2687d2..a879579a75 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -66,7 +66,7 @@ export const backend = createBackend({ ); // If config changed and we have clients, broadcast the new config to all existing clients - if (oldConfig && this.clients.size > 0) { + if (oldConfig && this.clients.size > 0) { const configJson = JSON.stringify({ type: 'config', sampleRate: config.sampleRate, From 90788907e479b71da8087f09c43ced79466be1ad Mon Sep 17 00:00:00 2001 From: ArjixWasTaken Date: Mon, 12 Jan 2026 01:08:45 +0200 Subject: [PATCH 38/39] init rewrite of plugin --- src/pear-desktop.ts | 6 + src/plugins/audio-stream/BroadcastStream.ts | 34 ++ src/plugins/audio-stream/StreamProcessor.js | 35 ++ src/plugins/audio-stream/backend.ts | 572 ++++---------------- src/plugins/audio-stream/config.ts | 2 - src/plugins/audio-stream/index.ts | 5 +- src/plugins/audio-stream/renderer.ts | 334 +++++------- 7 files changed, 291 insertions(+), 697 deletions(-) create mode 100644 src/plugins/audio-stream/BroadcastStream.ts create mode 100644 src/plugins/audio-stream/StreamProcessor.js 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..c5df99e9d6 --- /dev/null +++ b/src/plugins/audio-stream/BroadcastStream.ts @@ -0,0 +1,34 @@ +export class BroadcastStream { + private subscribers: Set> = + new Set(); + + // A way for readers to get a new stream + subscribe() { + let controller!: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(c) { + controller = c; + }, + cancel: () => { + this.subscribers.delete(controller); + }, + }); + + this.subscribers.add(controller); + return stream; + } + + // A way for you to write data to all readers + write(chunk: Uint8Array) { + for (const controller of this.subscribers) { + controller.enqueue(chunk); + } + } + + 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..374f1f1679 --- /dev/null +++ b/src/plugins/audio-stream/StreamProcessor.js @@ -0,0 +1,35 @@ +// audio-processor.js (loaded by audioContext.audioWorklet.addModule) +class RecorderProcessor extends AudioWorkletProcessor { + constructor(options) { + super(); + const bufferSize = options.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 + if (outputs[0] && inputs[0]) { + outputs[0][0].set(inputs[0][0]); + if (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 index a879579a75..c9f2d4eada 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -1,506 +1,118 @@ -import { - createServer, - type IncomingMessage, - type ServerResponse, -} from 'node:http'; +import { Hono } from 'hono'; +import { streamText } from 'hono/streaming'; +import { serve, type ServerType } from '@hono/node-server'; -import { ipcMain } from 'electron'; +import { createBackend } from '@/utils'; +import { type AudioStreamConfig } from './config'; +import { BroadcastStream } from './BroadcastStream'; -import { createBackend, LoggerPrefix } from '@/utils'; +const META_INT = 16_000; -import type { BackendContext } from '@/types/contexts'; +let config: AudioStreamConfig; +const broadcast = new BroadcastStream(); -import type { AudioStreamConfig } from './config'; - -type ClientInfo = { - response: ServerResponse; - ip: string; - lastActivity: number; -}; - -type BackendType = { - server?: ReturnType; - clients: Map; - context?: BackendContext; - oldConfig?: AudioStreamConfig; - audioConfig?: { - sampleRate: number; - bitDepth: number; - channels: number; - }; - pcmBuffer: Array<{ - metadata: { - timestamp: number; - sampleRate: number; - bitDepth: number; - channels: number; - }; - data: Buffer; - }>; - maxBufferSize: number; - startServer: (config: AudioStreamConfig) => void; - stopServer: () => void; -}; - -export const backend = createBackend({ - clients: new Map(), - audioConfig: undefined, - pcmBuffer: [], - maxBufferSize: 10, // Keep last 10 chunks for new clients - - async start(ctx: BackendContext) { - this.context = ctx; - const config = await ctx.getConfig(); - this.oldConfig = config; +export const backend = createBackend< + { + app: Hono; + server?: ServerType; + }, + AudioStreamConfig +>({ + app: new Hono().get('/stream', (ctx) => { + const icyMetadata = ctx.req.header('Icy-Metadata'); + if (icyMetadata === '1') { + ctx.header('icy-metaint', META_INT.toString(10)); + ctx.header('icy-name', 'Pear Desktop'); + ctx.header('icy-url', 'https://github.com/pear-devs/pear-desktop'); + ctx.header( + 'icy-audio-info', + `ice-channels=2;ice-samplerate=${config.sampleRate.toString( + 10, + )};ice-bitrate=128`, + ); + ctx.header('icy-pub', '1'); + ctx.header('icy-sr', config.sampleRate.toString(10)); + ctx.header('Content-Type', 'audio/L16'); + ctx.header('Server', 'Pear Desktop'); + } - // Listen for audio configuration - ctx.ipc.on( - 'audio-stream:config', - (config: { sampleRate: number; bitDepth: number; channels: number }) => { - const oldConfig = this.audioConfig; - this.audioConfig = config; - console.log( - LoggerPrefix, - '[Audio Stream] Received audio config:', - config, - ); + return streamText(ctx, async (stream) => { + let readable = broadcast.subscribe(); + if (icyMetadata === '1') { + let bytesUntilMetadata = META_INT; - // If config changed and we have clients, broadcast the new config to all existing clients - if (oldConfig && this.clients.size > 0) { - const configJson = JSON.stringify({ - type: 'config', - sampleRate: config.sampleRate, - bitDepth: config.bitDepth, - channels: config.channels, - }); - const configBuffer = Buffer.from(configJson, 'utf-8'); - const configLength = Buffer.allocUnsafe(4); - configLength.writeUInt32BE(configBuffer.length, 0); + readable = readable.pipeThrough( + new TransformStream({ + transform( + chunk: Uint8Array, + controller: TransformStreamDefaultController, + ) { + console.log({ bytesUntilMetadata }); + let offset = 0; - this.clients.forEach((client) => { - try { - if (client.response.writable && !client.response.destroyed) { - client.response.write(configLength); - client.response.write(configBuffer); - console.log( - LoggerPrefix, - `[Audio Stream] Sent updated config to client ${client.ip}`, - ); - } - } catch (error) { - console.error( - LoggerPrefix, - `[Audio Stream] Error sending updated config to client ${client.ip}:`, - error, - ); - } - }); - } - }); + while (offset < chunk.byteLength) { + if (bytesUntilMetadata === 0) { + const encoder = new TextEncoder(); - // Listen for PCM audio data from renderer - ctx.ipc.on('audio-stream:pcm-data', (data: { metadata: any; data: string }) => { - if (!this.audioConfig) return; + // TODO: add real metadata + const metaBuffer = encoder.encode( + ".StreamTitle='My Cool Stream Title';", + ); - try { - // Decode base64 to buffer - const pcmBuffer = Buffer.from(data.data, 'base64'); + const padding = (16 - (metaBuffer.byteLength % 16)) % 16; + const metaLength = metaBuffer.byteLength + padding; + const lengthByte = metaLength / 16; - const chunk = { - metadata: { - timestamp: data.metadata.timestamp || Date.now(), - sampleRate: this.audioConfig.sampleRate, - bitDepth: this.audioConfig.bitDepth, - channels: this.audioConfig.channels, - }, - data: pcmBuffer, - }; + controller.enqueue(Uint8Array.from([lengthByte])); - // Add to buffer for new clients - this.pcmBuffer.push(chunk); - if (this.pcmBuffer.length > this.maxBufferSize) { - this.pcmBuffer.shift(); - } + if (metaLength > 0) { + controller.enqueue(Uint8Array.from(metaBuffer)); + } - // Send to all connected clients - const clientsToRemove: string[] = []; - - // Pre-compute metadata to avoid repeated JSON.stringify - const metadataJson = JSON.stringify(chunk.metadata); - const metadataBuffer = Buffer.from(metadataJson, 'utf-8'); - const metadataLength = Buffer.allocUnsafe(4); - metadataLength.writeUInt32BE(metadataBuffer.length, 0); - - // Combine all data into single buffer for efficient write (reduces syscalls) - const combinedBuffer = Buffer.concat([metadataLength, metadataBuffer, pcmBuffer]); - - this.clients.forEach((client, clientId) => { - try { - // Check if response is writable to prevent blocking - if (client.response.writable && !client.response.destroyed) { - // Single write call is more efficient than multiple writes - const canWrite = client.response.write(combinedBuffer); - client.lastActivity = Date.now(); - - // Handle backpressure - if write returns false, buffer is full - // Don't remove client, just skip this write to prevent blocking - if (!canWrite) { - // Set up drain handler if not already set - if (!client.response.listenerCount('drain')) { - client.response.once('drain', () => { - // Buffer drained, can continue writing - }); + bytesUntilMetadata = META_INT; } - } - } else { - // Response is not writable, mark for removal - clientsToRemove.push(clientId); - } - } catch (error) { - console.error( - LoggerPrefix, - `[Audio Stream] Error sending PCM data to client ${client.ip}:`, - error, - ); - clientsToRemove.push(clientId); - } - }); - - // Remove failed clients - clientsToRemove.forEach((clientId) => { - const client = this.clients.get(clientId); - if (client) { - try { - client.response.end(); - } catch { - // Ignore errors when closing - } - } - this.clients.delete(clientId); - }); - } catch (error) { - console.error( - LoggerPrefix, - '[Audio Stream] Error processing PCM data:', - error, - ); - } - }); - - // New: Listen for binary PCM data (ArrayBuffer / Uint8Array / Buffer) - ctx.ipc.on('audio-stream:pcm-binary', (data: { metadata: any; data: any }) => { - if (!this.audioConfig) return; - - try { - let pcmBuffer: Buffer; - // Accept Node Buffer, Uint8Array, or plain ArrayBuffer - if (Buffer.isBuffer(data.data)) { - pcmBuffer = data.data as Buffer; - } else if (data.data instanceof Uint8Array) { - pcmBuffer = Buffer.from(data.data.buffer, data.data.byteOffset, data.data.byteLength); - } else if (data.data && data.data instanceof ArrayBuffer) { - pcmBuffer = Buffer.from(data.data); - } else { - // Fallback: try to create buffer from whatever was sent - pcmBuffer = Buffer.from(data.data); - } + const chunkRemaining = chunk.byteLength - offset; + const canSend = Math.min(chunkRemaining, bytesUntilMetadata); + controller.enqueue(chunk.subarray(offset, offset + canSend)); - const chunk = { - metadata: { - timestamp: data.metadata?.timestamp || Date.now(), - sampleRate: this.audioConfig.sampleRate, - bitDepth: this.audioConfig.bitDepth, - channels: this.audioConfig.channels, - }, - data: pcmBuffer, - }; - - this.pcmBuffer.push(chunk); - if (this.pcmBuffer.length > this.maxBufferSize) this.pcmBuffer.shift(); - - const clientsToRemove: string[] = []; - - const metadataJson = JSON.stringify(chunk.metadata); - const metadataBuffer = Buffer.from(metadataJson, 'utf-8'); - const metadataLength = Buffer.allocUnsafe(4); - metadataLength.writeUInt32BE(metadataBuffer.length, 0); - - const combinedBuffer = Buffer.concat([metadataLength, metadataBuffer, pcmBuffer]); - - this.clients.forEach((client, clientId) => { - try { - if (client.response.writable && !client.response.destroyed) { - const canWrite = client.response.write(combinedBuffer); - client.lastActivity = Date.now(); - if (!canWrite) { - if (!client.response.listenerCount('drain')) { - client.response.once('drain', () => {}); - } + bytesUntilMetadata -= canSend; + offset += canSend; } - } else { - clientsToRemove.push(clientId); - } - } catch (error) { - console.error( - LoggerPrefix, - `[Audio Stream] Error sending PCM data to client ${client.ip}:`, - error, - ); - clientsToRemove.push(clientId); - } - }); - - clientsToRemove.forEach((clientId) => { - const client = this.clients.get(clientId); - if (client) { - try { client.response.end(); } catch {} - } - this.clients.delete(clientId); - }); - } catch (error) { - console.error( - LoggerPrefix, - '[Audio Stream] Error processing PCM binary data:', - error, + }, + }), ); } - }); - - if (config.enabled) { - this.startServer(config); - } - }, - - stop() { - // Remove IPC listeners - ipcMain.removeAllListeners('audio-stream:config'); - ipcMain.removeAllListeners('audio-stream:pcm-data'); - ipcMain.removeAllListeners('audio-stream:pcm-binary'); - - this.stopServer(); - }, - onConfigChange(config: AudioStreamConfig) { - const wasEnabled = this.oldConfig?.enabled ?? false; - const portChanged = this.oldConfig?.port !== config.port; - const hostnameChanged = this.oldConfig?.hostname !== config.hostname; - - // If port or hostname changed and server is enabled, restart it - if (config.enabled && (portChanged || hostnameChanged || !wasEnabled)) { - this.stopServer(); - this.startServer(config); - } else if (!config.enabled && wasEnabled) { - // If disabled, stop the server - this.stopServer(); - } - - this.oldConfig = config; - }, - - startServer(config: AudioStreamConfig) { - if (this.server) { - this.stopServer(); - } - - const httpServer = createServer((req: IncomingMessage, res: ServerResponse) => { - // Handle CORS - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - - if (req.method === 'OPTIONS') { - res.writeHead(200); - res.end(); - return; - } - - if (req.method !== 'GET' || req.url !== '/stream') { - res.writeHead(404, { 'Content-Type': 'text/plain' }); - res.end('Not Found'); - return; - } - - const clientIp = req.socket.remoteAddress || 'unknown'; - const clientId = `${clientIp}-${Date.now()}`; - - // Optimize socket for low latency - const socket = req.socket; - if (socket) { - socket.setNoDelay(true); // Disable Nagle's algorithm for lower latency - socket.setKeepAlive(true, 60000); // Keep connection alive - // Increase socket buffer sizes for better throughput (if available) - if ('setReceiveBufferSize' in socket && typeof socket.setReceiveBufferSize === 'function') { - try { - (socket as any).setReceiveBufferSize(1024 * 1024); // 1MB receive buffer - } catch { - // Ignore if not supported - } - } - if ('setSendBufferSize' in socket && typeof socket.setSendBufferSize === 'function') { - try { - (socket as any).setSendBufferSize(1024 * 1024); // 1MB send buffer - } catch { - // Ignore if not supported - } - } - } - - // Set up streaming response - res.writeHead(200, { - 'Content-Type': 'application/octet-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'Transfer-Encoding': 'chunked', - 'X-Accel-Buffering': 'no', // Disable buffering for nginx (if used) - }); - - const clientInfo: ClientInfo = { - response: res, - ip: clientIp, - lastActivity: Date.now(), - }; - - this.clients.set(clientId, clientInfo); - - console.log( - LoggerPrefix, - `[Audio Stream] Client connected from ${clientIp}. Total clients: ${this.clients.size}`, - ); - - // Send audio configuration first - if (this.audioConfig) { - const configJson = JSON.stringify({ - type: 'config', - sampleRate: this.audioConfig.sampleRate, - bitDepth: this.audioConfig.bitDepth, - channels: this.audioConfig.channels, - }); - const configBuffer = Buffer.from(configJson, 'utf-8'); - const configLength = Buffer.allocUnsafe(4); - configLength.writeUInt32BE(configBuffer.length, 0); - - try { - res.write(configLength); - res.write(configBuffer); - } catch (error) { - console.error( - LoggerPrefix, - `[Audio Stream] Error sending config to client ${clientIp}:`, - error, - ); - } - } - - // Send buffered chunks to new client - this.pcmBuffer.forEach((chunk) => { - try { - const metadataJson = JSON.stringify(chunk.metadata); - const metadataBuffer = Buffer.from(metadataJson, 'utf-8'); - const metadataLength = Buffer.allocUnsafe(4); - metadataLength.writeUInt32BE(metadataBuffer.length, 0); - - res.write(metadataLength); - res.write(metadataBuffer); - res.write(chunk.data); - } catch (error) { - console.error( - LoggerPrefix, - `[Audio Stream] Error sending buffered chunk to client ${clientIp}:`, - error, - ); - } - }); - - // Handle client disconnect - req.on('close', () => { - this.clients.delete(clientId); - console.log( - LoggerPrefix, - `[Audio Stream] Client disconnected from ${clientIp}. Total clients: ${this.clients.size}`, - ); - }); - - req.on('error', (error: NodeJS.ErrnoException) => { - // Ignore ECONNRESET and EPIPE errors (common when client disconnects) - if (error.code !== 'ECONNRESET' && error.code !== 'EPIPE') { - console.error( - LoggerPrefix, - `[Audio Stream] Error from client ${clientIp}:`, - error, - ); - } - this.clients.delete(clientId); - }); - - res.on('error', (error: NodeJS.ErrnoException) => { - // Ignore ECONNRESET and EPIPE errors - if (error.code !== 'ECONNRESET' && error.code !== 'EPIPE') { - console.error( - LoggerPrefix, - `[Audio Stream] Response error from client ${clientIp}:`, - error, - ); - } - this.clients.delete(clientId); - }); - }); - - httpServer.listen(config.port, config.hostname, () => { - console.log( - LoggerPrefix, - `[Audio Stream] PCM streaming server listening on http://${config.hostname}:${config.port}/stream`, - ); + return await stream.pipe(readable); }); - - httpServer.on('error', (error: NodeJS.ErrnoException) => { - console.error( - LoggerPrefix, - `[Audio Stream] Server error on ${config.hostname}:${config.port}:`, - error.message, - ); - // If port is in use, log a helpful message - if (error.code === 'EADDRINUSE') { - console.error( - LoggerPrefix, - `[Audio Stream] Port ${config.port} is already in use. Please choose a different port.`, - ); - } + }), + + async start({ getConfig, ipc }) { + config = await getConfig(); + + this.server = serve( + { + fetch: this.app.fetch.bind(this.app), + hostname: config.hostname, + port: config.port, + }, + ({ address, port }) => console.log('Listening on', { address, port }), + ); + + ipc.on('audio-stream:pcm-binary', (chunk: Uint8Array) => { + broadcast.write(chunk); }); - - this.server = httpServer; }, + async stop() { + let resolve; - stopServer() { - // Close all client connections - if (this.clients.size > 0) { - this.clients.forEach((client) => { - try { - client.response.end(); - } catch (error) { - // Ignore errors when closing - } - }); - this.clients.clear(); - } - - if (this.server) { - this.server.close((error) => { - if (error) { - console.error( - LoggerPrefix, - '[Audio Stream] Error closing server:', - error, - ); - } else { - console.log(LoggerPrefix, '[Audio Stream] HTTP server stopped'); - } - }); - this.server = undefined; - } + const promise = new Promise((r) => (resolve = r)); + this.server?.close(resolve); - // Clear buffers - this.pcmBuffer = []; - this.audioConfig = undefined; + await promise; + }, + onConfigChange(newConfig) { + config = newConfig; }, }); diff --git a/src/plugins/audio-stream/config.ts b/src/plugins/audio-stream/config.ts index a4f319a07d..2ccac25f48 100644 --- a/src/plugins/audio-stream/config.ts +++ b/src/plugins/audio-stream/config.ts @@ -20,5 +20,3 @@ export const defaultAudioStreamConfig: AudioStreamConfig = { channels: 2, // Stereo bufferSize: 2048, // Low latency buffer size }; - - diff --git a/src/plugins/audio-stream/index.ts b/src/plugins/audio-stream/index.ts index c1c08ec977..8504216cb8 100644 --- a/src/plugins/audio-stream/index.ts +++ b/src/plugins/audio-stream/index.ts @@ -6,15 +6,12 @@ import { backend } from './backend'; import { onMenu } from './menu'; import { renderer } from './renderer'; -import type { AudioStreamConfig } from './config'; - export default createPlugin({ name: () => t('plugins.audio-stream.name'), description: () => t('plugins.audio-stream.description'), restartNeeded: false, - config: defaultAudioStreamConfig as AudioStreamConfig, + config: defaultAudioStreamConfig, backend, renderer, menu: onMenu, }); - diff --git a/src/plugins/audio-stream/renderer.ts b/src/plugins/audio-stream/renderer.ts index b75dd54b8a..9d0389ce7a 100644 --- a/src/plugins/audio-stream/renderer.ts +++ b/src/plugins/audio-stream/renderer.ts @@ -1,5 +1,7 @@ import { createRenderer } from '@/utils'; +import workletCode from './StreamProcessor.js?raw'; + import type { RendererContext } from '@/types/contexts'; import type { MusicPlayer } from '@/types/music-player'; @@ -26,9 +28,42 @@ type RendererProperties = { batchCount: number; processingQueue: ProcessingQueueItem[]; isProcessing: boolean; - startStreaming: (audioContext: AudioContext, audioSource: AudioNode) => void; + startStreaming: ( + ipc: RendererContext<{ enabled: boolean }>['ipc'], + audioContext: AudioContext, + audioSource: AudioNode, + ) => void; }; +function writeString(view: DataView, offset: number, str: string) { + for (let i = 0; i < str.length; i++) { + view.setUint8(offset + i, str.charCodeAt(i)); + } +} + +function createWavHeader( + sampleRate: number, + numChannels: number, + dataLength: number, +) { + const header = new ArrayBuffer(44); + const view = new DataView(header); + writeString(view, 0, 'RIFF'); + view.setUint32(4, 36 + dataLength, true); // file size - 8 + writeString(view, 8, 'WAVE'); + writeString(view, 12, 'fmt '); + view.setUint32(16, 16, true); // subchunk1 size (16 for PCM) + view.setUint16(20, 1, true); // audio format (1 = PCM) + view.setUint16(22, numChannels, true); // number of channels + view.setUint32(24, sampleRate, true); // sample rate + view.setUint32(28, sampleRate * numChannels * 2, true); // byte rate + view.setUint16(32, numChannels * 2, true); // block align + view.setUint16(34, 16, true); // bits per sample + writeString(view, 36, 'data'); + view.setUint32(40, dataLength, true); // data chunk size + return new Uint8Array(header); +} + export const renderer = createRenderer({ isStreaming: false, batchBuffer: null, @@ -36,7 +71,10 @@ export const renderer = createRenderer({ processingQueue: [], isProcessing: false, - async onPlayerApiReady(_: MusicPlayer, context: RendererContext) { + async onPlayerApiReady( + _: MusicPlayer, + context: RendererContext, + ) { this.context = context; this.config = await context.getConfig(); @@ -48,15 +86,21 @@ export const renderer = createRenderer({ document.addEventListener( 'peard:audio-can-play', (e) => { - this.startStreaming(e.detail.audioContext, e.detail.audioSource); + this.startStreaming( + context.ipc, + e.detail.audioContext, + e.detail.audioSource, + ); }, { once: true, passive: true }, ); }, startStreaming( + ipc: RendererContext<{ enabled: boolean }>['ipc'], audioContext: AudioContext, audioSource: AudioNode, + bufferSize = 4096, ) { if (this.isStreaming || !this.context) { return; @@ -65,207 +109,68 @@ export const renderer = createRenderer({ this.audioContext = audioContext; this.audioSource = audioSource; - // Get fresh config to ensure we have the latest values - const config = this.config!; - // Use the actual AudioContext sample rate, not config (audio might be resampled) const sampleRate = audioContext.sampleRate; - const bitDepth = config.bitDepth || 16; - // Use actual number of channels from the audio source - const channels = config.channels || 2; - - // Send audio configuration to backend - this.context.ipc.send('audio-stream:config', { - sampleRate, - bitDepth, - channels, - }); - - // Prefer AudioWorkletNode for stable timing and higher-quality capture. - // We create a small inline AudioWorkletProcessor via a Blob so bundling - // doesn't need extra files. The worklet interleaves and converts to - // Int16 and posts transferable ArrayBuffers to the main thread. - this.batchBuffer = null; - this.batchCount = 0; - this.processingQueue = []; - this.isProcessing = false; - - - - // Process queue with limited items per tick to prevent blocking - // Increased queue size to handle bursts better - const MAX_QUEUE_SIZE = 16; // Increased from 8 to handle bursts - const ITEMS_PER_TICK = 2; // Process 2 items per tick for better throughput - - const processQueue = () => { - if (this.isProcessing || this.processingQueue.length === 0 || !this.context) { - return; - } - - this.isProcessing = true; - - // Process multiple items per tick to keep up with audio callback rate - let itemsProcessed = 0; - const processBatch = () => { - while (itemsProcessed < ITEMS_PER_TICK && this.processingQueue.length > 0 && this.context) { - const item = this.processingQueue.shift(); - if (!item) break; - - try { - // Convert to regular ArrayBuffer (handle SharedArrayBuffer case) - let buffer: ArrayBuffer; - if (item.buffer.buffer instanceof SharedArrayBuffer) { - buffer = new ArrayBuffer(item.buffer.buffer.byteLength); - new Uint8Array(buffer).set(new Uint8Array(item.buffer.buffer)); - } else { - buffer = item.buffer.buffer; - } - - // Send binary PCM to backend (avoid base64 to reduce CPU/GC) - const uint8 = new Uint8Array(buffer); - this.context!.ipc.send('audio-stream:pcm-binary', { - metadata: item.metadata, - data: uint8, - }); - - itemsProcessed++; - } catch (error) { - console.error('[Audio Stream] Error processing queue item:', error); - itemsProcessed++; - } - } - - this.isProcessing = false; - - // Schedule next batch if queue still has items - if (this.processingQueue.length > 0) { - // Use immediate microtask for low latency when queue is small - // Use setTimeout(0) for larger queues to prevent blocking - if (this.processingQueue.length > 8) { - setTimeout(processQueue, 0); - } else { - queueMicrotask(processQueue); - } - } - }; - - // Start processing immediately - processBatch(); - }; - - // Create AudioWorklet module inline (avoids bundling extra files). - const workletCode = `class PCMProcessor extends AudioWorkletProcessor { - constructor() { - super(); - this.port.onmessage = (e) => { - if (e.data && e.data.sampleRate) { - this._sampleRate = e.data.sampleRate; - } - }; - } - - process(inputs) { - try { - const input = inputs[0]; - if (!input || input.length === 0) return true; - - const channels = input.length; - const frames = input[0].length; - const interleaved = new Int16Array(frames * channels); - const MAX_INT16 = 0x7fff; - - if (channels === 2) { - const left = input[0]; - const right = input[1]; - for (let i = 0; i < frames; i++) { - const l = left[i] < -1 ? -1 : left[i] > 1 ? 1 : left[i]; - const r = right[i] < -1 ? -1 : right[i] > 1 ? 1 : right[i]; - interleaved[i * 2] = Math.round(l * MAX_INT16); - interleaved[i * 2 + 1] = Math.round(r * MAX_INT16); - } - } else { - for (let ch = 0; ch < channels; ch++) { - const data = input[ch]; - for (let i = 0; i < frames; i++) { - const s = data[i] < -1 ? -1 : data[i] > 1 ? 1 : data[i]; - interleaved[i * channels + ch] = Math.round(s * MAX_INT16); - } - } - } - - this.port.postMessage({ - buffer: interleaved.buffer, - metadata: { - timestamp: Date.now(), - sampleRate: this._sampleRate || 48000, - bitDepth: 16, - channels: channels, - } - }, [interleaved.buffer]); - } catch (err) { - // keep audio thread alive - } - return true; - } -} -registerProcessor('pcm-processor', PCMProcessor); -`; const blob = new Blob([workletCode], { type: 'application/javascript' }); const blobUrl = URL.createObjectURL(blob); try { - audioContext.audioWorklet.addModule(blobUrl).then(() => { - const workletNode = new AudioWorkletNode(audioContext, 'pcm-processor', { - numberOfInputs: 1, - numberOfOutputs: 0, - channelCount: channels, - }); - - workletNode.port.postMessage({ sampleRate }); - - workletNode.port.onmessage = (event) => { - if (!this.isStreaming || !this.context) return; - - try { - const ab = event.data.buffer as ArrayBuffer; - const metadata = event.data.metadata || {}; - - while (this.processingQueue.length >= MAX_QUEUE_SIZE) { - this.processingQueue.shift(); + audioContext.audioWorklet + .addModule(blobUrl) + .then(() => { + const workletNode = new AudioWorkletNode( + audioContext, + 'recorder-processor', + { + sampleRate: this.config!.sampleRate, + bufferSize: bufferSize, + }, + ); + + workletNode.port.onmessage = (event) => { + // Received a Float32Array of interleaved stereo samples from the worklet + const float32Data = event.data; + + // Convert floats [-1,1] to 16-bit PCM + const int16Buffer = new ArrayBuffer(float32Data.length * 2); + const view = new DataView(int16Buffer); + for (let i = 0; i < float32Data.length; i++) { + const s = Math.max(-1, Math.min(1, float32Data[i])); // clamp + // Scale to 16-bit signed range + view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true); } - - this.processingQueue.push({ - buffer: new Int16Array(ab), - metadata: { - timestamp: metadata.timestamp || Date.now(), - sampleRate: metadata.sampleRate || sampleRate, - bitDepth: metadata.bitDepth || 16, - channels: metadata.channels || channels, - }, - }); - - if (!this.isProcessing) queueMicrotask(processQueue); - } catch (err) { - console.error('[Audio Stream] Worklet message error:', err); - } - }; - - audioSource.connect(workletNode); - - this.scriptProcessor = undefined; - this.isStreaming = true; - }).catch((err) => { - console.error('[Audio Stream] Failed to add audio worklet module:', err); - }); + const pcmData = new Uint8Array(int16Buffer); + + // Build WAV header (16-bit, stereo, given sample rate, data length = pcmData.byteLength) + const wavHeader = createWavHeader( + audioContext.sampleRate, + 2, + pcmData.byteLength, + ); + + // Combine header + PCM data into one Uint8Array + const wavChunk = new Uint8Array(wavHeader.length + pcmData.length); + wavChunk.set(wavHeader, 0); + wavChunk.set(pcmData, wavHeader.length); + + ipc.send('audio-stream:pcm-binary', wavChunk); + }; + + audioSource.connect(workletNode); + this.isStreaming = true; + }) + .catch((err) => { + console.error( + '[Audio Stream] Failed to add audio worklet module:', + err, + ); + }); } catch (err) { console.error('[Audio Stream] AudioWorklet setup failed:', err); } this.isStreaming = true; - console.log( - '[Audio Stream] Started PCM streaming:', - `${sampleRate}Hz, ${bitDepth}-bit, ${channels} channel(s)`, - ); + console.log('[Audio Stream] Started PCM streaming:'); }, stop() { @@ -278,8 +183,6 @@ registerProcessor('pcm-processor', PCMProcessor); // Flush any remaining batched data if (this.batchBuffer && this.batchBuffer.length > 0 && this.context) { try { - - let buffer: ArrayBuffer; if (this.batchBuffer.buffer instanceof SharedArrayBuffer) { buffer = new ArrayBuffer(this.batchBuffer.buffer.byteLength); @@ -288,15 +191,7 @@ registerProcessor('pcm-processor', PCMProcessor); buffer = this.batchBuffer.buffer; } const uint8 = new Uint8Array(buffer); - this.context.ipc.send('audio-stream:pcm-binary', { - metadata: { - timestamp: Date.now(), - sampleRate: this.config?.sampleRate || 48000, - bitDepth: this.config?.bitDepth || 16, - channels: 2, - }, - data: uint8, - }); + this.context.ipc.send('audio-stream:pcm-binary', uint8); } catch { // Ignore flush errors } @@ -335,13 +230,21 @@ registerProcessor('pcm-processor', PCMProcessor); // Wait for audio to be ready if not already streaming if (!this.isStreaming && this.audioContext && this.audioSource) { // Already have audio context, start immediately - this.startStreaming(this.audioContext, this.audioSource); + this.startStreaming( + this.context!.ipc, + this.audioContext, + this.audioSource, + ); } else if (!this.isStreaming) { // Wait for audio to be ready document.addEventListener( 'peard:audio-can-play', (e) => { - this.startStreaming(e.detail.audioContext, e.detail.audioSource); + this.startStreaming( + this.context!.ipc, + e.detail.audioContext, + e.detail.audioSource, + ); }, { once: true, passive: true }, ); @@ -361,7 +264,12 @@ registerProcessor('pcm-processor', PCMProcessor); this.audioContext = undefined; this.audioSource = undefined; - } else if (config.enabled && wasEnabled && qualityChanged && this.isStreaming) { + } else if ( + config.enabled && + wasEnabled && + qualityChanged && + this.isStreaming + ) { // Quality/latency settings changed while streaming - restart with new settings if (this.audioContext && this.audioSource) { // Stop current streaming @@ -370,11 +278,11 @@ registerProcessor('pcm-processor', PCMProcessor); // Clear processing queue to prevent sending stale data this.processingQueue = []; this.isProcessing = false; - + // Store references before cleanup const audioContext = this.audioContext; const audioSource = this.audioSource; - + if (this.scriptProcessor) { try { this.scriptProcessor.disconnect(); @@ -387,13 +295,17 @@ registerProcessor('pcm-processor', PCMProcessor); // Use requestAnimationFrame to ensure cleanup is complete before restarting requestAnimationFrame(() => { // Double-check we're not streaming and have valid references - if (audioContext && audioSource && !this.isStreaming && this.context) { + if ( + audioContext && + audioSource && + !this.isStreaming && + this.context + ) { // Restart with new settings - this will send new config to backend - this.startStreaming(audioContext, audioSource); + this.startStreaming(this.context.ipc, audioContext, audioSource); } }); } } }, }); - From 88bb2a0629109260cf5d1bc6f96b9f07914c0aef Mon Sep 17 00:00:00 2001 From: ArjixWasTaken <53124886+ArjixWasTaken@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:00:27 +0300 Subject: [PATCH 39/39] bla --- src/plugins/audio-stream/backend.ts | 47 ++++++++++++++++++----------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/plugins/audio-stream/backend.ts b/src/plugins/audio-stream/backend.ts index c9f2d4eada..8eaacf9385 100644 --- a/src/plugins/audio-stream/backend.ts +++ b/src/plugins/audio-stream/backend.ts @@ -1,6 +1,8 @@ import { Hono } from 'hono'; -import { streamText } from 'hono/streaming'; +import { stream } from 'hono/streaming'; import { serve, type ServerType } from '@hono/node-server'; +import { lazy } from 'lazy-var'; +import { Mutex } from 'async-mutex'; import { createBackend } from '@/utils'; import { type AudioStreamConfig } from './config'; @@ -11,6 +13,15 @@ const META_INT = 16_000; let config: AudioStreamConfig; const broadcast = new BroadcastStream(); +const ffmpeg = lazy(async () => + (await import('@ffmpeg.wasm/main')).createFFmpeg({ + log: true, + logger: console.log, + progress: console.log, + }), +); +const ffmpegMutex = new Mutex(); + export const backend = createBackend< { app: Hono; @@ -19,24 +30,24 @@ export const backend = createBackend< AudioStreamConfig >({ app: new Hono().get('/stream', (ctx) => { + ctx.header('Transfer-Encoding', 'chunked'); const icyMetadata = ctx.req.header('Icy-Metadata'); - if (icyMetadata === '1') { - ctx.header('icy-metaint', META_INT.toString(10)); - ctx.header('icy-name', 'Pear Desktop'); - ctx.header('icy-url', 'https://github.com/pear-devs/pear-desktop'); - ctx.header( - 'icy-audio-info', - `ice-channels=2;ice-samplerate=${config.sampleRate.toString( - 10, - )};ice-bitrate=128`, - ); - ctx.header('icy-pub', '1'); - ctx.header('icy-sr', config.sampleRate.toString(10)); - ctx.header('Content-Type', 'audio/L16'); - ctx.header('Server', 'Pear Desktop'); - } - - return streamText(ctx, async (stream) => { + + ctx.header('icy-metaint', META_INT.toString(10)); + ctx.header('icy-name', 'Pear Desktop'); + ctx.header('icy-url', 'https://github.com/pear-devs/pear-desktop'); + ctx.header( + 'icy-audio-info', + `ice-channels=2;ice-samplerate=${config.sampleRate.toString( + 10, + )};ice-bitrate=128`, + ); + ctx.header('icy-pub', '1'); + ctx.header('icy-sr', config.sampleRate.toString(10)); + ctx.header('Content-Type', 'audio/L16'); + ctx.header('Server', 'Pear Desktop'); + + return stream(ctx, async (stream) => { let readable = broadcast.subscribe(); if (icyMetadata === '1') { let bytesUntilMetadata = META_INT;