feat(eq): remember EQ profile per audio output device (#1146)

* feat(eq): add per-device EQ snapshot state to eqStore

* feat(eq): wire per-device EQ sync into audio listener setup

* feat(settings): add 'remember EQ per device' toggle to audio output section

* i18n(settings): add 'remember EQ per device' strings (10 locales)

* test(eq): cover per-device EQ snapshot store + device sync orchestration

* docs(changelog): per-device EQ entry, credit and what's new (#1146)

* i18n(settings): note per-device EQ scope (system default + explicit switch) in toggle description

* test(eq): cover system-default bucket and device-reset (null) path
This commit is contained in:
Psychotoxical
2026-06-21 22:50:06 +02:00
committed by GitHub
parent d9969ed76f
commit 887c940e1b
19 changed files with 434 additions and 0 deletions
+6
View File
@@ -62,6 +62,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* New opt-in **External Artwork Scraper** (Settings → Integrations, off by default): artist imagery from fanart.tv — a 16:9 background on the fullscreen player and a wide banner on the artist page — with Navidrome staying the canonical cover. Optional personal key; turning it off removes the fetched images again.
### Remember the equalizer per audio output device
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1146](https://github.com/Psychotoxical/psysonic/pull/1146)**, suggested by [@JustBuddy](https://github.com/JustBuddy)
* New opt-in **Remember EQ per device** toggle (Settings → Audio → Audio Output Device, off by default): the equalizer profile — bands, pre-gain, enabled state and active preset — is saved per audio output device and restored automatically when you switch devices.
## Changed
+4
View File
@@ -38,6 +38,10 @@ Within each section, order by **user impact** (most noticeable first) — not PR
- Switch on **External Artwork Scraper** under **Settings → Integrations** to pull artist imagery from fanart.tv: a wide backdrop on the fullscreen player and a banner across the top of the artist page. Off by default, your Navidrome covers stay in charge, and turning it back off removes the fetched images again.
### Equalizer — a profile per output device
- Turn on **Remember EQ per device** under **Settings → Audio** and Psysonic keeps a separate equalizer setup for each output — speakers, headphones, a USB DAC — and switches to the right one automatically when you change devices. Off by default.
## Fixed
### Playback and audio
@@ -5,7 +5,9 @@ import type { TFunction } from 'i18next';
import CustomSelect from '../../CustomSelect';
import SettingsSubSection from '../../SettingsSubSection';
import { SettingsGroup } from '../SettingsGroup';
import { SettingsToggle } from '../SettingsToggle';
import { useAuthStore } from '../../../store/authStore';
import { useEqStore } from '../../../store/eqStore';
import { buildAudioDeviceSelectOptions } from '../../../utils/audio/audioDeviceLabels';
interface Props {
@@ -37,6 +39,8 @@ export function AudioOutputDeviceSection({
}: Props) {
const audioOutputDevice = useAuthStore(s => s.audioOutputDevice);
const setAudioOutputDevice = useAuthStore(s => s.setAudioOutputDevice);
const rememberEqPerDevice = useEqStore(s => s.rememberPerDevice);
const setRememberEqPerDevice = useEqStore(s => s.setRememberPerDevice);
return (
<SettingsSubSection
@@ -81,6 +85,15 @@ export function AudioOutputDeviceSection({
</button>
</div>
</SettingsGroup>
<SettingsGroup>
<SettingsToggle
label={t('settings.audioOutputDeviceRememberEq')}
desc={t('settings.audioOutputDeviceRememberEqDesc')}
checked={rememberEqPerDevice}
onChange={setRememberEqPerDevice}
searchText={t('settings.audioOutputDeviceRememberEq')}
/>
</SettingsGroup>
</div>
</SettingsSubSection>
);
+1
View File
@@ -379,6 +379,7 @@ const CONTRIBUTOR_ENTRIES = [
'AutoDJ as a standalone playback mode — own toolbar button and audio-settings entry, mutually exclusive with Crossfade and Gapless via a shared transition helper, combined playlist save/load submenu, and boxed playback settings sections (PR #1124)',
'Settings refactor — reusable boxed panels (SettingsGroup/SettingsToggle) rolled across every tab, Normalization and Track transitions promoted to their own Audio categories, a unified Queue Settings group on Personalisation, and the Audio Output Device category gated out on macOS (PR #1126, #1130)',
'Artist artwork from fanart.tv — opt-in fullscreen background + artist-header banner, off by default (PR #1137)',
'Per-device equalizer profiles — opt-in, remembers and restores the EQ for each audio output device (PR #1146)',
],
},
{
+2
View File
@@ -210,6 +210,8 @@ export const settings = {
audioOutputDeviceOsDefaultNow: 'aktuelle Systemausgabe',
audioOutputDeviceListError: 'Audiogeräteliste konnte nicht geladen werden.',
audioOutputDeviceNotInCurrentList: 'nicht in der aktuellen Liste',
audioOutputDeviceRememberEq: 'EQ pro Gerät merken',
audioOutputDeviceRememberEqDesc: 'Speichert die Equalizer-Einstellungen für jedes Audioausgabegerät und stellt sie beim Wechsel automatisch wieder her. Das Profil wechselt nur, wenn du hier ein Gerät auswählst; die Systemstandard-Ausgabe nutzt ein gemeinsames Profil.',
hiResTitle: 'Native Hi-Res-Wiedergabe',
hiResEnabled: 'Native Hi-Res-Wiedergabe aktivieren',
hiResDesc: "Spielt jeden Titel mit seiner ursprünglichen Abtastrate ab, statt alles auf 44,1 kHz herunterzurechnen, und stellt das Ausgabegerät passend zur Datei um (88,2 kHz und höher). Nur aktivieren, wenn Hardware und Netzwerk hohe Abtastraten zuverlässig verarbeiten.",
+2
View File
@@ -213,6 +213,8 @@ export const settings = {
audioOutputDeviceOsDefaultNow: 'current system output',
audioOutputDeviceListError: 'Could not load the audio device list.',
audioOutputDeviceNotInCurrentList: 'not in current list',
audioOutputDeviceRememberEq: 'Remember EQ per device',
audioOutputDeviceRememberEqDesc: 'Save the equalizer settings for each audio output device and restore them automatically when you switch devices. Profiles switch only when you pick a device here, and the system default uses a single shared profile.',
hiResTitle: 'Native Hi-Res Playback',
hiResEnabled: 'Enable native hi-res playback',
hiResDesc: "Plays each track at its original sample rate instead of resampling everything to 44.1 kHz, switching the audio device to match the file (88.2 kHz and up). Enable only if your hardware and network reliably handle high sample rates.",
+2
View File
@@ -209,6 +209,8 @@ export const settings = {
audioOutputDeviceOsDefaultNow: 'salida actual del sistema',
audioOutputDeviceListError: 'No se pudo cargar la lista de dispositivos de audio.',
audioOutputDeviceNotInCurrentList: 'no está en la lista actual',
audioOutputDeviceRememberEq: 'Recordar el ecualizador por dispositivo',
audioOutputDeviceRememberEqDesc: 'Guarda los ajustes del ecualizador para cada dispositivo de salida de audio y los restaura automáticamente al cambiar de dispositivo. Los perfiles solo cambian cuando eliges un dispositivo aquí; la salida predeterminada del sistema usa un único perfil compartido.',
hiResTitle: 'Reproducción Nativa Hi-Res',
hiResEnabled: 'Habilitar reproducción nativa hi-res',
hiResDesc: "Reproduce cada pista a su frecuencia de muestreo original en vez de remuestrear todo a 44.1 kHz, ajustando el dispositivo de salida para que coincida con el archivo (88.2 kHz o más). Habilítalo solo si tu hardware y tu red soportan de forma fiable altas tasas de muestreo.",
+2
View File
@@ -209,6 +209,8 @@ export const settings = {
audioOutputDeviceOsDefaultNow: 'sortie système actuelle',
audioOutputDeviceListError: 'Impossible de charger la liste des périphériques audio.',
audioOutputDeviceNotInCurrentList: 'absent de la liste actuelle',
audioOutputDeviceRememberEq: 'Mémoriser l’égaliseur par périphérique',
audioOutputDeviceRememberEqDesc: 'Enregistre les réglages de l’égaliseur pour chaque périphérique de sortie audio et les restaure automatiquement lors du changement de périphérique. Les profils ne changent que lorsque vous choisissez un périphérique ici ; la sortie système par défaut utilise un seul profil partagé.',
hiResTitle: 'Lecture haute résolution native',
hiResEnabled: 'Activer la lecture haute résolution native',
hiResDesc: "Lit chaque piste à sa fréquence d'échantillonnage d'origine au lieu de tout rééchantillonner à 44,1 kHz, en réglant le périphérique de sortie sur celle du fichier (88,2 kHz et plus). N'activer que si le matériel et le réseau gèrent ces hautes fréquences de façon fiable.",
+2
View File
@@ -213,6 +213,8 @@ export const settings = {
audioOutputDeviceOsDefaultNow: '現在のシステム出力',
audioOutputDeviceListError: '音声デバイス一覧を読み込めませんでした。',
audioOutputDeviceNotInCurrentList: '現在の一覧にありません',
audioOutputDeviceRememberEq: 'デバイスごとにイコライザーを記憶',
audioOutputDeviceRememberEqDesc: 'オーディオ出力デバイスごとにイコライザー設定を保存し、デバイスを切り替えたときに自動的に復元します。プロファイルが切り替わるのはここでデバイスを選んだときだけで、システム既定の出力は1つの共有プロファイルを使います。',
hiResTitle: 'ネイティブ Hi-Res 再生',
hiResEnabled: 'ネイティブ Hi-Res 再生を有効化',
hiResDesc: 'すべてを 44.1 kHz にリサンプリングせず、各トラックを元のサンプルレートで再生し、ファイルに合わせて音声デバイスを切り替えます (88.2 kHz 以上)。高サンプルレートをハードウェアとネットワークが安定して扱える場合のみ有効にしてください。',
+2
View File
@@ -210,6 +210,8 @@ export const settings = {
audioOutputDeviceOsDefaultNow: 'gjeldende systemutgang',
audioOutputDeviceListError: 'Kunne ikke laste listen over lydenheter.',
audioOutputDeviceNotInCurrentList: 'ikke i gjeldende liste',
audioOutputDeviceRememberEq: 'Husk EQ per enhet',
audioOutputDeviceRememberEqDesc: 'Lagrer equalizer-innstillingene for hver lydutgangsenhet og gjenoppretter dem automatisk når du bytter enhet. Profiler bytter bare når du velger en enhet her; systemstandarden bruker én delt profil.',
hiResTitle: 'Innebygd hi-res-avspilling',
hiResEnabled: 'Aktiver innebygd hi-res-avspilling',
hiResDesc: "Spiller hvert spor med sin opprinnelige samplingsrate i stedet for å nedsample alt til 44,1 kHz, og stiller utdataenheten etter filen (88,2 kHz og høyere). Aktiver kun hvis maskinvare og nettverk håndterer høye samplingsrater pålitelig.",
+2
View File
@@ -209,6 +209,8 @@ export const settings = {
audioOutputDeviceOsDefaultNow: 'huidige systeemuitvoer',
audioOutputDeviceListError: 'De lijst met audio-apparaten kon niet worden geladen.',
audioOutputDeviceNotInCurrentList: 'staat niet in de huidige lijst',
audioOutputDeviceRememberEq: 'EQ per apparaat onthouden',
audioOutputDeviceRememberEqDesc: 'Slaat de equalizer-instellingen op voor elk audio-uitvoerapparaat en herstelt ze automatisch wanneer je van apparaat wisselt. Profielen wisselen alleen als je hier een apparaat kiest; de systeemstandaard gebruikt één gedeeld profiel.',
hiResTitle: 'Natieve hi-res-weergave',
hiResEnabled: 'Natieve hi-res-weergave inschakelen',
hiResDesc: "Speelt elke track af op zijn oorspronkelijke samplerate in plaats van alles te herbemonsteren naar 44,1 kHz, en stelt het uitvoerapparaat af op het bestand (88,2 kHz en hoger). Alleen inschakelen als hardware en netwerk hoge samplerates betrouwbaar aankunnen.",
+2
View File
@@ -212,6 +212,8 @@ export const settings = {
audioOutputDeviceOsDefaultNow: 'ieșirea curentă a sistemului',
audioOutputDeviceListError: 'Nu s-a putut încărca lista dispozitivelor audio',
audioOutputDeviceNotInCurrentList: 'nu există în lista curentă',
audioOutputDeviceRememberEq: 'Reține egalizatorul per dispozitiv',
audioOutputDeviceRememberEqDesc: 'Salvează setările egalizatorului pentru fiecare dispozitiv de ieșire audio și le restaurează automat la schimbarea dispozitivului. Profilurile se schimbă doar când alegi un dispozitiv aici; ieșirea implicită a sistemului folosește un singur profil comun.',
hiResTitle: 'Playback Hi-Res nativ',
hiResEnabled: 'Pornește playback-ul hi-res nativ',
hiResDesc: "Redă fiecare piesă la rata sa de eșantionare originală în loc să reeșantioneze totul la 44.1 kHz, comutând dispozitivul de ieșire pentru a se potrivi cu fișierul (88.2 kHz și peste). Pornește doar dacă hardware-ul și rețeaua gestionează fiabil rate mari de eșantionare.",
+2
View File
@@ -213,6 +213,8 @@ export const settings = {
audioOutputDeviceOsDefaultNow: 'текущий системный вывод',
audioOutputDeviceListError: 'Не удалось загрузить список аудиоустройств.',
audioOutputDeviceNotInCurrentList: 'нет в текущем списке',
audioOutputDeviceRememberEq: 'Запоминать эквалайзер для каждого устройства',
audioOutputDeviceRememberEqDesc: 'Сохраняет настройки эквалайзера для каждого устройства вывода звука и восстанавливает их автоматически при переключении устройств. Профиль меняется только когда вы выбираете устройство здесь; системный вывод по умолчанию использует один общий профиль.',
hiResTitle: 'Нативное воспроизведение Hi-Res',
hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
hiResDesc: "Воспроизводит каждый трек с его исходной частотой дискретизации вместо передискретизации всего в 44,1 кГц, переключая устройство вывода под частоту файла (88,2 кГц и выше). Включайте только если оборудование и сеть надёжно справляются с высокими частотами.",
+2
View File
@@ -209,6 +209,8 @@ export const settings = {
audioOutputDeviceOsDefaultNow: '当前系统输出',
audioOutputDeviceListError: '无法加载音频设备列表。',
audioOutputDeviceNotInCurrentList: '不在当前列表中',
audioOutputDeviceRememberEq: '为每个设备记住均衡器',
audioOutputDeviceRememberEqDesc: '为每个音频输出设备保存均衡器设置,并在切换设备时自动恢复。仅当你在此处选择设备时配置才会切换;系统默认输出共用一个配置。',
hiResTitle: '原生高清晰度播放',
hiResEnabled: '启用原生高清晰度播放',
hiResDesc: "以每个曲目的原始采样率播放,而不是将所有内容重采样到 44.1 kHz,并将输出设备切换为与文件匹配(88.2 kHz 及以上)。仅在硬件和网络能可靠处理高采样率时启用。",
@@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn(async () => null as unknown) }));
vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock }));
import { useEqStore, type EqSnapshot } from '../eqStore';
import { useAuthStore } from '../authStore';
import { setupEqDeviceSync } from './eqDeviceSync';
const FLAT = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
function resetEq(over: Partial<{
gains: number[];
enabled: boolean;
preGain: number;
activePreset: string | null;
rememberPerDevice: boolean;
byDevice: Record<string, EqSnapshot>;
}> = {}): void {
useEqStore.setState({
gains: [...FLAT],
enabled: false,
preGain: 0,
activePreset: 'Flat',
customPresets: [],
rememberPerDevice: false,
byDevice: {},
...over,
});
}
function snap(gain0: number, over: Partial<EqSnapshot> = {}): EqSnapshot {
return { gains: [gain0, 0, 0, 0, 0, 0, 0, 0, 0, 0], enabled: false, preGain: 0, activePreset: null, ...over };
}
describe('eqDeviceSync', () => {
let cleanup: () => void = () => {};
beforeEach(() => {
invokeMock.mockClear();
resetEq();
useAuthStore.getState().setAudioOutputDevice(null);
});
afterEach(() => {
cleanup();
cleanup = () => {};
});
it('mirrors live EQ edits into the current device snapshot when enabled', () => {
useAuthStore.getState().setAudioOutputDevice('Speakers');
resetEq({ rememberPerDevice: true });
cleanup = setupEqDeviceSync();
useEqStore.getState().setBandGain(0, 4);
expect(useEqStore.getState().byDevice['Speakers'].gains[0]).toBe(4);
});
it('does not mirror edits when the feature is off', () => {
useAuthStore.getState().setAudioOutputDevice('Speakers');
resetEq({ rememberPerDevice: false });
cleanup = setupEqDeviceSync();
useEqStore.getState().setBandGain(0, 4);
expect(useEqStore.getState().byDevice).toEqual({});
});
it('seeds the current device snapshot when the feature is switched on', () => {
useAuthStore.getState().setAudioOutputDevice('Speakers');
resetEq({ gains: [2, 0, 0, 0, 0, 0, 0, 0, 0, 0] });
cleanup = setupEqDeviceSync();
useEqStore.getState().setRememberPerDevice(true);
expect(useEqStore.getState().byDevice['Speakers']?.gains[0]).toBe(2);
});
it('saves the old device and restores the new device on switch', () => {
useAuthStore.getState().setAudioOutputDevice('A');
resetEq({ rememberPerDevice: true, byDevice: { B: snap(7, { enabled: true, preGain: 1 }) } });
cleanup = setupEqDeviceSync();
// Edit on A is mirrored into A's snapshot.
useEqStore.getState().setBandGain(0, 3);
expect(useEqStore.getState().byDevice['A'].gains[0]).toBe(3);
// Switching to B restores B's saved profile to the live EQ.
useAuthStore.getState().setAudioOutputDevice('B');
expect(useEqStore.getState().gains[0]).toBe(7);
expect(useEqStore.getState().enabled).toBe(true);
// A's saved snapshot is preserved (not overwritten by applying B).
expect(useEqStore.getState().byDevice['A'].gains[0]).toBe(3);
expect(useEqStore.getState().byDevice['B'].gains[0]).toBe(7);
});
it('keeps the current EQ when the new device has no saved snapshot', () => {
useAuthStore.getState().setAudioOutputDevice('A');
resetEq({ rememberPerDevice: true, gains: [5, 0, 0, 0, 0, 0, 0, 0, 0, 0] });
cleanup = setupEqDeviceSync();
useAuthStore.getState().setAudioOutputDevice('NoProfile');
expect(useEqStore.getState().gains[0]).toBe(5);
});
it('applies the saved snapshot for the current device on startup', () => {
useAuthStore.getState().setAudioOutputDevice('A');
resetEq({ rememberPerDevice: true, byDevice: { A: snap(9, { enabled: true, preGain: 2, activePreset: 'X' }) } });
cleanup = setupEqDeviceSync();
const s = useEqStore.getState();
expect(s.gains[0]).toBe(9);
expect(s.enabled).toBe(true);
expect(s.activePreset).toBe('X');
});
// Frank's exact scenario: Jazz on device 1, Rock on device 2 (neither
// pre-seeded), then back to device 1 — must restore Jazz, not Rock.
it('preset on dev1, preset on dev2, back to dev1 restores dev1 preset', () => {
useAuthStore.getState().setAudioOutputDevice('Device1');
resetEq({ rememberPerDevice: true });
cleanup = setupEqDeviceSync();
useEqStore.getState().applyPreset('Jazz');
expect(useEqStore.getState().activePreset).toBe('Jazz');
useAuthStore.getState().setAudioOutputDevice('Device2');
useEqStore.getState().applyPreset('Rock');
expect(useEqStore.getState().activePreset).toBe('Rock');
useAuthStore.getState().setAudioOutputDevice('Device1');
expect(useEqStore.getState().activePreset).toBe('Jazz');
});
it('mirrors the system default (null device) into the __default__ bucket', () => {
useAuthStore.getState().setAudioOutputDevice(null);
resetEq({ rememberPerDevice: true });
cleanup = setupEqDeviceSync();
useEqStore.getState().setBandGain(0, 4);
expect(useEqStore.getState().byDevice['__default__'].gains[0]).toBe(4);
});
it('restores the __default__ profile when the device resets to null (unplug / audio:device-reset)', () => {
// The audio:device-reset event sets audioOutputDevice = null; the sync
// reacts to that store change like any other device switch.
useAuthStore.getState().setAudioOutputDevice('A');
resetEq({ rememberPerDevice: true, byDevice: { __default__: snap(8, { enabled: true }) } });
cleanup = setupEqDeviceSync();
useAuthStore.getState().setAudioOutputDevice(null);
expect(useEqStore.getState().gains[0]).toBe(8);
expect(useEqStore.getState().enabled).toBe(true);
});
it('cleanup stops mirroring further edits', () => {
useAuthStore.getState().setAudioOutputDevice('A');
resetEq({ rememberPerDevice: true });
cleanup = setupEqDeviceSync();
cleanup();
useEqStore.getState().setBandGain(0, 6);
expect(useEqStore.getState().byDevice['A']).toBeUndefined();
});
});
@@ -0,0 +1,90 @@
import { useAuthStore } from '../authStore';
import { useEqStore, type EqSnapshot } from '../eqStore';
/** Key used when no specific device is selected (system default). */
const DEFAULT_DEVICE_KEY = '__default__';
function deviceKey(name: string | null): string {
return name ?? DEFAULT_DEVICE_KEY;
}
// The device key currently in effect. Updated on every device change.
let currentKey = DEFAULT_DEVICE_KEY;
// Suppress the mirror subscription while we programmatically apply a saved
// snapshot (on a device switch or at startup), so applying a profile does not
// immediately write it straight back.
let applying = false;
function applySnapshot(snap: EqSnapshot): void {
applying = true;
try {
useEqStore.getState().applySnapshot(snap);
} finally {
applying = false;
}
}
/**
* Per-device EQ memory. Opt-in via `eqStore.rememberPerDevice` (default off);
* while off, every branch below returns early so behaviour is unchanged.
*
* Keeps the equalizer profile (bands, enabled, pre-gain, active preset) for
* each audio output device and restores it automatically when the device
* changes. Device identity is the canonical device-name string already held in
* `authStore.audioOutputDevice` (null = system default `__default__`) the
* same key the device-selection feature relies on. The audio backend exposes no
* stable device UUID, so this deliberately inherits that feature's identity
* model rather than inventing a weaker one.
*
* Returns a cleanup that removes both subscriptions (StrictMode-safe via
* `initAudioListeners`).
*/
export function setupEqDeviceSync(): () => void {
currentKey = deviceKey(useAuthStore.getState().audioOutputDevice);
// Startup: restore the saved profile for the current device, if any. A no-op
// in the common case (the persisted global EQ already equals this device's
// mirrored snapshot); it only matters when the resolved device differs from
// the one active at shutdown.
const eqAtStart = useEqStore.getState();
if (eqAtStart.rememberPerDevice) {
const snap = eqAtStart.byDevice[currentKey];
if (snap) applySnapshot(snap);
}
// Sub 1 — device changed. Covers both the explicit picker selection and the
// `audio:device-reset` unplug event, since both flow through this field.
const unsubDevice = useAuthStore.subscribe((state, prev) => {
if (state.audioOutputDevice === prev.audioOutputDevice) return;
currentKey = deviceKey(state.audioOutputDevice);
const eq = useEqStore.getState();
if (!eq.rememberPerDevice) return;
const snap = eq.byDevice[currentKey];
if (snap) applySnapshot(snap);
// No saved profile for this device → keep the current EQ as-is; the next
// edit mirrors it under this device's key.
});
// Sub 2 — mirror live EQ edits into the current device's snapshot, and seed
// the current device when the feature is switched on. Writing `byDevice` does
// not touch the content fields, so the re-triggered listener is a no-op (no
// feedback loop).
const unsubEq = useEqStore.subscribe((state, prev) => {
if (applying) return;
if (!state.rememberPerDevice) return;
const justEnabled = !prev.rememberPerDevice;
const contentChanged =
state.gains !== prev.gains ||
state.enabled !== prev.enabled ||
state.preGain !== prev.preGain ||
state.activePreset !== prev.activePreset;
if (justEnabled || contentChanged) {
useEqStore.getState().saveSnapshotFor(currentKey);
}
});
return () => {
unsubDevice();
unsubEq();
};
}
+87
View File
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn(async () => null as unknown) }));
vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock }));
import { useEqStore } from './eqStore';
const FLAT = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
function resetEq(): void {
useEqStore.setState({
gains: [...FLAT],
enabled: false,
preGain: 0,
activePreset: 'Flat',
customPresets: [],
rememberPerDevice: false,
byDevice: {},
});
}
describe('eqStore per-device snapshots', () => {
beforeEach(() => {
invokeMock.mockClear();
resetEq();
});
it('defaults rememberPerDevice off and byDevice empty', () => {
const s = useEqStore.getState();
expect(s.rememberPerDevice).toBe(false);
expect(s.byDevice).toEqual({});
});
it('saveSnapshotFor captures the current live EQ under the given key', () => {
useEqStore.setState({ gains: [3, 0, 0, 0, 0, 0, 0, 0, 0, 0], enabled: true, preGain: -2, activePreset: null });
useEqStore.getState().saveSnapshotFor('dev-a');
expect(useEqStore.getState().byDevice['dev-a']).toEqual({
gains: [3, 0, 0, 0, 0, 0, 0, 0, 0, 0],
enabled: true,
preGain: -2,
activePreset: null,
});
});
it('saveSnapshotFor stores an independent copy of the gains', () => {
useEqStore.getState().saveSnapshotFor('dev-a');
useEqStore.getState().setBandGain(0, 5);
expect(useEqStore.getState().byDevice['dev-a'].gains[0]).toBe(0);
});
it('applySnapshot sets the live EQ and pushes it to Rust', () => {
useEqStore.getState().applySnapshot({
gains: [6, 0, 0, 0, 0, 0, 0, 0, 0, 0],
enabled: true,
preGain: 3,
activePreset: 'Custom',
});
const s = useEqStore.getState();
expect(s.gains[0]).toBe(6);
expect(s.enabled).toBe(true);
expect(s.preGain).toBe(3);
expect(s.activePreset).toBe('Custom');
expect(invokeMock).toHaveBeenCalledWith('audio_set_eq', {
gains: [6, 0, 0, 0, 0, 0, 0, 0, 0, 0],
enabled: true,
preGain: 3,
});
});
it('applySnapshot clamps out-of-range values', () => {
useEqStore.getState().applySnapshot({
gains: [99, -99, 0, 0, 0, 0, 0, 0, 0, 0],
enabled: false,
preGain: 50,
activePreset: null,
});
const s = useEqStore.getState();
expect(s.gains[0]).toBe(12);
expect(s.gains[1]).toBe(-12);
expect(s.preGain).toBe(6);
});
it('setRememberPerDevice toggles the flag', () => {
useEqStore.getState().setRememberPerDevice(true);
expect(useEqStore.getState().rememberPerDevice).toBe(true);
});
});
+39
View File
@@ -21,6 +21,14 @@ export interface EqPreset {
builtin: boolean;
}
/** The per-device-storable slice of the EQ (everything except the global preset list). */
export interface EqSnapshot {
gains: number[];
enabled: boolean;
preGain: number;
activePreset: string | null;
}
export const BUILTIN_PRESETS: EqPreset[] = [
{ name: 'Flat', builtin: true, gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] },
{ name: 'Bass Boost', builtin: true, gains: [6, 5, 4, 2, 0, 0, 0, 0, 0, 0] },
@@ -41,6 +49,11 @@ interface EqState {
activePreset: string | null;
customPresets: EqPreset[];
/** Opt-in: remember the EQ profile per audio output device (default off). */
rememberPerDevice: boolean;
/** Saved EQ snapshot per device key (canonical device name; '__default__' = system default). */
byDevice: Record<string, EqSnapshot>;
setBandGain: (index: number, gain: number) => void;
setEnabled: (v: boolean) => void;
setPreGain: (v: number) => void;
@@ -49,6 +62,12 @@ interface EqState {
saveCustomPreset: (name: string) => void;
deleteCustomPreset: (name: string) => void;
syncToRust: () => void;
setRememberPerDevice: (v: boolean) => void;
/** Apply a saved snapshot to the live EQ (clamped) and push it to Rust. */
applySnapshot: (snap: EqSnapshot) => void;
/** Capture the current live EQ into `byDevice[deviceKey]`. */
saveSnapshotFor: (deviceKey: string) => void;
}
function syncEq(gains: number[], enabled: boolean, preGain: number) {
@@ -63,6 +82,8 @@ export const useEqStore = create<EqState>()(
preGain: 0,
activePreset: 'Flat',
customPresets: [],
rememberPerDevice: false,
byDevice: {},
setBandGain: (index, gain) => {
const clamped = Math.max(-12, Math.min(12, gain));
@@ -115,6 +136,22 @@ export const useEqStore = create<EqState>()(
const { gains, enabled, preGain } = get();
syncEq(gains, enabled, preGain);
},
setRememberPerDevice: (v) => set({ rememberPerDevice: v }),
applySnapshot: (snap) => {
const gains = snap.gains.map(g => Math.max(-12, Math.min(12, g)));
const preGain = Math.max(-30, Math.min(6, snap.preGain));
set({ gains, enabled: snap.enabled, preGain, activePreset: snap.activePreset });
syncEq(gains, snap.enabled, preGain);
},
saveSnapshotFor: (deviceKey) => {
const { gains, enabled, preGain, activePreset } = get();
set(s => ({
byDevice: { ...s.byDevice, [deviceKey]: { gains: [...gains], enabled, preGain, activePreset } },
}));
},
}),
{
name: 'psysonic-eq',
@@ -125,6 +162,8 @@ export const useEqStore = create<EqState>()(
preGain: s.preGain,
activePreset: s.activePreset,
customPresets: s.customPresets,
rememberPerDevice: s.rememberPerDevice,
byDevice: s.byDevice,
}),
}
)
+3
View File
@@ -4,6 +4,7 @@ import { setupAuthSync } from './audioListenerSetup/authSyncListener';
import { setupMprisSync } from './audioListenerSetup/mprisSync';
import { setupRadioMprisMetadata } from './audioListenerSetup/radioMprisMetadata';
import { setupDiscordPresence } from './audioListenerSetup/discordPresence';
import { setupEqDeviceSync } from './audioListenerSetup/eqDeviceSync';
/**
* Set up Tauri event listeners for the Rust audio engine.
@@ -21,6 +22,7 @@ export function initAudioListeners(): () => void {
const stopMprisSync = setupMprisSync();
const stopRadioMprisMetadata = setupRadioMprisMetadata();
const stopDiscordPresence = setupDiscordPresence();
const stopEqDeviceSync = setupEqDeviceSync();
return () => {
stopAuthSync();
@@ -28,5 +30,6 @@ export function initAudioListeners(): () => void {
stopDiscordPresence();
stopEngineListeners();
stopRadioMprisMetadata();
stopEqDeviceSync();
};
}