mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 12:52:29 +00:00
* Update GitHub repo references from feedback* to feedBack* * rename: slopsmith -> feedBack, byron -> got-feedBack Renames across the entire codebase: - slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack - byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack - /home/byron/ -> /opt/got-feedBack/ - byron@ougsoft.com -> hi@got-feedBack.org - github.com/byrongamatos/ -> github.com/got-feedback/ - com.byron. -> com.got-feedback. - SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback - Protocol/storage strings migrated with read-old/write-new pattern - window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias Refs: #rename-slopsmith * rename: complete regen against current main + fix backward-compat alias Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of current main (3 commits had landed since the branch: #572/#554/#574), resolving the four content conflicts in favour of main's newer content (autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge). Completion fixes on top of the mechanical rename: - Re-apply rename to post-branch content the original rename never saw: window.slopsmith(.Tour) consumers in lessons.js / notifications.js / onboarding-tour.js, and the matching JS + python tests (autoplay_exit, progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env vars now match server.py (which reads FEEDBACK_SYNC_STARTUP / FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real path again. - Restore the window.slopsmith backward-compat alias dropped during conflict resolution, and move the bus aliases to AFTER the _feedBackExisting merge block so they reference the fully-assembled object (also fixes the loop_api.test.js API-surface regex, which the original PR latently broke). - Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR) and gitignore it. - Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped source labels. Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669 passed / 1683 collected with 0 import errors; zero residual slopsmith/byron except the two intentional window.slopsmith aliases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * rename: implement advertised backward-compat + prune dead community plugins Address gaps where PR #537's "Backward compatibility" section was advertised but not implemented, and clean up the community plugin list. Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured): - New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI / SLOPSMITH_PLUGINS_DIR / etc. deployments keep working. - Fix the rename collapsing plugins/__init__.py and minigames/routes.py from `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost). Storage (app.js update-channel): - Read feedBack-update-channel, fall back to legacy slopsmith-update-channel, and clear the legacy key on write — so a user's update-channel preference survives the rename instead of resetting to "stable". Community plugin list (README): the rename rewrote third-party repo URLs we don't own. Probed every one; their owners never renamed, so: - Restore the 13 live community plugins to their real slopsmith-* names. - Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle). - Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory). Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors, rename-sensitive + startup suites green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: byrongamatos <xasiklas@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2975 lines
194 KiB
JavaScript
2975 lines
194 KiB
JavaScript
// Core audio graph/session capability domain host.
|
|
(function () {
|
|
'use strict';
|
|
|
|
window.feedBack = window.feedBack || {};
|
|
const capabilities = window.feedBack.capabilities;
|
|
if (!capabilities || capabilities.version !== 1) return;
|
|
if (window.feedBack.audioSession && window.feedBack.audioSession.version === 1) return;
|
|
|
|
const SCHEMA = 'feedBack.audio_session.diagnostics.v1';
|
|
const DOMAINS = Object.freeze(['audio-mix', 'audio-input', 'audio-monitoring', 'stems']);
|
|
const MAX_OUTCOMES = 100;
|
|
const MAX_DOMAIN_ITEMS = 50;
|
|
const FADER_OPERATION_TIMEOUT_MS = 2000;
|
|
const INPUT_OPERATION_TIMEOUT_MS = 2000;
|
|
const MONITORING_OPERATION_TIMEOUT_MS = 2000;
|
|
const SELECTED_INPUT_STORAGE_KEY = 'feedBack.audioInput.selectedLogicalSourceKey';
|
|
const SELECTED_MONITORING_PROVIDER_STORAGE_KEY = 'feedBack.audioMonitoring.selectedLogicalMonitoringKey';
|
|
const DIRECT_MONITOR_STORAGE_KEY = 'feedBack.audioMonitoring.directMonitorPreference';
|
|
const OWNER_ID = 'core.audio.session';
|
|
const REQUIRED_MIX_KINDS = Object.freeze(['song', 'plugin', 'stem', 'monitoring', 'preview']);
|
|
const MIX_KINDS = new Set([...REQUIRED_MIX_KINDS, 'analyser', 'other']);
|
|
const AVAILABILITY = new Set(['available', 'pending', 'unavailable', 'disabled', 'denied', 'failed', 'incompatible', 'unknown']);
|
|
const SOURCE_MODES = new Set(['native', 'compatibility', 'core']);
|
|
const INPUT_KINDS = new Set(['instrument', 'microphone', 'desktop', 'plugin', 'virtual', 'unknown']);
|
|
const CHANNEL_SHAPES = new Set(['mono', 'stereo', 'multi', 'unknown']);
|
|
const MONITORING_STATES = new Set(['active', 'degraded', 'stopped', 'unavailable', 'denied', 'failed', 'orphaned', 'unknown']);
|
|
const MONITORING_OUTCOMES = new Set(['handled', 'denied', 'degraded', 'failed', 'no-owner', 'no-handler', 'unsupported-command', 'incompatible', 'incompatible-version', 'unavailable', 'provider-selection-required', 'user-action-required', 'stopped', 'overridden']);
|
|
// Outcomes accepted from audio-mix / audio-input provider handlers. Deliberately excludes the
|
|
// monitoring-only outcomes (provider-selection-required, user-action-required, stopped, unavailable)
|
|
// so a mix/input provider can't leak a monitoring-specific terminal outcome into generic dispatch.
|
|
const PROVIDER_OUTCOMES = new Set(['handled', 'denied', 'degraded', 'failed', 'no-owner', 'no-handler', 'unsupported-command', 'incompatible', 'incompatible-version', 'overridden']);
|
|
const AUTHORIZATION_MODES = new Set(['user-action', 'attach-existing', 'background']);
|
|
const DIRECT_MONITOR_STATES = new Set(['muted', 'unmuted', 'unsupported', 'unavailable', 'unknown']);
|
|
const DIRECT_MONITOR_CONTROLS = new Set(['supported', 'unsupported', 'unavailable', 'unknown']);
|
|
// Matched against a normalized key (camelCase split to snake_case, lowercased — see _safeInputValue)
|
|
// so concatenated names like streamHandle / rawDeviceId / nativeHandleRef are caught, not just
|
|
// underscore/exact-token forms.
|
|
const LIVE_HANDLE_KEYS = /(^|_)(stream|mediastream|audionode|node|nativehandle|handle|buffer|buffers|sample|samples|waveform|rawlabel|label|device|deviceid|hardware|hardwareid|path|secret|token|password|api|apikey)(_|$)/;
|
|
// Explicitly redaction-safe label keys (normalized snake_case form) that bypass LIVE_HANDLE_KEYS
|
|
// so a provider can pass a safe display label through enumerate/_safeInputValue to _safeInputLabel.
|
|
const SAFE_INPUT_LABEL_KEYS = new Set(['safe_label', 'display_label', 'label_pseudonym', 'label_safe']);
|
|
|
|
let sequence = 0;
|
|
const knownMixParticipants = new Map();
|
|
const knownInputSources = new Map();
|
|
const knownInputProviders = new Map();
|
|
const knownMonitoringProviders = new Map();
|
|
let currentSession = _newSession({});
|
|
|
|
function _now() { return new Date().toISOString(); }
|
|
|
|
function _id(prefix) {
|
|
sequence += 1;
|
|
return `${prefix}-${sequence}`;
|
|
}
|
|
|
|
function _string(value, fallback = '') {
|
|
const normalized = String(value == null ? '' : value).trim();
|
|
return normalized || fallback;
|
|
}
|
|
|
|
function _number(value, fallback = null) {
|
|
const numeric = Number(value);
|
|
return Number.isFinite(numeric) ? numeric : fallback;
|
|
}
|
|
|
|
function _bool(value, fallback = false) {
|
|
if (value === true || value === false) return value;
|
|
return fallback;
|
|
}
|
|
|
|
function _strings(value) {
|
|
return Array.isArray(value) ? value.map(item => _string(item)).filter(Boolean) : [];
|
|
}
|
|
|
|
function _plainObject(value) {
|
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
}
|
|
|
|
function _availability(value, fallback = 'available') {
|
|
const normalized = _string(value, fallback);
|
|
return AVAILABILITY.has(normalized) ? normalized : fallback;
|
|
}
|
|
|
|
function _mixKind(value) {
|
|
const normalized = _string(value, 'other');
|
|
return MIX_KINDS.has(normalized) ? normalized : 'other';
|
|
}
|
|
|
|
function _sourceMode(value, fallback = 'native') {
|
|
const normalized = _string(value, fallback);
|
|
return SOURCE_MODES.has(normalized) ? normalized : fallback;
|
|
}
|
|
|
|
function _inputKind(value) {
|
|
const normalized = _string(value, 'unknown');
|
|
return INPUT_KINDS.has(normalized) ? normalized : 'unknown';
|
|
}
|
|
|
|
function _channelShape(value, fallback = 'unknown') {
|
|
const normalized = _string(value, fallback);
|
|
return CHANNEL_SHAPES.has(normalized) ? normalized : fallback;
|
|
}
|
|
|
|
function _requesterId(value, fallback = 'unknown') {
|
|
// Capability dispatch does not validate `source`, so redact paths/tokens (via _boundedReason)
|
|
// before stripping to the id charset — otherwise fragments of a path/token in a malicious or
|
|
// buggy requester id would survive into redaction-safe diagnostics.
|
|
return _boundedReason(value).replace(/[^A-Za-z0-9_.:-]+/g, '-').slice(0, 80) || fallback;
|
|
}
|
|
|
|
function _redactString(value) {
|
|
// Redact absolute paths (macOS, Linux/Docker home & root, Windows). Spaces
|
|
// are allowed because song files/paths routinely contain them, but matching
|
|
// stops at line breaks, tabs, and structural delimiters so unrelated trailing
|
|
// diagnostic text on the same line is preserved rather than over-redacted.
|
|
const tail = `[^\\r\\n\\t"'\`,;(){}\\[\\]<>|]*`;
|
|
const path = new RegExp(`(?:/Users/|/home/|/root\\b/?|[A-Za-z]:\\\\)${tail}`, 'g');
|
|
return _string(value)
|
|
.replace(path, '[path]')
|
|
.replace(/\b(raw[-_ ]?audio|audio[-_ ]?buffer|sample[s]?|waveform[s]?|recording[s]?)\b/gi, '[audio-data]')
|
|
.replace(/\b(token|secret|password|api[_-]?key)=([^\s&]+)/gi, '$1=[redacted]');
|
|
}
|
|
|
|
function _boundedReason(value) {
|
|
return _redactString(value).replace(/\s+/g, ' ').slice(0, 240);
|
|
}
|
|
|
|
function _storageStatus() {
|
|
try {
|
|
if (!window.localStorage || typeof window.localStorage.getItem !== 'function') return 'unavailable';
|
|
window.localStorage.getItem(SELECTED_INPUT_STORAGE_KEY);
|
|
return 'available';
|
|
} catch (_) {
|
|
return 'failed';
|
|
}
|
|
}
|
|
|
|
function _readStorage(key) {
|
|
try {
|
|
if (!window.localStorage || typeof window.localStorage.getItem !== 'function') return '';
|
|
const stored = _string(window.localStorage.getItem(key));
|
|
// localStorage is untrusted/mutable: ignore (and clear) a persisted key that isn't already
|
|
// redaction-safe, so an injected path/token can't be restored into selected logical keys.
|
|
if (stored && key !== DIRECT_MONITOR_STORAGE_KEY && _boundedReason(stored) !== stored) {
|
|
try { window.localStorage.removeItem(key); } catch (_) { /* best effort */ }
|
|
return '';
|
|
}
|
|
return stored;
|
|
} catch (_) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function _writeStorage(key, value) {
|
|
try {
|
|
if (!window.localStorage || typeof window.localStorage.setItem !== 'function') return 'unavailable';
|
|
window.localStorage.setItem(key, _string(value));
|
|
return 'available';
|
|
} catch (_) {
|
|
return 'failed';
|
|
}
|
|
}
|
|
|
|
function _readSelectedLogicalSourceKey() {
|
|
return _readStorage(SELECTED_INPUT_STORAGE_KEY);
|
|
}
|
|
|
|
function _writeSelectedLogicalSourceKey(logicalSourceKey) {
|
|
return _writeStorage(SELECTED_INPUT_STORAGE_KEY, logicalSourceKey);
|
|
}
|
|
|
|
function _readSelectedLogicalMonitoringKey() {
|
|
return _readStorage(SELECTED_MONITORING_PROVIDER_STORAGE_KEY);
|
|
}
|
|
|
|
function _writeSelectedLogicalMonitoringKey(logicalMonitoringKey) {
|
|
return _writeStorage(SELECTED_MONITORING_PROVIDER_STORAGE_KEY, logicalMonitoringKey);
|
|
}
|
|
|
|
function _readDirectMonitorPreference() {
|
|
const stored = _readStorage(DIRECT_MONITOR_STORAGE_KEY);
|
|
return stored === 'muted' || stored === 'unmuted' ? stored : 'muted';
|
|
}
|
|
|
|
function _writeDirectMonitorPreference(state) {
|
|
return _writeStorage(DIRECT_MONITOR_STORAGE_KEY, state === 'unmuted' ? 'unmuted' : 'muted');
|
|
}
|
|
|
|
function _storedSelectedInput() {
|
|
const logicalSourceKey = _readSelectedLogicalSourceKey();
|
|
if (!logicalSourceKey) return null;
|
|
return {
|
|
logicalSourceKey,
|
|
sourceId: '',
|
|
providerId: '',
|
|
availability: 'unavailable',
|
|
restored: true,
|
|
restoreStatus: 'missing-provider',
|
|
selectedAt: '',
|
|
lastSelectedAt: '',
|
|
lastRestoredAt: _now(),
|
|
};
|
|
}
|
|
|
|
function _storedSelectedMonitoringProvider() {
|
|
const logicalMonitoringKey = _readSelectedLogicalMonitoringKey();
|
|
if (!logicalMonitoringKey) return null;
|
|
return {
|
|
providerId: '',
|
|
logicalMonitoringKey,
|
|
availability: 'unavailable',
|
|
restored: true,
|
|
restoreStatus: 'missing-provider',
|
|
selectedAt: '',
|
|
lastSelectedAt: '',
|
|
lastRestoredAt: _now(),
|
|
requesterId: 'storage',
|
|
};
|
|
}
|
|
|
|
function _storedDirectMonitorPreference() {
|
|
return {
|
|
state: _readDirectMonitorPreference(),
|
|
control: 'unknown',
|
|
preference: _readDirectMonitorPreference(),
|
|
applied: false,
|
|
reason: '',
|
|
lastChangedAt: '',
|
|
};
|
|
}
|
|
|
|
function _clone(value) {
|
|
if (value == null || typeof value !== 'object') return value;
|
|
try { return JSON.parse(JSON.stringify(value)); }
|
|
catch (_) { return null; }
|
|
}
|
|
|
|
function _safeInputValue(value, depth = 0, seen = null) {
|
|
if (typeof value === 'string') return _boundedReason(value);
|
|
if (value == null || typeof value === 'number' || typeof value === 'boolean') return value;
|
|
if (depth > 6) return '[truncated]';
|
|
if (typeof value === 'object') {
|
|
const refs = seen || new WeakSet();
|
|
if (refs.has(value)) return '[circular]';
|
|
refs.add(value);
|
|
if (Array.isArray(value)) return value.slice(0, 20).map(item => _safeInputValue(item, depth + 1, refs));
|
|
const out = {};
|
|
for (const [key, item] of Object.entries(value).slice(0, 30)) {
|
|
// Normalize camelCase to snake_case so concatenated keys (streamHandle, rawDeviceId,
|
|
// nativeHandleRef) are caught by the segment-delimited LIVE_HANDLE_KEYS pattern.
|
|
const normalizedKey = _string(key).replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2').toLowerCase();
|
|
// Explicitly-safe label keys are allowed through (the `label` token would otherwise
|
|
// strip them) so providers can supply a redaction-safe display label via enumerate.
|
|
if (!SAFE_INPUT_LABEL_KEYS.has(normalizedKey) && LIVE_HANDLE_KEYS.test(normalizedKey)) continue;
|
|
if (typeof item === 'function') continue;
|
|
out[key] = _safeInputValue(item, depth + 1, refs);
|
|
}
|
|
return out;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function _safeInputLabel(source, fallback) {
|
|
const explicit = _string(source.safeLabel || source.displayLabel || source.labelPseudonym || source.label || source.name, fallback);
|
|
const redacted = _boundedReason(explicit).slice(0, 80);
|
|
const suspicious = redacted !== explicit || /\b(serial|hardware|device|token|secret|password|api[_-]?key)\b/i.test(redacted) || /\d{4,}/.test(redacted);
|
|
if (suspicious && source.labelSafe !== true && !source.safeLabel && !source.displayLabel && !source.labelPseudonym) return fallback;
|
|
return redacted || fallback;
|
|
}
|
|
|
|
function _newSession(options) {
|
|
const source = _plainObject(options);
|
|
return {
|
|
sessionId: _string(source.sessionId, 'main:idle'),
|
|
playerId: _string(source.playerId, 'main'),
|
|
songKey: _string(source.songKey, ''),
|
|
songFormat: _string(source.songFormat, 'unknown'),
|
|
state: _string(source.state, 'idle'),
|
|
routeState: _normalizeRoute(source.routeState || source.route || {}),
|
|
analyserState: _normalizeAnalyser(source.analyserState || source.analyser || {}),
|
|
mixParticipants: new Map(),
|
|
inputProviders: new Map(),
|
|
inputSources: new Map(),
|
|
selectedInput: _storedSelectedInput(),
|
|
openInputSessions: new Map(),
|
|
storageStatus: _storageStatus(),
|
|
monitoringProviders: new Map(),
|
|
selectedMonitoringProvider: _storedSelectedMonitoringProvider(),
|
|
directMonitor: _storedDirectMonitorPreference(),
|
|
monitoringSessions: new Map(),
|
|
stemOwner: null,
|
|
stemClaims: new Map(),
|
|
bridges: new Map(),
|
|
outcomes: [],
|
|
createdAt: _now(),
|
|
updatedAt: _now(),
|
|
};
|
|
}
|
|
|
|
function _touch() {
|
|
currentSession.updatedAt = _now();
|
|
_contributeDiagnostics();
|
|
}
|
|
|
|
function _normalizeRoute(route) {
|
|
const source = _plainObject(route);
|
|
return {
|
|
routeId: _string(source.routeId || source.id, 'route:unknown'),
|
|
routeKind: _string(source.routeKind || source.kind, 'unknown'),
|
|
availability: _string(source.availability, 'unknown'),
|
|
selectedByUser: source.selectedByUser === true,
|
|
devicePseudonym: _string(source.devicePseudonym || source.deviceId || source.deviceLabel, ''),
|
|
fallbackReason: _boundedReason(source.fallbackReason || source.reason),
|
|
lastChangedAt: _string(source.lastChangedAt, _now()),
|
|
};
|
|
}
|
|
|
|
function _normalizeAnalyser(analyser) {
|
|
const source = _plainObject(analyser);
|
|
return {
|
|
source: _string(source.source, 'unavailable'),
|
|
availability: _string(source.availability, 'unavailable'),
|
|
participantId: _string(source.participantId || source.providerId, ''),
|
|
reason: _boundedReason(source.reason),
|
|
lastChangedAt: _string(source.lastChangedAt, _now()),
|
|
};
|
|
}
|
|
|
|
function _normalizeFader(fader) {
|
|
const source = _plainObject(fader);
|
|
if (!Object.keys(source).length) return null;
|
|
const min = _number(source.min, 0);
|
|
const max = _number(source.max, 1);
|
|
const step = _number(source.step, 0.01);
|
|
const defaultValue = _clamp(_number(source.defaultValue, min), min, max > min ? max : min + 1);
|
|
const currentValue = _number(source.currentValue, _number(source.value, defaultValue));
|
|
return {
|
|
id: _string(source.faderId || source.id, 'main'),
|
|
label: _string(source.label, 'Volume'),
|
|
unit: _string(source.unit, ''),
|
|
min,
|
|
max: max > min ? max : min + 1,
|
|
step: step > 0 ? step : 0.01,
|
|
defaultValue,
|
|
currentValue: _clamp(currentValue, min, max > min ? max : min + 1),
|
|
lastRequestedValue: _number(source.lastRequestedValue, null),
|
|
lastRejectedValue: _number(source.lastRejectedValue, null),
|
|
userAdjustable: source.userAdjustable !== false && source.readOnly !== true,
|
|
availability: _availability(source.availability, 'available'),
|
|
lastCommittedAt: _string(source.lastCommittedAt, ''),
|
|
};
|
|
}
|
|
|
|
function _clamp(value, min, max) {
|
|
const numeric = Number(value);
|
|
if (!Number.isFinite(numeric)) return min;
|
|
return Math.min(max, Math.max(min, numeric));
|
|
}
|
|
|
|
function _normalizeOperationHandlers(source) {
|
|
const handlers = {};
|
|
const operationHandlers = _plainObject(source.operationHandlers || source.handlers || source.providerOperations);
|
|
for (const [key, value] of Object.entries(operationHandlers)) {
|
|
if (typeof value === 'function') handlers[key] = value;
|
|
}
|
|
if (typeof source.getValue === 'function') handlers['fader.get-value'] = source.getValue;
|
|
if (typeof source.setValue === 'function') handlers['fader.set-value'] = source.setValue;
|
|
const fader = _plainObject(source.fader);
|
|
if (typeof fader.getValue === 'function') handlers['fader.get-value'] = fader.getValue;
|
|
if (typeof fader.setValue === 'function') handlers['fader.set-value'] = fader.setValue;
|
|
return handlers;
|
|
}
|
|
|
|
function _logicalFaderKey(participant, source) {
|
|
const explicit = source.logicalFaderKey || source.logicalKey || (source.fader && source.fader.logicalFaderKey);
|
|
if (explicit) return _string(explicit);
|
|
const faderId = participant.fader ? participant.fader.id : 'main';
|
|
const owner = participant.ownerPluginId || participant.participantId;
|
|
return `${owner}:${faderId}`;
|
|
}
|
|
|
|
function _normalizeParticipant(spec) {
|
|
const source = _plainObject(spec);
|
|
const participantId = _string(source.participantId || source.id);
|
|
if (!participantId) return null;
|
|
const compatibilitySource = _string(source.compatibilitySource || source.legacySurface, '');
|
|
const sourceMode = _sourceMode(source.sourceMode, source.ownerPluginId === 'core' || source.pluginId === 'core' || source.source === 'core' ? 'core' : (compatibilitySource ? 'compatibility' : 'native'));
|
|
return {
|
|
participantId,
|
|
ownerPluginId: _string(source.ownerPluginId || source.pluginId || source.source, 'core'),
|
|
label: _string(source.label || source.name, participantId),
|
|
kind: _mixKind(source.kind),
|
|
fader: _normalizeFader(source.fader),
|
|
operations: _strings(source.operations || source.providerOperations),
|
|
operationHandlers: _normalizeOperationHandlers(source),
|
|
availability: _availability(source.availability, source.disabled ? 'disabled' : (currentSession.state === 'active' ? 'available' : 'pending')),
|
|
sourceMode,
|
|
compatibilitySource,
|
|
supersededBy: _string(source.supersededBy, ''),
|
|
registeredAt: _string(source.registeredAt, _now()),
|
|
lastSeenAt: _now(),
|
|
};
|
|
}
|
|
|
|
function _summaryParticipant(participant) {
|
|
const clone = _clone(participant);
|
|
if (clone) delete clone.operationHandlers;
|
|
return clone;
|
|
}
|
|
|
|
function _summaryFader(participant, options = {}) {
|
|
if (!participant || !participant.fader) return null;
|
|
const fader = participant.fader;
|
|
const availability = options.availability || _effectiveFaderAvailability(participant);
|
|
return {
|
|
participantId: participant.participantId,
|
|
ownerPluginId: participant.ownerPluginId,
|
|
label: participant.label,
|
|
kind: participant.kind,
|
|
sourceMode: participant.sourceMode,
|
|
compatibilitySource: participant.compatibilitySource,
|
|
supersededBy: participant.supersededBy || '',
|
|
logicalFaderKey: participant.logicalFaderKey,
|
|
faderId: fader.id,
|
|
id: fader.id,
|
|
faderKey: _faderKey(participant.participantId, fader.id),
|
|
faderLabel: fader.label,
|
|
unit: fader.unit,
|
|
min: fader.min,
|
|
max: fader.max,
|
|
step: fader.step,
|
|
defaultValue: fader.defaultValue,
|
|
currentValue: fader.currentValue,
|
|
lastRequestedValue: fader.lastRequestedValue,
|
|
lastRejectedValue: fader.lastRejectedValue,
|
|
userAdjustable: fader.userAdjustable && availability === 'available',
|
|
availability,
|
|
lastCommittedAt: fader.lastCommittedAt,
|
|
};
|
|
}
|
|
|
|
function _normalizeSource(spec) {
|
|
const source = _plainObject(spec);
|
|
const sourceId = _string(source.sourceId || source.id);
|
|
const providerId = _string(source.providerId || source.participantId);
|
|
const logicalSourceKey = _string(source.logicalSourceKey || source.logicalKey || source.sourceKey);
|
|
if (!sourceId || !providerId || !logicalSourceKey) return null;
|
|
const channelSummary = _normalizeChannelSummary(source.channelSummary || source.channel || source);
|
|
const now = _now();
|
|
const compatibilitySource = _string(source.compatibilitySource || source.legacySurface, '');
|
|
const sourceMode = _sourceMode(source.sourceMode, compatibilitySource ? 'compatibility' : 'native');
|
|
return {
|
|
sourceId,
|
|
logicalSourceKey,
|
|
providerId,
|
|
ownerPluginId: _string(source.ownerPluginId || source.pluginId || providerId, providerId),
|
|
kind: _inputKind(source.kind),
|
|
label: _safeInputLabel(source, _string(source.diagnosticsPseudonym || source.pseudonym, 'Input source')),
|
|
channelSummary,
|
|
channelCount: channelSummary.channelCount,
|
|
availability: _availability(source.availability, source.disabled ? 'disabled' : 'available'),
|
|
selected: source.selected === true,
|
|
sourceMode,
|
|
compatibilitySource,
|
|
supersededBy: _string(source.supersededBy, ''),
|
|
reason: _boundedReason(source.reason || source.failureReason || source.unavailableReason),
|
|
operations: _strings(source.operations || source.providerOperations),
|
|
diagnosticsPseudonym: _string(source.diagnosticsPseudonym || source.pseudonym || sourceId, sourceId),
|
|
registeredAt: _string(source.registeredAt, now),
|
|
lastSeenAt: now,
|
|
lastChangedAt: _string(source.lastChangedAt, now),
|
|
};
|
|
}
|
|
|
|
function _normalizeChannelSummary(value) {
|
|
const source = _plainObject(value);
|
|
const count = _number(source.channelCount ?? source.channels, null);
|
|
const inferred = count === 1 ? 'mono' : (count === 2 ? 'stereo' : (count && count > 2 ? 'multi' : 'unknown'));
|
|
const shape = _channelShape(source.channelShape || source.shape, inferred);
|
|
const supports = _strings(source.supports || source.supportedShapes).map(item => _channelShape(item, '')).filter(Boolean);
|
|
return {
|
|
channelCount: count,
|
|
channelShape: shape,
|
|
supports: supports.length ? Array.from(new Set(supports)) : (shape !== 'unknown' ? [shape] : []),
|
|
defaultShape: _channelShape(source.defaultShape, shape),
|
|
reason: _boundedReason(source.reason),
|
|
};
|
|
}
|
|
|
|
function _normalizeInputProvider(source, input) {
|
|
const providerId = input.providerId;
|
|
const existing = currentSession.inputProviders.get(providerId) || knownInputProviders.get(providerId) || {};
|
|
const operationHandlers = { ...(existing.operationHandlers || {}), ..._normalizeOperationHandlers(source) };
|
|
const operations = Array.from(new Set([...(existing.operations || []), ...input.operations, ...Object.keys(operationHandlers)]));
|
|
const provider = {
|
|
providerId,
|
|
ownerPluginId: _string(source.ownerPluginId || source.pluginId || existing.ownerPluginId || input.ownerPluginId, input.ownerPluginId),
|
|
label: _safeInputLabel(source, _string(existing.label || providerId, providerId)),
|
|
sourceMode: input.sourceMode,
|
|
operations,
|
|
operationHandlers,
|
|
availability: _availability(source.providerAvailability || source.availability || existing.availability, 'available'),
|
|
version: _number(source.version || existing.version, 1),
|
|
registeredAt: existing.registeredAt || _now(),
|
|
lastSeenAt: _now(),
|
|
};
|
|
knownInputProviders.set(providerId, provider);
|
|
currentSession.inputProviders.set(providerId, provider);
|
|
return provider;
|
|
}
|
|
|
|
function _inputSourcePriority(source) {
|
|
if (source.sourceMode === 'core') return 0;
|
|
if (source.sourceMode === 'native') return 1;
|
|
return 2;
|
|
}
|
|
|
|
function _inputSourceAvailabilityRank(source) {
|
|
return source.availability === 'available' ? 0 : (source.availability === 'pending' ? 1 : 2);
|
|
}
|
|
|
|
function _sourceRecordKey(source) {
|
|
return `${source.providerId}:${source.sourceMode}:${source.logicalSourceKey}`;
|
|
}
|
|
|
|
function _findInputSource(query = {}) {
|
|
const source = _plainObject(query);
|
|
const sourceId = _string(source.sourceId || source.id);
|
|
const providerId = _string(source.providerId);
|
|
const logicalSourceKey = _string(source.logicalSourceKey || source.logicalKey || source.sourceKey);
|
|
if (sourceId && currentSession.inputSources.has(sourceId)) return currentSession.inputSources.get(sourceId);
|
|
let matches = Array.from(currentSession.inputSources.values()).filter(item => item.logicalSourceKey === logicalSourceKey);
|
|
// When a providerId is supplied (e.g. unregister-source), prefer that provider's source so a
|
|
// native + compatibility duplicate sharing one logical key resolves to the intended one.
|
|
if (providerId) {
|
|
const byProvider = matches.filter(item => item.providerId === providerId);
|
|
if (byProvider.length) matches = byProvider;
|
|
}
|
|
if (!matches.length) return null;
|
|
matches.sort((a, b) => _inputSourcePriority(a) - _inputSourcePriority(b) || _inputSourceAvailabilityRank(a) - _inputSourceAvailabilityRank(b) || a.providerId.localeCompare(b.providerId) || a.sourceId.localeCompare(b.sourceId));
|
|
return matches[0];
|
|
}
|
|
|
|
function _visibleInputSources() {
|
|
_refreshInputDuplicateSuppression();
|
|
return Array.from(currentSession.inputSources.values())
|
|
.filter(source => !source.supersededBy)
|
|
.sort((a, b) => _inputSourcePriority(a) - _inputSourcePriority(b) || a.label.localeCompare(b.label) || a.providerId.localeCompare(b.providerId));
|
|
}
|
|
|
|
function _refreshInputDuplicateSuppression() {
|
|
const groups = new Map();
|
|
for (const source of currentSession.inputSources.values()) {
|
|
source.supersededBy = '';
|
|
const list = groups.get(source.logicalSourceKey) || [];
|
|
list.push(source);
|
|
groups.set(source.logicalSourceKey, list);
|
|
}
|
|
for (const list of groups.values()) {
|
|
if (list.length < 2) continue;
|
|
list.sort((a, b) => _inputSourcePriority(a) - _inputSourcePriority(b) || _inputSourceAvailabilityRank(a) - _inputSourceAvailabilityRank(b) || a.providerId.localeCompare(b.providerId) || a.sourceId.localeCompare(b.sourceId));
|
|
const winner = list[0];
|
|
for (const loser of list.slice(1)) {
|
|
loser.supersededBy = winner.sourceId;
|
|
}
|
|
}
|
|
_syncSelectedInput();
|
|
}
|
|
|
|
function _recordInputDuplicateSuppression() {
|
|
for (const source of currentSession.inputSources.values()) {
|
|
if (!source.supersededBy || source.sourceMode !== 'compatibility') continue;
|
|
recordBridgeHit({
|
|
domain: 'audio-input',
|
|
bridgeId: 'audio-input.legacy-source',
|
|
legacySurface: source.compatibilitySource || 'plugin/browser input source handoff',
|
|
participantId: source.providerId,
|
|
logicalSourceKey: source.logicalSourceKey,
|
|
outcome: 'overridden',
|
|
status: 'overshadowed',
|
|
reason: `Native source owns logical source ${source.logicalSourceKey}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
function _monitoringState(value, fallback = 'unknown') {
|
|
const normalized = _string(value, fallback);
|
|
if (MONITORING_STATES.has(normalized)) return normalized;
|
|
// Callers sometimes pass a non-state outcome (e.g. 'no-handler') as the fallback; clamp it
|
|
// so a session.state is always a declared MONITORING_STATES member for inspectors/events.
|
|
return MONITORING_STATES.has(fallback) ? fallback : 'unknown';
|
|
}
|
|
|
|
function _monitoringOutcome(value, fallback = 'handled') {
|
|
const normalized = _string(value, fallback);
|
|
return MONITORING_OUTCOMES.has(normalized) ? normalized : fallback;
|
|
}
|
|
|
|
function _authorizationMode(value) {
|
|
const normalized = _string(value, 'background');
|
|
return AUTHORIZATION_MODES.has(normalized) ? normalized : 'background';
|
|
}
|
|
|
|
function _directMonitorState(value, fallback = 'unknown') {
|
|
const normalized = _string(value, fallback);
|
|
return DIRECT_MONITOR_STATES.has(normalized) ? normalized : fallback;
|
|
}
|
|
|
|
function _directMonitorControl(value, fallback = 'unknown') {
|
|
const normalized = _string(value, fallback);
|
|
return DIRECT_MONITOR_CONTROLS.has(normalized) ? normalized : fallback;
|
|
}
|
|
|
|
function _normalizeDirectMonitor(value = {}) {
|
|
const source = _plainObject(value);
|
|
const preference = source.preference === 'muted' || source.preference === 'unmuted' ? source.preference : _readDirectMonitorPreference();
|
|
return {
|
|
state: _directMonitorState(source.state, preference || 'unknown'),
|
|
control: _directMonitorControl(source.control, source.supported === false ? 'unsupported' : 'unknown'),
|
|
preference,
|
|
applied: source.applied === true,
|
|
reason: _boundedReason(source.reason),
|
|
lastChangedAt: _string(source.lastChangedAt, source.changedAt || ''),
|
|
};
|
|
}
|
|
|
|
function _normalizeLatencySummary(value = {}) {
|
|
const source = _plainObject(value);
|
|
if (!Object.keys(source).length) return null;
|
|
return {
|
|
// bucket/level is provider-supplied and surfaced in diagnostics, so bound it like reason.
|
|
bucket: _boundedReason(source.bucket || source.level),
|
|
ms: _number(source.ms ?? source.latencyMs, null),
|
|
reason: _boundedReason(source.reason),
|
|
};
|
|
}
|
|
|
|
function _logicalMonitoringKey(provider, source) {
|
|
const explicit = source.logicalMonitoringKey || source.logicalKey || source.monitoringKey;
|
|
if (explicit) return _string(explicit);
|
|
return `${provider.sourceMode}:${provider.providerId}`;
|
|
}
|
|
|
|
function _normalizeMonitoringProvider(spec) {
|
|
const source = _plainObject(spec);
|
|
// providerId is surfaced in diagnostics/events and used for selection, so normalize it the same
|
|
// way requester ids are (redact paths/tokens, restrict to the id charset, bound length) before it
|
|
// becomes session state — a buggy/malicious provider must not leak a path/token through it.
|
|
const providerId = _boundedReason(_string(source.providerId || source.participantId || source.id)).replace(/[^A-Za-z0-9_.:-]+/g, '-').slice(0, 80);
|
|
if (!providerId) return null;
|
|
const existing = currentSession.monitoringProviders.get(providerId) || knownMonitoringProviders.get(providerId) || {};
|
|
const compatibilitySource = _string(source.compatibilitySource || source.legacySurface || existing.compatibilitySource, '');
|
|
const sourceMode = _sourceMode(source.sourceMode, compatibilitySource ? 'compatibility' : (source.ownerPluginId === 'core' || source.pluginId === 'core' || source.source === 'core' ? 'core' : 'native'));
|
|
const operationHandlers = { ...(existing.operationHandlers || {}), ..._normalizeOperationHandlers(source) };
|
|
const operations = Array.from(new Set([...(existing.operations || []), ..._strings(source.operations || source.providerOperations), ...Object.keys(operationHandlers)]));
|
|
const provider = {
|
|
providerId,
|
|
ownerPluginId: _string(source.ownerPluginId || source.pluginId || existing.ownerPluginId || providerId, providerId),
|
|
label: _safeInputLabel(source, _string(existing.label || providerId, providerId)),
|
|
sourceMode,
|
|
compatibilitySource,
|
|
logicalMonitoringKey: _string(source.logicalMonitoringKey || source.logicalKey || source.monitoringKey || existing.logicalMonitoringKey, ''),
|
|
availability: _availability(source.providerAvailability || source.availability || existing.availability, source.disabled ? 'disabled' : 'available'),
|
|
operations,
|
|
operationHandlers,
|
|
version: _number(source.version || existing.version, 1),
|
|
directMonitor: _normalizeDirectMonitor(source.directMonitor || existing.directMonitor || {}),
|
|
latencySummary: _normalizeLatencySummary(source.latencySummary || existing.latencySummary || {}),
|
|
reason: _boundedReason(source.reason || source.failureReason || source.unavailableReason || existing.reason),
|
|
supersededBy: _string(source.supersededBy || existing.supersededBy, ''),
|
|
registeredAt: existing.registeredAt || _string(source.registeredAt, _now()),
|
|
lastSeenAt: _now(),
|
|
lastChangedAt: _string(source.lastChangedAt || existing.lastChangedAt, _now()),
|
|
};
|
|
provider.logicalMonitoringKey = _logicalMonitoringKey(provider, source);
|
|
return provider;
|
|
}
|
|
|
|
function _monitoringProviderPriority(provider) {
|
|
if (provider.sourceMode === 'core') return 0;
|
|
if (provider.sourceMode === 'native') return 1;
|
|
return 2;
|
|
}
|
|
|
|
function _findMonitoringProvider(query = {}) {
|
|
const source = _plainObject(query);
|
|
const providerId = _string(source.providerId || source.participantId || source.id);
|
|
const logicalMonitoringKey = _string(source.logicalMonitoringKey || source.logicalKey || source.monitoringKey);
|
|
if (providerId && currentSession.monitoringProviders.has(providerId)) return currentSession.monitoringProviders.get(providerId);
|
|
const matches = Array.from(currentSession.monitoringProviders.values()).filter(item => item.logicalMonitoringKey === logicalMonitoringKey);
|
|
if (!matches.length) return null;
|
|
matches.sort((a, b) => _monitoringProviderPriority(a) - _monitoringProviderPriority(b) || _inputSourceAvailabilityRank(a) - _inputSourceAvailabilityRank(b) || a.providerId.localeCompare(b.providerId));
|
|
return matches[0];
|
|
}
|
|
|
|
function _refreshMonitoringDuplicateSuppression() {
|
|
const groups = new Map();
|
|
for (const provider of currentSession.monitoringProviders.values()) {
|
|
provider.supersededBy = '';
|
|
const list = groups.get(provider.logicalMonitoringKey) || [];
|
|
list.push(provider);
|
|
groups.set(provider.logicalMonitoringKey, list);
|
|
}
|
|
for (const list of groups.values()) {
|
|
if (list.length < 2) continue;
|
|
list.sort((a, b) => _monitoringProviderPriority(a) - _monitoringProviderPriority(b) || _inputSourceAvailabilityRank(a) - _inputSourceAvailabilityRank(b) || a.providerId.localeCompare(b.providerId));
|
|
const winner = list[0];
|
|
for (const loser of list.slice(1)) loser.supersededBy = winner.providerId;
|
|
}
|
|
_syncSelectedMonitoringProvider();
|
|
}
|
|
|
|
function _recordMonitoringDuplicateSuppression() {
|
|
for (const provider of currentSession.monitoringProviders.values()) {
|
|
if (!provider.supersededBy || provider.sourceMode !== 'compatibility') continue;
|
|
recordBridgeHit({
|
|
domain: 'audio-monitoring',
|
|
bridgeId: provider.compatibilitySource || 'audio-monitoring.legacy-provider',
|
|
legacySurface: provider.compatibilitySource || 'plugin/browser monitoring handoff',
|
|
participantId: provider.providerId,
|
|
outcome: 'overridden',
|
|
status: 'overshadowed',
|
|
reason: `Native monitoring provider ${provider.supersededBy} owns logical path ${provider.logicalMonitoringKey}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
function _visibleMonitoringProviders() {
|
|
_refreshMonitoringDuplicateSuppression();
|
|
return Array.from(currentSession.monitoringProviders.values())
|
|
.filter(provider => !provider.supersededBy)
|
|
.sort((a, b) => _monitoringProviderPriority(a) - _monitoringProviderPriority(b) || a.label.localeCompare(b.label) || a.providerId.localeCompare(b.providerId));
|
|
}
|
|
|
|
function _syncSelectedMonitoringProvider() {
|
|
const selected = currentSession.selectedMonitoringProvider;
|
|
if (!selected || !selected.logicalMonitoringKey) return null;
|
|
const provider = _findMonitoringProvider({ logicalMonitoringKey: selected.logicalMonitoringKey });
|
|
if (provider && !provider.supersededBy) {
|
|
selected.providerId = provider.providerId;
|
|
selected.availability = provider.availability;
|
|
selected.restoreStatus = provider.availability === 'available' ? (selected.restored ? 'restored' : 'available') : provider.availability;
|
|
if (selected.restored && !selected.lastRestoredAt) selected.lastRestoredAt = _now();
|
|
} else {
|
|
selected.providerId = '';
|
|
selected.availability = 'unavailable';
|
|
selected.restoreStatus = selected.restored ? 'missing-provider' : 'unavailable';
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
function _makeSelectedMonitoringProvider(provider, requester) {
|
|
const now = _now();
|
|
return {
|
|
providerId: provider.providerId,
|
|
logicalMonitoringKey: provider.logicalMonitoringKey,
|
|
availability: provider.availability,
|
|
restored: false,
|
|
restoreStatus: provider.availability === 'available' ? 'available' : provider.availability,
|
|
selectedAt: now,
|
|
lastSelectedAt: now,
|
|
lastRestoredAt: '',
|
|
requesterId: _requesterId(requester),
|
|
};
|
|
}
|
|
|
|
function _summaryMonitoringProvider(provider) {
|
|
if (!provider) return null;
|
|
return {
|
|
providerId: provider.providerId,
|
|
ownerPluginId: provider.ownerPluginId,
|
|
label: provider.label,
|
|
sourceMode: provider.sourceMode,
|
|
compatibilitySource: provider.compatibilitySource || '',
|
|
logicalMonitoringKey: provider.logicalMonitoringKey,
|
|
availability: provider.availability,
|
|
operations: _strings(provider.operations),
|
|
directMonitor: _clone(provider.directMonitor),
|
|
latencySummary: _clone(provider.latencySummary),
|
|
reason: provider.reason || '',
|
|
supersededBy: provider.supersededBy || '',
|
|
registeredAt: provider.registeredAt || '',
|
|
lastSeenAt: provider.lastSeenAt || '',
|
|
lastChangedAt: provider.lastChangedAt || '',
|
|
};
|
|
}
|
|
|
|
function _summarySelectedMonitoringProvider(selected) {
|
|
if (!selected) return null;
|
|
return {
|
|
providerId: selected.providerId || '',
|
|
logicalMonitoringKey: selected.logicalMonitoringKey || '',
|
|
availability: selected.availability || 'unavailable',
|
|
restored: !!selected.restored,
|
|
restoreStatus: selected.restoreStatus || 'not-selected',
|
|
selectedAt: selected.selectedAt || selected.lastSelectedAt || '',
|
|
lastSelectedAt: selected.lastSelectedAt || '',
|
|
lastRestoredAt: selected.lastRestoredAt || '',
|
|
requesterId: selected.requesterId || '',
|
|
};
|
|
}
|
|
|
|
function _monitoringSourceRef(openResult, fallback = {}) {
|
|
const payload = _plainObject(openResult && openResult.payload ? openResult.payload : openResult);
|
|
const source = _plainObject(fallback);
|
|
return {
|
|
logicalSourceKey: _string(payload.logicalSourceKey || source.logicalSourceKey, ''),
|
|
sourceId: _string(payload.sourceId || source.sourceId, ''),
|
|
providerId: _string(payload.providerId || source.providerId, ''),
|
|
availability: _string(payload.state || payload.availability || source.availability, ''),
|
|
channelShape: _channelShape(payload.channelShape || source.channelShape, 'unknown'),
|
|
openSessionId: _string(payload.openSessionId || payload.openInputSessionId || source.openSessionId, ''),
|
|
};
|
|
}
|
|
|
|
function _monitoringSessionKey(provider, sourceRef, requiredChannelShape, directMonitorPolicy) {
|
|
return [
|
|
provider && provider.logicalMonitoringKey,
|
|
sourceRef && sourceRef.logicalSourceKey,
|
|
_channelShape(requiredChannelShape, 'unknown'),
|
|
directMonitorPolicy || 'default',
|
|
].map(item => _string(item, 'unknown')).join('::');
|
|
}
|
|
|
|
function _findCompatibleMonitoringSession(provider, sourceRef, requiredChannelShape, directMonitorPolicy) {
|
|
const key = _monitoringSessionKey(provider, sourceRef, requiredChannelShape, directMonitorPolicy);
|
|
for (const session of currentSession.monitoringSessions.values()) {
|
|
if (session.sessionKey === key && (session.state === 'active' || session.state === 'degraded')) return session;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function _monitoringRequesterRef(source = {}) {
|
|
return {
|
|
requesterId: _requesterId(source.requesterId || source.requester || source.source, 'unknown'),
|
|
purpose: _boundedReason(source.purpose).slice(0, 80),
|
|
requiredChannelShape: _channelShape(source.requiredChannelShape, 'unknown'),
|
|
directMonitorRequirement: _directMonitorState(source.directMonitorRequirement, source.directMonitorRequirement ? 'unknown' : 'unknown'),
|
|
status: _string(source.status, ''),
|
|
reason: _boundedReason(source.reason),
|
|
attachedAt: _string(source.attachedAt, _now()),
|
|
};
|
|
}
|
|
|
|
function _withMonitoringTimeout(promise, operation) {
|
|
let timer = null;
|
|
const timeout = new Promise((_, reject) => {
|
|
timer = setTimeout(() => {
|
|
const err = new Error(`${operation} timed out after ${MONITORING_OPERATION_TIMEOUT_MS}ms`);
|
|
err.timedOut = true;
|
|
reject(err);
|
|
}, MONITORING_OPERATION_TIMEOUT_MS);
|
|
});
|
|
return Promise.race([Promise.resolve(promise), timeout]).finally(() => { if (timer !== null) clearTimeout(timer); });
|
|
}
|
|
|
|
function _monitoringProviderOutcome(raw, fallbackPayload = {}, voidIsFailure = false) {
|
|
// Per the control-plane contract a malformed (non-object) provider response is a failure, not a
|
|
// silently-'handled' active session. A void return (null/undefined) is lenient for stop/status
|
|
// (some handlers legitimately return nothing) but a failure for monitoring.start (voidIsFailure),
|
|
// which must not report monitoring active when the provider returned no result.
|
|
const isVoid = raw === null || raw === undefined;
|
|
if ((isVoid && voidIsFailure) || (!isVoid && (typeof raw !== 'object' || Array.isArray(raw)))) {
|
|
return { outcome: 'failed', status: 'failed', reason: _boundedReason(isVoid ? 'Monitoring provider returned no result' : 'Malformed monitoring provider response'), payload: fallbackPayload };
|
|
}
|
|
if (isVoid) {
|
|
// Void return tolerated for stop/status: treat as handled but with a neutral 'unknown' status
|
|
// (never 'active', which would fake an active session) and surface fallbackPayload verbatim.
|
|
return { outcome: 'handled', status: 'unknown', reason: '', payload: fallbackPayload };
|
|
}
|
|
const source = _plainObject(raw);
|
|
const outcome = _monitoringOutcome(source.outcome, 'handled');
|
|
const payload = _safeInputValue(source.payload || source.session || source.summary || source, 0) || fallbackPayload;
|
|
return {
|
|
outcome,
|
|
status: _monitoringState(source.status || source.state, outcome === 'handled' ? 'active' : outcome),
|
|
reason: _boundedReason(source.reason || source.error || source.message),
|
|
payload,
|
|
};
|
|
}
|
|
|
|
function _monitoringEventFor(outcome, state) {
|
|
if (outcome === 'handled' && state === 'active') return 'monitoring-started';
|
|
if (outcome === 'degraded' || state === 'degraded') return 'monitoring-degraded';
|
|
if (outcome === 'denied' || state === 'denied') return 'monitoring-denied';
|
|
if (outcome === 'failed' || state === 'failed' || outcome === 'incompatible' || outcome === 'incompatible-version') return 'monitoring-failed';
|
|
if (outcome === 'stopped' || state === 'stopped') return 'monitoring-stopped';
|
|
if (outcome === 'provider-selection-required') return 'provider-selection-required';
|
|
return 'monitoring-unavailable';
|
|
}
|
|
|
|
function _emitMonitoringOutcome(outcome, session, extra = {}) {
|
|
// Events are broadcast to all observers, so emit the redaction-safe session shape (same as
|
|
// diagnostics) rather than a raw clone that would leak internal sessionKey/monitoringId/sourceRef.
|
|
const payload = { ..._redactedMonitoringSession(session, _newPseudonymizer()), ...extra };
|
|
const event = _monitoringEventFor(outcome, session && session.state);
|
|
capabilities.emitEvent('audio-monitoring', event, payload);
|
|
if (outcome === 'denied' || (session && session.state === 'denied')) capabilities.emitEvent('audio-monitoring', 'permission-denied', payload);
|
|
}
|
|
|
|
function _monitoringResultFor(outcome, reason, payload) {
|
|
if (outcome === 'handled') return _handled(payload);
|
|
if (outcome === 'denied') return _denied(reason, payload);
|
|
if (outcome === 'degraded') return _degraded(reason, payload);
|
|
if (outcome === 'failed') return _failed(reason, payload);
|
|
if (outcome === 'no-owner') return _noOwner(reason, payload);
|
|
if (outcome === 'no-handler') return _noHandler(reason, payload);
|
|
if (outcome === 'unsupported-command') return _unsupportedCommand(reason, payload);
|
|
if (outcome === 'incompatible') return _incompatible(reason, payload);
|
|
if (outcome === 'incompatible-version') return _incompatibleVersion(reason, payload);
|
|
if (outcome === 'provider-selection-required') return _providerSelectionRequired(reason, payload);
|
|
if (outcome === 'user-action-required') return _userActionRequired(reason, payload);
|
|
if (outcome === 'stopped') return _stopped(reason, payload);
|
|
if (outcome === 'unavailable') return _unavailable(reason, payload);
|
|
if (outcome === 'overridden') return { outcome: 'overridden', reason, payload };
|
|
return _degraded(reason, payload);
|
|
}
|
|
|
|
function _syncSelectedInput() {
|
|
const selected = currentSession.selectedInput;
|
|
if (!selected || !selected.logicalSourceKey) {
|
|
for (const source of currentSession.inputSources.values()) source.selected = false;
|
|
return null;
|
|
}
|
|
const source = _findInputSource({ logicalSourceKey: selected.logicalSourceKey });
|
|
for (const item of currentSession.inputSources.values()) item.selected = item.logicalSourceKey === selected.logicalSourceKey && item.sourceId === (source && source.sourceId);
|
|
if (source) {
|
|
selected.sourceId = source.sourceId;
|
|
selected.providerId = source.providerId;
|
|
selected.availability = source.availability;
|
|
selected.restoreStatus = source.availability === 'available' ? (selected.restored ? 'restored' : 'available') : source.availability;
|
|
if (selected.restored && !selected.lastRestoredAt) selected.lastRestoredAt = _now();
|
|
} else {
|
|
selected.sourceId = '';
|
|
selected.providerId = '';
|
|
selected.availability = 'unavailable';
|
|
selected.restoreStatus = selected.restored ? 'missing-provider' : 'unavailable';
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
function _makeSelectedInput(source, requester) {
|
|
const now = _now();
|
|
return {
|
|
logicalSourceKey: source.logicalSourceKey,
|
|
sourceId: source.sourceId,
|
|
providerId: source.providerId,
|
|
availability: source.availability,
|
|
restored: false,
|
|
restoreStatus: source.availability === 'available' ? 'available' : source.availability,
|
|
selectedAt: now,
|
|
lastSelectedAt: now,
|
|
lastRestoredAt: '',
|
|
requesterId: _requesterId(requester),
|
|
};
|
|
}
|
|
|
|
function _requiredChannelShape(value) {
|
|
const source = _plainObject(value);
|
|
return _channelShape(source.requiredChannelShape || source.channelShape || source.shape || value, 'unknown');
|
|
}
|
|
|
|
function _channelCompatible(channelSummary, requiredChannelShape) {
|
|
const required = _channelShape(requiredChannelShape, 'unknown');
|
|
if (required === 'unknown') return { ok: true, channelShape: _channelShape(channelSummary && channelSummary.defaultShape, _channelShape(channelSummary && channelSummary.channelShape, 'unknown')) };
|
|
const summary = _plainObject(channelSummary);
|
|
const supports = Array.isArray(summary.supports) ? summary.supports.map(item => _channelShape(item, '')).filter(Boolean) : [];
|
|
const shape = _channelShape(summary.channelShape, 'unknown');
|
|
const count = _number(summary.channelCount, null);
|
|
const compatible = supports.includes(required) || shape === required || (required === 'mono' && count && count >= 1) || (required === 'stereo' && count && count >= 2) || (required === 'multi' && count && count > 2);
|
|
return { ok: compatible, channelShape: required };
|
|
}
|
|
|
|
function _openSessionKey(logicalSourceKey, channelShape) {
|
|
return `${_string(logicalSourceKey)}::${_channelShape(channelShape, 'unknown')}`;
|
|
}
|
|
|
|
function _requesterRef(requesterId, purpose) {
|
|
return { requesterId: _requesterId(requesterId), purpose: _boundedReason(purpose).slice(0, 80), openedAt: _now() };
|
|
}
|
|
|
|
function _withInputTimeout(promise, operation) {
|
|
let timer = null;
|
|
const timeout = new Promise((_, reject) => {
|
|
timer = setTimeout(() => {
|
|
const err = new Error(`${operation} timed out after ${INPUT_OPERATION_TIMEOUT_MS}ms`);
|
|
err.timedOut = true;
|
|
reject(err);
|
|
}, INPUT_OPERATION_TIMEOUT_MS);
|
|
});
|
|
return Promise.race([Promise.resolve(promise), timeout]).finally(() => { if (timer !== null) clearTimeout(timer); });
|
|
}
|
|
|
|
function _unsupportedCommand(reason, payload) { return { outcome: 'unsupported-command', reason, payload }; }
|
|
|
|
function _providerOutcome(raw, fallbackPayload = {}) {
|
|
const source = _plainObject(raw);
|
|
const outcome = _string(source.outcome, 'handled');
|
|
const payload = _safeInputValue(source.payload || source.session || source.summary || source, 0) || fallbackPayload;
|
|
return {
|
|
outcome: PROVIDER_OUTCOMES.has(outcome) ? outcome : 'handled',
|
|
status: _string(source.status || source.state, ''),
|
|
reason: _boundedReason(source.reason || source.error || source.message),
|
|
payload,
|
|
};
|
|
}
|
|
|
|
function _recordOutcome(entry) {
|
|
const source = _plainObject(entry);
|
|
const outcome = _string(source.outcome, 'handled');
|
|
const record = {
|
|
domain: DOMAINS.includes(source.domain) ? source.domain : 'audio-mix',
|
|
operation: _string(source.operation || source.command || source.event, 'inspect'),
|
|
participantId: _string(source.participantId || source.ownerId || source.requester, ''),
|
|
providerId: _string(source.providerId, ''),
|
|
monitoringId: _string(source.monitoringId, ''),
|
|
sourceId: _string(source.sourceId, ''),
|
|
logicalSourceKey: _string(source.logicalSourceKey, ''),
|
|
requesterId: _string(source.requesterId, ''),
|
|
openSessionId: _string(source.openSessionId, ''),
|
|
faderId: _string(source.faderId, ''),
|
|
bridgeId: _string(source.bridgeId, ''),
|
|
outcome,
|
|
status: _string(source.status, ''),
|
|
reason: _boundedReason(source.reason),
|
|
timestamp: _string(source.timestamp, _now()),
|
|
};
|
|
currentSession.outcomes.push(record);
|
|
while (currentSession.outcomes.length > MAX_OUTCOMES) currentSession.outcomes.shift();
|
|
currentSession.updatedAt = _now();
|
|
return record;
|
|
}
|
|
|
|
function _redactedOutcome(outcome, pseudonymize) {
|
|
const clone = _clone(outcome) || {};
|
|
if (clone.domain === 'audio-input' || clone.domain === 'audio-monitoring') {
|
|
if (clone.sourceId) clone.sourceId = pseudonymize(clone.sourceId, 'source');
|
|
// openSessionId is left verbatim: it is an internally-generated `input-open-<n>` id (not
|
|
// sensitive) and must correlate with openSessions[].openSessionId in the same snapshot.
|
|
// The one untrusted entry point (a close-source miss) is bounded at its call site instead.
|
|
// logicalSourceKey/providerId/participantId/requesterId are only *supposed* to be
|
|
// redaction-safe; some paths derive them from caller-supplied values (e.g. an enumerate
|
|
// option payload), so bound them so a path/token cannot leak into diagnostics.
|
|
if (clone.logicalSourceKey) clone.logicalSourceKey = _boundedReason(clone.logicalSourceKey);
|
|
if (clone.providerId) clone.providerId = _boundedReason(clone.providerId);
|
|
if (clone.participantId) clone.participantId = _boundedReason(clone.participantId);
|
|
if (clone.requesterId) clone.requesterId = _boundedReason(clone.requesterId);
|
|
}
|
|
if (clone.domain === 'audio-monitoring' && clone.monitoringId) clone.monitoringId = pseudonymize(clone.monitoringId, 'monitoring');
|
|
// audio-input openSessionId is always an internally-generated `input-open-<n>` id (the one
|
|
// untrusted close-source path bounds it at its call site), so it stays verbatim to correlate
|
|
// with openSessions[]. On audio-monitoring it can be caller-supplied via sourceRef.openSessionId,
|
|
// so only keep it verbatim when it names a live internally-tracked open-input session (preserving
|
|
// the cross-domain correlation); pseudonymize anything else — a raw/native handle, or a spoofed
|
|
// id matching the safe format that would otherwise falsely correlate to another session.
|
|
if (clone.domain === 'audio-monitoring' && clone.openSessionId
|
|
&& !Array.from(currentSession.openInputSessions.values()).some(item => item.openSessionId === clone.openSessionId)) {
|
|
clone.openSessionId = pseudonymize(clone.openSessionId, 'input-open');
|
|
}
|
|
if (clone.reason) clone.reason = _boundedReason(clone.reason);
|
|
return clone;
|
|
}
|
|
|
|
function _handled(payload) { return { outcome: 'handled', payload }; }
|
|
function _degraded(reason, payload) { return { outcome: 'degraded', reason, payload }; }
|
|
function _failed(reason, payload) { return { outcome: 'failed', reason, payload }; }
|
|
function _noHandler(reason, payload) { return { outcome: 'no-handler', reason, payload }; }
|
|
function _noOwner(reason, payload) { return { outcome: 'no-owner', reason, payload }; }
|
|
function _incompatible(reason, payload) { return { outcome: 'incompatible', reason, payload }; }
|
|
function _incompatibleVersion(reason, payload) { return { outcome: 'incompatible-version', reason, payload }; }
|
|
function _unavailable(reason, payload) { return { outcome: 'unavailable', reason, payload }; }
|
|
function _providerSelectionRequired(reason, payload) { return { outcome: 'provider-selection-required', reason, payload }; }
|
|
function _userActionRequired(reason, payload) { return { outcome: 'user-action-required', reason, payload }; }
|
|
function _stopped(reason, payload) { return { outcome: 'stopped', reason, payload }; }
|
|
|
|
function _denied(reason, payload) { return { outcome: 'denied', reason, payload }; }
|
|
|
|
function _faderKey(participantId, faderId) {
|
|
return `${_string(participantId)}:${_string(faderId, 'main')}`;
|
|
}
|
|
|
|
function _findParticipant(participantId, faderId) {
|
|
const id = _string(participantId);
|
|
if (id && currentSession.mixParticipants.has(id)) return currentSession.mixParticipants.get(id);
|
|
const requestedFaderId = _string(faderId);
|
|
for (const participant of currentSession.mixParticipants.values()) {
|
|
if (participant.fader && participant.fader.id === requestedFaderId) return participant;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function _effectiveFaderAvailability(participant) {
|
|
if (!participant || !participant.fader) return 'unavailable';
|
|
if (participant.supersededBy) return 'disabled';
|
|
if (participant.availability !== 'available') return participant.availability;
|
|
if (participant.fader.availability !== 'available') return participant.fader.availability;
|
|
if (!participant.fader.userAdjustable) return 'disabled';
|
|
if (currentSession.state !== 'active' && participant.kind !== 'song') return 'pending';
|
|
return 'available';
|
|
}
|
|
|
|
function _operationPriority(participant) {
|
|
if (participant.sourceMode === 'core') return 0;
|
|
if (participant.sourceMode === 'native') return 1;
|
|
return 2;
|
|
}
|
|
|
|
function _refreshDuplicateSuppression() {
|
|
const groups = new Map();
|
|
for (const participant of currentSession.mixParticipants.values()) {
|
|
participant.supersededBy = '';
|
|
if (!participant.fader) continue;
|
|
const list = groups.get(participant.logicalFaderKey) || [];
|
|
list.push(participant);
|
|
groups.set(participant.logicalFaderKey, list);
|
|
}
|
|
for (const list of groups.values()) {
|
|
if (list.length < 2) continue;
|
|
list.sort((a, b) => _operationPriority(a) - _operationPriority(b) || a.participantId.localeCompare(b.participantId));
|
|
const winner = list[0];
|
|
for (const loser of list.slice(1)) {
|
|
loser.supersededBy = winner.participantId;
|
|
}
|
|
}
|
|
}
|
|
|
|
function _recordDuplicateSuppression() {
|
|
for (const participant of currentSession.mixParticipants.values()) {
|
|
if (!participant.supersededBy || participant.sourceMode !== 'compatibility') continue;
|
|
recordBridgeHit({
|
|
domain: 'audio-mix',
|
|
bridgeId: participant.compatibilitySource || 'audio-mix.fader-registry',
|
|
legacySurface: participant.compatibilitySource || 'registerFader',
|
|
participantId: participant.participantId,
|
|
outcome: 'overridden',
|
|
status: 'overshadowed',
|
|
reason: `Native audio-mix participant ${participant.supersededBy} owns logical fader ${participant.logicalFaderKey}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
function _visibleFaderParticipants() {
|
|
_refreshDuplicateSuppression();
|
|
return Array.from(currentSession.mixParticipants.values())
|
|
.filter(participant => participant.fader && !participant.supersededBy)
|
|
.sort((a, b) => REQUIRED_MIX_KINDS.indexOf(a.kind) - REQUIRED_MIX_KINDS.indexOf(b.kind) || a.label.localeCompare(b.label) || a.participantId.localeCompare(b.participantId));
|
|
}
|
|
|
|
function _withFaderTimeout(promise, participant, operation) {
|
|
let timer = null;
|
|
const timeout = new Promise((_, reject) => {
|
|
timer = setTimeout(() => {
|
|
const err = new Error(`${operation} timed out after ${FADER_OPERATION_TIMEOUT_MS}ms`);
|
|
err.timedOut = true;
|
|
reject(err);
|
|
}, FADER_OPERATION_TIMEOUT_MS);
|
|
});
|
|
return Promise.race([Promise.resolve(promise), timeout]).finally(() => { if (timer !== null) clearTimeout(timer); });
|
|
}
|
|
|
|
function _extractCommittedValue(result, fallback) {
|
|
if (Number.isFinite(Number(result))) return Number(result);
|
|
const source = _plainObject(result);
|
|
const value = _number(source.committedValue, _number(source.currentValue, _number(source.value, fallback)));
|
|
return Number.isFinite(value) ? value : fallback;
|
|
}
|
|
|
|
function _emitFaderUnavailable(participant, reason) {
|
|
const payload = _summaryFader(participant, { availability: _effectiveFaderAvailability(participant) });
|
|
if (payload) payload.reason = _boundedReason(reason);
|
|
capabilities.emitEvent('audio-mix', 'fader-unavailable', payload || { participantId: participant && participant.participantId, reason: _boundedReason(reason) });
|
|
}
|
|
|
|
async function listFaders() {
|
|
const faders = _visibleFaderParticipants().map(participant => _summaryFader(participant));
|
|
const kinds = Object.fromEntries(REQUIRED_MIX_KINDS.map(kind => [kind, faders.some(fader => fader.kind === kind)]));
|
|
_recordOutcome({ domain: 'audio-mix', operation: 'list-faders', participantId: OWNER_ID, outcome: 'handled', status: `${faders.length}` });
|
|
return _handled({ faders, requiredKinds: kinds, timeoutMs: FADER_OPERATION_TIMEOUT_MS });
|
|
}
|
|
|
|
async function getFaderValue(payload = {}) {
|
|
const participant = _findParticipant(payload.participantId, payload.faderId || payload.id);
|
|
if (!participant || !participant.fader) {
|
|
const result = _noHandler('Unknown audio-mix fader', { participantId: payload.participantId || '', faderId: payload.faderId || payload.id || '' });
|
|
_recordOutcome({ domain: 'audio-mix', operation: 'get-fader-value', participantId: result.payload.participantId, faderId: result.payload.faderId, outcome: result.outcome, reason: result.reason });
|
|
return result;
|
|
}
|
|
const availability = _effectiveFaderAvailability(participant);
|
|
if (availability !== 'available') {
|
|
const result = _degraded(`Fader is ${availability}`, _summaryFader(participant, { availability }));
|
|
_emitFaderUnavailable(participant, result.reason);
|
|
_recordOutcome({ domain: 'audio-mix', operation: 'get-fader-value', participantId: participant.participantId, faderId: participant.fader.id, outcome: result.outcome, status: availability, reason: result.reason });
|
|
return result;
|
|
}
|
|
const previous = participant.fader.currentValue;
|
|
try {
|
|
const handler = participant.operationHandlers['fader.get-value'];
|
|
const raw = handler ? await _withFaderTimeout(handler({ participant: _summaryParticipant(participant), fader: _summaryFader(participant) }), participant, 'fader.get-value') : previous;
|
|
const committed = _clamp(_extractCommittedValue(raw, previous), participant.fader.min, participant.fader.max);
|
|
participant.fader.currentValue = committed;
|
|
participant.fader.lastCommittedAt = _now();
|
|
_recordOutcome({ domain: 'audio-mix', operation: 'get-fader-value', participantId: participant.participantId, faderId: participant.fader.id, outcome: 'handled', status: 'available' });
|
|
_touch();
|
|
return _handled({ ..._summaryFader(participant), committedValue: committed, previousCommittedValue: previous });
|
|
} catch (err) {
|
|
const timedOut = !!(err && err.timedOut);
|
|
const reason = timedOut ? 'Fader get-value timed out' : _boundedReason(err && err.message ? err.message : String(err));
|
|
_recordOutcome({ domain: 'audio-mix', operation: 'get-fader-value', participantId: participant.participantId, faderId: participant.fader.id, outcome: 'failed', status: timedOut ? 'timeout' : 'failed', reason });
|
|
_touch();
|
|
return _failed(reason, { ..._summaryFader(participant), committedValue: previous, timedOut });
|
|
}
|
|
}
|
|
|
|
async function setFaderValue(payload = {}) {
|
|
const participant = _findParticipant(payload.participantId, payload.faderId || payload.id);
|
|
const requestedValue = _number(payload.value ?? payload.requestedValue, null);
|
|
if (!participant || !participant.fader) {
|
|
const result = _noHandler('Unknown audio-mix fader', { participantId: payload.participantId || '', faderId: payload.faderId || payload.id || '', requestedValue });
|
|
_recordOutcome({ domain: 'audio-mix', operation: 'set-fader-value', participantId: result.payload.participantId, faderId: result.payload.faderId, outcome: result.outcome, reason: result.reason });
|
|
return result;
|
|
}
|
|
if (!Number.isFinite(requestedValue)) {
|
|
const result = _denied('Fader value must be a finite number', { ..._summaryFader(participant), requestedValue });
|
|
_recordOutcome({ domain: 'audio-mix', operation: 'set-fader-value', participantId: participant.participantId, faderId: participant.fader.id, outcome: result.outcome, status: 'invalid-value', reason: result.reason });
|
|
return result;
|
|
}
|
|
const availability = _effectiveFaderAvailability(participant);
|
|
if (availability !== 'available') {
|
|
const result = _degraded(`Fader is ${availability}`, { ..._summaryFader(participant, { availability }), requestedValue });
|
|
_emitFaderUnavailable(participant, result.reason);
|
|
_recordOutcome({ domain: 'audio-mix', operation: 'set-fader-value', participantId: participant.participantId, faderId: participant.fader.id, outcome: result.outcome, status: availability, reason: result.reason });
|
|
return result;
|
|
}
|
|
const previous = participant.fader.currentValue;
|
|
const normalizedValue = _clamp(requestedValue, participant.fader.min, participant.fader.max);
|
|
participant.fader.lastRequestedValue = requestedValue;
|
|
try {
|
|
const handler = participant.operationHandlers['fader.set-value'];
|
|
const raw = handler ? await _withFaderTimeout(handler(normalizedValue, { requestedValue, participant: _summaryParticipant(participant), fader: _summaryFader(participant) }), participant, 'fader.set-value') : normalizedValue;
|
|
const committed = _clamp(_extractCommittedValue(raw, normalizedValue), participant.fader.min, participant.fader.max);
|
|
participant.fader.currentValue = committed;
|
|
participant.fader.lastRejectedValue = null;
|
|
participant.fader.lastCommittedAt = _now();
|
|
const result = { ..._summaryFader(participant), requestedValue, normalizedValue, committedValue: committed, previousCommittedValue: previous };
|
|
_recordOutcome({ domain: 'audio-mix', operation: 'set-fader-value', participantId: participant.participantId, faderId: participant.fader.id, outcome: 'handled', status: committed === normalizedValue ? 'committed' : 'normalized' });
|
|
capabilities.emitEvent('audio-mix', 'fader-value-changed', result);
|
|
_touch();
|
|
return _handled(result);
|
|
} catch (err) {
|
|
const timedOut = !!(err && err.timedOut);
|
|
const reason = timedOut ? 'Fader set-value timed out' : _boundedReason(err && err.message ? err.message : String(err));
|
|
participant.fader.lastRejectedValue = requestedValue;
|
|
participant.fader.currentValue = previous;
|
|
_recordOutcome({ domain: 'audio-mix', operation: 'set-fader-value', participantId: participant.participantId, faderId: participant.fader.id, outcome: 'failed', status: timedOut ? 'timeout' : 'failed', reason });
|
|
_touch();
|
|
return _failed(reason, { ..._summaryFader(participant), requestedValue, normalizedValue, committedValue: previous, previousCommittedValue: previous, timedOut });
|
|
}
|
|
}
|
|
|
|
function startSession(options = {}) {
|
|
// Close any open input sessions on the outgoing session before it is replaced, so a direct
|
|
// startSession() (e.g. a song switch that does not call stopSession first) doesn't orphan
|
|
// provider-owned capture with no state left to close it.
|
|
if (currentSession) _closeOpenInputSessions('Session restarted');
|
|
const previousSelectedInput = currentSession && currentSession.selectedInput ? _clone(currentSession.selectedInput) : null;
|
|
const previousSelectedMonitoringProvider = currentSession && currentSession.selectedMonitoringProvider ? _clone(currentSession.selectedMonitoringProvider) : null;
|
|
const previousDirectMonitor = currentSession && currentSession.directMonitor ? _clone(currentSession.directMonitor) : null;
|
|
const previousMonitoringSessions = currentSession ? Array.from(currentSession.monitoringSessions.values()).map(_clone) : [];
|
|
const previousStorageStatus = currentSession && currentSession.storageStatus;
|
|
currentSession = _newSession({ ...options, state: 'active' });
|
|
if (previousStorageStatus === 'failed') {
|
|
// Persistence is failed, so the stored key may be stale — keep the in-memory selection
|
|
// stable instead of silently reverting to whatever storage happens to hold.
|
|
if (previousSelectedInput) currentSession.selectedInput = previousSelectedInput;
|
|
if (previousSelectedMonitoringProvider) currentSession.selectedMonitoringProvider = previousSelectedMonitoringProvider;
|
|
if (previousDirectMonitor) currentSession.directMonitor = previousDirectMonitor;
|
|
currentSession.storageStatus = 'failed';
|
|
} else if (!currentSession.selectedInput && previousSelectedInput) {
|
|
currentSession.selectedInput = previousSelectedInput;
|
|
if (!currentSession.selectedMonitoringProvider && previousSelectedMonitoringProvider) currentSession.selectedMonitoringProvider = previousSelectedMonitoringProvider;
|
|
if (previousDirectMonitor) currentSession.directMonitor = previousDirectMonitor;
|
|
} else {
|
|
if (!currentSession.selectedMonitoringProvider && previousSelectedMonitoringProvider) currentSession.selectedMonitoringProvider = previousSelectedMonitoringProvider;
|
|
if (previousDirectMonitor) currentSession.directMonitor = previousDirectMonitor;
|
|
}
|
|
for (const participant of knownMixParticipants.values()) {
|
|
const attached = { ...participant, lastSeenAt: _now() };
|
|
if (attached.availability === 'pending') attached.availability = 'available';
|
|
if (attached.fader && attached.fader.availability === 'pending') attached.fader.availability = 'available';
|
|
currentSession.mixParticipants.set(attached.participantId, attached);
|
|
}
|
|
for (const provider of knownInputProviders.values()) {
|
|
currentSession.inputProviders.set(provider.providerId, { ...provider, lastSeenAt: _now() });
|
|
}
|
|
for (const source of knownInputSources.values()) {
|
|
currentSession.inputSources.set(source.sourceId, { ...source, lastSeenAt: _now(), selected: false });
|
|
}
|
|
for (const provider of knownMonitoringProviders.values()) {
|
|
currentSession.monitoringProviders.set(provider.providerId, { ...provider, lastSeenAt: _now() });
|
|
}
|
|
for (const session of previousMonitoringSessions) {
|
|
if (session && (session.state === 'active' || session.state === 'degraded')) currentSession.monitoringSessions.set(session.monitoringId, { ...session, updatedAt: _now() });
|
|
}
|
|
_refreshDuplicateSuppression();
|
|
_refreshInputDuplicateSuppression();
|
|
_refreshMonitoringDuplicateSuppression();
|
|
_recordOutcome({ domain: 'audio-mix', operation: 'session.start', participantId: OWNER_ID, outcome: 'handled' });
|
|
_recordOutcome({ domain: 'audio-input', operation: 'session.start', participantId: OWNER_ID, outcome: 'handled', status: currentSession.selectedInput ? currentSession.selectedInput.restoreStatus : 'not-selected' });
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'session.start', participantId: OWNER_ID, outcome: 'handled', status: currentSession.monitoringSessions.size ? 'active' : 'stopped' });
|
|
_touch();
|
|
return snapshot();
|
|
}
|
|
|
|
function stopSession(reason = 'Session stopped') {
|
|
currentSession.state = 'stopped';
|
|
currentSession.routeState = _normalizeRoute({ routeKind: 'unknown', availability: 'unavailable', reason });
|
|
_closeOpenInputSessions(reason);
|
|
_recordOutcome({ domain: 'audio-mix', operation: 'session.stop', participantId: OWNER_ID, outcome: 'handled', reason });
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'session.stop', participantId: OWNER_ID, outcome: 'handled', status: currentSession.monitoringSessions.size ? 'active' : 'stopped', reason: 'Playback stopped; live monitoring remains requester-owned' });
|
|
_touch();
|
|
return snapshot();
|
|
}
|
|
|
|
// Tear down any open input sessions so providers release live capture when the
|
|
// session stops (e.g. song switch) instead of leaving microphones/instruments hot.
|
|
// Provider close is best-effort and non-blocking — teardown must not depend on it.
|
|
function _closeOpenInputSessions(reason) {
|
|
if (!currentSession.openInputSessions.size) return;
|
|
const boundedReason = _boundedReason(reason);
|
|
// One pseudonymizer for the whole batch so distinct sources keep distinct source-NN ids
|
|
// across the emitted source-closed events instead of every event reusing source-01.
|
|
const pseudonymize = _newPseudonymizer();
|
|
for (const session of Array.from(currentSession.openInputSessions.values())) {
|
|
const provider = currentSession.inputProviders.get(session.providerId);
|
|
const handler = provider && provider.operationHandlers && provider.operationHandlers['source.close'];
|
|
if (typeof handler === 'function') {
|
|
try {
|
|
Promise.resolve(handler({ sourceId: session.sourceId, logicalSourceKey: session.logicalSourceKey, openSessionId: session.openSessionId, requesterIds: session.requesters.map(item => item.requesterId) })).catch(() => {});
|
|
} catch (err) { /* best-effort teardown */ }
|
|
}
|
|
session.state = 'closed';
|
|
session.closedAt = _now();
|
|
session.reason = boundedReason;
|
|
session.requesters = [];
|
|
_recordOutcome({ domain: 'audio-input', operation: 'close-source', participantId: OWNER_ID, providerId: session.providerId, sourceId: session.sourceId, logicalSourceKey: session.logicalSourceKey, openSessionId: session.openSessionId, outcome: 'handled', status: 'closed', reason: boundedReason });
|
|
capabilities.emitEvent('audio-input', 'source-closed', _redactedOpenSession(session, pseudonymize));
|
|
}
|
|
currentSession.openInputSessions.clear();
|
|
}
|
|
|
|
function setRoute(route) {
|
|
currentSession.routeState = _normalizeRoute(route);
|
|
const degraded = currentSession.routeState.availability === 'degraded' || currentSession.routeState.availability === 'unavailable';
|
|
_recordOutcome({
|
|
domain: 'audio-mix',
|
|
operation: 'route.set',
|
|
participantId: OWNER_ID,
|
|
outcome: degraded ? 'degraded' : 'handled',
|
|
status: currentSession.routeState.availability,
|
|
reason: currentSession.routeState.fallbackReason,
|
|
});
|
|
// Redact the device identity before it reaches event subscribers or the
|
|
// caller; raw deviceLabel/deviceId only ever live in currentSession.
|
|
// Snapshot the return clone before dispatching the (mutable) event payload
|
|
// so a subscriber can't desync or mutate the caller's copy.
|
|
const redactedRoute = _redactedRoute(currentSession.routeState, _newPseudonymizer());
|
|
const returnedRoute = _clone(redactedRoute);
|
|
capabilities.emitEvent('audio-mix', degraded ? 'route-degraded' : 'route-changed', redactedRoute);
|
|
_touch();
|
|
return returnedRoute;
|
|
}
|
|
|
|
function setAnalyser(analyser) {
|
|
currentSession.analyserState = _normalizeAnalyser(analyser);
|
|
const degraded = currentSession.analyserState.availability !== 'available';
|
|
_recordOutcome({ domain: 'audio-mix', operation: 'analyser.set', participantId: currentSession.analyserState.participantId || OWNER_ID, outcome: degraded ? 'degraded' : 'handled', status: currentSession.analyserState.availability, reason: currentSession.analyserState.reason });
|
|
capabilities.emitEvent('audio-mix', degraded ? 'analyser-unavailable' : 'analyser-changed', currentSession.analyserState);
|
|
_touch();
|
|
return _clone(currentSession.analyserState);
|
|
}
|
|
|
|
function registerMixParticipant(spec) {
|
|
const source = _plainObject(spec);
|
|
if (source.incompatible || (source.version && Number(source.version) !== 1)) {
|
|
const result = _incompatibleVersion('Mix participant requires audio-session contract version 1');
|
|
_recordOutcome({ domain: 'audio-mix', operation: 'register-participant', participantId: source.participantId || source.id, outcome: result.outcome, reason: result.reason });
|
|
return result;
|
|
}
|
|
const participant = _normalizeParticipant(source);
|
|
if (!participant) {
|
|
const result = _failed('Mix participant registration requires participantId');
|
|
_recordOutcome({ domain: 'audio-mix', operation: 'register-participant', outcome: result.outcome, reason: result.reason });
|
|
return result;
|
|
}
|
|
// Preserve operation handlers across re-registration, the same way
|
|
// registerInputSource and the monitoring provider already do. Without
|
|
// this, a caller that re-registers to refresh availability / current
|
|
// value but doesn't re-pass getValue/setValue would silently drop the
|
|
// fader handlers — which is exactly how the song fader's volume-apply
|
|
// handler was being wiped on every song load. New handlers still
|
|
// override; only ones the new spec omits are retained.
|
|
const _existingParticipant = knownMixParticipants.get(participant.participantId);
|
|
if (_existingParticipant && _existingParticipant.operationHandlers) {
|
|
participant.operationHandlers = {
|
|
..._existingParticipant.operationHandlers,
|
|
...participant.operationHandlers,
|
|
};
|
|
}
|
|
participant.logicalFaderKey = _logicalFaderKey(participant, source);
|
|
if (participant.fader && participant.fader.availability === 'available' && participant.availability !== 'available') {
|
|
participant.fader.availability = participant.availability;
|
|
}
|
|
knownMixParticipants.set(participant.participantId, participant);
|
|
currentSession.mixParticipants.set(participant.participantId, participant);
|
|
_refreshDuplicateSuppression();
|
|
_recordDuplicateSuppression();
|
|
_recordOutcome({ domain: 'audio-mix', operation: 'register-participant', participantId: participant.participantId, outcome: 'handled', status: participant.availability });
|
|
capabilities.emitEvent('audio-mix', 'participant-registered', _summaryParticipant(participant));
|
|
_touch();
|
|
return _handled(_summaryParticipant(participant));
|
|
}
|
|
|
|
function unregisterMixParticipant(participantId) {
|
|
const id = _string(participantId);
|
|
const removed = currentSession.mixParticipants.delete(id);
|
|
knownMixParticipants.delete(id);
|
|
const outcome = removed ? 'handled' : 'no-handler';
|
|
_recordOutcome({ domain: 'audio-mix', operation: 'unregister-participant', participantId: id, outcome });
|
|
if (removed) capabilities.emitEvent('audio-mix', 'participant-removed', { participantId: id });
|
|
_touch();
|
|
return removed ? _handled({ participantId: id, removed: true }) : _noHandler(`Unknown mix participant: ${id}`, { participantId: id, removed: false });
|
|
}
|
|
|
|
function registerInputSource(spec) {
|
|
const source = _plainObject(spec);
|
|
if (source.incompatible || (source.version && Number(source.version) !== 1)) {
|
|
const result = _incompatibleVersion('Input source requires audio-session contract version 1');
|
|
_recordOutcome({ domain: 'audio-input', operation: 'register-source', participantId: source.providerId, providerId: source.providerId, outcome: result.outcome, reason: result.reason });
|
|
_touch();
|
|
return result;
|
|
}
|
|
const input = _normalizeSource(source);
|
|
if (!input) {
|
|
const result = _failed('Input source registration requires sourceId, providerId, and logicalSourceKey');
|
|
_recordOutcome({ domain: 'audio-input', operation: 'register-source', outcome: result.outcome, reason: result.reason });
|
|
_touch();
|
|
return result;
|
|
}
|
|
// The logical key is surfaced verbatim in redaction-safe diagnostics, so the contract
|
|
// requires it to already be redaction-safe. Reject a key that _boundedReason would alter
|
|
// (a path, token, whitespace, or over-long value) rather than silently leaking it.
|
|
const boundedLogicalKey = _boundedReason(input.logicalSourceKey);
|
|
if (boundedLogicalKey !== input.logicalSourceKey) {
|
|
const result = _failed('Input source logicalSourceKey must be redaction-safe');
|
|
_recordOutcome({ domain: 'audio-input', operation: 'register-source', participantId: input.providerId, providerId: input.providerId, logicalSourceKey: boundedLogicalKey, outcome: result.outcome, status: 'unsafe-key', reason: result.reason });
|
|
_touch();
|
|
return result;
|
|
}
|
|
// sourceId is the global Map key, so a sourceId already owned by a different provider would
|
|
// silently overwrite that provider's record. Reject the collision instead — providers must
|
|
// namespace sourceId to be globally unique (the stable cross-provider handle is logicalSourceKey).
|
|
const existingById = currentSession.inputSources.get(input.sourceId);
|
|
if (existingById && existingById.providerId !== input.providerId) {
|
|
const result = _failed('Input source sourceId collides with another provider; sourceId must be globally unique');
|
|
_recordOutcome({ domain: 'audio-input', operation: 'register-source', participantId: input.providerId, providerId: input.providerId, sourceId: input.sourceId, logicalSourceKey: input.logicalSourceKey, outcome: result.outcome, status: 'sourceid-collision', reason: result.reason });
|
|
_touch();
|
|
return result;
|
|
}
|
|
const provider = _normalizeInputProvider(source, input);
|
|
const existingByRecord = Array.from(currentSession.inputSources.values()).find(item => _sourceRecordKey(item) === _sourceRecordKey(input));
|
|
const existing = currentSession.inputSources.get(input.sourceId) || existingByRecord || null;
|
|
const previousAvailability = existing && existing.availability;
|
|
if (existing && existing.sourceId !== input.sourceId) {
|
|
currentSession.inputSources.delete(existing.sourceId);
|
|
knownInputSources.delete(existing.sourceId);
|
|
}
|
|
if (existing) {
|
|
input.registeredAt = existing.registeredAt;
|
|
input.selected = existing.selected;
|
|
}
|
|
currentSession.inputSources.set(input.sourceId, input);
|
|
knownInputSources.set(input.sourceId, input);
|
|
_refreshInputDuplicateSuppression();
|
|
_recordInputDuplicateSuppression();
|
|
const pseudonymize = _newPseudonymizer();
|
|
const summary = _redactedSource(input, pseudonymize);
|
|
const returned = _clone(summary);
|
|
_recordOutcome({ domain: 'audio-input', operation: 'register-source', participantId: input.providerId, providerId: provider.providerId, sourceId: input.sourceId, logicalSourceKey: input.logicalSourceKey, outcome: 'handled', status: input.availability });
|
|
capabilities.emitEvent('audio-input', 'source-registered', summary);
|
|
if (previousAvailability && previousAvailability !== input.availability) capabilities.emitEvent('audio-input', 'source-availability-changed', summary);
|
|
_touch();
|
|
return _handled(returned);
|
|
}
|
|
|
|
function unregisterInputSource(query) {
|
|
const payload = typeof query === 'string' ? { sourceId: query } : _plainObject(query);
|
|
const source = _findInputSource(payload);
|
|
const removed = source ? currentSession.inputSources.delete(source.sourceId) : false;
|
|
if (source) knownInputSources.delete(source.sourceId);
|
|
const outcome = removed ? 'handled' : 'no-handler';
|
|
_refreshInputDuplicateSuppression();
|
|
_recordOutcome({ domain: 'audio-input', operation: 'unregister-source', participantId: source && source.providerId, providerId: source && source.providerId, sourceId: source && source.sourceId, logicalSourceKey: source && source.logicalSourceKey, outcome });
|
|
const pseudonymize = _newPseudonymizer();
|
|
// Only a raw sourceId is pseudonymized; logicalSourceKey/providerId are redaction-safe and
|
|
// echoed back so a miss still tells the caller which source it was looking for.
|
|
const resolvedSourceId = source ? source.sourceId : _string(payload.sourceId || payload.id);
|
|
const redacted = {
|
|
sourceId: resolvedSourceId ? pseudonymize(resolvedSourceId, 'source') : '',
|
|
// On a miss these come straight from an untrusted caller — bound them so a path/token
|
|
// cannot land in the echoed payload or diagnostics. Resolved-source values are safe.
|
|
logicalSourceKey: source ? source.logicalSourceKey : _boundedReason(payload.logicalSourceKey || payload.logicalKey || payload.sourceKey),
|
|
providerId: source ? source.providerId : _boundedReason(payload.providerId),
|
|
removed,
|
|
};
|
|
if (removed) capabilities.emitEvent('audio-input', 'source-removed', redacted);
|
|
_touch();
|
|
return removed ? _handled(_clone(redacted)) : _noHandler('Unknown input source', redacted);
|
|
}
|
|
|
|
function selectInputSource(query, requester = 'unknown') {
|
|
const payload = typeof query === 'string' ? { sourceId: query } : _plainObject(query);
|
|
const selected = _findInputSource(payload);
|
|
// Bound/sanitize the requester before it reaches redaction-safe diagnostics, consistent
|
|
// with open/close-source attribution.
|
|
const requesterId = _requesterId(requester, 'unknown');
|
|
// A caller-provided logical key is only *supposed* to be redaction-safe; bound it before it
|
|
// reaches diagnostics or the echoed payload in case the caller passed a path/token.
|
|
const requestedLogicalKey = _boundedReason(payload.logicalSourceKey || payload.logicalKey || payload.sourceKey);
|
|
const requestedSourceId = _string(payload.sourceId || payload.id);
|
|
if (!selected) {
|
|
const missingSourceId = requestedSourceId ? _newPseudonymizer()(requestedSourceId, 'source') : '';
|
|
const result = _degraded('Input source is unavailable', { logicalSourceKey: requestedLogicalKey, sourceId: missingSourceId, availability: 'unavailable' });
|
|
_recordOutcome({ domain: 'audio-input', operation: 'select-source', participantId: requesterId, requesterId, logicalSourceKey: requestedLogicalKey, outcome: result.outcome, status: 'unavailable', reason: result.reason });
|
|
_touch();
|
|
return result;
|
|
}
|
|
currentSession.selectedInput = _makeSelectedInput(selected, requesterId);
|
|
currentSession.storageStatus = _writeSelectedLogicalSourceKey(selected.logicalSourceKey);
|
|
_syncSelectedInput();
|
|
const summary = _redactedSelectedInput(currentSession.selectedInput, _newPseudonymizer());
|
|
_recordOutcome({ domain: 'audio-input', operation: 'select-source', participantId: requesterId, requesterId, providerId: selected.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, outcome: 'handled', status: selected.availability });
|
|
capabilities.emitEvent('audio-input', 'source-selected', summary);
|
|
_touch();
|
|
return _handled(summary);
|
|
}
|
|
|
|
function listInputSources(payload = {}) {
|
|
const includeUnavailable = payload.includeUnavailable !== false;
|
|
const pseudonymize = _newPseudonymizer();
|
|
const sources = _visibleInputSources()
|
|
.filter(source => includeUnavailable || source.availability === 'available' || (currentSession.selectedInput && source.logicalSourceKey === currentSession.selectedInput.logicalSourceKey))
|
|
.map(source => _redactedSource(source, pseudonymize));
|
|
_recordOutcome({ domain: 'audio-input', operation: 'list-sources', participantId: OWNER_ID, outcome: 'handled', status: `${sources.length}` });
|
|
return _handled({ sources, selected: _redactedSelectedInput(_syncSelectedInput(), pseudonymize), timeoutMs: INPUT_OPERATION_TIMEOUT_MS });
|
|
}
|
|
|
|
function inspectInput(payload = {}) {
|
|
const snapshotData = snapshot().domains['audio-input'];
|
|
if (payload.sourceId || payload.logicalSourceKey) {
|
|
// Snapshot sourceIds are per-snapshot pseudonyms, so a raw sourceId can't match them.
|
|
// Resolve the request to its source and pin the snapshot entry by stable, redaction-safe
|
|
// identity (logicalSourceKey + provider + mode) — the snapshot still contains suppressed
|
|
// duplicates, so a logicalSourceKey-only match could return a compatibility loser.
|
|
const resolved = _findInputSource(payload);
|
|
const logicalSourceKey = resolved ? resolved.logicalSourceKey : _string(payload.logicalSourceKey || payload.logicalKey || payload.sourceKey);
|
|
const match = resolved
|
|
? snapshotData.sources.find(source => source.logicalSourceKey === resolved.logicalSourceKey && source.providerId === resolved.providerId && source.sourceMode === resolved.sourceMode)
|
|
: (logicalSourceKey ? snapshotData.sources.find(source => source.logicalSourceKey === logicalSourceKey) : null);
|
|
return _handled({ ...snapshotData, source: match || null });
|
|
}
|
|
return _handled(snapshotData);
|
|
}
|
|
|
|
async function enumerateInputSources(options = {}) {
|
|
const payload = _plainObject(options);
|
|
if (payload.explicit !== true && payload.userInitiated !== true) {
|
|
const result = _denied('Source enumeration requires an explicit user/provider request');
|
|
_recordOutcome({ domain: 'audio-input', operation: 'source.enumerate', participantId: payload.providerId || OWNER_ID, providerId: payload.providerId, outcome: result.outcome, reason: result.reason });
|
|
_touch();
|
|
return result;
|
|
}
|
|
const providers = Array.from(currentSession.inputProviders.values()).filter(provider => !payload.providerId || provider.providerId === payload.providerId);
|
|
if (!providers.length) {
|
|
const result = _noOwner('No input provider is available for source enumeration');
|
|
_recordOutcome({ domain: 'audio-input', operation: 'source.enumerate', participantId: payload.providerId || OWNER_ID, providerId: payload.providerId, outcome: result.outcome, reason: result.reason });
|
|
_touch();
|
|
return result;
|
|
}
|
|
const registered = [];
|
|
// Re-redact every registered source with one pseudonymizer so distinct sources get distinct,
|
|
// stable pseudonyms across the whole enumerate response. registerInputSource() redacts each
|
|
// payload with its own fresh pseudonymizer, which would otherwise emit duplicate `source-01`s.
|
|
const pseudonymize = _newPseudonymizer();
|
|
let handledAny = false;
|
|
for (const provider of providers) {
|
|
const handler = provider.operationHandlers && provider.operationHandlers['source.enumerate'];
|
|
if (typeof handler !== 'function') continue;
|
|
handledAny = true;
|
|
try {
|
|
const raw = await _withInputTimeout(handler({ providerId: provider.providerId, explicit: true, requesterId: _requesterId(payload.requesterId || payload.requester, 'enumerate') }), 'source.enumerate');
|
|
const result = _providerOutcome(raw);
|
|
// Preserve a provider's explicit non-handled outcome exactly (denied/failed/degraded/
|
|
// no-owner/no-handler/unsupported-command/incompatible-version/overridden) instead of
|
|
// treating it as an empty success or collapsing it to a generic degrade.
|
|
if (result.outcome !== 'handled') {
|
|
const reason = _boundedReason(result.reason) || `Source enumeration ${result.outcome}`;
|
|
_recordOutcome({ domain: 'audio-input', operation: 'source.enumerate', participantId: provider.providerId, providerId: provider.providerId, outcome: result.outcome, status: result.status || result.outcome, reason });
|
|
_touch();
|
|
return { outcome: result.outcome, reason, payload: { providerId: provider.providerId } };
|
|
}
|
|
const list = Array.isArray(result.payload && result.payload.sources) ? result.payload.sources : [];
|
|
for (const item of list) {
|
|
const response = registerInputSource({ ...item, providerId: item.providerId || provider.providerId, ownerPluginId: item.ownerPluginId || provider.ownerPluginId });
|
|
if (response.outcome !== 'handled') continue;
|
|
const stored = currentSession.inputSources.get(_string(item.sourceId || item.id));
|
|
registered.push(stored ? _redactedSource(stored, pseudonymize) : response.payload);
|
|
}
|
|
} catch (err) {
|
|
const timedOut = !!(err && err.timedOut);
|
|
const reason = timedOut ? 'Source enumeration timed out' : _boundedReason(err && err.message ? err.message : String(err));
|
|
_recordOutcome({ domain: 'audio-input', operation: 'source.enumerate', participantId: provider.providerId, providerId: provider.providerId, outcome: 'failed', status: timedOut ? 'timeout' : 'failed', reason });
|
|
_touch();
|
|
return _failed(reason, { providerId: provider.providerId, timedOut });
|
|
}
|
|
}
|
|
if (!handledAny) {
|
|
// Providers exist but none expose a source.enumerate handler, so the explicit request
|
|
// was a no-op — report it as no-handler rather than an empty success.
|
|
const result = _noHandler('No input provider supports source enumeration');
|
|
_recordOutcome({ domain: 'audio-input', operation: 'source.enumerate', participantId: payload.providerId || OWNER_ID, providerId: payload.providerId, outcome: result.outcome, status: 'no-handler', reason: result.reason });
|
|
_touch();
|
|
return result;
|
|
}
|
|
_recordOutcome({ domain: 'audio-input', operation: 'source.enumerate', participantId: payload.providerId || OWNER_ID, providerId: payload.providerId, outcome: 'handled', status: `${registered.length}` });
|
|
_touch();
|
|
return _handled({ sources: registered });
|
|
}
|
|
|
|
async function openInputSource(payload = {}, requester = 'unknown') {
|
|
const source = _plainObject(payload);
|
|
// Identity comes from the authenticated dispatch caller, never the payload —
|
|
// a payload-supplied requesterId could otherwise spoof another requester and
|
|
// misattribute or release shared open sessions it does not own.
|
|
const requesterId = _requesterId(requester, 'unknown');
|
|
const selectedInput = currentSession.selectedInput;
|
|
if (!selectedInput) {
|
|
const session = _inputSessionSummary({ requesterId, purpose: source.purpose, state: 'unavailable', reason: 'No selected input source is available' }, _newPseudonymizer());
|
|
const result = _noOwner('No selected input source is available', session);
|
|
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, outcome: result.outcome, status: 'not-selected', reason: result.reason });
|
|
capabilities.emitEvent('audio-input', 'source-open-degraded', session);
|
|
_touch();
|
|
return result;
|
|
}
|
|
// open-source uses the selected source by default; an explicit logicalSourceKey/sourceId
|
|
// may disambiguate but must resolve to the selected source — it must not silently switch.
|
|
// Resolve the canonical winner for the selected logical key (duplicate suppression may have
|
|
// changed which concrete source wins since selection), and compare the hint against its
|
|
// sourceId — not just logicalSourceKey — so a non-selected native/compatibility duplicate
|
|
// sharing the same logical key cannot be opened by passing its sourceId.
|
|
const selectedSource = _findInputSource({ logicalSourceKey: selectedInput.logicalSourceKey });
|
|
const hasHint = !!(source.logicalSourceKey || source.sourceId);
|
|
const selected = hasHint ? _findInputSource(source) : selectedSource;
|
|
if (!selectedSource) {
|
|
// A source is selected but no matching source is registered (e.g. a restored selection
|
|
// before its provider hydrates, or the provider unregistered). Report it as unavailable
|
|
// rather than implying the requester asked for a different source.
|
|
const selectedKey = _boundedReason(selectedInput.logicalSourceKey);
|
|
const session = _inputSessionSummary({ logicalSourceKey: selectedKey, requesterId, purpose: source.purpose, state: 'unavailable', reason: 'Selected input source is unavailable' }, _newPseudonymizer());
|
|
const result = _degraded('Selected input source is unavailable', session);
|
|
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, logicalSourceKey: selectedKey, outcome: result.outcome, status: 'unavailable', reason: result.reason });
|
|
capabilities.emitEvent('audio-input', 'source-open-degraded', session);
|
|
_touch();
|
|
return result;
|
|
}
|
|
if (!selected || selected.sourceId !== selectedSource.sourceId) {
|
|
// Bound the caller-provided hint key, and echo a pseudonymized sourceId hint (callers often
|
|
// disambiguate by sourceId) so the payload/snapshot can correlate it without leaking it.
|
|
const hintKey = _boundedReason(source.logicalSourceKey);
|
|
const hintSourceId = _string(source.sourceId || source.id);
|
|
const session = _inputSessionSummary({ source: { logicalSourceKey: hintKey, sourceId: hintSourceId }, requesterId, purpose: source.purpose, state: 'unavailable', reason: 'Requested input source is not the selected source' }, _newPseudonymizer());
|
|
const result = _degraded('Requested input source is not the selected source', session);
|
|
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, sourceId: hintSourceId, logicalSourceKey: hintKey, outcome: result.outcome, status: 'unavailable', reason: result.reason });
|
|
capabilities.emitEvent('audio-input', 'source-open-degraded', session);
|
|
_touch();
|
|
return result;
|
|
}
|
|
if (selected.availability !== 'available') {
|
|
// Emit an OpenInputSessionSummary-shaped payload (with requester attribution) for a
|
|
// denied/unavailable selected source too, so consumers see one consistent event schema
|
|
// across pre-marked and provider-reported denials.
|
|
const reason = _boundedReason(selected.reason) || (selected.availability === 'denied' ? 'Input permission denied' : `Input source is ${selected.availability}`);
|
|
const session = _inputSessionSummary({ source: selected, requesterId, purpose: source.purpose, channelShape: selected.channelSummary && selected.channelSummary.channelShape, state: selected.availability, reason }, _newPseudonymizer());
|
|
const result = selected.availability === 'denied' ? _denied(reason, session) : _degraded(reason, session);
|
|
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: selected.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, outcome: result.outcome, status: selected.availability, reason: result.reason });
|
|
capabilities.emitEvent('audio-input', result.outcome === 'denied' ? 'permission-denied' : 'source-open-degraded', session);
|
|
_touch();
|
|
return result;
|
|
}
|
|
const requiredShape = _requiredChannelShape(source.requiredChannelShape || source.channelShape || source.channel);
|
|
const compatible = _channelCompatible(selected.channelSummary, requiredShape);
|
|
if (!compatible.ok) {
|
|
const session = _inputSessionSummary({ source: selected, requesterId, purpose: source.purpose, channelShape: compatible.channelShape, state: 'incompatible', reason: 'Selected source cannot satisfy requested channel shape' }, _newPseudonymizer());
|
|
const result = _degraded(session.reason, session);
|
|
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: selected.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, outcome: result.outcome, status: 'incompatible', reason: result.reason });
|
|
capabilities.emitEvent('audio-input', 'source-open-degraded', session);
|
|
_touch();
|
|
return result;
|
|
}
|
|
const channelShape = compatible.channelShape;
|
|
const key = _openSessionKey(selected.logicalSourceKey, channelShape);
|
|
const existing = currentSession.openInputSessions.get(key);
|
|
if (existing && existing.state === 'open') {
|
|
if (!existing.requesters.some(item => item.requesterId === requesterId)) existing.requesters.push(_requesterRef(requesterId, source.purpose));
|
|
existing.lastUsedAt = _now();
|
|
const summary = _redactedOpenSession(existing, _newPseudonymizer());
|
|
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: selected.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, openSessionId: existing.openSessionId, outcome: 'handled', status: 'open' });
|
|
_touch();
|
|
return _handled(summary);
|
|
}
|
|
const provider = currentSession.inputProviders.get(selected.providerId);
|
|
if (!provider) {
|
|
const session = _inputSessionSummary({ source: selected, requesterId, purpose: source.purpose, state: 'no-owner', reason: 'No provider owns the selected input source' }, _newPseudonymizer());
|
|
const result = _noOwner('No provider owns the selected input source', session);
|
|
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, outcome: result.outcome, status: 'no-owner', reason: result.reason });
|
|
capabilities.emitEvent('audio-input', 'source-open-degraded', session);
|
|
_touch();
|
|
return result;
|
|
}
|
|
if (provider.version !== 1) {
|
|
const result = _incompatibleVersion('Input provider requires audio-session contract version 1');
|
|
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: provider.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, outcome: result.outcome, status: 'incompatible-version', reason: result.reason });
|
|
_touch();
|
|
return result;
|
|
}
|
|
const handler = provider.operationHandlers && provider.operationHandlers['source.open'];
|
|
if (!provider.operations.includes('source.open')) {
|
|
const result = _unsupportedCommand('Selected input provider does not support source.open');
|
|
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: provider.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, outcome: result.outcome, status: 'unsupported-command', reason: result.reason });
|
|
_touch();
|
|
return result;
|
|
}
|
|
if (typeof handler !== 'function') {
|
|
const result = _noHandler('Selected input provider has no source.open handler');
|
|
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: provider.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, outcome: result.outcome, status: 'no-handler', reason: result.reason });
|
|
_touch();
|
|
return result;
|
|
}
|
|
const openSession = {
|
|
openSessionId: _id('input-open'),
|
|
logicalSourceKey: selected.logicalSourceKey,
|
|
sourceId: selected.sourceId,
|
|
providerId: selected.providerId,
|
|
channelShape,
|
|
state: 'opening',
|
|
requesters: [_requesterRef(requesterId, source.purpose)],
|
|
openedAt: '',
|
|
lastUsedAt: _now(),
|
|
closedAt: '',
|
|
reason: '',
|
|
key,
|
|
};
|
|
try {
|
|
const raw = await _withInputTimeout(handler({ sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, requesterId, requiredChannelShape: channelShape, purpose: source.purpose }), 'source.open');
|
|
const providerResult = _providerOutcome(raw);
|
|
openSession.state = providerResult.outcome === 'handled' ? 'open' : (providerResult.status || providerResult.outcome);
|
|
openSession.reason = providerResult.reason;
|
|
if (providerResult.outcome === 'handled') {
|
|
openSession.openedAt = _now();
|
|
currentSession.openInputSessions.set(key, openSession);
|
|
const summary = _redactedOpenSession(openSession, _newPseudonymizer());
|
|
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: provider.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, openSessionId: openSession.openSessionId, outcome: 'handled', status: 'open' });
|
|
capabilities.emitEvent('audio-input', 'source-opened', summary);
|
|
_touch();
|
|
return _handled(summary);
|
|
}
|
|
const summary = _redactedOpenSession(openSession, _newPseudonymizer());
|
|
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: provider.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, openSessionId: openSession.openSessionId, outcome: providerResult.outcome, status: openSession.state, reason: providerResult.reason });
|
|
capabilities.emitEvent('audio-input', providerResult.outcome === 'denied' ? 'permission-denied' : 'source-open-degraded', summary);
|
|
_touch();
|
|
if (providerResult.outcome === 'denied') return _denied(providerResult.reason || 'Input permission denied', summary);
|
|
if (providerResult.outcome === 'failed') return _failed(providerResult.reason || 'Input provider failed', summary);
|
|
// Preserve the provider's exact non-handled outcome (no-handler/no-owner/unsupported-command/
|
|
// incompatible-version/overridden/degraded) instead of collapsing it to a generic degrade.
|
|
return { outcome: providerResult.outcome, reason: providerResult.reason || `Input open returned ${providerResult.outcome}`, payload: summary };
|
|
} catch (err) {
|
|
const timedOut = !!(err && err.timedOut);
|
|
openSession.state = 'failed';
|
|
openSession.reason = timedOut ? 'Input source open timed out' : _boundedReason(err && err.message ? err.message : String(err));
|
|
const summary = _redactedOpenSession(openSession, _newPseudonymizer());
|
|
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: provider.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, openSessionId: openSession.openSessionId, outcome: 'failed', status: timedOut ? 'timeout' : 'failed', reason: openSession.reason });
|
|
capabilities.emitEvent('audio-input', 'source-open-degraded', summary);
|
|
_touch();
|
|
return _failed(openSession.reason, { ...summary, timedOut });
|
|
}
|
|
}
|
|
|
|
async function closeInputSource(payload = {}, requester = 'unknown') {
|
|
const source = _plainObject(payload);
|
|
// Identity comes from the authenticated dispatch caller, never the payload, so a
|
|
// caller cannot release a shared open-session reference it does not own.
|
|
const requesterId = _requesterId(requester, 'unknown');
|
|
// openSessionId may be caller-supplied on a miss; bound it (a generated `input-open-<n>` id is
|
|
// unchanged) so it can't leak a path/token into diagnostics, and still match for lookup.
|
|
const openSessionId = _boundedReason(source.openSessionId || source.id);
|
|
// Accept the same logicalSourceKey aliases the other audio-input paths do.
|
|
const logicalSourceKey = _string(source.logicalSourceKey || source.logicalKey || source.sourceKey);
|
|
const hasExplicitChannelShape = source.requiredChannelShape != null || source.channelShape != null || source.channel != null;
|
|
const requiredShape = _requiredChannelShape(source.requiredChannelShape || source.channelShape || source.channel);
|
|
const key = logicalSourceKey ? _openSessionKey(logicalSourceKey, requiredShape) : '';
|
|
let session = null;
|
|
if (openSessionId) {
|
|
session = Array.from(currentSession.openInputSessions.values()).find(item => item.openSessionId === openSessionId) || null;
|
|
} else if (key && currentSession.openInputSessions.has(key)) {
|
|
session = currentSession.openInputSessions.get(key);
|
|
} else if (logicalSourceKey && !hasExplicitChannelShape) {
|
|
// requiredChannelShape is optional, so the exact channel-shape key may not match the
|
|
// shape the session was opened with. Only when the caller omitted the shape, fall back
|
|
// to the logical source key — unambiguous only when a single session is open for it;
|
|
// otherwise require shape/openSessionId. When a shape WAS given but didn't match, fall
|
|
// through to "no match" rather than closing a differently-shaped session.
|
|
const matches = Array.from(currentSession.openInputSessions.values()).filter(item => item.logicalSourceKey === logicalSourceKey);
|
|
if (matches.length === 1) {
|
|
session = matches[0];
|
|
} else if (matches.length > 1) {
|
|
const result = _failed('Multiple open input sessions match the logical source key; specify requiredChannelShape or openSessionId');
|
|
_recordOutcome({ domain: 'audio-input', operation: 'close-source', participantId: requesterId, requesterId, logicalSourceKey, outcome: result.outcome, status: 'ambiguous', reason: result.reason });
|
|
_touch();
|
|
return result;
|
|
}
|
|
}
|
|
if (!session) {
|
|
const result = _noHandler('No matching open input session exists');
|
|
_recordOutcome({ domain: 'audio-input', operation: 'close-source', participantId: requesterId, requesterId, openSessionId, outcome: result.outcome, status: 'missing', reason: result.reason });
|
|
_touch();
|
|
return result;
|
|
}
|
|
session.requesters = session.requesters.filter(item => item.requesterId !== requesterId);
|
|
session.lastUsedAt = _now();
|
|
if (session.requesters.length) {
|
|
const summary = _redactedOpenSession(session, _newPseudonymizer());
|
|
_recordOutcome({ domain: 'audio-input', operation: 'close-source', participantId: requesterId, requesterId, providerId: session.providerId, sourceId: session.sourceId, logicalSourceKey: session.logicalSourceKey, openSessionId: session.openSessionId, outcome: 'handled', status: 'open' });
|
|
_touch();
|
|
return _handled(summary);
|
|
}
|
|
const provider = currentSession.inputProviders.get(session.providerId);
|
|
const handler = provider && provider.operationHandlers && provider.operationHandlers['source.close'];
|
|
if (!provider || typeof handler !== 'function') {
|
|
session.state = 'failed';
|
|
session.reason = 'Selected input provider has no source.close handler';
|
|
const summary = _redactedOpenSession(session, _newPseudonymizer());
|
|
const result = _noHandler(session.reason, summary);
|
|
_recordOutcome({ domain: 'audio-input', operation: 'close-source', participantId: requesterId, requesterId, providerId: session.providerId, sourceId: session.sourceId, logicalSourceKey: session.logicalSourceKey, openSessionId: session.openSessionId, outcome: result.outcome, status: 'no-handler', reason: result.reason });
|
|
_touch();
|
|
return result;
|
|
}
|
|
session.state = 'closing';
|
|
try {
|
|
const raw = await _withInputTimeout(handler({ sourceId: session.sourceId, logicalSourceKey: session.logicalSourceKey, openSessionId: session.openSessionId, requesterIds: [requesterId] }), 'source.close');
|
|
const providerResult = _providerOutcome(raw);
|
|
if (providerResult.outcome !== 'handled') {
|
|
session.state = providerResult.status || providerResult.outcome;
|
|
session.reason = providerResult.reason;
|
|
const summary = _redactedOpenSession(session, _newPseudonymizer());
|
|
_recordOutcome({ domain: 'audio-input', operation: 'close-source', participantId: requesterId, requesterId, providerId: session.providerId, sourceId: session.sourceId, logicalSourceKey: session.logicalSourceKey, openSessionId: session.openSessionId, outcome: providerResult.outcome, status: session.state, reason: providerResult.reason });
|
|
_touch();
|
|
if (providerResult.outcome === 'failed') return _failed(providerResult.reason || 'Input close failed', summary);
|
|
// Preserve the provider's exact non-handled outcome instead of collapsing it to degraded.
|
|
return { outcome: providerResult.outcome, reason: providerResult.reason || `Input close returned ${providerResult.outcome}`, payload: summary };
|
|
}
|
|
session.state = 'closed';
|
|
session.closedAt = _now();
|
|
const summary = _redactedOpenSession(session, _newPseudonymizer());
|
|
currentSession.openInputSessions.delete(session.key || _openSessionKey(session.logicalSourceKey, session.channelShape));
|
|
_recordOutcome({ domain: 'audio-input', operation: 'close-source', participantId: requesterId, requesterId, providerId: session.providerId, sourceId: session.sourceId, logicalSourceKey: session.logicalSourceKey, openSessionId: session.openSessionId, outcome: 'handled', status: 'closed' });
|
|
capabilities.emitEvent('audio-input', 'source-closed', summary);
|
|
_touch();
|
|
return _handled(summary);
|
|
} catch (err) {
|
|
const timedOut = !!(err && err.timedOut);
|
|
session.state = 'failed';
|
|
session.reason = timedOut ? 'Input source close timed out' : _boundedReason(err && err.message ? err.message : String(err));
|
|
const summary = _redactedOpenSession(session, _newPseudonymizer());
|
|
_recordOutcome({ domain: 'audio-input', operation: 'close-source', participantId: requesterId, requesterId, providerId: session.providerId, sourceId: session.sourceId, logicalSourceKey: session.logicalSourceKey, openSessionId: session.openSessionId, outcome: 'failed', status: timedOut ? 'timeout' : 'failed', reason: session.reason });
|
|
_touch();
|
|
return _failed(session.reason, { ...summary, timedOut });
|
|
}
|
|
}
|
|
|
|
function registerMonitoringProvider(spec) {
|
|
const source = _plainObject(spec);
|
|
if (source.incompatible || (source.version && Number(source.version) !== 1)) {
|
|
const result = _incompatibleVersion('Monitoring provider requires audio-session contract version 1');
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'register-provider', participantId: source.providerId || source.id, providerId: source.providerId || source.id, outcome: result.outcome, reason: result.reason });
|
|
return result;
|
|
}
|
|
const provider = _normalizeMonitoringProvider(source);
|
|
if (!provider) {
|
|
const result = _failed('Monitoring provider registration requires providerId');
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'register-provider', outcome: result.outcome, reason: result.reason });
|
|
return result;
|
|
}
|
|
// logicalMonitoringKey is surfaced verbatim via list/inspect and persisted, so reject a key
|
|
// that isn't redaction-safe (path/token) the same way audio-input guards logicalSourceKey.
|
|
if (provider.logicalMonitoringKey && _boundedReason(provider.logicalMonitoringKey) !== provider.logicalMonitoringKey) {
|
|
const result = _failed('Monitoring provider logicalMonitoringKey must be redaction-safe');
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'register-provider', participantId: provider.providerId, providerId: provider.providerId, outcome: result.outcome, status: 'unsafe-key', reason: result.reason });
|
|
return result;
|
|
}
|
|
// providerId is the Map key surfaced in diagnostics/events; a different plugin claiming an
|
|
// already-owned providerId must fail rather than silently overwrite it (mirrors audio-input's
|
|
// sourceId-collision guard). Re-registration by the same owner is the normal idempotent update.
|
|
const ownerCollision = currentSession.monitoringProviders.get(provider.providerId) || knownMonitoringProviders.get(provider.providerId);
|
|
// Compute the effective new owner from the registration itself, defaulting to providerId — NOT
|
|
// inheriting the existing owner the way _normalizeMonitoringProvider does. Otherwise a colliding
|
|
// registration that omits ownerPluginId would silently inherit the original owner and bypass this.
|
|
const effectiveNewOwner = _string(source.ownerPluginId || source.pluginId, '') || provider.providerId;
|
|
if (ownerCollision && ownerCollision.ownerPluginId && ownerCollision.ownerPluginId !== effectiveNewOwner) {
|
|
const result = _failed(`Monitoring provider ${provider.providerId} is already registered by ${_boundedReason(ownerCollision.ownerPluginId)}`);
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'register-provider', participantId: provider.providerId, providerId: provider.providerId, outcome: result.outcome, status: 'providerid-collision', reason: result.reason });
|
|
return result;
|
|
}
|
|
const existingByKey = Array.from(currentSession.monitoringProviders.values()).find(item => item.logicalMonitoringKey === provider.logicalMonitoringKey && item.sourceMode === provider.sourceMode && item.providerId === provider.providerId);
|
|
const existing = currentSession.monitoringProviders.get(provider.providerId) || existingByKey || null;
|
|
const previousAvailability = existing && existing.availability;
|
|
if (existing) provider.registeredAt = existing.registeredAt;
|
|
currentSession.monitoringProviders.set(provider.providerId, provider);
|
|
knownMonitoringProviders.set(provider.providerId, provider);
|
|
_refreshMonitoringDuplicateSuppression();
|
|
_recordMonitoringDuplicateSuppression();
|
|
const summary = _summaryMonitoringProvider(provider);
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'register-provider', participantId: provider.providerId, providerId: provider.providerId, outcome: 'handled', status: provider.availability });
|
|
capabilities.emitEvent('audio-monitoring', 'provider-registered', summary);
|
|
if (previousAvailability && previousAvailability !== provider.availability) capabilities.emitEvent('audio-monitoring', 'provider-availability-changed', summary);
|
|
_touch();
|
|
return _handled(summary);
|
|
}
|
|
|
|
function unregisterMonitoringProvider(query) {
|
|
const payload = typeof query === 'string' ? { providerId: query } : _plainObject(query);
|
|
const provider = _findMonitoringProvider(payload);
|
|
const id = provider ? provider.providerId : _string(payload.providerId || payload.id || payload.logicalMonitoringKey);
|
|
const removed = provider ? currentSession.monitoringProviders.delete(provider.providerId) : false;
|
|
if (provider) knownMonitoringProviders.delete(provider.providerId);
|
|
if (removed) {
|
|
for (const session of currentSession.monitoringSessions.values()) {
|
|
if (session.providerId !== provider.providerId || (session.state !== 'active' && session.state !== 'degraded')) continue;
|
|
session.state = 'orphaned';
|
|
session.reason = 'Monitoring provider disappeared';
|
|
session.updatedAt = _now();
|
|
capabilities.emitEvent('audio-monitoring', 'monitoring-orphaned', _redactedMonitoringSession(session, _newPseudonymizer()));
|
|
}
|
|
}
|
|
_refreshMonitoringDuplicateSuppression();
|
|
const outcome = removed ? 'handled' : 'no-handler';
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'unregister-provider', participantId: id, providerId: id, outcome });
|
|
if (removed) capabilities.emitEvent('audio-monitoring', 'provider-removed', { providerId: id, logicalMonitoringKey: provider.logicalMonitoringKey, removed: true });
|
|
_touch();
|
|
if (removed) return _handled({ providerId: id, logicalMonitoringKey: provider.logicalMonitoringKey, removed: true });
|
|
// `id` here is caller-supplied (no provider matched), so bound it before reflecting it back.
|
|
const safeId = _boundedReason(id);
|
|
return _noHandler(`Unknown monitoring provider: ${safeId}`, { providerId: safeId, removed: false });
|
|
}
|
|
|
|
function selectMonitoringProvider(query, requester = 'unknown') {
|
|
const payload = typeof query === 'string' ? { providerId: query } : _plainObject(query);
|
|
const provider = _findMonitoringProvider(payload);
|
|
// requestedKey is caller-supplied; bound it before echoing it back in the reason/payload.
|
|
const requestedKey = _boundedReason(_string(payload.logicalMonitoringKey || payload.logicalKey || payload.monitoringKey || payload.providerId || payload.id));
|
|
if (!provider || provider.supersededBy) {
|
|
const result = _unavailable(`Monitoring provider is unavailable: ${requestedKey}`, { logicalMonitoringKey: requestedKey, availability: 'unavailable' });
|
|
// No logicalSourceKey here: requestedKey is a monitoring key, not an audio-input logical
|
|
// source key, and reusing that field would misrepresent the outcome in diagnostics. The
|
|
// requested key is already present in the bounded reason.
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'select-provider', participantId: requester, requesterId: requester, outcome: result.outcome, status: 'unavailable', reason: result.reason });
|
|
return result;
|
|
}
|
|
currentSession.selectedMonitoringProvider = _makeSelectedMonitoringProvider(provider, requester);
|
|
currentSession.storageStatus = _writeSelectedLogicalMonitoringKey(provider.logicalMonitoringKey);
|
|
_syncSelectedMonitoringProvider();
|
|
const summary = _summarySelectedMonitoringProvider(currentSession.selectedMonitoringProvider);
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'select-provider', participantId: requester, requesterId: requester, providerId: provider.providerId, outcome: 'handled', status: provider.availability });
|
|
capabilities.emitEvent('audio-monitoring', 'provider-selected', summary);
|
|
_touch();
|
|
return _handled(summary);
|
|
}
|
|
|
|
function listMonitoringProviders(payload = {}) {
|
|
const includeUnavailable = payload.includeUnavailable !== false;
|
|
const providers = _visibleMonitoringProviders()
|
|
.filter(provider => includeUnavailable || provider.availability === 'available')
|
|
.map(_summaryMonitoringProvider);
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'list-providers', participantId: OWNER_ID, outcome: 'handled', status: `${providers.length}` });
|
|
return _handled({ providers, selectedProvider: _summarySelectedMonitoringProvider(_syncSelectedMonitoringProvider()), timeoutMs: MONITORING_OPERATION_TIMEOUT_MS });
|
|
}
|
|
|
|
function _resolveMonitoringProvider(payload = {}) {
|
|
_refreshMonitoringDuplicateSuppression();
|
|
const explicit = _findMonitoringProvider(payload);
|
|
if (explicit && !explicit.supersededBy) return { outcome: 'handled', provider: explicit };
|
|
const selected = _syncSelectedMonitoringProvider();
|
|
if (selected && selected.providerId) {
|
|
const selectedProvider = currentSession.monitoringProviders.get(selected.providerId);
|
|
if (selectedProvider && !selectedProvider.supersededBy) return { outcome: 'handled', provider: selectedProvider };
|
|
}
|
|
const compatible = _visibleMonitoringProviders().filter(provider => provider.availability === 'available' || provider.availability === 'pending');
|
|
if (compatible.length === 1) return { outcome: 'handled', provider: compatible[0] };
|
|
if (compatible.length > 1) return { outcome: 'provider-selection-required', reason: 'Multiple compatible monitoring providers require an explicit provider choice', providers: compatible.map(_summaryMonitoringProvider) };
|
|
return { outcome: 'no-owner', reason: 'No monitoring provider is available', providers: [] };
|
|
}
|
|
|
|
async function _refreshMonitoringStatuses(payload = {}) {
|
|
const providers = _visibleMonitoringProviders().filter(provider => !payload.providerId || provider.providerId === payload.providerId);
|
|
// Prompt-free status must not hand the raw device sourceId to providers: selectedInput.sourceId is
|
|
// the raw input source id, so strip it (keep only the redaction-safe logicalSourceKey context).
|
|
const statusSourceRef = { ..._monitoringSourceRef(currentSession.selectedInput || {}), sourceId: '' };
|
|
// Refresh provider statuses concurrently: a single hung/slow provider must not serialize the
|
|
// whole prompt-free inspect path into O(N * timeout). Each provider mutates only its own record.
|
|
await Promise.allSettled(providers.map(async (provider) => {
|
|
if (provider.version !== 1) return;
|
|
const handler = provider.operationHandlers && provider.operationHandlers['monitoring.status'];
|
|
if (!provider.operations.includes('monitoring.status') || typeof handler !== 'function') return;
|
|
try {
|
|
const raw = await _withMonitoringTimeout(handler({ providerId: provider.providerId, sourceRef: statusSourceRef }), 'monitoring.status');
|
|
const result = _monitoringProviderOutcome(raw);
|
|
if (result.payload && typeof result.payload === 'object') {
|
|
// result.status is a clamped/derived value: a non-state provider outcome (e.g.
|
|
// 'no-handler') resolves to 'unknown', which is itself a valid availability and would
|
|
// wrongly overwrite a healthy provider. Only let an explicit payload.availability or a
|
|
// genuine reported state drive availability; a derived 'unknown' preserves the prior value.
|
|
const statusAvailability = result.status && result.status !== 'unknown' ? result.status : '';
|
|
provider.availability = _availability(result.payload.availability || statusAvailability || provider.availability, provider.availability);
|
|
provider.directMonitor = _normalizeDirectMonitor(result.payload.directMonitor || provider.directMonitor || {});
|
|
provider.latencySummary = _normalizeLatencySummary(result.payload.latencySummary || provider.latencySummary || {});
|
|
}
|
|
provider.reason = result.reason || provider.reason;
|
|
provider.lastChangedAt = _now();
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'status', participantId: provider.providerId, providerId: provider.providerId, outcome: result.outcome, status: result.status, reason: result.reason });
|
|
} catch (err) {
|
|
provider.availability = 'failed';
|
|
provider.reason = err && err.timedOut ? 'Monitoring status timed out' : _boundedReason(err && err.message ? err.message : String(err));
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'status', participantId: provider.providerId, providerId: provider.providerId, outcome: 'failed', status: err && err.timedOut ? 'timeout' : 'failed', reason: provider.reason });
|
|
}
|
|
}));
|
|
_syncSelectedMonitoringProvider();
|
|
// An explicit status refresh mutates provider state/outcomes, so bump diagnostics freshness.
|
|
_touch();
|
|
}
|
|
|
|
async function inspectMonitoring(payload = {}) {
|
|
if (payload.refreshStatus === true || payload.includeStatus === true) await _refreshMonitoringStatuses(payload);
|
|
const snapshotData = snapshot().domains['audio-monitoring'];
|
|
if (payload.providerId || payload.logicalMonitoringKey) {
|
|
const match = snapshotData.providers.find(provider => (payload.providerId && provider.providerId === payload.providerId) || (payload.logicalMonitoringKey && provider.logicalMonitoringKey === payload.logicalMonitoringKey));
|
|
return _handled({ ...snapshotData, provider: match || null });
|
|
}
|
|
return _handled(snapshotData);
|
|
}
|
|
|
|
async function _ensureMonitoringInput(payload, requesterId) {
|
|
const requiredChannelShape = _channelShape(payload.requiredChannelShape || payload.channelShape, 'unknown');
|
|
// Always resolve through audio-input open-source and derive the monitoring sourceRef from its
|
|
// redaction-safe payload — never the caller payload — so a caller can't bypass open-source
|
|
// readiness/ownership or inject arbitrary sourceId/logicalSourceKey fields that would leak into
|
|
// the shared session and other requesters' start() responses. openInputSource reuses an
|
|
// already-open session for the selected source, so this does not double-open; openSessionId is the
|
|
// only trusted cross-domain correlation.
|
|
return openInputSource({ ...(payload.sourceRef || {}), logicalSourceKey: payload.logicalSourceKey || payload.sourceRef?.logicalSourceKey, sourceId: payload.sourceId || payload.sourceRef?.sourceId, requesterId, purpose: payload.purpose, requiredChannelShape }, requesterId);
|
|
}
|
|
|
|
function _blockingInputOutcome(openResult) {
|
|
if (openResult.outcome === 'handled') return null;
|
|
const payload = _plainObject(openResult.payload);
|
|
const status = _string(payload.state || openResult.status, openResult.outcome);
|
|
if (openResult.outcome === 'denied') return { outcome: 'denied', state: 'denied', reason: openResult.reason || 'Input permission denied' };
|
|
if (status === 'incompatible') return { outcome: 'incompatible', state: 'failed', reason: openResult.reason || 'Selected source cannot satisfy requested channel shape' };
|
|
if (openResult.outcome === 'no-owner') return { outcome: 'no-owner', state: 'unavailable', reason: openResult.reason || 'No selected input source is available' };
|
|
if (openResult.outcome === 'failed') return { outcome: 'failed', state: 'failed', reason: openResult.reason || 'Input source failed to open' };
|
|
return { outcome: 'unavailable', state: 'unavailable', reason: openResult.reason || 'Input source is unavailable' };
|
|
}
|
|
|
|
async function startMonitoring(spec = {}, requester = 'unknown') {
|
|
const payload = _plainObject(spec);
|
|
// Identity comes from the authenticated dispatch caller, never the payload — a
|
|
// payload-supplied requesterId/source could otherwise spoof another requester and
|
|
// misattribute or share a monitoring session it does not own (mirrors openInputSource).
|
|
const requesterId = _requesterId(requester, 'unknown');
|
|
const requiredChannelShape = _channelShape(payload.requiredChannelShape || payload.channelShape, 'unknown');
|
|
const directMonitorPreference = payload.directMonitorPreference === 'muted' || payload.directMonitorPreference === 'unmuted' ? payload.directMonitorPreference : _readDirectMonitorPreference();
|
|
const authorization = _authorizationMode(payload.authorization || payload.mode);
|
|
capabilities.emitEvent('audio-monitoring', 'monitoring-start-requested', { requesterId, authorization, requiredChannelShape });
|
|
|
|
const resolved = _resolveMonitoringProvider(payload);
|
|
if (resolved.outcome !== 'handled') {
|
|
const result = _monitoringResultFor(resolved.outcome, resolved.reason, { providers: resolved.providers || [] });
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'start', participantId: requesterId, requesterId, outcome: result.outcome, status: resolved.outcome, reason: result.reason });
|
|
if (result.outcome === 'provider-selection-required') capabilities.emitEvent('audio-monitoring', 'provider-selection-required', result.payload);
|
|
return result;
|
|
}
|
|
const provider = resolved.provider;
|
|
const selectedRef = _monitoringSourceRef(payload.sourceRef || currentSession.selectedInput || {});
|
|
const existing = _findCompatibleMonitoringSession(provider, selectedRef, requiredChannelShape, directMonitorPreference);
|
|
if (existing) {
|
|
// Attaching to a shared session must still honor this requester's direct-monitor requirement:
|
|
// if it conflicts with the active preference, surface degraded (or unsupported-command when
|
|
// strict) and annotate the requester ref, mirroring the fresh-start conflict handling.
|
|
const attachRequirement = _directMonitorState(payload.directMonitorRequirement, 'unknown');
|
|
const attachConflict = (attachRequirement === 'muted' || attachRequirement === 'unmuted') && attachRequirement !== directMonitorPreference;
|
|
const attachOutcome = attachConflict ? (payload.directMonitorStrict === true ? 'unsupported-command' : 'degraded') : 'handled';
|
|
const conflictReason = 'Direct-monitor requirement conflicts with user/default setting';
|
|
if (!existing.requesters.some(item => item.requesterId === requesterId)) existing.requesters.push(_monitoringRequesterRef({ ...payload, requesterId, requiredChannelShape, directMonitorRequirement: attachRequirement, status: attachConflict ? attachOutcome : '', reason: attachConflict ? conflictReason : '' }));
|
|
existing.lastUsedAt = _now();
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'start', participantId: requesterId, requesterId, providerId: provider.providerId, monitoringId: existing.monitoringId, outcome: attachOutcome, status: existing.state, reason: attachConflict ? conflictReason : '' });
|
|
if (attachConflict) _emitMonitoringOutcome(attachOutcome, existing);
|
|
_touch();
|
|
return _monitoringResultFor(attachOutcome, attachConflict ? conflictReason : '', _clone(existing));
|
|
}
|
|
if (authorization !== 'user-action') {
|
|
const result = _userActionRequired('Fresh monitoring start requires explicit user action', { requesterId, providerId: provider.providerId });
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'start', participantId: requesterId, requesterId, providerId: provider.providerId, outcome: result.outcome, status: 'user-action-required', reason: result.reason });
|
|
// user-action-required is not a permission denial — emit a dedicated event (like
|
|
// provider-selection-required) so subscribers don't conflate it with 'monitoring-denied'.
|
|
capabilities.emitEvent('audio-monitoring', 'monitoring-user-action-required', result.payload);
|
|
return result;
|
|
}
|
|
if (provider.availability !== 'available' && provider.availability !== 'pending') {
|
|
const result = _unavailable(provider.reason || `Monitoring provider is ${provider.availability}`, _summaryMonitoringProvider(provider));
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'start', participantId: requesterId, requesterId, providerId: provider.providerId, outcome: result.outcome, status: provider.availability, reason: result.reason });
|
|
capabilities.emitEvent('audio-monitoring', 'monitoring-unavailable', result.payload);
|
|
return result;
|
|
}
|
|
if (provider.version !== 1) {
|
|
const result = _incompatibleVersion('Monitoring provider requires audio-session contract version 1', _summaryMonitoringProvider(provider));
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'start', participantId: requesterId, requesterId, providerId: provider.providerId, outcome: result.outcome, status: 'incompatible-version', reason: result.reason });
|
|
return result;
|
|
}
|
|
const openResult = await _ensureMonitoringInput(payload, requesterId);
|
|
const blockingInput = _blockingInputOutcome(openResult);
|
|
const sourceRef = _monitoringSourceRef(openResult.payload || payload.sourceRef || currentSession.selectedInput || {});
|
|
if (blockingInput) {
|
|
const session = {
|
|
monitoringId: _string(payload.monitoringId || payload.id, _id('monitoring')),
|
|
sessionKey: _monitoringSessionKey(provider, sourceRef, requiredChannelShape, directMonitorPreference),
|
|
providerId: provider.providerId,
|
|
logicalMonitoringKey: provider.logicalMonitoringKey,
|
|
sourceRef,
|
|
openInputSessionId: sourceRef.openSessionId || '',
|
|
state: blockingInput.state,
|
|
requesters: [_monitoringRequesterRef({ ...payload, requesterId, requiredChannelShape, status: blockingInput.outcome, reason: blockingInput.reason })],
|
|
directMonitor: _normalizeDirectMonitor({ ...provider.directMonitor, preference: directMonitorPreference }),
|
|
latencySummary: _clone(provider.latencySummary),
|
|
reason: blockingInput.reason,
|
|
startedAt: '',
|
|
lastUsedAt: _now(),
|
|
stoppedAt: '',
|
|
updatedAt: _now(),
|
|
};
|
|
currentSession.monitoringSessions.set(session.monitoringId, session);
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'start', participantId: requesterId, requesterId, providerId: provider.providerId, monitoringId: session.monitoringId, logicalSourceKey: sourceRef.logicalSourceKey, sourceId: sourceRef.sourceId, openSessionId: sourceRef.openSessionId, outcome: blockingInput.outcome, status: session.state, reason: blockingInput.reason });
|
|
_emitMonitoringOutcome(blockingInput.outcome, session);
|
|
_touch();
|
|
return _monitoringResultFor(blockingInput.outcome, blockingInput.reason, _clone(session));
|
|
}
|
|
if (!provider.operations.includes('monitoring.start')) {
|
|
const result = _unsupportedCommand('Monitoring provider does not support monitoring.start', _summaryMonitoringProvider(provider));
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'start', participantId: requesterId, requesterId, providerId: provider.providerId, outcome: result.outcome, status: 'unsupported-command', reason: result.reason });
|
|
return result;
|
|
}
|
|
const handler = provider.operationHandlers && provider.operationHandlers['monitoring.start'];
|
|
if (typeof handler !== 'function') {
|
|
const result = _noHandler('Monitoring provider has no monitoring.start handler', _summaryMonitoringProvider(provider));
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'start', participantId: requesterId, requesterId, providerId: provider.providerId, outcome: result.outcome, status: 'no-handler', reason: result.reason });
|
|
return result;
|
|
}
|
|
const monitoringId = _string(payload.monitoringId || payload.id, _id('monitoring'));
|
|
const directMonitorRequirement = _directMonitorState(payload.directMonitorRequirement, payload.directMonitorRequirement ? 'unknown' : 'unknown');
|
|
const conflict = (directMonitorRequirement === 'muted' || directMonitorRequirement === 'unmuted') && directMonitorRequirement !== directMonitorPreference;
|
|
try {
|
|
const raw = await _withMonitoringTimeout(handler({ monitoringId, providerId: provider.providerId, requesterId, sourceRef, requiredChannelShape, directMonitorPreference, directMonitorRequirement }), 'monitoring.start');
|
|
const providerResult = _monitoringProviderOutcome(raw, {}, true);
|
|
// A provider that reports a terminal state (denied/unavailable/failed/stopped/orphaned) but a
|
|
// 'handled' (or omitted) outcome must not be coerced into an active session — derive the
|
|
// effective outcome from the reported state so the real condition surfaces. 'degraded' is
|
|
// intentionally NOT promoted: it stays a handled start with session.state 'degraded'
|
|
// (a working-but-degraded session), matching the established degraded contract.
|
|
const reportedState = providerResult.status;
|
|
const TERMINAL_MONITORING_STATES = ['denied', 'unavailable', 'failed', 'stopped', 'orphaned'];
|
|
const effectiveOutcome = providerResult.outcome === 'handled' && TERMINAL_MONITORING_STATES.includes(reportedState)
|
|
? (reportedState === 'orphaned' ? 'failed' : reportedState)
|
|
: providerResult.outcome;
|
|
const baseState = effectiveOutcome === 'handled' ? (reportedState === 'degraded' ? 'degraded' : 'active') : _monitoringState(reportedState, effectiveOutcome === 'degraded' ? 'degraded' : effectiveOutcome);
|
|
// The direct-monitor conflict only downgrades a session that the provider otherwise
|
|
// accepted (handled/degraded). When the provider itself reported a terminal state the
|
|
// real provider reason must win, so the conflict status/reason must NOT overwrite it.
|
|
const conflictApplies = conflict && (effectiveOutcome === 'handled' || effectiveOutcome === 'degraded');
|
|
const resultOutcome = conflictApplies ? (payload.directMonitorStrict === true ? 'unsupported-command' : 'degraded') : effectiveOutcome;
|
|
const sessionState = conflictApplies && baseState === 'active' ? 'degraded' : baseState;
|
|
const session = {
|
|
monitoringId,
|
|
sessionKey: _monitoringSessionKey(provider, sourceRef, requiredChannelShape, directMonitorPreference),
|
|
providerId: provider.providerId,
|
|
logicalMonitoringKey: provider.logicalMonitoringKey,
|
|
sourceRef,
|
|
openInputSessionId: sourceRef.openSessionId || '',
|
|
state: sessionState,
|
|
requesters: [_monitoringRequesterRef({ ...payload, requesterId, requiredChannelShape, directMonitorRequirement, status: conflictApplies ? resultOutcome : '', reason: conflictApplies ? 'Direct-monitor requirement conflicts with user/default setting' : '' })],
|
|
directMonitor: _normalizeDirectMonitor({ ...((providerResult.payload && providerResult.payload.directMonitor) || provider.directMonitor || {}), preference: directMonitorPreference }),
|
|
latencySummary: _normalizeLatencySummary((providerResult.payload && providerResult.payload.latencySummary) || provider.latencySummary || {}),
|
|
reason: conflictApplies ? 'Direct-monitor requirement conflicts with user/default setting' : providerResult.reason,
|
|
startedAt: sessionState === 'active' || sessionState === 'degraded' ? _now() : '',
|
|
lastUsedAt: _now(),
|
|
stoppedAt: '',
|
|
updatedAt: _now(),
|
|
};
|
|
currentSession.monitoringSessions.set(monitoringId, session);
|
|
provider.directMonitor = _normalizeDirectMonitor({ ...provider.directMonitor, ...session.directMonitor, preference: directMonitorPreference });
|
|
provider.latencySummary = session.latencySummary;
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'start', participantId: requesterId, requesterId, providerId: provider.providerId, monitoringId, logicalSourceKey: sourceRef.logicalSourceKey, sourceId: sourceRef.sourceId, openSessionId: sourceRef.openSessionId, outcome: resultOutcome, status: session.state, reason: session.reason || providerResult.reason });
|
|
_emitMonitoringOutcome(resultOutcome, session);
|
|
_touch();
|
|
return _monitoringResultFor(resultOutcome, session.reason || providerResult.reason || `Monitoring returned ${resultOutcome}`, _clone(session));
|
|
} catch (err) {
|
|
const reason = err && err.timedOut ? 'Monitoring start timed out' : _boundedReason(err && err.message ? err.message : String(err));
|
|
const session = { monitoringId, sessionKey: _monitoringSessionKey(provider, sourceRef, requiredChannelShape, directMonitorPreference), providerId: provider.providerId, logicalMonitoringKey: provider.logicalMonitoringKey, sourceRef, openInputSessionId: sourceRef.openSessionId || '', state: 'failed', requesters: [_monitoringRequesterRef({ ...payload, requesterId, requiredChannelShape, status: 'failed', reason })], directMonitor: _normalizeDirectMonitor({ ...provider.directMonitor, preference: directMonitorPreference }), latencySummary: _clone(provider.latencySummary), reason, startedAt: '', lastUsedAt: _now(), stoppedAt: '', updatedAt: _now() };
|
|
currentSession.monitoringSessions.set(monitoringId, session);
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'start', participantId: requesterId, requesterId, providerId: provider.providerId, monitoringId, outcome: 'failed', status: err && err.timedOut ? 'timeout' : 'failed', reason });
|
|
_emitMonitoringOutcome('failed', session);
|
|
_touch();
|
|
return _failed(reason, { ..._clone(session), timedOut: !!(err && err.timedOut) });
|
|
}
|
|
}
|
|
|
|
async function stopMonitoring(target, requester = 'unknown') {
|
|
const payload = typeof target === 'object' ? _plainObject(target) : { monitoringId: target };
|
|
const requesterId = _requesterId(requester, requester);
|
|
const id = _string(payload.monitoringId || payload.id);
|
|
// stopAll is a user-authoritative global stop (see the audio-monitoring control-plane contract).
|
|
// Require an explicit user action so a background requester can't tear down everyone's monitoring
|
|
// (a DoS); targeted stops remain ownership-scoped below.
|
|
if (payload.stopAll && payload.authorization !== 'user-action') {
|
|
const result = _userActionRequired('Stopping all monitoring sessions requires explicit user action', { requesterId });
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'stop', participantId: requesterId, requesterId, outcome: result.outcome, status: 'user-action-required', reason: result.reason });
|
|
return result;
|
|
}
|
|
const sessions = payload.stopAll ? Array.from(currentSession.monitoringSessions.values()).filter(session => session.state === 'active' || session.state === 'degraded') : [currentSession.monitoringSessions.get(id)].filter(Boolean);
|
|
if (!sessions.length) {
|
|
// `id` is caller-supplied and didn't match a session; bound it before reflecting it back.
|
|
const safeId = _boundedReason(id);
|
|
const result = _noHandler(`Unknown monitoring session: ${safeId || 'active session'}`, { monitoringId: safeId });
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'stop', participantId: requesterId, requesterId, monitoringId: safeId, outcome: result.outcome, reason: result.reason });
|
|
return result;
|
|
}
|
|
let lastResult = null;
|
|
for (const session of sessions) {
|
|
// A targeted stop may only release/tear down a session the caller actually owns —
|
|
// otherwise any requester could end another's monitoring. `stopAll` is the documented
|
|
// explicit user-authoritative global stop (see audio-monitoring-control-plane contract)
|
|
// and is intentionally not ownership-scoped. An already-ownerless session (every requester
|
|
// released, or a failed/timed-out final stop that emptied requesters before the provider
|
|
// confirmed) has no owner left to protect, so any caller may retry the stop on it.
|
|
if (!payload.stopAll && session.requesters.length > 0 && !session.requesters.some(item => item.requesterId === requesterId)) {
|
|
lastResult = _noHandler('Requester does not own this monitoring session', { monitoringId: session.monitoringId });
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'stop', participantId: requesterId, requesterId, providerId: session.providerId, monitoringId: session.monitoringId, outcome: lastResult.outcome, status: 'not-owner', reason: lastResult.reason });
|
|
continue;
|
|
}
|
|
const beforeCount = session.requesters.length;
|
|
if (!payload.stopAll) session.requesters = session.requesters.filter(item => item.requesterId !== requesterId);
|
|
if (!payload.stopAll && beforeCount > 1 && session.requesters.length > 0) {
|
|
session.lastUsedAt = _now();
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'stop', participantId: requesterId, requesterId, providerId: session.providerId, monitoringId: session.monitoringId, outcome: 'handled', status: session.state });
|
|
lastResult = _handled(_clone(session));
|
|
continue;
|
|
}
|
|
const provider = currentSession.monitoringProviders.get(session.providerId) || knownMonitoringProviders.get(session.providerId);
|
|
const handler = provider && provider.operationHandlers && provider.operationHandlers['monitoring.stop'];
|
|
try {
|
|
if (!provider) {
|
|
// Provider disappeared — we can't confirm the live capture actually stopped, so orphan
|
|
// the session and report no-owner rather than falsely claiming 'stopped'.
|
|
session.state = 'orphaned';
|
|
session.reason = 'Monitoring provider is no longer available';
|
|
session.updatedAt = _now();
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'stop', participantId: requesterId, requesterId, providerId: session.providerId, monitoringId: session.monitoringId, outcome: 'no-owner', status: 'orphaned', reason: session.reason });
|
|
capabilities.emitEvent('audio-monitoring', 'monitoring-orphaned', _redactedMonitoringSession(session, _newPseudonymizer()));
|
|
lastResult = _noOwner(session.reason, _clone(session));
|
|
continue;
|
|
}
|
|
if (!provider.operations.includes('monitoring.stop')) {
|
|
// Provider advertises no stop operation — don't pretend the live capture stopped.
|
|
session.state = 'failed';
|
|
session.reason = 'Monitoring provider does not support monitoring.stop';
|
|
session.updatedAt = _now();
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'stop', participantId: requesterId, requesterId, providerId: session.providerId, monitoringId: session.monitoringId, outcome: 'unsupported-command', status: 'unsupported-command', reason: session.reason });
|
|
lastResult = _unsupportedCommand(session.reason, _clone(session));
|
|
continue;
|
|
}
|
|
if (typeof handler === 'function') {
|
|
const raw = await _withMonitoringTimeout(handler({ monitoringId: session.monitoringId, providerId: session.providerId, requesterIds: Array.from(new Set(session.requesters.map(item => item.requesterId).concat(requesterId))), reason: payload.stopAll ? 'user-stop-all' : 'final-requester-released' }), 'monitoring.stop');
|
|
const providerResult = _monitoringProviderOutcome(raw, _clone(session));
|
|
// A 'handled'/'stopped' outcome paired with a terminal non-stopped status (failed/
|
|
// denied/unavailable/orphaned) must not be reported as a clean stop — derive the
|
|
// effective outcome from the reported state, mirroring startMonitoring.
|
|
const reportedState = providerResult.status;
|
|
const stopEffectiveOutcome = (providerResult.outcome === 'handled' || providerResult.outcome === 'stopped') && ['denied', 'unavailable', 'failed', 'orphaned'].includes(reportedState)
|
|
? (reportedState === 'orphaned' ? 'failed' : reportedState)
|
|
: providerResult.outcome;
|
|
if (stopEffectiveOutcome !== 'handled' && stopEffectiveOutcome !== 'stopped') {
|
|
session.state = stopEffectiveOutcome === 'failed' ? 'failed' : _monitoringState(reportedState, stopEffectiveOutcome);
|
|
session.reason = providerResult.reason;
|
|
session.updatedAt = _now();
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'stop', participantId: requesterId, requesterId, providerId: session.providerId, monitoringId: session.monitoringId, outcome: stopEffectiveOutcome, status: session.state, reason: providerResult.reason });
|
|
_emitMonitoringOutcome(stopEffectiveOutcome, session);
|
|
lastResult = _monitoringResultFor(stopEffectiveOutcome, providerResult.reason || `Monitoring stop returned ${stopEffectiveOutcome}`, _clone(session));
|
|
continue;
|
|
}
|
|
} else {
|
|
// Provider advertises monitoring.stop but supplied no handler.
|
|
session.state = 'failed';
|
|
session.reason = 'Monitoring provider has no monitoring.stop handler';
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'stop', participantId: requesterId, requesterId, providerId: session.providerId, monitoringId: session.monitoringId, outcome: 'no-handler', status: 'no-handler', reason: session.reason });
|
|
lastResult = _noHandler(session.reason, _clone(session));
|
|
continue;
|
|
}
|
|
session.state = 'stopped';
|
|
session.stoppedAt = _now();
|
|
session.updatedAt = _now();
|
|
session.requesters = [];
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'stop', participantId: requesterId, requesterId, providerId: session.providerId, monitoringId: session.monitoringId, outcome: 'stopped', status: 'stopped' });
|
|
capabilities.emitEvent('audio-monitoring', 'monitoring-stopped', _redactedMonitoringSession(session, _newPseudonymizer()));
|
|
lastResult = _stopped('Monitoring stopped', _clone(session));
|
|
} catch (err) {
|
|
session.state = 'failed';
|
|
session.reason = err && err.timedOut ? 'Monitoring stop timed out' : _boundedReason(err && err.message ? err.message : String(err));
|
|
session.updatedAt = _now();
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'stop', participantId: requesterId, requesterId, providerId: session.providerId, monitoringId: session.monitoringId, outcome: 'failed', status: err && err.timedOut ? 'timeout' : 'failed', reason: session.reason });
|
|
_emitMonitoringOutcome('failed', session);
|
|
lastResult = _failed(session.reason, { ..._clone(session), timedOut: !!(err && err.timedOut) });
|
|
}
|
|
}
|
|
_touch();
|
|
return lastResult || _stopped('Monitoring stopped', {});
|
|
}
|
|
|
|
async function setDirectMonitoringState(payload = {}, requester = 'unknown') {
|
|
const source = typeof payload === 'string' ? { state: payload } : _plainObject(payload);
|
|
const state = _directMonitorState(source.state, 'unknown');
|
|
// Identity comes from the authenticated dispatch caller, never the payload (anti-spoofing).
|
|
const requesterId = _requesterId(requester, 'unknown');
|
|
if (state !== 'muted' && state !== 'unmuted') {
|
|
const result = _denied('Direct-monitor preference must be muted or unmuted', { state });
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'set-direct-monitor', participantId: requesterId, requesterId, outcome: result.outcome, status: 'invalid-state', reason: result.reason });
|
|
return result;
|
|
}
|
|
currentSession.directMonitor = _normalizeDirectMonitor({ ...currentSession.directMonitor, state, preference: state, applied: false, lastChangedAt: _now() });
|
|
currentSession.storageStatus = _writeDirectMonitorPreference(state);
|
|
const activeSessions = Array.from(currentSession.monitoringSessions.values()).filter(session => session.state === 'active' || session.state === 'degraded');
|
|
let outcome = 'handled';
|
|
let reason = '';
|
|
for (const session of activeSessions) {
|
|
const provider = currentSession.monitoringProviders.get(session.providerId);
|
|
const handler = provider && provider.operationHandlers && (provider.operationHandlers['monitoring.set-direct-monitor'] || provider.operationHandlers['direct-monitor.set']);
|
|
const supports = provider && (provider.operations.includes('monitoring.set-direct-monitor') || provider.operations.includes('direct-monitor.set'));
|
|
if (supports && typeof handler === 'function') {
|
|
try {
|
|
const raw = await _withMonitoringTimeout(handler({ monitoringId: session.monitoringId, providerId: session.providerId, state, requesterId }), 'set-direct-monitor');
|
|
const result = _monitoringProviderOutcome(raw, session.directMonitor);
|
|
if (result.outcome !== 'handled' && result.outcome !== 'degraded') {
|
|
outcome = result.outcome;
|
|
reason = result.reason || reason;
|
|
}
|
|
// Only claim applied when the provider explicitly reports a directMonitor summary; a
|
|
// bare 'handled' doesn't confirm the hardware preference was actually applied.
|
|
session.directMonitor = _normalizeDirectMonitor((result.payload && result.payload.directMonitor) || { state, control: 'supported', preference: state, applied: false, reason: result.reason, lastChangedAt: _now() });
|
|
} catch (err) {
|
|
outcome = 'failed';
|
|
reason = err && err.timedOut ? 'Direct-monitor apply timed out' : _boundedReason(err && err.message ? err.message : String(err));
|
|
session.directMonitor = _normalizeDirectMonitor({ state, control: 'unknown', preference: state, applied: false, reason, lastChangedAt: _now() });
|
|
}
|
|
} else {
|
|
outcome = outcome === 'handled' ? 'unsupported-command' : outcome;
|
|
reason = reason || 'Monitoring provider does not support direct-monitor control';
|
|
session.directMonitor = _normalizeDirectMonitor({ state, control: 'unsupported', preference: state, applied: false, reason, lastChangedAt: _now() });
|
|
}
|
|
// The session key encodes the direct-monitor policy; refresh its policy segment to the new
|
|
// user preference so a later start with this preference re-attaches to the live session
|
|
// instead of failing to match and forking a duplicate (or returning user-action-required).
|
|
session.sessionKey = String(session.sessionKey).split('::').slice(0, -1).concat(state).join('::');
|
|
session.updatedAt = _now();
|
|
}
|
|
// Reflect the actual per-session apply results back into the domain-level summary so
|
|
// snapshot().domains['audio-monitoring'].directMonitor and the emitted event are not stale
|
|
// (the pre-call seed above optimistically set applied:false/unknown control). Derive from the
|
|
// sessions' own directMonitor states — not the aggregate outcome — so a provider that returns
|
|
// outcome:'handled' but directMonitor.applied:false is reported as not-applied.
|
|
if (activeSessions.length) {
|
|
const monitors = activeSessions.map(session => session.directMonitor || {});
|
|
const applied = monitors.every(monitor => monitor.applied === true);
|
|
const control = monitors.some(monitor => monitor.control === 'unsupported') ? 'unsupported'
|
|
: monitors.some(monitor => monitor.control === 'unavailable') ? 'unavailable'
|
|
: (monitors.every(monitor => monitor.control === 'supported') ? 'supported' : 'unknown');
|
|
// Prefer the command-level error reason (unsupported/failed/timeout), else surface the first
|
|
// per-session reason so a handled-but-not-applied provider note (e.g. 'hardware busy') isn't lost.
|
|
const summaryReason = reason || monitors.map(monitor => monitor.reason).find(value => value) || '';
|
|
currentSession.directMonitor = _normalizeDirectMonitor({ ...currentSession.directMonitor, state, preference: state, control, applied, reason: summaryReason, lastChangedAt: _now() });
|
|
}
|
|
const summary = { directMonitor: _clone(currentSession.directMonitor), sessions: activeSessions.map(_clone) };
|
|
_recordOutcome({ domain: 'audio-monitoring', operation: 'set-direct-monitor', participantId: requesterId, requesterId, outcome, status: state, reason });
|
|
// The event is broadcast to all observers, so emit redaction-safe sessions; the raw summary is
|
|
// only returned to the (owning) dispatch caller.
|
|
const eventPseudonymizer = _newPseudonymizer();
|
|
capabilities.emitEvent('audio-monitoring', 'direct-monitor-changed', { directMonitor: _clone(currentSession.directMonitor), sessions: activeSessions.map(session => _redactedMonitoringSession(session, eventPseudonymizer)) });
|
|
_touch();
|
|
return _monitoringResultFor(outcome, reason || 'Direct-monitor preference updated', summary);
|
|
}
|
|
|
|
function registerStemOwner(owner) {
|
|
const source = _plainObject(owner);
|
|
const previousOwnerId = currentSession.stemOwner ? currentSession.stemOwner.ownerId : '';
|
|
if (currentSession.stemOwner && source.availability && source.availability !== 'available') {
|
|
for (const claim of currentSession.stemClaims.values()) {
|
|
if (claim.state === 'active') {
|
|
claim.state = 'orphaned';
|
|
claim.updatedAt = _now();
|
|
capabilities.emitEvent('stems', 'claim-orphaned', _clone(claim));
|
|
}
|
|
}
|
|
}
|
|
currentSession.stemOwner = {
|
|
ownerId: _string(source.ownerId || source.participantId || source.id, 'stems'),
|
|
stemIds: _strings(source.stemIds),
|
|
stemStates: _clone(source.stemStates || {}) || {},
|
|
availability: _string(source.availability, 'available'),
|
|
};
|
|
// When the provider identity changes, retire the prior provider participant
|
|
// so the stems pipeline/diagnostics don't keep a stale active provider.
|
|
// Never retire the audio-session coordinator (OWNER_ID) itself.
|
|
if (previousOwnerId && previousOwnerId !== currentSession.stemOwner.ownerId && previousOwnerId !== OWNER_ID && typeof capabilities.unregisterParticipant === 'function') {
|
|
capabilities.unregisterParticipant(previousOwnerId, 'stems');
|
|
}
|
|
if (typeof capabilities.registerParticipant === 'function') {
|
|
capabilities.registerParticipant(currentSession.stemOwner.ownerId, {
|
|
stems: {
|
|
roles: ['provider'],
|
|
operations: ['stem.get-state', 'stem.apply-automation', 'stem.restore-automation'],
|
|
events: ['owner-available', 'owner-unavailable'],
|
|
kind: 'provider-coordinator',
|
|
mode: currentSession.stemOwner.availability === 'available' ? 'active' : 'disabled',
|
|
compatibility: 'none',
|
|
ownership: 'multi-provider',
|
|
safety: 'safe',
|
|
runtime: true,
|
|
version: 1,
|
|
description: 'Active Stems provider registered behind the audio-session coordinator.',
|
|
},
|
|
});
|
|
}
|
|
_recordOutcome({ domain: 'stems', operation: 'owner.register', participantId: currentSession.stemOwner.ownerId, outcome: 'handled', status: currentSession.stemOwner.availability });
|
|
const ownerEvent = currentSession.stemOwner.availability === 'available' ? 'owner-available' : 'owner-unavailable';
|
|
capabilities.emitEvent('stems', ownerEvent, _clone(currentSession.stemOwner));
|
|
_touch();
|
|
return _handled(_clone(currentSession.stemOwner));
|
|
}
|
|
|
|
function _pruneStemClaims() {
|
|
// Bound claim growth for long-running/buggy sessions so snapshots and
|
|
// diagnostics bundles stay small. Drop the oldest terminal-state claims
|
|
// first (Map preserves insertion order); never drop an active claim.
|
|
if (currentSession.stemClaims.size <= MAX_DOMAIN_ITEMS) return;
|
|
for (const [claimId, claim] of currentSession.stemClaims) {
|
|
if (currentSession.stemClaims.size <= MAX_DOMAIN_ITEMS) break;
|
|
if (claim.state !== 'active') currentSession.stemClaims.delete(claimId);
|
|
}
|
|
}
|
|
|
|
function muteStems(spec = {}) {
|
|
if (!currentSession.stemOwner || currentSession.stemOwner.availability !== 'available') {
|
|
const result = _noOwner('No active stem owner is available');
|
|
_recordOutcome({ domain: 'stems', operation: 'mute', participantId: _string(spec.requester, ''), outcome: result.outcome, status: currentSession.stemOwner ? currentSession.stemOwner.availability : 'no-owner', reason: result.reason });
|
|
return result;
|
|
}
|
|
const source = _plainObject(spec);
|
|
const claimId = _string(source.claimId, _id('stem-claim'));
|
|
const claim = {
|
|
claimId,
|
|
requesterId: _string(source.requester || source.requesterId, 'unknown'),
|
|
targetStemIds: _strings(source.stemIds || source.targetStemIds),
|
|
requestedAction: _string(source.action || source.requestedAction, 'mute'),
|
|
restoreSnapshot: _clone(source.restoreSnapshot || currentSession.stemOwner.stemStates) || {},
|
|
state: 'active',
|
|
createdAt: _now(),
|
|
updatedAt: _now(),
|
|
};
|
|
currentSession.stemClaims.set(claimId, claim);
|
|
_pruneStemClaims();
|
|
capabilities.claim({ capability: 'stems', claimId, requester: claim.requesterId, owner: currentSession.stemOwner.ownerId, target: { stemIds: claim.targetStemIds } });
|
|
_recordOutcome({ domain: 'stems', operation: 'mute', participantId: claim.requesterId, outcome: 'handled' });
|
|
capabilities.emitEvent('stems', 'automation-applied', _clone(claim));
|
|
_touch();
|
|
return _handled(_clone(claim));
|
|
}
|
|
|
|
function restoreStems(spec = {}) {
|
|
const source = _plainObject(spec);
|
|
const claimId = _string(source.claimId || source.id);
|
|
const claim = currentSession.stemClaims.get(claimId);
|
|
if (!claim) {
|
|
const result = _noHandler(`Unknown stem automation claim: ${claimId}`);
|
|
_recordOutcome({ domain: 'stems', operation: 'restore', participantId: source.requester, outcome: result.outcome, reason: result.reason });
|
|
return result;
|
|
}
|
|
claim.state = source.overridden || claim.state === 'overridden' ? 'overridden' : 'restored';
|
|
claim.updatedAt = _now();
|
|
capabilities.release({ capability: 'stems', claimId, requester: source.requester || claim.requesterId });
|
|
_recordOutcome({ domain: 'stems', operation: 'restore', participantId: source.requester || claim.requesterId, outcome: claim.state === 'overridden' ? 'overridden' : 'handled' });
|
|
capabilities.emitEvent('stems', claim.state === 'overridden' ? 'automation-overridden' : 'automation-restored', _clone(claim));
|
|
_touch();
|
|
return claim.state === 'overridden' ? { outcome: 'overridden', payload: _clone(claim) } : _handled(_clone(claim));
|
|
}
|
|
|
|
function recordStemManualOverride(override = {}) {
|
|
const source = _plainObject(override);
|
|
const targets = _strings(source.stemIds || source.targetStemIds);
|
|
const overriddenClaims = [];
|
|
for (const claim of currentSession.stemClaims.values()) {
|
|
if (claim.state !== 'active') continue;
|
|
const claimTargets = Array.isArray(claim.targetStemIds) ? claim.targetStemIds : [];
|
|
const matches = !targets.length || !claimTargets.length || claimTargets.some(stemId => targets.includes(stemId));
|
|
if (!matches) continue;
|
|
claim.state = 'overridden';
|
|
claim.updatedAt = _now();
|
|
overriddenClaims.push(_clone(claim));
|
|
capabilities.emitEvent('stems', 'automation-overridden', _clone(claim));
|
|
}
|
|
if (typeof capabilities.recordUserOverride === 'function') {
|
|
capabilities.recordUserOverride({ capability: 'stems', source: source.requester || 'user', target: { stemIds: targets }, reason: source.reason || 'Manual stem choice overrode automation' });
|
|
}
|
|
_recordOutcome({ domain: 'stems', operation: 'manual-override', participantId: source.requester || 'user', outcome: overriddenClaims.length ? 'overridden' : 'handled', status: overriddenClaims.length ? 'overridden' : 'no-active-claim', reason: source.reason });
|
|
_touch();
|
|
return { overriddenClaims };
|
|
}
|
|
|
|
function recordBridgeHit(bridge = {}) {
|
|
const source = _plainObject(bridge);
|
|
const domain = DOMAINS.includes(source.domain) ? source.domain : _string(source.capability, 'audio-mix');
|
|
// Bridge entries are exported verbatim in snapshot().domains[].bridges, so bound the
|
|
// caller-provided string fields (a legacy caller could pass a path/token-like value).
|
|
const legacySurface = _boundedReason(source.legacySurface) || 'unknown';
|
|
// Preserve the original bridgeId fallback key (`${domain}.legacy`, not `.unknown`) for stability.
|
|
const bridgeId = _boundedReason(source.bridgeId || source.shimId) || `${domain}.${_boundedReason(source.legacySurface) || 'legacy'}`;
|
|
const entry = {
|
|
bridgeId,
|
|
legacySurface,
|
|
domain,
|
|
participantId: _boundedReason(source.participantId || source.source || source.requester) || 'legacy-runtime',
|
|
logicalSourceKey: _boundedReason(source.logicalSourceKey || source.logicalKey || source.sourceKey),
|
|
outcome: _string(source.outcome, 'handled'),
|
|
status: _string(source.status, 'used'),
|
|
reason: _boundedReason(source.reason),
|
|
hitCount: (currentSession.bridges.get(bridgeId)?.hitCount || 0) + 1,
|
|
lastHitAt: _now(),
|
|
};
|
|
currentSession.bridges.set(bridgeId, entry);
|
|
if (typeof capabilities.recordLegacyHit === 'function') {
|
|
capabilities.recordLegacyHit({ capability: domain, legacySurface: entry.legacySurface, source: entry.participantId, reason: entry.reason, shimId: bridgeId });
|
|
}
|
|
_recordOutcome({ domain, operation: 'bridge-hit', participantId: entry.participantId, bridgeId, outcome: entry.outcome, status: entry.status, reason: entry.reason });
|
|
capabilities.emitEvent(domain, 'bridge-hit', _clone(entry));
|
|
_touch();
|
|
return _clone(entry);
|
|
}
|
|
|
|
function _newPseudonymizer() {
|
|
const seen = new Map();
|
|
let count = 0;
|
|
return function pseudonym(value, prefix = 'source') {
|
|
const key = `${prefix}:${_string(value, prefix)}`;
|
|
if (!seen.has(key)) {
|
|
count += 1;
|
|
seen.set(key, `${prefix}-${String(count).padStart(2, '0')}`);
|
|
}
|
|
return seen.get(key);
|
|
};
|
|
}
|
|
|
|
function _redactedSource(source, pseudonymize) {
|
|
// Per-bundle pseudonym (spec FR-011/SC-005: scoped to one export, not stable
|
|
// across exports). Keyed off the unique sourceId — used only as an internal
|
|
// pseudonymizer key, never exposed — so distinct sources stay distinct even
|
|
// when they share a device label, and monitoring (also keyed off sourceId)
|
|
// correlates within the bundle.
|
|
const sourcePseudonym = pseudonymize(source.sourceId, 'source');
|
|
return {
|
|
sourceId: sourcePseudonym,
|
|
logicalSourceKey: source.logicalSourceKey,
|
|
providerId: source.providerId,
|
|
ownerPluginId: source.ownerPluginId,
|
|
kind: source.kind,
|
|
label: source.label || sourcePseudonym,
|
|
channelSummary: _clone(source.channelSummary),
|
|
channelCount: source.channelCount,
|
|
availability: source.availability,
|
|
selected: !!source.selected,
|
|
sourceMode: source.sourceMode,
|
|
compatibilitySource: source.compatibilitySource || '',
|
|
supersededBy: source.supersededBy ? pseudonymize(source.supersededBy, 'source') : '',
|
|
reason: source.reason || '',
|
|
lastSeenAt: source.lastSeenAt || '',
|
|
diagnosticsPseudonym: sourcePseudonym,
|
|
};
|
|
}
|
|
|
|
function _redactedSelectedInput(selected, pseudonymize) {
|
|
if (!selected) return null;
|
|
return {
|
|
logicalSourceKey: selected.logicalSourceKey,
|
|
sourceId: selected.sourceId ? pseudonymize(selected.sourceId, 'source') : '',
|
|
providerId: selected.providerId || '',
|
|
availability: selected.availability || 'unavailable',
|
|
restored: !!selected.restored,
|
|
restoreStatus: selected.restoreStatus || 'not-selected',
|
|
selectedAt: selected.selectedAt || selected.lastSelectedAt || '',
|
|
lastSelectedAt: selected.lastSelectedAt || '',
|
|
lastRestoredAt: selected.lastRestoredAt || '',
|
|
requesterId: selected.requesterId || '',
|
|
};
|
|
}
|
|
|
|
function _inputSessionSummary(spec, pseudonymize) {
|
|
const source = spec.source || {};
|
|
const openSessionId = spec.openSessionId || _id('input-open');
|
|
return {
|
|
openSessionId,
|
|
logicalSourceKey: source.logicalSourceKey || spec.logicalSourceKey || '',
|
|
sourceId: source.sourceId ? pseudonymize(source.sourceId, 'source') : '',
|
|
providerId: source.providerId || spec.providerId || '',
|
|
channelShape: _channelShape(spec.channelShape, 'unknown'),
|
|
state: _string(spec.state, 'open'),
|
|
// Match _redactedOpenSession's requester shape ({ requesterId, purpose }) — no per-requester
|
|
// openedAt — so degraded session summaries and real open sessions share one schema.
|
|
requesters: [{ requesterId: _requesterId(spec.requesterId), purpose: _boundedReason(spec.purpose).slice(0, 80) }].filter(item => item.requesterId),
|
|
reason: _boundedReason(spec.reason),
|
|
};
|
|
}
|
|
|
|
function _redactedOpenSession(session, pseudonymize) {
|
|
if (!session) return null;
|
|
return {
|
|
openSessionId: session.openSessionId,
|
|
logicalSourceKey: session.logicalSourceKey,
|
|
sourceId: session.sourceId ? pseudonymize(session.sourceId, 'source') : '',
|
|
providerId: session.providerId,
|
|
channelShape: session.channelShape,
|
|
state: session.state,
|
|
requesters: Array.isArray(session.requesters) ? session.requesters.map(requester => ({ requesterId: requester.requesterId, purpose: requester.purpose || '' })) : [],
|
|
openedAt: session.openedAt || '',
|
|
lastUsedAt: session.lastUsedAt || '',
|
|
closedAt: session.closedAt || '',
|
|
reason: session.reason || '',
|
|
};
|
|
}
|
|
|
|
function _redactedMonitoringProvider(provider) {
|
|
// supersededBy is a (normalized, redaction-safe) providerId, and providerId itself is surfaced
|
|
// verbatim, so keep supersededBy verbatim too — pseudonymizing only it would break correlation
|
|
// with the winning provider in the same snapshot (and diverges from the audio-input pattern).
|
|
return _summaryMonitoringProvider(provider) || {};
|
|
}
|
|
|
|
function _redactedMonitoringSession(session, pseudonymize) {
|
|
if (!session) return null;
|
|
const sourceRef = _plainObject(session.sourceRef);
|
|
// Keep an internally-generated `input-open-<n>` id verbatim so monitoring sessions still
|
|
// correlate with audio-input openSessions[] in the same snapshot (and match the diagnostics
|
|
// schema); pseudonymize anything else as defense-in-depth.
|
|
const _openId = value => (value && /^input-open-\d+$/.test(value)) ? value : (value ? pseudonymize(value, 'input-open') : '');
|
|
return {
|
|
monitoringId: session.monitoringId ? pseudonymize(session.monitoringId, 'monitoring') : '',
|
|
sessionKey: session.sessionKey ? pseudonymize(session.sessionKey, 'monitoring-session') : '',
|
|
providerId: session.providerId || '',
|
|
logicalMonitoringKey: session.logicalMonitoringKey || '',
|
|
sourceRef: {
|
|
logicalSourceKey: sourceRef.logicalSourceKey || '',
|
|
sourceId: sourceRef.sourceId ? pseudonymize(sourceRef.sourceId, 'source') : '',
|
|
providerId: sourceRef.providerId || '',
|
|
availability: sourceRef.availability || '',
|
|
channelShape: sourceRef.channelShape || '',
|
|
openSessionId: _openId(sourceRef.openSessionId),
|
|
},
|
|
openInputSessionId: _openId(session.openInputSessionId),
|
|
state: session.state || 'unknown',
|
|
requesters: Array.isArray(session.requesters) ? session.requesters.map(requester => ({ requesterId: requester.requesterId, purpose: requester.purpose || '', requiredChannelShape: requester.requiredChannelShape || 'unknown', directMonitorRequirement: requester.directMonitorRequirement || 'unknown', status: requester.status || '', reason: requester.reason || '' })) : [],
|
|
directMonitor: _clone(session.directMonitor),
|
|
latencySummary: _clone(session.latencySummary),
|
|
reason: _boundedReason(session.reason),
|
|
startedAt: session.startedAt || '',
|
|
lastUsedAt: session.lastUsedAt || '',
|
|
stoppedAt: session.stoppedAt || '',
|
|
updatedAt: session.updatedAt || '',
|
|
};
|
|
}
|
|
|
|
function _redactedRoute(route, pseudonymize) {
|
|
return {
|
|
...route,
|
|
devicePseudonym: route.devicePseudonym ? pseudonymize(route.devicePseudonym, 'device') : '',
|
|
};
|
|
}
|
|
|
|
function _domainBridges(domain) {
|
|
return Array.from(currentSession.bridges.values()).filter(bridge => bridge.domain === domain).map(_clone);
|
|
}
|
|
|
|
function snapshot() {
|
|
// One per-bundle pseudonymizer for the whole snapshot: sources correlate
|
|
// within this export but pseudonyms are not stable across exports.
|
|
const pseudonymize = _newPseudonymizer();
|
|
_refreshInputDuplicateSuppression();
|
|
_refreshMonitoringDuplicateSuppression();
|
|
const inputSources = Array.from(currentSession.inputSources.values()).slice(-MAX_DOMAIN_ITEMS);
|
|
const openInputSessions = Array.from(currentSession.openInputSessions.values()).slice(-MAX_DOMAIN_ITEMS);
|
|
const monitoringProviders = Array.from(currentSession.monitoringProviders.values()).slice(-MAX_DOMAIN_ITEMS);
|
|
const monitoringSessions = Array.from(currentSession.monitoringSessions.values()).slice(-MAX_DOMAIN_ITEMS);
|
|
return {
|
|
schema: SCHEMA,
|
|
session: {
|
|
sessionId: _redactString(currentSession.sessionId),
|
|
playerId: currentSession.playerId,
|
|
songKey: currentSession.songKey ? pseudonymize(currentSession.songKey, 'song') : '',
|
|
songFormat: currentSession.songFormat,
|
|
state: currentSession.state,
|
|
route: _redactedRoute(currentSession.routeState, pseudonymize),
|
|
analyser: _clone(currentSession.analyserState),
|
|
createdAt: currentSession.createdAt,
|
|
updatedAt: currentSession.updatedAt,
|
|
},
|
|
domains: {
|
|
'audio-mix': {
|
|
state: currentSession.state,
|
|
participants: Array.from(currentSession.mixParticipants.values()).map(_summaryParticipant),
|
|
faders: _visibleFaderParticipants().map(participant => _summaryFader(participant)),
|
|
requiredKinds: Object.fromEntries(REQUIRED_MIX_KINDS.map(kind => [kind, Array.from(currentSession.mixParticipants.values()).some(participant => participant.kind === kind)])),
|
|
route: _redactedRoute(currentSession.routeState, pseudonymize),
|
|
analyser: _clone(currentSession.analyserState),
|
|
bridges: _domainBridges('audio-mix'),
|
|
},
|
|
'audio-input': {
|
|
sources: inputSources.map(source => _redactedSource(source, pseudonymize)),
|
|
selected: _redactedSelectedInput(_syncSelectedInput(), pseudonymize),
|
|
openSessions: openInputSessions.map(session => _redactedOpenSession(session, pseudonymize)),
|
|
totalSources: currentSession.inputSources.size,
|
|
totalOpenSessions: currentSession.openInputSessions.size,
|
|
storageStatus: currentSession.storageStatus || _storageStatus(),
|
|
bridges: _domainBridges('audio-input'),
|
|
},
|
|
'audio-monitoring': {
|
|
providers: monitoringProviders.map(provider => _redactedMonitoringProvider(provider)),
|
|
selectedProvider: _summarySelectedMonitoringProvider(_syncSelectedMonitoringProvider()),
|
|
sessions: monitoringSessions.map(session => _redactedMonitoringSession(session, pseudonymize)),
|
|
totalProviders: currentSession.monitoringProviders.size,
|
|
totalSessions: currentSession.monitoringSessions.size,
|
|
directMonitor: _clone(currentSession.directMonitor),
|
|
bridges: _domainBridges('audio-monitoring'),
|
|
},
|
|
stems: {
|
|
owner: _clone(currentSession.stemOwner),
|
|
claims: Array.from(currentSession.stemClaims.values()).map(_clone),
|
|
bridges: _domainBridges('stems'),
|
|
},
|
|
},
|
|
recentOutcomes: currentSession.outcomes.map(outcome => _redactedOutcome(outcome, pseudonymize)),
|
|
};
|
|
}
|
|
|
|
function _payload(ctx) {
|
|
return _plainObject(ctx && ctx.payload);
|
|
}
|
|
|
|
function _target(ctx) {
|
|
const target = _plainObject(ctx && ctx.target);
|
|
return { ..._payload(ctx), ...target };
|
|
}
|
|
|
|
function _audioMixCommand(commandName, ctx = {}) {
|
|
const payload = _target(ctx);
|
|
if (commandName === 'inspect') return _handled(snapshot().domains['audio-mix']);
|
|
if (commandName === 'list-faders') return listFaders();
|
|
if (commandName === 'get-fader-value') return getFaderValue(payload);
|
|
if (commandName === 'set-fader-value') return setFaderValue(payload);
|
|
if (commandName === 'inspect-route') return _handled(_clone(snapshot().domains['audio-mix'].route));
|
|
if (commandName === 'inspect-analyser') return _handled(_clone(snapshot().domains['audio-mix'].analyser));
|
|
if (commandName === 'register-participant') return registerMixParticipant(payload);
|
|
if (commandName === 'unregister-participant') return unregisterMixParticipant(payload.participantId || payload.id);
|
|
return _degraded(`Unsupported audio-mix command: ${commandName}`);
|
|
}
|
|
|
|
function _audioInputCommand(commandName, ctx = {}) {
|
|
const payload = _target(ctx);
|
|
if (commandName === 'inspect') return inspectInput(payload);
|
|
if (commandName === 'list-sources') return listInputSources(payload);
|
|
if (commandName === 'register-source') return registerInputSource(payload);
|
|
if (commandName === 'unregister-source') return unregisterInputSource(payload);
|
|
if (commandName === 'select-source') return selectInputSource(payload, ctx.requester);
|
|
if (commandName === 'open-source') return openInputSource(payload, ctx.requester);
|
|
if (commandName === 'close-source') return closeInputSource(payload, ctx.requester);
|
|
return _degraded(`Unsupported audio-input command: ${commandName}`);
|
|
}
|
|
|
|
function _audioMonitoringCommand(commandName, ctx = {}) {
|
|
const payload = _target(ctx);
|
|
if (commandName === 'inspect') return inspectMonitoring(payload);
|
|
if (commandName === 'list-providers') return listMonitoringProviders(payload);
|
|
if (commandName === 'register-provider') return registerMonitoringProvider(payload);
|
|
if (commandName === 'unregister-provider') return unregisterMonitoringProvider(payload);
|
|
if (commandName === 'select-provider') return selectMonitoringProvider(payload, ctx.requester);
|
|
if (commandName === 'start') return startMonitoring(payload, ctx.requester);
|
|
if (commandName === 'stop') return stopMonitoring(payload, ctx.requester);
|
|
if (commandName === 'set-direct-monitor') return setDirectMonitoringState(payload, ctx.requester);
|
|
return _degraded(`Unsupported audio-monitoring command: ${commandName}`);
|
|
}
|
|
|
|
function _stemsCommand(commandName, ctx = {}) {
|
|
const payload = _target(ctx);
|
|
if (commandName === 'inspect') {
|
|
const stems = snapshot().domains.stems;
|
|
return stems.owner ? _handled(stems) : _noOwner('No active stem owner is available', stems);
|
|
}
|
|
if (commandName === 'mute') return muteStems({ ...payload, requester: ctx.requester });
|
|
if (commandName === 'restore') return restoreStems({ ...payload, requester: ctx.requester });
|
|
return _degraded(`Unsupported stems command: ${commandName}`);
|
|
}
|
|
|
|
function _registerDomains() {
|
|
capabilities.registerOwner('audio-mix', {
|
|
pluginId: OWNER_ID,
|
|
kind: 'provider-coordinator',
|
|
commands: ['inspect', 'list-faders', 'get-fader-value', 'set-fader-value', 'inspect-route', 'inspect-analyser', 'register-participant', 'unregister-participant'],
|
|
operations: ['fader.get-value', 'fader.set-value', 'analyser.get-summary', 'route.get-current'],
|
|
events: ['participant-registered', 'participant-removed', 'fader-value-changed', 'fader-unavailable', 'route-changed', 'route-degraded', 'analyser-changed', 'analyser-unavailable', 'bridge-hit'],
|
|
safety: 'safe',
|
|
description: 'Coordinates the active player audio mix, route, fader participants, analyser inspection, and audio compatibility bridge usage.',
|
|
handlers: {
|
|
inspect: ctx => _audioMixCommand('inspect', ctx),
|
|
'list-faders': ctx => _audioMixCommand('list-faders', ctx),
|
|
'get-fader-value': ctx => _audioMixCommand('get-fader-value', ctx),
|
|
'set-fader-value': ctx => _audioMixCommand('set-fader-value', ctx),
|
|
'inspect-route': ctx => _audioMixCommand('inspect-route', ctx),
|
|
'inspect-analyser': ctx => _audioMixCommand('inspect-analyser', ctx),
|
|
'register-participant': ctx => _audioMixCommand('register-participant', ctx),
|
|
'unregister-participant': ctx => _audioMixCommand('unregister-participant', ctx),
|
|
},
|
|
});
|
|
capabilities.registerOwner('audio-input', {
|
|
pluginId: OWNER_ID,
|
|
kind: 'provider-coordinator',
|
|
commands: ['inspect', 'list-sources', 'register-source', 'unregister-source', 'select-source', 'open-source', 'close-source'],
|
|
operations: ['source.enumerate', 'source.describe', 'source.open', 'source.close'],
|
|
events: ['source-registered', 'source-removed', 'source-selected', 'source-availability-changed', 'permission-denied', 'source-opened', 'source-open-degraded', 'source-closed', 'bridge-hit'],
|
|
safety: 'sensitive',
|
|
description: 'Coordinates redaction-safe input source identity, availability, selection, open-session state, and bridge diagnostics.',
|
|
handlers: {
|
|
inspect: ctx => _audioInputCommand('inspect', ctx),
|
|
'list-sources': ctx => _audioInputCommand('list-sources', ctx),
|
|
'register-source': ctx => _audioInputCommand('register-source', ctx),
|
|
'unregister-source': ctx => _audioInputCommand('unregister-source', ctx),
|
|
'select-source': ctx => _audioInputCommand('select-source', ctx),
|
|
'open-source': ctx => _audioInputCommand('open-source', ctx),
|
|
'close-source': ctx => _audioInputCommand('close-source', ctx),
|
|
},
|
|
});
|
|
capabilities.registerOwner('audio-monitoring', {
|
|
pluginId: OWNER_ID,
|
|
kind: 'provider-coordinator',
|
|
commands: ['inspect', 'list-providers', 'register-provider', 'unregister-provider', 'select-provider', 'start', 'stop', 'set-direct-monitor'],
|
|
operations: ['monitoring.start', 'monitoring.stop', 'monitoring.status', 'monitoring.set-direct-monitor'],
|
|
events: ['provider-registered', 'provider-removed', 'provider-availability-changed', 'provider-selected', 'provider-selection-required', 'monitoring-start-requested', 'monitoring-started', 'monitoring-degraded', 'monitoring-unavailable', 'monitoring-failed', 'monitoring-denied', 'monitoring-user-action-required', 'monitoring-stopped', 'monitoring-orphaned', 'direct-monitor-changed', 'permission-denied', 'bridge-hit'],
|
|
safety: 'sensitive',
|
|
description: 'Coordinates monitoring provider selection, live monitoring lifecycle, direct-monitor state, consent/availability state, and monitoring compatibility bridge diagnostics.',
|
|
handlers: {
|
|
inspect: ctx => _audioMonitoringCommand('inspect', ctx),
|
|
'list-providers': ctx => _audioMonitoringCommand('list-providers', ctx),
|
|
'register-provider': ctx => _audioMonitoringCommand('register-provider', ctx),
|
|
'unregister-provider': ctx => _audioMonitoringCommand('unregister-provider', ctx),
|
|
'select-provider': ctx => _audioMonitoringCommand('select-provider', ctx),
|
|
start: ctx => _audioMonitoringCommand('start', ctx),
|
|
stop: ctx => _audioMonitoringCommand('stop', ctx),
|
|
'set-direct-monitor': ctx => _audioMonitoringCommand('set-direct-monitor', ctx),
|
|
},
|
|
});
|
|
capabilities.registerParticipant(OWNER_ID, {
|
|
stems: {
|
|
roles: ['coordinator'],
|
|
kind: 'provider-coordinator',
|
|
commands: ['inspect', 'mute', 'restore'],
|
|
operations: ['stem.get-state', 'stem.apply-automation', 'stem.restore-automation'],
|
|
events: ['owner-available', 'owner-unavailable', 'automation-applied', 'automation-restored', 'automation-overridden', 'claim-orphaned', 'bridge-hit'],
|
|
safety: 'safe',
|
|
compatibility: 'none',
|
|
ownership: 'multi-provider',
|
|
runtime: true,
|
|
version: 1,
|
|
description: 'Coordinates the active Stems provider and temporary stem automation claims without taking ownership of stem playback.',
|
|
handlers: {
|
|
inspect: ctx => _stemsCommand('inspect', ctx),
|
|
mute: ctx => _stemsCommand('mute', ctx),
|
|
restore: ctx => _stemsCommand('restore', ctx),
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function _registerBridgeMetadata() {
|
|
if (typeof capabilities.registerCompatibilityShim !== 'function') return;
|
|
const shims = [
|
|
{ shimId: 'audio-mix.fader-registry', capability: 'audio-mix', source: OWNER_ID, legacySurface: 'window.feedBack.audio.registerFader', reason: 'Legacy mixer faders are attributed as audio-mix participants.' },
|
|
{ shimId: 'audio-mix.song-volume', capability: 'audio-mix', source: OWNER_ID, legacySurface: 'applySongVolume', reason: 'Legacy song volume writes are attributed as audio-mix route/fader bridge hits.' },
|
|
{ shimId: 'audio-mix.analyser', capability: 'audio-mix', source: OWNER_ID, legacySurface: '3D Highway analyser fallback', reason: 'Legacy analyser taps are attributed until renderers request analyser providers through audio-mix.' },
|
|
{ shimId: 'audio-input.legacy-source', capability: 'audio-input', source: OWNER_ID, legacySurface: 'plugin/browser input source handoff', reason: 'Legacy input source handoffs are attributed while note-detection input providers migrate to audio-input.' },
|
|
{ shimId: 'audio-monitoring.audio-barrier', capability: 'audio-monitoring', source: OWNER_ID, legacySurface: 'window.feedBackAudioBarrier', reason: 'Legacy audio startup barriers are attributed as monitoring readiness bridges.' },
|
|
{ shimId: 'stems.master-volume', capability: 'stems', source: OWNER_ID, legacySurface: 'window.feedBack.stems.setMasterVolume', reason: 'Legacy Stems master volume calls are attributed while native owner operations are staged.' },
|
|
{ shimId: 'stems.private-state', capability: 'stems', source: OWNER_ID, legacySurface: 'plugin-specific Stems/NAM ducking handshake', reason: 'Existing stem automation requesters are tracked until they migrate to native claims.' },
|
|
];
|
|
for (const shim of shims) capabilities.registerCompatibilityShim({ ...shim, status: 'active' });
|
|
}
|
|
|
|
function _contributeDiagnostics() {
|
|
const diagnostics = window.feedBack && window.feedBack.diagnostics;
|
|
if (diagnostics && typeof diagnostics.contribute === 'function') {
|
|
try { diagnostics.contribute('audio-session', snapshot()); }
|
|
catch (_) { /* diagnostics must not break playback */ }
|
|
}
|
|
}
|
|
|
|
const api = {
|
|
version: 1,
|
|
startSession,
|
|
stopSession,
|
|
setRoute,
|
|
setAnalyser,
|
|
registerMixParticipant,
|
|
unregisterMixParticipant,
|
|
listFaders,
|
|
getFaderValue,
|
|
setFaderValue,
|
|
registerInputSource,
|
|
unregisterInputSource,
|
|
selectInputSource,
|
|
listInputSources,
|
|
inspectInput,
|
|
enumerateInputSources,
|
|
openInputSource,
|
|
closeInputSource,
|
|
registerMonitoringProvider,
|
|
unregisterMonitoringProvider,
|
|
selectMonitoringProvider,
|
|
listMonitoringProviders,
|
|
inspectMonitoring,
|
|
startMonitoring,
|
|
stopMonitoring,
|
|
setDirectMonitoringState,
|
|
registerStemOwner,
|
|
muteStems,
|
|
restoreStems,
|
|
recordStemManualOverride,
|
|
recordBridgeHit,
|
|
recordOutcome: (entry) => { const outcome = _recordOutcome(entry); _touch(); return _clone(outcome); },
|
|
snapshot,
|
|
getDiagnostics: snapshot,
|
|
};
|
|
|
|
window.feedBack.audioSession = api;
|
|
_registerDomains();
|
|
_registerBridgeMetadata();
|
|
_contributeDiagnostics();
|
|
})(); |