diff --git a/public/app.js b/public/app.js index d45aca8..9a20907 100644 --- a/public/app.js +++ b/public/app.js @@ -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 @@ `).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 ` +
+
+ ${escapeHtml(f.name)} + ${escapeHtml(f.description || '')} +
+
+ + +
+
`; + }).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(); diff --git a/public/index.html b/public/index.html index 9772c1e..e5fa4bd 100644 --- a/public/index.html +++ b/public/index.html @@ -45,6 +45,17 @@ +
+
+
+

Atmosphere Filters

+

Change the live device stream: click Toggle to latch, or hold Momentary and release to clear.

+
+ No filter +
+
+
+

Current Set

diff --git a/public/styles.css b/public/styles.css index e191318..8494356 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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; } diff --git a/src/routes.js b/src/routes.js index 811cdca..653059f 100644 --- a/src/routes.js +++ b/src/routes.js @@ -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) { diff --git a/src/streamMixer.js b/src/streamMixer.js index c9247c8..61c9e42 100644 --- a/src/streamMixer.js +++ b/src/streamMixer.js @@ -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 };