Skip to content

Room EQ apply/bypass corrupts DSP filter state (non-atomic settings store + per-filter bank rewrites + bypass race) #626

Description

@hifiberry

Reported on the support forum: https://support.hifiberry.com/forum/c/support/10611 (see the last two comments by Neuman Ben, 2026-08-01).

Applying a Room EQ correction — or toggling the per-bank bypass control on the Speaker Equalizer page — leaves the DSP in an inconsistent state: audible pops/clicks, and left/right banks ending up with different filter values or different bypassed flags via GET /api/dsptoolkit/filters. Slots are sometimes left holding the transparent placeholder {a0:1,a1:0,a2:0,b0:1,b1:0,b2:0} instead of the intended filter. The write endpoints return HTTP 200 regardless, so nothing surfaces in the UI.

Reporter's environment: Raspberry Pi 5, Digi2 Pro + DSP Add-on (v1 / "-14"), hifiberry-dspprofiles 1.1.1 (dsp-addon-96-14.xml), hifiberry-webui 0.20.3, sigmatcpserver --enable-rest --alsa --disable-tcp --store --restore.

The reporter's analysis was AI-assisted and they flagged it as such, but all three findings check out against the source.

1. save_store() is not atomic and its lock does nothing

packages/dsptoolkit/hifiberry-dsp/src/hifiberrydsp/api/settings_store.py:236

The save writes to a fixed, shared dspsettings.json.tmp and takes fcntl.flock on the temp file itself, after open(temp_file, 'w') has already truncated it. Two concurrent savers therefore share one inode:

  • A opens/truncates tmp, writes, releases, os.renames it over dspsettings.json.
  • B opened the same path and now holds an fd to that same inode — which is now the live store — and writes its content into it, corrupting it mid-file.
  • B's own os.rename(temp_file, ...) then fails with ENOENT, because the tmp path no longer exists.

This matches the reported logs exactly:

ERROR:root:Error saving settings store: [Errno 2] No such file or directory: '/var/lib/hifiberry/dspsettings.json.tmp' -> '/var/lib/hifiberry/dspsettings.json'
WARNING:root:Settings store file is empty, creating new store
ERROR:root:Filter customFilterRegisterBankLeft_5 not found in store
ERROR:root:JSON decode error in settings store at line 1092, column 3: Extra data
WARNING:root:Corrupted settings store backed up to /var/lib/hifiberry/dspsettings.json.corrupted.<timestamp>, starting with empty store

Because the biquad writes go straight to hardware while the corresponding store entries are lost, the live DSP and the persisted settings drift apart — which is what produces the asymmetric state after a batch.

Fix: unique temp filename (e.g. mkstemp in the same directory), os.replace instead of os.rename, and a lock held on a separate persistent lockfile (plus an in-process mutex) rather than on the file being replaced.

2. useBypass press-and-hold race

packages/webui/hbos-ui/src/composables/useBypass.ts

startBypass() sets isBypassed.value = true at line 30, before awaiting its bypass-on writes. endBypass() (line 51) only guards on !isBypassed.value, so a normal quick tap fires both in succession and the bypass-off requests race the still-in-flight bypass-on requests for the same banks. Both land on live hardware registers concurrently.

Fix: serialize the two operations per bank (await the in-flight start before running end), not just guard on the boolean.

3. Every addFilter() rewrites the entire bank

packages/webui/hbos-ui/src/stores/dsp_toolkit_filter_backend.ts:749 (updateDSPHardware)

The method loops 0..maxFilters, writing the real filter for populated slots and a transparent placeholder for the rest — and it runs on every single addFilter() call (line 573). useRoomEQ.ts:127 adds the filters of a correction one at a time in a loop, so loading a 16-filter correction is 1+2+…+16 = 136 sequential setBiquadFilter round-trips per channel (272 for both), each of which transiently leaves the not-yet-reached slots transparent.

Any interruption mid-sequence — an overlapping call, a dropped connection, a WiFi hiccup — leaves the bank half-applied with real filters in some slots and inert placeholders in others. The reporter caught one such case with a 499 (client-aborted) request and a simultaneous WebSocket reconnect mid-apply.

Fix: a bulk bank-write endpoint (e.g. POST /api/dsptoolkit/filters/bank) taking the full filter array and writing it in one server-side pass, with the webui using that for a full load instead of N addFilter calls. That removes the redundant rewrites and shrinks the interruption window by ~100x — and it also cuts the concurrency that triggers (1).

Reproduction

  1. Save a Room EQ correction profile via the Room Acoustics wizard.
  2. Speaker Equalizer → "Load Room EQ Configuration" → pick the profile, channel target "both", confirm.
  3. journalctl -u sigmatcpserver -f during the load — Error saving settings store fires repeatedly.
  4. GET /api/dsptoolkit/filters — compare bypassed and filter values across customFilterRegisterBankLeft_* vs customFilterRegisterBankRight_*; they frequently differ despite both channels receiving the same correction.
  5. The per-bank bypass toggle on the same page reproduces the identical error pattern.

Workaround for affected users

After a bulk apply, check GET /api/dsptoolkit/filters for matching bypass states and filter definitions across both banks; if they differ, clear each register with a transparent coefficient set and reapply. Avoid the bypass-bank toggle for A/B listening until (2) is fixed.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions