feat: add atmosphere audio filters

This commit is contained in:
Hermes Agent
2026-05-21 09:23:49 +02:00
parent 17bf8d4e3a
commit 94d7a411da
5 changed files with 255 additions and 2 deletions

View File

@@ -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) {

View File

@@ -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 };