feat: add atmosphere audio filters
This commit is contained in:
@@ -34,6 +34,8 @@
|
||||
drawerStopAll: document.getElementById('drawer-stop-all'),
|
||||
activeList: document.getElementById('active-list'),
|
||||
activeEmpty: document.getElementById('active-empty'),
|
||||
filterGrid: document.getElementById('filter-grid'),
|
||||
filterStatus: document.getElementById('filter-status'),
|
||||
};
|
||||
|
||||
const state = {
|
||||
@@ -53,6 +55,8 @@
|
||||
sets: [],
|
||||
currentSetId: null,
|
||||
lastVariantPerGroup: new Map(), // anti-immediate-repeat memory
|
||||
filters: [],
|
||||
filter: { id: null, name: null, active: false },
|
||||
};
|
||||
|
||||
state.sets = loadSets();
|
||||
@@ -239,6 +243,53 @@
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderFilters() {
|
||||
const activeId = state.filter && state.filter.active ? state.filter.id : null;
|
||||
els.filterStatus.textContent = activeId ? `Active: ${state.filter.name || activeId}` : 'No filter';
|
||||
els.filterStatus.classList.toggle('filter-active', Boolean(activeId));
|
||||
els.filterGrid.innerHTML = state.filters.map((f) => {
|
||||
const active = f.id === activeId ? ' active' : '';
|
||||
return `
|
||||
<article class="filter-card${active}" data-filter-id="${escapeHtml(f.id)}">
|
||||
<div>
|
||||
<strong>${escapeHtml(f.name)}</strong>
|
||||
<span>${escapeHtml(f.description || '')}</span>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<button class="mini${active ? ' ok' : ''}" type="button" data-filter-action="toggle">${active ? 'Clear' : 'Toggle'}</button>
|
||||
<button class="mini momentary" type="button" data-filter-action="hold" title="Hold down to apply, release to clear">Hold</button>
|
||||
</div>
|
||||
</article>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function setFilter(id, active = true) {
|
||||
try {
|
||||
const res = await fetchJson('/api/filter', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, active }),
|
||||
});
|
||||
state.filter = res.filter || (res.stream && res.stream.filter) || state.filter;
|
||||
renderFilters();
|
||||
setStatus('ok', state.filter && state.filter.active ? `Filter active — ${state.filter.name}` : 'Filter cleared');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setStatus('err', `Filter failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFilters() {
|
||||
try {
|
||||
const res = await fetchJson('/api/filters');
|
||||
state.filters = Array.isArray(res.filters) ? res.filters : [];
|
||||
state.filter = res.filter || state.filter;
|
||||
renderFilters();
|
||||
} catch (err) {
|
||||
console.warn('filters load failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const cfg = await fetchJson('/api/config');
|
||||
@@ -365,6 +416,11 @@
|
||||
function applyStreamStatus(stream) {
|
||||
if (!stream) return;
|
||||
state.active = stream.active || [];
|
||||
if (stream.filter) {
|
||||
state.filter = stream.filter;
|
||||
if (Array.isArray(stream.filters)) state.filters = stream.filters;
|
||||
renderFilters();
|
||||
}
|
||||
renderActive();
|
||||
}
|
||||
|
||||
@@ -482,6 +538,35 @@
|
||||
|
||||
els.grid.addEventListener('click', gridClick);
|
||||
els.setGrid.addEventListener('click', gridClick);
|
||||
els.filterGrid.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-filter-action="toggle"]');
|
||||
if (!btn) return;
|
||||
const card = btn.closest('[data-filter-id]');
|
||||
if (!card) return;
|
||||
const id = card.dataset.filterId;
|
||||
const alreadyActive = state.filter && state.filter.active && state.filter.id === id;
|
||||
setFilter(id, !alreadyActive);
|
||||
});
|
||||
|
||||
els.filterGrid.addEventListener('pointerdown', (e) => {
|
||||
const btn = e.target.closest('[data-filter-action="hold"]');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
const card = btn.closest('[data-filter-id]');
|
||||
if (!card) return;
|
||||
btn.setPointerCapture?.(e.pointerId);
|
||||
btn.classList.add('holding');
|
||||
setFilter(card.dataset.filterId, true);
|
||||
});
|
||||
for (const eventName of ['pointerup', 'pointercancel', 'lostpointercapture']) {
|
||||
els.filterGrid.addEventListener(eventName, (e) => {
|
||||
const btn = e.target.closest?.('[data-filter-action="hold"]');
|
||||
if (!btn || !btn.classList.contains('holding')) return;
|
||||
btn.classList.remove('holding');
|
||||
setFilter(null, false);
|
||||
});
|
||||
}
|
||||
|
||||
els.categories.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.chip'); if (!btn) return;
|
||||
state.activeCategory = btn.dataset.cat || 'all'; renderCategories(); renderGrid();
|
||||
@@ -510,6 +595,7 @@
|
||||
});
|
||||
|
||||
loadConfig();
|
||||
loadFilters();
|
||||
loadEffects();
|
||||
renderSetsSelect();
|
||||
renderActive();
|
||||
|
||||
@@ -45,6 +45,17 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel filter-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Atmosphere Filters</h2>
|
||||
<p class="muted">Change the live device stream: click Toggle to latch, or hold Momentary and release to clear.</p>
|
||||
</div>
|
||||
<span class="badge" id="filter-status">No filter</span>
|
||||
</div>
|
||||
<div id="filter-grid" class="filter-grid" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2 id="set-title">Current Set</h2>
|
||||
|
||||
@@ -74,6 +74,15 @@ input[type="search"]:focus { border-color: var(--accent); }
|
||||
.variant-row { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 10px 6px; }
|
||||
.variant-btn { padding: 3px 8px; font-size: 11px; border-radius: 6px; min-width: 26px; background: rgba(179,136,255,.08); border: 1px solid rgba(179,136,255,.28); color: var(--ink-dim); }
|
||||
.variant-btn:hover { background: rgba(179,136,255,.20); color: var(--ink); border-color: var(--accent); }
|
||||
.filter-panel .panel-head { align-items: flex-start; justify-content: space-between; }
|
||||
.filter-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; }
|
||||
.filter-card { display: flex; justify-content: space-between; gap: 12px; align-items: center; padding: 12px; border: 1px solid var(--panel-border); border-radius: 12px; background: linear-gradient(160deg, rgba(255,255,255,.035), rgba(255,255,255,.01)); transition: border-color .15s ease, background .15s ease, box-shadow .15s ease; }
|
||||
.filter-card.active { border-color: rgba(110,255,200,.7); background: radial-gradient(260px 110px at 20% 0%, rgba(110,255,200,.14), rgba(255,255,255,.02)); box-shadow: inset 0 0 0 1px rgba(110,255,200,.12); }
|
||||
.filter-card strong { display: block; font-size: 13px; letter-spacing: .08em; text-transform: uppercase; margin-bottom: 4px; }
|
||||
.filter-card span { display: block; color: var(--ink-dim); font-size: 12px; line-height: 1.35; }
|
||||
.filter-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
.momentary.holding { color: #06140f; background: linear-gradient(135deg, var(--accent-2), #a5ffe1); border-color: rgba(110,255,200,.8); }
|
||||
.badge.filter-active { color: var(--accent-2); background: rgba(110,255,200,.12); border-color: rgba(110,255,200,.5); }
|
||||
.active-row.is-loop { border-color: rgba(255,180,84,.45); background: rgba(255,180,84,.06); }
|
||||
.active-row .loop-glyph { font-weight: 700; }
|
||||
.empty { margin: 16px 4px 0; color: var(--ink-dim); font-style: italic; }
|
||||
|
||||
@@ -57,6 +57,22 @@ function createApiRouter({ config, effects, filestash, ha, mixer }) {
|
||||
res.json({ ok: true, stream: mixer.stats(), entity: allowedEntity });
|
||||
});
|
||||
|
||||
router.get('/api/filters', (_req, res) => {
|
||||
res.json({ ok: true, filters: mixer.listFilters(), filter: mixer.getFilter() });
|
||||
});
|
||||
|
||||
router.post('/api/filter', express.json({ limit: '4kb' }), (req, res, next) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const id = typeof body.id === 'string' ? body.id : null;
|
||||
const active = body.active !== false;
|
||||
const filter = mixer.setFilter(id, active);
|
||||
res.json({ ok: true, filter, stream: mixer.stats() });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/stream/start', async (_req, res, next) => {
|
||||
try {
|
||||
if (!publicBaseUrl) {
|
||||
|
||||
@@ -20,6 +20,14 @@ const MAX_QUEUE_BYTES = FRAME_BYTES * 250; // 5 seconds of decoded audio bufferi
|
||||
// latency to drift upward the longer the device stream ran.
|
||||
const MAX_CLIENT_BUFFER_BYTES = FRAME_BYTES * 3;
|
||||
|
||||
const FILTER_PRESETS = [
|
||||
{ id: 'underwater', name: 'Underwater', description: 'Deep low-pass, slow pressure wobble, and a soft slapback.' },
|
||||
{ id: 'cave', name: 'Cave Echo', description: 'Wide cavern echo/reverb tail for open stone spaces.' },
|
||||
{ id: 'nextdoor', name: 'Next Door', description: 'Quiet, boxy, muffled audio as if heard through a wall.' },
|
||||
{ id: 'dream', name: 'Dream Veil', description: 'Soft low-pass with shimmering echo for magical atmosphere shifts.' },
|
||||
];
|
||||
const FILTER_PRESET_BY_ID = new Map(FILTER_PRESETS.map((f) => [f.id, f]));
|
||||
|
||||
class StreamMixer extends EventEmitter {
|
||||
constructor({ effects, localMediaBaseUrl }) {
|
||||
super();
|
||||
@@ -32,6 +40,7 @@ class StreamMixer extends EventEmitter {
|
||||
this.timer = null;
|
||||
this.sequence = 0;
|
||||
this.nextFrameAt = 0;
|
||||
this.filter = createFilterState();
|
||||
this.setMaxListeners(64);
|
||||
}
|
||||
|
||||
@@ -169,6 +178,32 @@ class StreamMixer extends EventEmitter {
|
||||
return count;
|
||||
}
|
||||
|
||||
listFilters() {
|
||||
return FILTER_PRESETS.map((f) => ({ ...f }));
|
||||
}
|
||||
|
||||
getFilter() {
|
||||
return publicFilterState(this.filter);
|
||||
}
|
||||
|
||||
setFilter(id, active = true) {
|
||||
if (!active || !id) {
|
||||
const changed = this.filter.id !== null;
|
||||
this.filter = createFilterState();
|
||||
if (changed) this.emit('change', this._reason('filter', { id: null }));
|
||||
return this.getFilter();
|
||||
}
|
||||
if (!FILTER_PRESET_BY_ID.has(id)) {
|
||||
const err = new Error('unknown filter');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
const changed = this.filter.id !== id;
|
||||
this.filter = createFilterState(id);
|
||||
if (changed) this.emit('change', this._reason('filter', { id }));
|
||||
return this.getFilter();
|
||||
}
|
||||
|
||||
stats() {
|
||||
return {
|
||||
sampleRate: SAMPLE_RATE,
|
||||
@@ -176,6 +211,8 @@ class StreamMixer extends EventEmitter {
|
||||
clients: this.clients.size,
|
||||
droppedFrames: [...this.clients].reduce((n, c) => n + c.droppedFrames, 0),
|
||||
active: this.listActive(),
|
||||
filter: this.getFilter(),
|
||||
filters: this.listFilters(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -229,8 +266,10 @@ class StreamMixer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
applyAtmosphereFilter(mix, this.filter);
|
||||
|
||||
for (let i = 0; i < mix.length; i += 1) {
|
||||
output.writeInt16LE(clamp(mix[i], -32768, 32767), i * 2);
|
||||
output.writeInt16LE(clamp(Math.round(mix[i]), -32768, 32767), i * 2);
|
||||
}
|
||||
|
||||
let clientChanged = false;
|
||||
@@ -268,6 +307,98 @@ class StreamMixer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function createFilterState(id = null) {
|
||||
const preset = id ? FILTER_PRESET_BY_ID.get(id) : null;
|
||||
return {
|
||||
id: preset ? id : null,
|
||||
name: preset ? preset.name : null,
|
||||
enabledAt: preset ? Date.now() : null,
|
||||
frame: 0,
|
||||
lowpassL: 0,
|
||||
lowpassR: 0,
|
||||
delays: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function publicFilterState(filter) {
|
||||
return {
|
||||
id: filter.id,
|
||||
name: filter.name,
|
||||
active: Boolean(filter.id),
|
||||
enabledAt: filter.enabledAt,
|
||||
ageMs: filter.enabledAt ? Date.now() - filter.enabledAt : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function applyAtmosphereFilter(mix, filter) {
|
||||
if (!filter || !filter.id) return;
|
||||
if (filter.id === 'underwater') {
|
||||
lowpass(mix, filter, 0.045, 0.82);
|
||||
echo(mix, filter, 'underwater-slap', 115, 0.22, 0.18, 0.78);
|
||||
wobble(mix, filter, 0.82, 0.18, 0.42);
|
||||
} else if (filter.id === 'cave') {
|
||||
echo(mix, filter, 'cave-long', 310, 0.48, 0.42, 0.86);
|
||||
echo(mix, filter, 'cave-early', 137, 0.20, 0.18, 0.93);
|
||||
lowpass(mix, filter, 0.22, 0.96);
|
||||
} else if (filter.id === 'nextdoor') {
|
||||
lowpass(mix, filter, 0.055, 0.48);
|
||||
boxyResonance(mix, filter, 0.26);
|
||||
} else if (filter.id === 'dream') {
|
||||
lowpass(mix, filter, 0.12, 0.76);
|
||||
echo(mix, filter, 'dream-shimmer', 245, 0.34, 0.34, 0.90);
|
||||
wobble(mix, filter, 0.94, 0.08, 0.18);
|
||||
}
|
||||
filter.frame += 1;
|
||||
}
|
||||
|
||||
function lowpass(mix, filter, alpha, gain) {
|
||||
for (let i = 0; i < mix.length; i += CHANNELS) {
|
||||
filter.lowpassL += alpha * (mix[i] - filter.lowpassL);
|
||||
filter.lowpassR += alpha * (mix[i + 1] - filter.lowpassR);
|
||||
mix[i] = filter.lowpassL * gain;
|
||||
mix[i + 1] = filter.lowpassR * gain;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDelay(filter, key, delayMs) {
|
||||
const samples = Math.max(CHANNELS, Math.floor(SAMPLE_RATE * CHANNELS * delayMs / 1000));
|
||||
const existing = filter.delays.get(key);
|
||||
if (existing && existing.buffer.length === samples) return existing;
|
||||
const created = { buffer: new Float32Array(samples), index: 0 };
|
||||
filter.delays.set(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function echo(mix, filter, key, delayMs, feedback, wet, dry) {
|
||||
const delayState = ensureDelay(filter, key, delayMs);
|
||||
const delay = delayState.buffer;
|
||||
let index = delayState.index;
|
||||
for (let i = 0; i < mix.length; i += 1) {
|
||||
const delayed = delay[index] || 0;
|
||||
const input = mix[i];
|
||||
delay[index] = clamp(input + delayed * feedback, -32768, 32767);
|
||||
mix[i] = input * dry + delayed * wet;
|
||||
index = (index + 1) % delay.length;
|
||||
}
|
||||
delayState.index = index;
|
||||
}
|
||||
|
||||
function wobble(mix, filter, base, depth, hz) {
|
||||
const t = (filter.frame * FRAME_MS) / 1000;
|
||||
const gain = base + Math.sin(t * Math.PI * 2 * hz) * depth;
|
||||
for (let i = 0; i < mix.length; i += 1) mix[i] *= gain;
|
||||
}
|
||||
|
||||
function boxyResonance(mix, filter, amount) {
|
||||
const monoGain = 1 - amount;
|
||||
for (let i = 0; i < mix.length; i += CHANNELS) {
|
||||
const mono = (mix[i] + mix[i + 1]) * 0.5;
|
||||
mix[i] = mix[i] * monoGain + mono * amount;
|
||||
mix[i + 1] = mix[i + 1] * monoGain + mono * amount;
|
||||
}
|
||||
}
|
||||
|
||||
function consumeFrame(item, size) {
|
||||
if (!item.queueBytes) return null;
|
||||
const out = Buffer.alloc(size);
|
||||
@@ -326,4 +457,4 @@ function clamp(n, min, max) {
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
|
||||
module.exports = { StreamMixer };
|
||||
module.exports = { StreamMixer, FILTER_PRESETS };
|
||||
|
||||
Reference in New Issue
Block a user