Audio Device Persistence Strategy
Date: 2026-04-07
Status: Strategy — implemented
Scope: ledfx/effects/audio.py, ledfx/api/audio_devices.py, ledfx/api/config.py, ledfx/config.py
Problem Statement
The audio device is persisted in config.json as an integer index only:
{
"audio": {
"audio_device": 17
}
}
Device indices are assigned by PortAudio/sounddevice during enumeration and are not stable across sessions. If a USB device is plugged in, a Bluetooth device pairs, or a driver updates between LedFx sessions, the enumeration order can shift. The saved index 17 may now point to a completely different device.
Current Runtime Mitigation (Incomplete)
During a running session, AudioInputSource tracks the active device by name in the class variable _last_device_name. When device hotplug events occur, handle_device_list_change() uses get_device_index_by_name() to find the device at its new index. This works well within a session.
The gap: _last_device_name is a runtime-only class variable. It is never persisted to config.json. When LedFx exits and restarts, _last_device_name is None, so there is no name to match against. The system falls back to the raw integer index, which may now be wrong.
Failure Scenario
User selects device
[17] Windows WASAPI: Speakers (Realtek) [Loopback]Config saves
{"audio_device": 17}LedFx exits
User plugs in a USB audio interface (or a Bluetooth device auto-connects)
On next enumeration, the USB device gets index 5, shifting everything above it up by 1
Index
17now points toWindows WASAPI: Microphone (Realtek)instead of the loopbackLedFx starts, reads
audio_device: 17, opens the wrong deviceUser gets no audio visualization or hears unexpected input
Strategy: Persist Device Name Alongside Index
Core Principle
Store the device name string as the primary identifier and the index as a hint/cache. On startup, resolve name → index. The index is only used as a fast path when the name still matches.
1. Config Schema Change
Add an audio_device_name field to the audio config:
{
"audio": {
"audio_device": 17,
"audio_device_name": "Windows WASAPI: Speakers (Realtek High Definition Audio) [Loopback]"
}
}
The name format matches what input_devices() returns: "{hostapi_name}: {device_name}". This is the same format already used by _last_device_name in the runtime tracking code (see update_device_tracking() in _activate_inner()).
2. Startup Resolution Logic
Add a new method or extend device_index_validator() with this resolution order:
1. If audio_device_name is set in config:
a. If audio_device index is valid AND its name matches audio_device_name → use index (fast path)
b. Else, search all input devices for audio_device_name using get_device_index_by_name()
- If found → use the new index, update audio_device in config, persist
- If not found → reset audio_device to default_device_index(), clear
audio_device_name and runtime tracking (_last_device_name, _last_active),
persist
2. If audio_device_name is empty (legacy config or first run) → use audio_device index as-is
3. Downstream validator / _activate_inner() handles any remaining invalid indices
3. Files Modified
ledfx/effects/audio.py
AUDIO_CONFIG_SCHEMA— Addedaudio_device_nameas an optional string field (default"")._resolve_device_from_name()— New method implementing the resolution algorithm from §2. Called fromupdate_config()after schema validation, beforeactivate()._update_device_config()— Updated to persist bothaudio_device(index) andaudio_device_name(string) together, then save to disk.update_device_tracking()(inner function in_activate_inner()) — Unchanged; still sets_last_device_nameat runtime.persist_device_name_if_needed()(inner function in_activate_inner()) — Post-activation hook that syncs both config keys (audio_device,audio_device_name) to match the actually-opened device. Handles legacy upgrade (first activation after upgrade auto-populates name) and fallback-open scenarios (configured device fails, default opens instead).update_config()— Calls_resolve_device_from_name()after schema validation, beforeactivate()._persist_config()— Helper that syncsself._configto central config and writes to disk. Used by all persistence paths.
ledfx/api/audio_devices.py
PUT handler — Persists
audio_device_namealongsideaudio_devicewhen the user selects a device via the API.GET handler — Returns
active_device_namein the response alongsideactive_device_index.
ledfx/api/config.py
update_config()— When the incoming payload containsaudio_device, the staleaudio_device_nameis cleared from the existing config before the merge. This prevents_resolve_device_from_name()from name-matching back to the old device and overriding the user’s selection. See §3.1 below.
ledfx/config.py
No new migration required. audio_device_name defaults to "" via the schema, so legacy configs load without error.
3.1 Config Merge Hazard — ledfx/api/config.py
Bug found 2026-04-08. The original implementation missed this path entirely.
The frontend changes audio devices via PUT /api/config {"audio": {"audio_device": 4}}, which is handled by ConfigEndpoint.update_config() in ledfx/api/config.py. This method does a partial merge:
self._ledfx.config["audio"].update(audio_config)
.update() only overwrites keys present in the incoming payload. When the frontend sends {"audio_device": 4} (no audio_device_name), the stale audio_device_name from the prior device selection survives the merge. The merged config is then passed to AudioInputSource.update_config(), which calls _resolve_device_from_name(). That method sees the stale name, searches by name, finds the old device, and overrides the user’s new index selection.
Fix: Before the merge, if the incoming payload contains audio_device, explicitly clear audio_device_name in the existing config:
if "audio_device" in audio_config:
self._ledfx.config["audio"]["audio_device_name"] = ""
This causes _resolve_device_from_name() to take the “no name stored” path and use the index as-is. The name is then correctly populated by persist_device_name_if_needed() after the new device opens.
Key lesson: Any code path that can write audio_device without simultaneously writing audio_device_name must clear the stale name. The dedicated audio devices API (ledfx/api/audio_devices.py) writes both together, but the general config API merges partial payloads — a distinction the original strategy failed to account for.
4. _resolve_device_from_name() Algorithm
See ledfx/effects/audio.py for the definitive implementation.
Called from update_config() after schema validation, before activate().
Resolution order:
If
audio_device_nameis empty → return immediately (legacy/first-run path)If the saved index exists in the current device list and its name matches → return (fast path, no drift)
Search all devices by name via
get_device_index_by_name()(handles exact + partial/truncated matching):If found → update
audio_deviceto the new index, persist
Device not found at all:
Reset
audio_devicetodefault_device_index()Clear
audio_device_nameClear runtime tracking (
_last_device_name,_last_active) so hotplug won’t attempt recovery to the gone devicePersist
5. Edge Cases
Scenario |
Behavior |
|---|---|
Legacy config (no |
Falls through to existing index-based logic. No regression. |
Device removed permanently |
Name search fails, index check fails, falls back to default. Name cleared from config. |
Device name truncated by PortAudio |
|
Multiple devices with similar names |
Exact match preferred, then longest partial match (existing logic). |
User manually edits config.json |
Works as long as name string matches |
WEB AUDIO / SENDSPIN devices |
These also use the |
Config saved on Windows, loaded on Linux |
Host API names differ ( |
6. Migration Path — Legacy Upgrade Strategy
This is a non-breaking, additive change. Users upgrading from any prior version will have configs with only audio_device (integer index) and no audio_device_name field.
Upgrade Flow (Seamless)
User upgrades LedFx to the version with this feature
LedFx starts, loads
config.jsonwith{"audio": {"audio_device": 17}}Schema validation adds
audio_device_namewith default""_resolve_device_from_name()sees empty name → skips name resolution entirelyExisting
device_index_validatorhandles index17as before — no change in behaviorDevice activates at index
17(taken at face value)_activate_inner()callsupdate_device_tracking()→ sets_last_device_nameat runtimeKey step:
persist_device_name_if_needed()detects the name is missing and writes both keys back to config (e.g.audio_device: 17+audio_device_name: "Windows WASAPI: Speakers (Realtek) [Loopback]")Config is saved to disk — config is now upgraded for all future sessions
From the next restart onward, name-based resolution is active
Critical Design Constraint
The name must be persisted on the first successful activation after upgrade, not only on user-initiated device changes via the API. The persist_device_name_if_needed() inner function in _activate_inner() handles this — it runs after every successful device open and syncs both config keys if the actual device differs from what’s in config. Without this, a user who never touches the audio settings would never get their config upgraded.
persist_device_name_if_needed() Algorithm
See ledfx/effects/audio.py _activate_inner() for the definitive implementation.
Called after each successful try_open_device() in the startup sequence.
Read
_last_device_nameand_last_active(the actually-opened device) under class lockIf either is unset → return (nothing to persist)
Compare against current config values for
audio_device_nameandaudio_deviceIf either differs → update both config keys to match the actual device, then persist
This handles three cases:
Legacy upgrade: config had no name, now it gets one
Fallback open: configured device failed, default opened — config updated to reflect reality
Normal confirmation: config already matches, no write needed
Summary
audio_device_namedefaults to""— existing configs load without errorLegacy index is trusted at face value on first boot after upgrade
Name is auto-populated on first successful activation
The integer
audio_devicefield is never removed — it remains the fast path and fallbackZero user interaction required for the upgrade
7. Testing Strategy
All tests should live in tests/test_audio_device_persistence.py. Tests mock AudioInputSource.input_devices() and AudioInputSource.default_device_index() to simulate different device enumeration states without requiring real audio hardware.
7.1 Mock Fixtures
See tests/test_audio_device_persistence.py for the definitive fixture definitions.
Tests use mock device dictionaries keyed by index with "{hostapi}: {name}" string values, simulating:
DEVICES_BEFORE — Standard device list (baseline state)
DEVICES_AFTER_USB_ADDED — USB device inserted, indices shifted upward
DEVICES_AFTER_REMOVAL — Target device removed entirely
DEVICES_TRUNCATED — PortAudio-truncated long device names
DEVICES_SIMILAR_NAMES — Multiple devices with overlapping name prefixes
7.2 Unit Tests — _resolve_device_from_name()
These test the core resolution logic in isolation.
# |
Test Name |
Config Input |
Mock Devices |
Expected Result |
|---|---|---|---|---|
1 |
|
|
|
Index stays |
2 |
|
|
|
Index updated to |
3 |
|
|
|
Index reset to default, name cleared, runtime tracking ( |
4 |
|
|
|
Index stays |
5 |
|
|
|
Index stays |
6 |
|
|
|
Partial match finds index |
7 |
|
|
|
Exact match at |
8 |
|
|
|
Name search finds |
9 |
|
|
|
Index reset to default, name cleared |
10 |
|
|
|
Name mismatch at |
7.3 Unit Tests — Legacy Upgrade Path
These verify that upgrading from index-only configs works seamlessly.
# |
Test Name |
Scenario |
Expected Result |
|---|---|---|---|
11 |
|
Config |
|
12 |
|
Config |
Default device index chosen, no name resolution attempted |
13 |
|
Config |
After activation, config contains |
14 |
|
Config |
|
15 |
|
Simulate: first boot persists name, second boot with shifted indices |
Second boot resolves by name to new index |
7.4 Unit Tests — API Endpoint
These verify the REST API correctly persists both fields. Tests must exercise the actual ConfigEndpoint.update_config() merge path against pre-existing config state, not just construct new dicts in isolation.
# |
Test Name |
Scenario |
Expected Result |
|---|---|---|---|
16 |
|
PUT |
Config saved with both |
16b |
|
PUT |
Stale name cleared before merge; |
17 |
|
PUT |
Error response, config unchanged |
18 |
|
GET with config containing name |
Response includes |
7.5 Unit Tests — _update_device_config()
These verify the config save helper persists both fields.
# |
Test Name |
Scenario |
Expected Result |
|---|---|---|---|
19 |
|
Call with valid |
Both |
20 |
|
Call with |
|
21 |
|
Call with |
|
7.6 Unit Tests — handle_device_list_change() Integration
These verify the runtime hotplug recovery still works correctly with the new name field.
# |
Test Name |
Scenario |
Expected Result |
|---|---|---|---|
22 |
|
Device shifts during session |
|
23 |
|
Device disappears during session |
Falls to default, |
24 |
|
Device change event with no active stream |
No error, device list refreshed only |
7.7 Unit Tests — get_device_index_by_name() Edge Cases
These validate the name matching logic that underpins resolution.
# |
Test Name |
Scenario |
Expected Result |
|---|---|---|---|
25 |
|
Exact name exists in device list |
Returns exact match index |
26 |
|
Stored name is truncated substring |
Returns best (longest) partial match |
27 |
|
Name doesn’t match anything |
Returns |
28 |
|
Empty string passed |
Returns |
29 |
|
Name differs only in case |
Verify current behavior (case-sensitive) |
30 |
|
Stored |
Exact match at index 0 is returned, not a longer partial match (e.g., |
7.8 Regression Guard Tests
These ensure the new code doesn’t break existing behavior.
# |
Test Name |
What It Guards |
|---|---|---|
31 |
|
Schema validation doesn’t reject old configs |
32 |
|
|
33 |
|
Validator still returns default for invalid indices |
34 |
|
Full activation path works with |
35 |
|
Save → load cycle preserves both |
36 |
|
WEB AUDIO virtual devices also get name persisted |
37 |
|
SENDSPIN devices also get name persisted |
7.9 Test Implementation Notes
Mocking pattern: Use
@patch.object(AudioInputSource, 'input_devices', return_value=DEVICES_BEFORE)and@patch.object(AudioInputSource, 'default_device_index', return_value=0)to control device enumeration without real hardware.Config save assertion: Mock
save_configand assert it was called with expected config dict. Use@patch('ledfx.effects.audio.save_config')or a mock_ledfxobject with mockconfigandconfig_dir.Activation mocking: For tests that touch
activate(), mockopen_audio_streamto prevent actual PortAudio calls. Alternatively, test_resolve_device_from_name()in isolation (preferred).Fixture for mock ledfx: Create a simple mock with
configdict andconfig_dirstring to satisfy_update_device_config()requirements.No real audio in CI: All tests must pass without any audio hardware. Never call
sd.query_devices()in tests.
8. Implementation Order & Progress
Branch: audio_name_2
PR: #1770 — Feat: Audio device persist by name
# |
Task |
File(s) |
Status |
|---|---|---|---|
1 |
Add |
|
[x] |
2 |
Add |
|
[x] |
3 |
Call resolver from |
|
[x] |
4 |
Update |
|
[x] |
5 |
Auto-persist name in |
|
[x] |
6 |
Update API PUT to persist name |
|
[x] |
7 |
Update API GET to return name |
|
[x] |
8 |
Write unit tests (37 cases from §7) |
|
[x] |
9 |
Run tests and verify |
— |
[x] |
10 |
Fix config merge hazard — clear stale name on API device change |
|
[x] |
11 |
Add regression test for config merge hazard (#16b) |
|
[x] |
Session recovery: If a session times out, read this document to restore context. The table above tracks progress. Resume from the first unchecked item.
Appendix: Code References
Component |
File |
Symbol |
|---|---|---|
Config schema |
|
|
Startup resolver |
|
|
Index validator |
|
|
Runtime name tracking |
|
|
Post-activation persistence |
|
|
Device list change handler |
|
|
Name-based search |
|
|
Config update + save |
|
|
API device selection |
|
|
General config merge (stale name fix) |
|
|
Input device enumeration |
|
|
Device activation |
|
|