// audio.js — pure audio mixer logic: clamp/format volume, role coloring, row shaping. // NO I/O, NO native PipeWire access, NO Quickshell imports. Receives plain field dicts // (lifted off native nodes by Audio.qml), returns dicts/numbers/strings. Dual-mode: // node `require` (tests) + QML import. Same pattern as clip.js/launch.js/sysinfo.js. // --- Nerd Font glyphs (FA4 PUA, verified present in SymbolsNerdFont-Regular). \uXXXX escapes. var GLYPH_OUT = ""; // speaker / volume-up (output) var GLYPH_OUT_MUTE = ""; // volume-off / mute (output muted) var GLYPH_IN = ""; // microphone (input) var GLYPH_IN_MUTE = ""; // microphone-slash (input muted) // --- clamp a Number into 0..1.5; null/NaN/non-finite -> 0 --- function clampVolume(v) { var n = Number(v); return isFinite(n) ? Math.max(0, Math.min(1.5, n)) : 0; } // --- integer percent of a clamped volume: 1.0->100, 1.5->150, 0->0 --- function volumePercent(v) { return Math.round(clampVolume(v) * 100); } // --- display name precedence; null-safe -> "Audio". Native props arrive as flat keys. --- function nodeName(n) { n = n || {}; return String(n.description || n.nickname || n.nodeDescription || n.applicationName || n.mediaName || n.name || "Audio"); } // --- normalize a list of channel labels to lowercase strings (enums resolved natively) --- function channelNames(channels) { var out = []; var list = channels || []; for (var i = 0; i < list.length; i++) { out.push(String(list[i]).toLowerCase()); } return out; } // --- muted flag lifted off node.audio.muted by the bridge --- function nodeMuted(n) { return !!(n && n.muted); } // --- role color key by mute/volume (spec §3.2 contract): // muted -> "warning", volume 0 -> "ui.minor", else "ui.accent" --- function roleForSink(n) { if (nodeMuted(n)) return "warning"; if (clampVolume(n && n.volume) <= 0) return "ui.minor"; return "ui.accent"; } // --- privacy chip label from {mic,cam}; no flags -> "Audio" (Bar chip needs non-empty) --- function privacyLabel(flags) { var f = flags || {}; if (f.mic && f.cam) return "Mic Cam"; if (f.mic) return "Mic"; if (f.cam) return "Cam"; return "Audio"; } // --- glyph for a row by kind + muted state --- function glyphForRow(kind, muted) { if (kind === "source") return muted ? GLYPH_IN_MUTE : GLYPH_IN; return muted ? GLYPH_OUT_MUTE : GLYPH_OUT; } // --- shape a raw bridge dict into the view/IPC row. ALL resolution happens here. --- function shapeRow(n, kind) { n = n || {}; var vol = clampVolume(n.volume); return { id: String(n.id == null ? "" : n.id), name: nodeName(n), kind: String(kind || ""), volume: Math.round(vol * 100) / 100, percent: volumePercent(vol), muted: nodeMuted(n), role: roleForSink(n), isDefault: !!n.isDefault, active: !!n.active, channels: channelNames(n.channels), glyph: glyphForRow(kind, nodeMuted(n)) }; } // --- index rows by id --- function rowsById(rows) { var out = {}; var list = rows || []; for (var i = 0; i < list.length; i++) { out[list[i].id] = list[i]; } return out; } if (typeof module !== 'undefined' && module.exports) { module.exports = { clampVolume: clampVolume, volumePercent: volumePercent, nodeName: nodeName, channelNames: channelNames, nodeMuted: nodeMuted, roleForSink: roleForSink, privacyLabel: privacyLabel, glyphForRow: glyphForRow, shapeRow: shapeRow, rowsById: rowsById }; }