mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-11 19:18:53 +00:00
1116801b2f
**Red commit:** `851cc8c3a024b1675558092d772444bf4f1ec625` — failing test on a stub branch (will link CI run after PR opens). Partial fix for #1648 (M5 of 6). **Do NOT close the tracking issue** — M6 (server-side residual emoji sweep + lint gate) still pending. ## Per-file swap counts | File | Phosphor `<use>` refs | Notes | |---|---|---| | `public/customize.js` | 20 | DEFAULTS → `ph:<name>` tokens; render path keeps legacy emoji branch (back-compat) | | `public/customize-v2.js` | 26 | same as v1; cv2 overrides path unchanged | | `public/home.js` | (helpers added) | `_renderHomeGlyph` / `_renderHomeLabel` accept both `ph:<name>` and legacy emoji | | `public/geofilter-builder.html` | 5 | clear / undo / save / load buttons (+inline `.ph-icon` CSS) | | `public/audio.js` | 1 | audio unlock prompt | | `public/filter-ux.js` | 5 (3 new) | help popover star + close, saved-filter delete | | `public/style.css` | 0 | `#chList .ch-share-btn::before { content: '📤' }` removed; JS now renders an inline sprite | | `cmd/server/routes.go` | (6 `ph:` tokens) | onboarding home defaults updated in lockstep with customize-v2.js | ## Operator config back-compat — PROMINENT Per design call #1 (user-locked): existing operator-stored emoji values in `config.json` / `localStorage` are **NOT** touched. The render path supports both: ```js function renderConfigGlyph(value) { var m = String(value || '').match(/^ph:([a-z][a-z0-9-]+)$/); if (m) return '<svg class="ph-icon"><use href="/icons/phosphor-sprite.svg#ph-' + m[1] + '"/></svg>'; return esc(value); // EMOJI-OK-LEGACY-RENDER — operator-stored emoji/text path } ``` Defaults flipped to `ph:<name>` tokens, so new operators (and operators who hit "Reset to Defaults") see Phosphor sprites. Operators with stored emoji values continue to see their emoji exactly as before. Verified end-to-end (see E2E (b) below). ## cmd/server/routes.go — changed in lockstep Per design call #2: the home-defaults `steps` / `footerLinks` mirror the JS DEFAULTS, so they MUST update together. routes.go now emits `ph:<name>` tokens; the frontend home-render path resolves them. Existing tests (`TestConfigThemeHomeDefaults`) still pass — they assert structure, not glyph values. ## E2E assertions added - `test-issue-1648-m5-emoji-scan.js` — per-file zero-emoji + ph-token DEFAULTS + sprite presence - `test-issue-1648-m5-icons-e2e.js`: - (a) customize chrome — tabs/header rendered as sprites; chrome text icon-free - **(b) back-compat — injects fake `🐙` operator step into localStorage, reloads, opens customize, asserts the emoji renders verbatim in both the input value AND the live preview span; asserts the ph-token step renders as a sprite** (design call #1 in action) - (c) `/channels` modal sprite count - (d) `/audio-lab` sprite presence - (e) `geofilter-builder.html` control buttons sprite-driven - (f) every `<use>` resolves to a defined symbol id ## Out of scope (M6 cleanup) - cmd/server/routes.go residual server-rendered emoji **not** tied to customize defaults (none found by my grep — file already audited) - `make lint-no-emoji` CI grep gate (M6 owns it) - `public/icons/README.md` workflow doc cross-stack: justified — design call #2 requires Go + JS update together. --------- Co-authored-by: openclaw-bot <bot@openclaw.local>
215 lines
6.4 KiB
JavaScript
215 lines
6.4 KiB
JavaScript
// Mesh Audio Engine — public/audio.js
|
|
// Core audio infrastructure + swappable voice modules
|
|
// Each voice module is a separate file (audio-v1.js, audio-v2.js, etc.)
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
// === Engine State ===
|
|
let audioEnabled = false;
|
|
let audioCtx = null;
|
|
let masterGain = null;
|
|
let bpm = 120;
|
|
let activeVoices = 0;
|
|
const MAX_VOICES = 12;
|
|
let currentVoice = null;
|
|
let _pendingVolume = 0.3; // active voice module
|
|
|
|
// === Shared Helpers (available to voice modules) ===
|
|
|
|
function buildScale(intervals, rootMidi) {
|
|
const notes = [];
|
|
for (let oct = 0; oct < 3; oct++) {
|
|
for (const interval of intervals) {
|
|
notes.push(rootMidi + oct * 12 + interval);
|
|
}
|
|
}
|
|
return notes;
|
|
}
|
|
|
|
function midiToFreq(midi) {
|
|
return 440 * Math.pow(2, (midi - 69) / 12);
|
|
}
|
|
|
|
function mapRange(value, inMin, inMax, outMin, outMax) {
|
|
return outMin + ((value - inMin) / (inMax - inMin)) * (outMax - outMin);
|
|
}
|
|
|
|
function quantizeToScale(byteVal, scale) {
|
|
const idx = Math.floor((byteVal / 256) * scale.length);
|
|
return scale[Math.min(idx, scale.length - 1)];
|
|
}
|
|
|
|
function tempoMultiplier() {
|
|
return 120 / bpm;
|
|
}
|
|
|
|
function parsePacketBytes(pkt) {
|
|
const rawHex = pkt.raw || pkt.raw_hex || (pkt.packet && pkt.packet.raw_hex) || '';
|
|
if (!rawHex || rawHex.length < 6) return null;
|
|
const allBytes = [];
|
|
for (let i = 0; i < rawHex.length; i += 2) {
|
|
const b = parseInt(rawHex.slice(i, i + 2), 16);
|
|
if (!isNaN(b)) allBytes.push(b);
|
|
}
|
|
if (allBytes.length < 3) return null;
|
|
|
|
const decoded = pkt.decoded || {};
|
|
const header = decoded.header || {};
|
|
const payload = decoded.payload || {};
|
|
const hops = decoded.path?.hops || [];
|
|
|
|
return {
|
|
allBytes,
|
|
headerBytes: allBytes.slice(0, 3),
|
|
payloadBytes: allBytes.slice(3),
|
|
typeName: header.payloadTypeName || 'UNKNOWN',
|
|
hopCount: Math.max(1, hops.length),
|
|
obsCount: pkt.observation_count || (pkt.packet && pkt.packet.observation_count) || 1,
|
|
payload,
|
|
hops,
|
|
};
|
|
}
|
|
|
|
// === Engine: Init ===
|
|
|
|
function initAudio() {
|
|
if (audioCtx) {
|
|
if (audioCtx.state === 'suspended') audioCtx.resume();
|
|
return;
|
|
}
|
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
masterGain = audioCtx.createGain();
|
|
masterGain.gain.value = _pendingVolume;
|
|
masterGain.connect(audioCtx.destination);
|
|
}
|
|
|
|
// === Engine: Sonify ===
|
|
|
|
function sonifyPacket(pkt) {
|
|
if (!audioEnabled || !currentVoice) return;
|
|
if (!audioCtx) initAudio();
|
|
if (!audioCtx) return;
|
|
if (audioCtx.state === 'suspended') {
|
|
// Show unlock overlay if not already showing
|
|
_showUnlockOverlay();
|
|
return; // don't schedule notes on suspended context
|
|
}
|
|
if (activeVoices >= MAX_VOICES) return;
|
|
|
|
const parsed = parsePacketBytes(pkt);
|
|
if (!parsed || parsed.payloadBytes.length === 0) return;
|
|
|
|
activeVoices++;
|
|
|
|
try {
|
|
const duration = currentVoice.play(audioCtx, masterGain, parsed, {
|
|
bpm, tempoMultiplier: tempoMultiplier(),
|
|
});
|
|
|
|
// Release voice slot after estimated duration
|
|
const releaseMs = (duration || 3) * 1000 + 500;
|
|
setTimeout(() => { activeVoices = Math.max(0, activeVoices - 1); }, releaseMs);
|
|
} catch (e) {
|
|
activeVoices = Math.max(0, activeVoices - 1);
|
|
console.error('[audio] voice error:', e);
|
|
}
|
|
}
|
|
|
|
// === Voice Registration ===
|
|
|
|
function registerVoice(name, voiceModule) {
|
|
// voiceModule must have: { name, play(audioCtx, masterGain, parsed, opts) → durationSec }
|
|
if (!window._meshAudioVoices) window._meshAudioVoices = {};
|
|
window._meshAudioVoices[name] = voiceModule;
|
|
// Auto-select first registered voice if none active
|
|
if (!currentVoice) currentVoice = voiceModule;
|
|
}
|
|
|
|
function setVoice(name) {
|
|
if (window._meshAudioVoices && window._meshAudioVoices[name]) {
|
|
currentVoice = window._meshAudioVoices[name];
|
|
localStorage.setItem('live-audio-voice', name);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function getVoiceName() {
|
|
return currentVoice ? currentVoice.name : null;
|
|
}
|
|
|
|
function getVoiceNames() {
|
|
return Object.keys(window._meshAudioVoices || {});
|
|
}
|
|
|
|
// === Public API ===
|
|
|
|
function setEnabled(on) {
|
|
audioEnabled = on;
|
|
if (on) initAudio();
|
|
localStorage.setItem('live-audio-enabled', on);
|
|
}
|
|
|
|
function isEnabled() { return audioEnabled; }
|
|
|
|
function setBPM(val) {
|
|
bpm = Math.max(40, Math.min(300, val));
|
|
localStorage.setItem('live-audio-bpm', bpm);
|
|
}
|
|
|
|
function getBPM() { return bpm; }
|
|
|
|
function setVolume(val) {
|
|
if (masterGain) masterGain.gain.value = Math.max(0, Math.min(1, val));
|
|
localStorage.setItem('live-audio-volume', val);
|
|
}
|
|
|
|
function getVolume() { return masterGain ? masterGain.gain.value : 0.3; }
|
|
|
|
function restore() {
|
|
const saved = localStorage.getItem('live-audio-enabled');
|
|
if (saved === 'true') audioEnabled = true;
|
|
const savedBpm = localStorage.getItem('live-audio-bpm');
|
|
if (savedBpm) bpm = parseInt(savedBpm, 10) || 120;
|
|
const savedVol = localStorage.getItem('live-audio-volume');
|
|
if (savedVol) _pendingVolume = parseFloat(savedVol) || 0.3;
|
|
const savedVoice = localStorage.getItem('live-audio-voice');
|
|
if (savedVoice) setVoice(savedVoice);
|
|
|
|
// If audio was enabled, create context eagerly. If browser suspends it,
|
|
// the unlock overlay will appear when the first packet arrives.
|
|
if (audioEnabled) {
|
|
initAudio();
|
|
}
|
|
}
|
|
|
|
let _overlayShown = false;
|
|
|
|
function _showUnlockOverlay() {
|
|
if (_overlayShown) return;
|
|
_overlayShown = true;
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'audio-unlock-overlay';
|
|
overlay.innerHTML = '<div class="audio-unlock-prompt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-speaker-high"/></svg> Tap to enable audio</div>';
|
|
overlay.addEventListener('click', () => {
|
|
if (audioCtx) audioCtx.resume();
|
|
overlay.remove();
|
|
}, { once: true });
|
|
document.body.appendChild(overlay);
|
|
}
|
|
|
|
// Export engine + helpers for voice modules
|
|
window.MeshAudio = {
|
|
sonifyPacket,
|
|
setEnabled, isEnabled,
|
|
setBPM, getBPM,
|
|
setVolume, getVolume,
|
|
registerVoice, setVoice, getVoiceName, getVoiceNames,
|
|
restore,
|
|
getContext() { return audioCtx; },
|
|
// Helpers for voice modules
|
|
helpers: { buildScale, midiToFreq, mapRange, quantizeToScale },
|
|
};
|
|
})();
|