// Audio mixer — registry + popover for per-channel volume control (feedBack#87). // // Plugins (or core) register a fader spec via window.feedBack.audio.registerFader(spec). // Each spec is the source of truth for its own value: the popover only calls // getValue() to render and setValue() to commit. Persistence is the plugin's // responsibility — the registry doesn't store values. // // Spec shape: // { id, label, min, max, step, defaultValue, getValue, setValue } (function () { 'use strict'; if (!window.feedBack) { console.warn('[mixer] window.feedBack missing — audio-mixer.js loaded too early'); return; } const _faders = new Map(); let _popoverEl = null; let _btnEl = null; let _open = false; let _openTimer = null; function _audioEl() { return document.getElementById('audio'); } function _audioSession() { return window.feedBack && window.feedBack.audioSession; } function _capabilities() { return window.feedBack && window.feedBack.capabilities; } async function _mixCommand(command, payload) { const api = _capabilities(); if (api && typeof api.command === 'function') { return api.command('audio-mix', command, { requester: 'core.audio-mixer', origin: 'player-ui', payload: payload || {}, timeoutMs: 2100, }); } const session = _audioSession(); if (!session) return { outcome: 'no-owner', reason: 'audio-mix is unavailable' }; if (command === 'list-faders' && typeof session.listFaders === 'function') return session.listFaders(); if (command === 'get-fader-value' && typeof session.getFaderValue === 'function') return session.getFaderValue(payload || {}); if (command === 'set-fader-value' && typeof session.setFaderValue === 'function') return session.setFaderValue(payload || {}); return { outcome: 'unsupported-command', reason: `Unsupported audio-mix command: ${command}` }; } function _registerAudioSessionFader(spec, currentValue, compatibilitySource) { const session = _audioSession(); if (!session || typeof session.registerMixParticipant !== 'function') return; const isSong = spec.id === 'song'; session.registerMixParticipant({ participantId: isSong ? 'core.song' : `fader.${spec.id}`, ownerPluginId: isSong ? 'core' : (spec.ownerPluginId || spec.id), label: spec.label || spec.id, kind: isSong ? 'song' : (spec.kind || 'plugin'), sourceMode: isSong ? 'core' : 'compatibility', logicalFaderKey: spec.logicalFaderKey || (isSong ? 'core.song:volume' : `${spec.id}:${spec.id}`), fader: { id: spec.id, label: spec.label || spec.id, unit: spec.unit || '', min: spec.min, max: spec.max, step: spec.step, defaultValue: spec.defaultValue, currentValue, getValue: spec.getValue, setValue: spec.setValue, }, operations: ['fader.get-value', 'fader.set-value'], operationHandlers: { 'fader.get-value': spec.getValue, 'fader.set-value': spec.setValue, }, availability: 'available', compatibilitySource, }); } function _recordAudioBridge(bridgeId, legacySurface, participantId, outcome, reason) { const session = _audioSession(); if (!session || typeof session.recordBridgeHit !== 'function') return; session.recordBridgeHit({ domain: bridgeId && bridgeId.startsWith('stems.') ? 'stems' : 'audio-mix', bridgeId, legacySurface, participantId, outcome: outcome || 'handled', status: 'used', reason, }); } function _reportSongRoute(routeKind, availability, reason) { const session = _audioSession(); if (!session || typeof session.setRoute !== 'function') return; session.setRoute({ routeId: 'song-output', routeKind: routeKind || (window._juceMode ? 'juce' : 'html5'), availability: availability || 'available', selectedByUser: true, fallbackReason: reason || '', }); } function _clampSongVolume(v) { const n = Number(v); if (!Number.isFinite(n)) return 80; return Math.min(100, Math.max(0, n)); } // In-memory fallback so volume changes survive the session even when // localStorage is blocked (private mode / sandboxed contexts). // Initialized from the persisted value so the fallback starts correct. let _songVolumeMemory = (() => { try { const stored = parseFloat(localStorage.getItem('volume')); return Number.isFinite(stored) ? Math.min(100, Math.max(0, stored)) : 80; } catch (e) { return 80; } })(); function _readSongVolume() { try { const stored = parseFloat(localStorage.getItem('volume')); return Number.isFinite(stored) ? _clampSongVolume(stored) : _songVolumeMemory; } catch (e) { return _songVolumeMemory; } } function _applySongVolume(v) { const normalized = _clampSongVolume(v == null ? _readSongVolume() : v); _songVolumeMemory = normalized; const a = _audioEl(); if (a) a.volume = normalized / 100; const linear = normalized / 100; // Multi-stem sloppak: the stems plugin mutes the core