mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
@@ -104,6 +104,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## Changed
|
||||
|
||||
### Equalizer — per-device profiles follow the active system default
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1274](https://github.com/Psychotoxical/psysonic/pull/1274)**, suggested by [@JustBuddy](https://github.com/JustBuddy)
|
||||
|
||||
* With **Remember EQ per device** enabled and **System Default** selected, the equalizer now keys profiles to the active OS default output and switches when that default changes externally (Windows sound settings, Stream Deck, etc.), instead of using one shared profile for all system-default outputs.
|
||||
* On Linux/PipeWire, the active default is resolved from WirePlumber (`wpctl`) first — including Hyprpanel, pavucontrol, and `wpctl set-default` — not cpal, which can keep a stale card name even after the default sink changes. When PipeWire has already moved the playback stream to the new default, the device watcher skips a redundant stream reopen (avoids a post-switch stutter).
|
||||
|
||||
### Frontend restructure — feature-folder architecture and hardening
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), with additional architecture by [@cucadmuh](https://github.com/cucadmuh), PR [#1225](https://github.com/Psychotoxical/psysonic/pull/1225)**
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ Within each section, order by **user impact** (most noticeable first) — not PR
|
||||
|
||||
### 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.
|
||||
- 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, including when **System Default** is selected and the OS default output changes outside the app. Off by default.
|
||||
|
||||
### Orbit — everyone hears transitions the host chose
|
||||
|
||||
|
||||
@@ -39,6 +39,365 @@ pub(crate) fn enumerate_output_device_names() -> Vec<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// cpal/rodio aliases for "follow the OS default" — not a stable per-device key.
|
||||
pub(crate) fn is_generic_default_output_alias(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"default"
|
||||
| "Default Audio Device"
|
||||
| "PipeWire Sound Server"
|
||||
| "Default ALSA Output (currently PipeWire Media Server)"
|
||||
)
|
||||
}
|
||||
|
||||
fn raw_cpal_default_output_device_name() -> Option<String> {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
with_suppressed_alsa_stderr(|| {
|
||||
let host = rodio::cpal::default_host();
|
||||
host.default_output_device()
|
||||
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
fn pick_listed_device_name(candidate: &str, list: &[String]) -> Option<String> {
|
||||
list.iter()
|
||||
.find(|d| d.as_str() == candidate || output_devices_logically_same(d, candidate))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn equivalent_list_entries(name: &str, list: &[String]) -> Vec<String> {
|
||||
let mut out: Vec<String> = list
|
||||
.iter()
|
||||
.filter(|d| d.as_str() == name || output_devices_logically_same(d, name))
|
||||
.cloned()
|
||||
.collect();
|
||||
if let Some(picked) = pick_listed_device_name(name, list) {
|
||||
if !out.iter().any(|d| d == &picked) {
|
||||
out.push(picked);
|
||||
}
|
||||
}
|
||||
if out.is_empty() && !name.is_empty() {
|
||||
out.push(name.to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True when two device keys refer to the same sink (exact, ALSA logical, or via list canon).
|
||||
pub(crate) fn output_device_keys_equivalent(a: &str, b: &str, list: &[String]) -> bool {
|
||||
if a == b || output_devices_logically_same(a, b) {
|
||||
return true;
|
||||
}
|
||||
if comma_and_alsa_device_equivalent(a, b) {
|
||||
return true;
|
||||
}
|
||||
let ea = equivalent_list_entries(a, list);
|
||||
let eb = equivalent_list_entries(b, list);
|
||||
ea.iter()
|
||||
.any(|x| eb.iter().any(|y| x == y || output_devices_logically_same(x, y)))
|
||||
}
|
||||
|
||||
/// Match wpctl/cpal `"CARD, PCM"` labels to ALSA `iface:CARD=…` picker ids.
|
||||
fn comma_and_alsa_device_equivalent(a: &str, b: &str) -> bool {
|
||||
let (comma, alsa) = if linux_alsa_sink_fingerprint(a).is_some() {
|
||||
(b, a)
|
||||
} else if linux_alsa_sink_fingerprint(b).is_some() {
|
||||
(a, b)
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
if comma.contains(':') {
|
||||
return false;
|
||||
}
|
||||
let mut parts = comma.splitn(2, ',');
|
||||
let Some(comma_card) = parts.next() else {
|
||||
return false;
|
||||
};
|
||||
let comma_card = comma_card.trim();
|
||||
let comma_pcm = parts.next().map(|s| s.trim()).unwrap_or("");
|
||||
if comma_pcm.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Some((_, alsa_card, _)) = linux_alsa_sink_fingerprint(alsa) else {
|
||||
return false;
|
||||
};
|
||||
let pcm = comma_pcm.to_ascii_lowercase();
|
||||
let alsa_lower = alsa.to_ascii_lowercase();
|
||||
let cc = comma_card.to_ascii_lowercase();
|
||||
let ac = alsa_card.to_ascii_lowercase();
|
||||
let card_ok = cc.contains(&ac) || ac.contains(&cc);
|
||||
if !card_ok {
|
||||
return false;
|
||||
}
|
||||
if alsa_lower.starts_with("hdmi:") {
|
||||
return !pcm.contains("analog");
|
||||
}
|
||||
if pcm.contains("analog") {
|
||||
return alsa_lower.starts_with("hw:") || alsa_lower.starts_with("plughw:");
|
||||
}
|
||||
alsa_lower.contains(&pcm) || pcm.contains(&alsa_lower)
|
||||
}
|
||||
|
||||
/// Build the cpal-style `"CARD, PCM name"` label PipeWire exposes for ALSA sinks.
|
||||
pub(crate) fn cpal_name_from_pipewire_alsa(card: &str, alsa_name: &str) -> String {
|
||||
format!("{card}, {alsa_name}")
|
||||
}
|
||||
|
||||
/// Read `node.driver-id` from `wpctl inspect` output (PipeWire stream → sink link).
|
||||
pub(crate) fn parse_wpctl_inspect_driver_id(inspect: &str) -> Option<u32> {
|
||||
for line in inspect.lines() {
|
||||
let line = line.trim().trim_start_matches('*').trim();
|
||||
if let Some(v) = line.strip_prefix("node.driver-id = ") {
|
||||
return v.trim_matches('"').parse().ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Collect PipeWire ALSA `[psysonic]` stream node ids that have at least one
|
||||
/// active playback link in `wpctl status` (ignores stale / idle nodes).
|
||||
pub(crate) fn parse_wpctl_status_psysonic_stream_ids(status: &str) -> Vec<u32> {
|
||||
let mut in_audio_streams = false;
|
||||
let mut ids = Vec::new();
|
||||
let mut current_id: Option<u32> = None;
|
||||
for line in status.lines() {
|
||||
if line.contains("Streams:") && line.contains('─') {
|
||||
in_audio_streams = true;
|
||||
continue;
|
||||
}
|
||||
if !in_audio_streams {
|
||||
continue;
|
||||
}
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("Video") || trimmed.starts_with("Settings") {
|
||||
break;
|
||||
}
|
||||
if trimmed.contains("PipeWire ALSA [psysonic]") && !trimmed.contains("(deleted)") {
|
||||
current_id = trimmed
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|s| s.trim().parse().ok());
|
||||
continue;
|
||||
}
|
||||
if trimmed.contains('>')
|
||||
&& (trimmed.contains("[active]") || trimmed.contains("[init]"))
|
||||
{
|
||||
if let Some(id) = current_id {
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
} else if trimmed.contains('.') {
|
||||
let prefix = trimmed.split('.').next().unwrap_or("").trim();
|
||||
if prefix.chars().all(|c| c.is_ascii_digit()) && !trimmed.contains('>') {
|
||||
current_id = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_wpctl_inspect_driver_id(node_id: u32) -> Option<u32> {
|
||||
use std::process::Command;
|
||||
let inspect = Command::new("wpctl")
|
||||
.args(["inspect", &node_id.to_string()])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())?;
|
||||
parse_wpctl_inspect_driver_id(&inspect)
|
||||
}
|
||||
|
||||
/// True when a live psysonic PipeWire stream is already routed to the default sink.
|
||||
/// Hyprpanel / WirePlumber often migrate streams on `set-default` before our poll
|
||||
/// sees the change — reopening CPAL in that case only causes an audible glitch.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn linux_psysonic_stream_routes_to_default_sink() -> bool {
|
||||
use std::process::Command;
|
||||
let Some(default_id) = linux_wpctl_default_sink_id() else {
|
||||
return false;
|
||||
};
|
||||
let Some(status) = Command::new("wpctl")
|
||||
.args(["status"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let stream_ids = parse_wpctl_status_psysonic_stream_ids(&status);
|
||||
stream_ids.iter().any(|&id| linux_wpctl_inspect_driver_id(id) == Some(default_id))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(crate) fn linux_psysonic_stream_routes_to_default_sink() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Parse `wpctl list audio sinks` and return the id of the default sink (trailing `*`).
|
||||
pub(crate) fn parse_wpctl_list_default_sink_id(listing: &str) -> Option<u32> {
|
||||
for line in listing.lines() {
|
||||
let line = line.trim_end();
|
||||
if !line.ends_with('*') {
|
||||
continue;
|
||||
}
|
||||
let id_str = line.split('\t').next()?.trim();
|
||||
return id_str.parse().ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse `wpctl status` and return the id of the default sink (line marked with `*`).
|
||||
pub(crate) fn parse_wpctl_default_sink_id(status: &str) -> Option<u32> {
|
||||
let mut in_sinks = false;
|
||||
for line in status.lines() {
|
||||
if line.contains("Sinks:") {
|
||||
in_sinks = true;
|
||||
continue;
|
||||
}
|
||||
if !in_sinks {
|
||||
continue;
|
||||
}
|
||||
if line.contains("Sources:") {
|
||||
break;
|
||||
}
|
||||
if !line.contains('*') {
|
||||
continue;
|
||||
}
|
||||
let after_star = line.split('*').nth(1)?.trim();
|
||||
let id_str = after_star.split('.').next()?.trim();
|
||||
return id_str.parse().ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read `api.alsa.card.name` + `alsa.name` from `wpctl inspect` output.
|
||||
pub(crate) fn parse_wpctl_inspect_alsa_names(inspect: &str) -> Option<(String, String)> {
|
||||
let mut card: Option<String> = None;
|
||||
let mut pcm: Option<String> = None;
|
||||
for line in inspect.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(v) = line.strip_prefix("api.alsa.card.name = ") {
|
||||
card = Some(v.trim_matches('"').to_string());
|
||||
} else if card.is_none() {
|
||||
if let Some(v) = line.strip_prefix("alsa.card_name = ") {
|
||||
card = Some(v.trim_matches('"').to_string());
|
||||
}
|
||||
}
|
||||
if let Some(v) = line.strip_prefix("alsa.name = ") {
|
||||
pcm = Some(v.trim_matches('"').to_string());
|
||||
}
|
||||
}
|
||||
match (card, pcm) {
|
||||
(Some(c), Some(n)) if !c.is_empty() && !n.is_empty() => Some((c, n)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_wpctl_default_sink_id() -> Option<u32> {
|
||||
use std::process::Command;
|
||||
let listing = Command::new("wpctl")
|
||||
.args(["list", "audio", "sinks"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned());
|
||||
if let Some(ref text) = listing {
|
||||
if let Some(id) = parse_wpctl_list_default_sink_id(text) {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
let status = Command::new("wpctl")
|
||||
.args(["status"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())?;
|
||||
parse_wpctl_default_sink_id(&status)
|
||||
}
|
||||
|
||||
/// Read `node.description` from `wpctl inspect` (Bluetooth and other non-ALSA sinks).
|
||||
pub(crate) fn parse_wpctl_inspect_node_description(inspect: &str) -> Option<String> {
|
||||
for line in inspect.lines() {
|
||||
let line = line.trim().trim_start_matches('*').trim();
|
||||
if let Some(v) = line.strip_prefix("node.description = ") {
|
||||
let desc = v.trim_matches('"').to_string();
|
||||
if !desc.is_empty() {
|
||||
return Some(desc);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_resolve_default_via_pipewire(list: &[String]) -> Option<String> {
|
||||
use std::process::Command;
|
||||
let sink_id = linux_wpctl_default_sink_id()?;
|
||||
let inspect = Command::new("wpctl")
|
||||
.args(["inspect", &sink_id.to_string()])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())?;
|
||||
let candidate = if let Some((card, pcm)) = parse_wpctl_inspect_alsa_names(&inspect) {
|
||||
cpal_name_from_pipewire_alsa(&card, &pcm)
|
||||
} else {
|
||||
parse_wpctl_inspect_node_description(&inspect)?
|
||||
};
|
||||
pick_listed_device_name(&candidate, list).or(Some(candidate))
|
||||
}
|
||||
|
||||
/// Resolve the active default output to a device key that matches `audio_list_devices`
|
||||
/// when possible. On Linux/PipeWire, cpal's default is often a generic alias or a
|
||||
/// stale card name that does not track WirePlumber default changes (Hyprpanel,
|
||||
/// pavucontrol, `wpctl set-default`, etc.) — prefer `wpctl` when available.
|
||||
pub fn effective_default_output_device_name() -> Option<String> {
|
||||
resolve_effective_default_output_device_name(true)
|
||||
}
|
||||
|
||||
/// Same as [`effective_default_output_device_name`] but skips the full
|
||||
/// `output_devices()` scan — for the device-watcher poll path (#996).
|
||||
pub(crate) fn effective_default_output_device_name_for_poll() -> Option<String> {
|
||||
resolve_effective_default_output_device_name(false)
|
||||
}
|
||||
|
||||
fn resolve_effective_default_output_device_name(enumerate_devices: bool) -> Option<String> {
|
||||
let list = if enumerate_devices {
|
||||
enumerate_output_device_names()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(resolved) = linux_resolve_default_via_pipewire(&list) {
|
||||
return Some(resolved);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if !enumerate_devices {
|
||||
// wpctl unavailable — last-resort cpal (skip generic/stale placeholder names).
|
||||
if linux_wpctl_default_sink_id().is_none() {
|
||||
if let Some(raw) = raw_cpal_default_output_device_name() {
|
||||
if !is_generic_default_output_alias(&raw) {
|
||||
return Some(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let raw = raw_cpal_default_output_device_name();
|
||||
if let Some(ref name) = raw {
|
||||
if !is_generic_default_output_alias(name) {
|
||||
if enumerate_devices {
|
||||
return pick_listed_device_name(name, &list).or_else(|| Some(name.clone()));
|
||||
}
|
||||
return Some(name.clone());
|
||||
}
|
||||
}
|
||||
raw
|
||||
}
|
||||
|
||||
/// Linux ALSA-style cpal names: same physical sink can appear with different suffixes;
|
||||
/// busy devices are sometimes omitted from `output_devices()` while playback works.
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -188,4 +547,154 @@ mod tests {
|
||||
assert!(linux_alsa_sink_fingerprint("hdmi:CARD=X,DEV=0").is_none());
|
||||
assert!(linux_alsa_sink_fingerprint("anything").is_none());
|
||||
}
|
||||
|
||||
// ── generic default alias / PipeWire wpctl parsing ────────────────────────
|
||||
|
||||
#[test]
|
||||
fn generic_default_alias_detects_cpal_pipewire_placeholders() {
|
||||
assert!(is_generic_default_output_alias("Default Audio Device"));
|
||||
assert!(is_generic_default_output_alias("PipeWire Sound Server"));
|
||||
assert!(!is_generic_default_output_alias("HDA NVidia, Gigabyte M32U"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wpctl_status_psysonic_stream_ids_accepts_init_links_when_paused() {
|
||||
let status = r#"
|
||||
Audio
|
||||
└─ Streams:
|
||||
84. PipeWire ALSA [psysonic]
|
||||
90. output_FL > ALC897 Analog:playback_FL [init]
|
||||
"#;
|
||||
assert_eq!(parse_wpctl_status_psysonic_stream_ids(status), vec![84]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wpctl_status_psysonic_stream_ids_ignores_streams_without_links() {
|
||||
let status = r#"
|
||||
Audio
|
||||
└─ Streams:
|
||||
84. PipeWire ALSA [psysonic]
|
||||
87. PipeWire ALSA [psysonic]
|
||||
106. output_FL > HDMI:playback_FL [active]
|
||||
"#;
|
||||
assert_eq!(parse_wpctl_status_psysonic_stream_ids(status), vec![87]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wpctl_status_psysonic_stream_ids_finds_active_streams() {
|
||||
let status = r#"
|
||||
Audio
|
||||
└─ Streams:
|
||||
84. PipeWire ALSA [psysonic]
|
||||
90. output_FL > ALC897 Analog:playback_FL [active]
|
||||
119. PipeWire ALSA [psysonic (deleted)]
|
||||
Video
|
||||
"#;
|
||||
assert_eq!(
|
||||
parse_wpctl_status_psysonic_stream_ids(status),
|
||||
vec![84]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wpctl_inspect_driver_id_reads_node_driver() {
|
||||
let inspect = r#"
|
||||
* node.driver-id = "58"
|
||||
node.name = "alsa_playback.psysonic"
|
||||
"#;
|
||||
assert_eq!(parse_wpctl_inspect_driver_id(inspect), Some(58));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wpctl_list_default_sink_id_finds_starred_sink() {
|
||||
let listing = "56\talsa_output.pci-hdmi\taudio/sink\t\n58\talsa_output.pci-analog\taudio/sink\t*";
|
||||
assert_eq!(parse_wpctl_list_default_sink_id(listing), Some(58));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wpctl_default_sink_id_finds_starred_sink() {
|
||||
let status = r#"
|
||||
Audio
|
||||
├─ Devices:
|
||||
├─ Sinks:
|
||||
│ 56. HDMI out
|
||||
│ * 58. Analog out
|
||||
├─ Sources:
|
||||
"#;
|
||||
assert_eq!(parse_wpctl_default_sink_id(status), Some(58));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wpctl_inspect_alsa_names_reads_card_and_pcm() {
|
||||
let inspect = r#"
|
||||
api.alsa.card.name = "HD-Audio Generic"
|
||||
alsa.name = "ALC897 Analog"
|
||||
"#;
|
||||
assert_eq!(
|
||||
parse_wpctl_inspect_alsa_names(inspect),
|
||||
Some(("HD-Audio Generic".into(), "ALC897 Analog".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
cpal_name_from_pipewire_alsa("HD-Audio Generic", "ALC897 Analog"),
|
||||
"HD-Audio Generic, ALC897 Analog"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wpctl_inspect_node_description_reads_bluetooth_sink() {
|
||||
let inspect = r#"
|
||||
* node.description = "BlueZ Audio Device"
|
||||
node.name = "bluez_output.xxx"
|
||||
"#;
|
||||
assert_eq!(
|
||||
parse_wpctl_inspect_node_description(inspect),
|
||||
Some("BlueZ Audio Device".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn output_device_keys_equivalent_links_hdmi_comma_and_alsa_id() {
|
||||
assert!(output_device_keys_equivalent(
|
||||
"HDA NVidia, Gigabyte M32U",
|
||||
"hdmi:CARD=NVidia,DEV=3",
|
||||
&[],
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn output_device_keys_equivalent_distinguishes_analog_and_hdmi() {
|
||||
assert!(!output_device_keys_equivalent(
|
||||
"HD-Audio Generic, ALC897 Analog",
|
||||
"hdmi:CARD=HD-Audio Generic,DEV=3",
|
||||
&[],
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_listed_device_name_prefers_enumerated_entry() {
|
||||
let list = vec![
|
||||
"Default Audio Device".to_string(),
|
||||
"HDA NVidia, Gigabyte M32U".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
pick_listed_device_name("HDA NVidia, Gigabyte M32U", &list),
|
||||
Some("HDA NVidia, Gigabyte M32U".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_listed_device_name_matches_linux_alsa_logical_alias() {
|
||||
let list = vec!["hdmi:CARD=NVidia,DEV=3".to_string()];
|
||||
assert_eq!(
|
||||
pick_listed_device_name("hw:CARD=NVidia,DEV=3", &list),
|
||||
None,
|
||||
"different ALSA ifaces are not logically the same"
|
||||
);
|
||||
assert_eq!(
|
||||
pick_listed_device_name("hdmi:CARD=NVidia,DEV=3", &list),
|
||||
Some("hdmi:CARD=NVidia,DEV=3".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ use std::sync::atomic::Ordering;
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use super::dev_io::{
|
||||
enumerate_output_device_names, output_devices_logically_same,
|
||||
output_enumeration_includes_pinned, with_suppressed_alsa_stderr,
|
||||
enumerate_output_device_names, output_device_keys_equivalent,
|
||||
output_devices_logically_same, output_enumeration_includes_pinned,
|
||||
};
|
||||
use super::engine::AudioEngine;
|
||||
|
||||
@@ -60,12 +60,28 @@ pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<String> {
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_default_output_device_name() -> Option<String> {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
with_suppressed_alsa_stderr(|| {
|
||||
let host = rodio::cpal::default_host();
|
||||
host.default_output_device()
|
||||
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
})
|
||||
super::dev_io::effective_default_output_device_name()
|
||||
}
|
||||
|
||||
/// Lightweight default query for EQ poll — skips full `output_devices()` scan (#996).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_default_output_device_name_for_poll() -> Option<String> {
|
||||
super::dev_io::effective_default_output_device_name_for_poll()
|
||||
}
|
||||
|
||||
/// Find a stored per-device EQ key that denotes the same sink as `candidate`
|
||||
/// (exact or Linux ALSA logical match).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_match_stored_output_device_key(
|
||||
candidate: String,
|
||||
stored_keys: Vec<String>,
|
||||
) -> Option<String> {
|
||||
let list = enumerate_output_device_names();
|
||||
stored_keys
|
||||
.into_iter()
|
||||
.find(|k| output_device_keys_equivalent(k, &candidate, &list))
|
||||
}
|
||||
|
||||
/// Switch the audio output device. `device_name = null` → follow system default.
|
||||
|
||||
@@ -132,10 +132,7 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut last_default: Option<String> = tauri::async_runtime::spawn_blocking(|| {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
rodio::cpal::default_host()
|
||||
.default_output_device()
|
||||
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
super::dev_io::effective_default_output_device_name_for_poll()
|
||||
}).await.unwrap_or(None);
|
||||
|
||||
// macOS/Windows: consecutive polls where a pinned device is absent from cpal's list.
|
||||
@@ -246,7 +243,6 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
|
||||
// Suppress stderr on Unix to avoid ALSA probing noise (JACK, OSS, dmix).
|
||||
let (current_default, available) = tauri::async_runtime::spawn_blocking(move || {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
#[cfg(unix)]
|
||||
let _guard = unsafe {
|
||||
struct StderrGuard(i32);
|
||||
@@ -259,17 +255,9 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
libc::close(devnull);
|
||||
StderrGuard(saved)
|
||||
};
|
||||
let host = rodio::cpal::default_host();
|
||||
let default = host
|
||||
.default_output_device()
|
||||
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()));
|
||||
let default = super::dev_io::effective_default_output_device_name_for_poll();
|
||||
let available: Vec<String> = if need_full_enum {
|
||||
host.output_devices()
|
||||
.map(|iter| {
|
||||
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
super::dev_io::enumerate_output_device_names()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -326,13 +314,54 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
continue;
|
||||
}
|
||||
|
||||
last_default = current_default.clone();
|
||||
let Some(new_name) = current_default else {
|
||||
// Transient wpctl/cpal miss — keep last known default.
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(_new_name) = current_default else { continue };
|
||||
if last_default.is_none() {
|
||||
last_default = Some(new_name.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(ref prev) = last_default {
|
||||
let prev_name = prev.clone();
|
||||
let new_name_for_eq = new_name.clone();
|
||||
let same_sink = tauri::async_runtime::spawn_blocking(move || {
|
||||
let list = super::dev_io::enumerate_output_device_names();
|
||||
super::dev_io::output_device_keys_equivalent(
|
||||
&prev_name,
|
||||
&new_name_for_eq,
|
||||
&list,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if same_sink {
|
||||
last_default = Some(new_name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
last_default = Some(new_name.clone());
|
||||
|
||||
// Debounce: give the OS time to finish configuring the new device.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let stream_on_default = tauri::async_runtime::spawn_blocking(|| {
|
||||
super::dev_io::linux_psysonic_stream_routes_to_default_sink()
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if stream_on_default {
|
||||
// PipeWire already moved playback — notify frontend (EQ sync) only.
|
||||
app.emit("audio:device-changed", Option::<f64>::None).ok();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if !reopen_output_stream(&app, None, ReopenNotify::DeviceChanged).await {
|
||||
crate::app_eprintln!("[psysonic] device-watcher: stream reopen timed out");
|
||||
}
|
||||
|
||||
@@ -167,6 +167,8 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
audio::device_commands::audio_list_devices,
|
||||
audio::device_commands::audio_canonicalize_selected_device,
|
||||
audio::device_commands::audio_default_output_device_name,
|
||||
audio::device_commands::audio_default_output_device_name_for_poll,
|
||||
audio::device_commands::audio_match_stored_output_device_key,
|
||||
audio::device_commands::audio_set_device,
|
||||
// psysonic-analysis (no Value / all ≤10 args — fully typeable)
|
||||
psysonic_analysis::commands::analysis_get_waveform,
|
||||
@@ -1000,6 +1002,8 @@ pub fn run() {
|
||||
audio::device_commands::audio_list_devices,
|
||||
audio::device_commands::audio_canonicalize_selected_device,
|
||||
audio::device_commands::audio_default_output_device_name,
|
||||
audio::device_commands::audio_default_output_device_name_for_poll,
|
||||
audio::device_commands::audio_match_stored_output_device_key,
|
||||
audio::device_commands::audio_set_device,
|
||||
audio::commands::audio_chain_preload,
|
||||
psysonic_integration::discord::discord_update_presence,
|
||||
|
||||
@@ -195,6 +195,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Library — follow getAlbum authoritatively for album artist reference so a server-side artist rename heals on resync instead of dead-ending at "Artist not found" (PR #1256)',
|
||||
'Library — prune orphaned identity keys from the multi-library dedup sidecar (library-cluster.db) on rebuild so it no longer bloats with rows for removed/renamed tracks (PR #1255)',
|
||||
'Servers — connect and run fully behind a custom-header gate (Cloudflare Access / Pangolin): native connect probe + REST proxy so browse/playback/covers pass, add-form failure reasons, live LAN/public badge on server switch, and LAN reclaim from a sticky public endpoint (PR #1273)',
|
||||
'Per-device EQ — when System Default is selected, profiles follow the active OS default output and switch on external output changes (PR #1274)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
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 { emitTauriEvent, onInvoke } from '@/test/mocks/tauri';
|
||||
import { useEqStore, type EqSnapshot } from '@/store/eqStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { setupEqDeviceSync } from '@/features/playback/store/audioListenerSetup/eqDeviceSync';
|
||||
@@ -33,13 +30,29 @@ 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 };
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
/** Wait until startup OS-default resolution has applied (when audioOutputDevice is null). */
|
||||
async function waitForOsDefaultInit(expectedGain0?: number): Promise<void> {
|
||||
await vi.waitFor(async () => {
|
||||
await flushAsync();
|
||||
if (expectedGain0 !== undefined) {
|
||||
expect(useEqStore.getState().gains[0]).toBe(expectedGain0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('eqDeviceSync', () => {
|
||||
let cleanup: () => void = () => {};
|
||||
|
||||
beforeEach(() => {
|
||||
invokeMock.mockClear();
|
||||
resetEq();
|
||||
useAuthStore.getState().setAudioOutputDevice(null);
|
||||
onInvoke('audio_default_output_device_name', () => 'Speakers');
|
||||
onInvoke('audio_default_output_device_name_for_poll', () => 'Speakers');
|
||||
onInvoke('audio_match_stored_output_device_key', () => null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -47,69 +60,74 @@ describe('eqDeviceSync', () => {
|
||||
cleanup = () => {};
|
||||
});
|
||||
|
||||
it('mirrors live EQ edits into the current device snapshot when enabled', () => {
|
||||
it('mirrors live EQ edits into the current device snapshot when enabled', async () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('Speakers');
|
||||
resetEq({ rememberPerDevice: true });
|
||||
cleanup = setupEqDeviceSync();
|
||||
await flushAsync();
|
||||
|
||||
useEqStore.getState().setBandGain(0, 4);
|
||||
|
||||
expect(useEqStore.getState().byDevice['Speakers'].gains[0]).toBe(4);
|
||||
});
|
||||
|
||||
it('does not mirror edits when the feature is off', () => {
|
||||
it('does not mirror edits when the feature is off', async () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('Speakers');
|
||||
resetEq({ rememberPerDevice: false });
|
||||
cleanup = setupEqDeviceSync();
|
||||
await flushAsync();
|
||||
|
||||
useEqStore.getState().setBandGain(0, 4);
|
||||
|
||||
expect(useEqStore.getState().byDevice).toEqual({});
|
||||
});
|
||||
|
||||
it('seeds the current device snapshot when the feature is switched on', () => {
|
||||
it('seeds the current device snapshot when the feature is switched on', async () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('Speakers');
|
||||
resetEq({ gains: [2, 0, 0, 0, 0, 0, 0, 0, 0, 0] });
|
||||
cleanup = setupEqDeviceSync();
|
||||
await flushAsync();
|
||||
|
||||
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', () => {
|
||||
it('saves the old device and restores the new device on switch', async () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('A');
|
||||
resetEq({ rememberPerDevice: true, byDevice: { B: snap(7, { enabled: true, preGain: 1 }) } });
|
||||
cleanup = setupEqDeviceSync();
|
||||
await flushAsync();
|
||||
|
||||
// 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');
|
||||
await flushAsync();
|
||||
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', () => {
|
||||
it('keeps the current EQ when the new device has no saved snapshot', async () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('A');
|
||||
resetEq({ rememberPerDevice: true, gains: [5, 0, 0, 0, 0, 0, 0, 0, 0, 0] });
|
||||
cleanup = setupEqDeviceSync();
|
||||
await flushAsync();
|
||||
|
||||
useAuthStore.getState().setAudioOutputDevice('NoProfile');
|
||||
await flushAsync();
|
||||
|
||||
expect(useEqStore.getState().gains[0]).toBe(5);
|
||||
});
|
||||
|
||||
it('applies the saved snapshot for the current device on startup', () => {
|
||||
it('applies the saved snapshot for the current device on startup', async () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('A');
|
||||
resetEq({ rememberPerDevice: true, byDevice: { A: snap(9, { enabled: true, preGain: 2, activePreset: 'X' }) } });
|
||||
cleanup = setupEqDeviceSync();
|
||||
await flushAsync();
|
||||
|
||||
const s = useEqStore.getState();
|
||||
expect(s.gains[0]).toBe(9);
|
||||
@@ -117,51 +135,156 @@ describe('eqDeviceSync', () => {
|
||||
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', () => {
|
||||
it('preset on dev1, preset on dev2, back to dev1 restores dev1 preset', async () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('Device1');
|
||||
resetEq({ rememberPerDevice: true });
|
||||
cleanup = setupEqDeviceSync();
|
||||
await flushAsync();
|
||||
|
||||
useEqStore.getState().applyPreset('Jazz');
|
||||
expect(useEqStore.getState().activePreset).toBe('Jazz');
|
||||
|
||||
useAuthStore.getState().setAudioOutputDevice('Device2');
|
||||
await flushAsync();
|
||||
useEqStore.getState().applyPreset('Rock');
|
||||
expect(useEqStore.getState().activePreset).toBe('Rock');
|
||||
|
||||
useAuthStore.getState().setAudioOutputDevice('Device1');
|
||||
await flushAsync();
|
||||
expect(useEqStore.getState().activePreset).toBe('Jazz');
|
||||
});
|
||||
|
||||
it('mirrors the system default (null device) into the __default__ bucket', () => {
|
||||
it('mirrors system default into the resolved OS default device bucket', async () => {
|
||||
onInvoke('audio_default_output_device_name', () => 'Speakers');
|
||||
useAuthStore.getState().setAudioOutputDevice(null);
|
||||
resetEq({ rememberPerDevice: true });
|
||||
cleanup = setupEqDeviceSync();
|
||||
await waitForOsDefaultInit();
|
||||
|
||||
useEqStore.getState().setBandGain(0, 4);
|
||||
|
||||
expect(useEqStore.getState().byDevice['__default__'].gains[0]).toBe(4);
|
||||
expect(useEqStore.getState().byDevice['Speakers'].gains[0]).toBe(4);
|
||||
expect(useEqStore.getState().byDevice['__default__']).toBeUndefined();
|
||||
});
|
||||
|
||||
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.
|
||||
it('restores the OS default device profile when switching back to system default', async () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('A');
|
||||
resetEq({ rememberPerDevice: true, byDevice: { __default__: snap(8, { enabled: true }) } });
|
||||
resetEq({
|
||||
rememberPerDevice: true,
|
||||
byDevice: { Speakers: snap(8, { enabled: true }) },
|
||||
});
|
||||
onInvoke('audio_default_output_device_name', () => 'Speakers');
|
||||
cleanup = setupEqDeviceSync();
|
||||
await flushAsync();
|
||||
|
||||
useAuthStore.getState().setAudioOutputDevice(null);
|
||||
|
||||
expect(useEqStore.getState().gains[0]).toBe(8);
|
||||
await waitForOsDefaultInit(8);
|
||||
expect(useEqStore.getState().enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('cleanup stops mirroring further edits', () => {
|
||||
it('falls back to the legacy Default Audio Device profile when the resolved device has no snapshot', async () => {
|
||||
onInvoke('audio_default_output_device_name', () => 'Speakers');
|
||||
useAuthStore.getState().setAudioOutputDevice(null);
|
||||
resetEq({ rememberPerDevice: true, byDevice: { 'Default Audio Device': snap(6, { enabled: true }) } });
|
||||
cleanup = setupEqDeviceSync();
|
||||
await waitForOsDefaultInit(6);
|
||||
});
|
||||
|
||||
it('falls back to the legacy __default__ profile when the resolved device has no snapshot', async () => {
|
||||
onInvoke('audio_default_output_device_name', () => 'Speakers');
|
||||
useAuthStore.getState().setAudioOutputDevice(null);
|
||||
resetEq({ rememberPerDevice: true, byDevice: { __default__: snap(6, { enabled: true }) } });
|
||||
cleanup = setupEqDeviceSync();
|
||||
await waitForOsDefaultInit(6);
|
||||
expect(useEqStore.getState().enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('switches EQ when the OS default changes externally (audio:device-changed)', async () => {
|
||||
onInvoke('audio_default_output_device_name', () => 'Speakers');
|
||||
useAuthStore.getState().setAudioOutputDevice(null);
|
||||
resetEq({
|
||||
rememberPerDevice: true,
|
||||
byDevice: {
|
||||
Speakers: snap(3),
|
||||
Headphones: snap(9, { enabled: true }),
|
||||
},
|
||||
});
|
||||
cleanup = setupEqDeviceSync();
|
||||
await waitForOsDefaultInit(3);
|
||||
|
||||
onInvoke('audio_default_output_device_name', () => 'Headphones');
|
||||
emitTauriEvent('audio:device-changed', null);
|
||||
await flushAsync();
|
||||
|
||||
expect(useEqStore.getState().gains[0]).toBe(9);
|
||||
expect(useEqStore.getState().enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('switches EQ on audio:device-reset when following system default', async () => {
|
||||
onInvoke('audio_default_output_device_name', () => 'Speakers');
|
||||
useAuthStore.getState().setAudioOutputDevice(null);
|
||||
resetEq({
|
||||
rememberPerDevice: true,
|
||||
byDevice: { Speakers: snap(2), Headphones: snap(7) },
|
||||
});
|
||||
cleanup = setupEqDeviceSync();
|
||||
await waitForOsDefaultInit(2);
|
||||
|
||||
onInvoke('audio_default_output_device_name', () => 'Headphones');
|
||||
emitTauriEvent('audio:device-reset', null);
|
||||
await flushAsync();
|
||||
|
||||
expect(useEqStore.getState().gains[0]).toBe(7);
|
||||
});
|
||||
|
||||
it('does not react to device events when a device is pinned', async () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('A');
|
||||
resetEq({
|
||||
rememberPerDevice: true,
|
||||
gains: [5, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
byDevice: { Headphones: snap(9) },
|
||||
});
|
||||
cleanup = setupEqDeviceSync();
|
||||
await flushAsync();
|
||||
|
||||
onInvoke('audio_default_output_device_name', () => 'Headphones');
|
||||
emitTauriEvent('audio:device-changed', null);
|
||||
await flushAsync();
|
||||
|
||||
expect(useEqStore.getState().gains[0]).toBe(5);
|
||||
});
|
||||
|
||||
it('does not apply the legacy __default__ profile to a pinned device without a snapshot', async () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('A');
|
||||
resetEq({
|
||||
rememberPerDevice: true,
|
||||
gains: [5, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
byDevice: { __default__: snap(9, { enabled: true }) },
|
||||
});
|
||||
cleanup = setupEqDeviceSync();
|
||||
await flushAsync();
|
||||
|
||||
expect(useEqStore.getState().gains[0]).toBe(5);
|
||||
expect(useEqStore.getState().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('saves the outgoing device snapshot when switching pinned devices', async () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('A');
|
||||
resetEq({ rememberPerDevice: true });
|
||||
cleanup = setupEqDeviceSync();
|
||||
await flushAsync();
|
||||
|
||||
useEqStore.getState().setBandGain(0, 3);
|
||||
useAuthStore.getState().setAudioOutputDevice('B');
|
||||
|
||||
expect(useEqStore.getState().byDevice['A'].gains[0]).toBe(3);
|
||||
});
|
||||
|
||||
it('cleanup stops mirroring further edits', async () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('A');
|
||||
resetEq({ rememberPerDevice: true });
|
||||
cleanup = setupEqDeviceSync();
|
||||
await flushAsync();
|
||||
|
||||
cleanup();
|
||||
useEqStore.getState().setBandGain(0, 6);
|
||||
|
||||
@@ -1,19 +1,84 @@
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import {
|
||||
audioDefaultOutputDeviceName,
|
||||
audioDefaultOutputDeviceNameForPoll,
|
||||
audioMatchStoredOutputDeviceKey,
|
||||
} from '@/lib/api/audio';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useEqStore, type EqSnapshot } from '@/store/eqStore';
|
||||
|
||||
/** Key used when no specific device is selected (system default). */
|
||||
/** Key used when no specific device is selected and the OS default is unknown. */
|
||||
const DEFAULT_DEVICE_KEY = '__default__';
|
||||
/** cpal generic alias saved before PipeWire default resolution (#1274). */
|
||||
const CPAL_GENERIC_DEFAULT_KEY = 'Default Audio Device';
|
||||
/** Match Rust device-watcher poll interval while following system default. */
|
||||
const SYSTEM_DEFAULT_POLL_MS = 3000;
|
||||
|
||||
function deviceKey(name: string | null): string {
|
||||
return name ?? DEFAULT_DEVICE_KEY;
|
||||
function isLegacyDefaultDeviceKey(key: string): boolean {
|
||||
return key === DEFAULT_DEVICE_KEY || key === CPAL_GENERIC_DEFAULT_KEY;
|
||||
}
|
||||
|
||||
let resolvedOsDefault: string | null = null;
|
||||
// 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;
|
||||
/** Serializes async OS-default queries so overlapping polls/events cannot apply stale EQ keys. */
|
||||
const osDefaultRefreshQueue: { tail: Promise<void> } = { tail: Promise.resolve() };
|
||||
|
||||
function enqueueOsDefaultRefresh(task: () => Promise<void>): Promise<void> {
|
||||
const next = osDefaultRefreshQueue.tail.then(task, task);
|
||||
osDefaultRefreshQueue.tail = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
|
||||
function resolveEqKey(pinnedDevice: string | null): string {
|
||||
if (pinnedDevice !== null) return pinnedDevice;
|
||||
return resolvedOsDefault ?? DEFAULT_DEVICE_KEY;
|
||||
}
|
||||
|
||||
function shouldFollowSystemDefaultEq(): boolean {
|
||||
return (
|
||||
useEqStore.getState().rememberPerDevice &&
|
||||
useAuthStore.getState().audioOutputDevice === null
|
||||
);
|
||||
}
|
||||
|
||||
/** Pre-#1233 profiles lived under `__default__`; keep as read fallback on upgrade. */
|
||||
function lookupSnapshot(
|
||||
byDevice: Record<string, EqSnapshot>,
|
||||
key: string,
|
||||
followingSystemDefault: boolean,
|
||||
): EqSnapshot | undefined {
|
||||
if (byDevice[key]) return byDevice[key];
|
||||
if (!followingSystemDefault) return undefined;
|
||||
for (const legacy of [DEFAULT_DEVICE_KEY, CPAL_GENERIC_DEFAULT_KEY]) {
|
||||
if (key !== legacy && byDevice[legacy]) {
|
||||
return byDevice[legacy];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function lookupSnapshotAsync(
|
||||
byDevice: Record<string, EqSnapshot>,
|
||||
key: string,
|
||||
followingSystemDefault: boolean,
|
||||
): Promise<EqSnapshot | undefined> {
|
||||
const direct = lookupSnapshot(byDevice, key, followingSystemDefault);
|
||||
if (direct || !followingSystemDefault) return direct;
|
||||
const storedKeys = Object.keys(byDevice);
|
||||
if (storedKeys.length === 0) return undefined;
|
||||
try {
|
||||
const matched = await audioMatchStoredOutputDeviceKey(key, storedKeys);
|
||||
if (matched && byDevice[matched]) return byDevice[matched];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function applySnapshot(snap: EqSnapshot): void {
|
||||
applying = true;
|
||||
@@ -24,6 +89,81 @@ function applySnapshot(snap: EqSnapshot): void {
|
||||
}
|
||||
}
|
||||
|
||||
function switchEqToKey(newKey: string, followingSystemDefault: boolean): void {
|
||||
const prevKey = currentKey;
|
||||
if (newKey === prevKey) return;
|
||||
const eq = useEqStore.getState();
|
||||
if (eq.rememberPerDevice) {
|
||||
eq.saveSnapshotFor(prevKey);
|
||||
}
|
||||
currentKey = newKey;
|
||||
if (!eq.rememberPerDevice) return;
|
||||
const snap = lookupSnapshot(eq.byDevice, newKey, followingSystemDefault);
|
||||
if (snap) {
|
||||
applySnapshot(snap);
|
||||
return;
|
||||
}
|
||||
// Key resolved from a generic placeholder → keep live EQ; next edit mirrors here.
|
||||
if (followingSystemDefault && isLegacyDefaultDeviceKey(prevKey)) {
|
||||
useEqStore.getState().saveSnapshotFor(newKey);
|
||||
}
|
||||
}
|
||||
|
||||
async function switchEqToKeyAsync(
|
||||
newKey: string,
|
||||
followingSystemDefault: boolean,
|
||||
): Promise<void> {
|
||||
const prevKey = currentKey;
|
||||
if (newKey === prevKey) return;
|
||||
const eq = useEqStore.getState();
|
||||
if (eq.rememberPerDevice) {
|
||||
eq.saveSnapshotFor(prevKey);
|
||||
}
|
||||
currentKey = newKey;
|
||||
if (!eq.rememberPerDevice) return;
|
||||
const snap = await lookupSnapshotAsync(eq.byDevice, newKey, followingSystemDefault);
|
||||
if (snap) {
|
||||
applySnapshot(snap);
|
||||
return;
|
||||
}
|
||||
if (followingSystemDefault && isLegacyDefaultDeviceKey(prevKey)) {
|
||||
useEqStore.getState().saveSnapshotFor(newKey);
|
||||
}
|
||||
}
|
||||
|
||||
async function queryOsDefault(forPoll = false): Promise<string | null> {
|
||||
try {
|
||||
return forPoll
|
||||
? await audioDefaultOutputDeviceNameForPoll()
|
||||
: await audioDefaultOutputDeviceName();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSystemDefaultKey(forPoll = false): Promise<string | null> {
|
||||
const next = await queryOsDefault(forPoll);
|
||||
if (next !== null) {
|
||||
resolvedOsDefault = next;
|
||||
return resolveEqKey(null);
|
||||
}
|
||||
if (resolvedOsDefault !== null) {
|
||||
return resolveEqKey(null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function refreshFollowingSystemDefault(forPoll = false): Promise<void> {
|
||||
if (!shouldFollowSystemDefaultEq()) return;
|
||||
await enqueueOsDefaultRefresh(async () => {
|
||||
if (!shouldFollowSystemDefaultEq()) return;
|
||||
const key = await resolveSystemDefaultKey(forPoll);
|
||||
if (key === null) return;
|
||||
if (!shouldFollowSystemDefaultEq()) return;
|
||||
await switchEqToKeyAsync(key, true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-device EQ memory. Opt-in via `eqStore.rememberPerDevice` (default off);
|
||||
* while off, every branch below returns early so behaviour is unchanged.
|
||||
@@ -31,41 +171,74 @@ function applySnapshot(snap: EqSnapshot): void {
|
||||
* 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.
|
||||
* `authStore.audioOutputDevice` (null = follow the active system default,
|
||||
* resolved via `audioDefaultOutputDeviceName` and refreshed on
|
||||
* `audio:device-changed` / `audio:device-reset`). Pinned devices use the same
|
||||
* name key as the device-selection feature. The audio backend exposes no stable
|
||||
* device UUID, so this deliberately inherits that feature's identity model.
|
||||
*
|
||||
* Returns a cleanup that removes both subscriptions (StrictMode-safe via
|
||||
* Returns a cleanup that removes all subscriptions (StrictMode-safe via
|
||||
* `initAudioListeners`).
|
||||
*/
|
||||
export function setupEqDeviceSync(): () => void {
|
||||
currentKey = deviceKey(useAuthStore.getState().audioOutputDevice);
|
||||
const eventUnsubs: Array<() => void> = [];
|
||||
let cancelled = false;
|
||||
|
||||
// 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);
|
||||
}
|
||||
const pinnedAtStart = useAuthStore.getState().audioOutputDevice;
|
||||
currentKey = resolveEqKey(pinnedAtStart);
|
||||
|
||||
// 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.
|
||||
void enqueueOsDefaultRefresh(async () => {
|
||||
if (pinnedAtStart === null) {
|
||||
await resolveSystemDefaultKey();
|
||||
if (cancelled) return;
|
||||
}
|
||||
const pinned = useAuthStore.getState().audioOutputDevice;
|
||||
currentKey = resolveEqKey(pinned);
|
||||
const eqAtStart = useEqStore.getState();
|
||||
if (eqAtStart.rememberPerDevice) {
|
||||
const snap = await lookupSnapshotAsync(
|
||||
eqAtStart.byDevice,
|
||||
currentKey,
|
||||
pinned === null,
|
||||
);
|
||||
if (snap) applySnapshot(snap);
|
||||
}
|
||||
});
|
||||
|
||||
// Sub 2 — mirror live EQ edits into the current device's snapshot, and seed
|
||||
// Sub 1 — pinned device changed (picker or audio:device-reset clearing pin).
|
||||
const unsubDevice = useAuthStore.subscribe((_state, prev) => {
|
||||
if (_state.audioOutputDevice === prev.audioOutputDevice) return;
|
||||
const latestPinned = _state.audioOutputDevice;
|
||||
if (latestPinned !== null) {
|
||||
switchEqToKey(latestPinned, false);
|
||||
return;
|
||||
}
|
||||
void enqueueOsDefaultRefresh(async () => {
|
||||
const key = await resolveSystemDefaultKey();
|
||||
if (cancelled) return;
|
||||
if (useAuthStore.getState().audioOutputDevice !== null) return;
|
||||
if (key === null) return;
|
||||
await switchEqToKeyAsync(key, true);
|
||||
});
|
||||
});
|
||||
|
||||
// Sub 2 — system default output changed externally (Rust device-watcher).
|
||||
for (const ev of ['audio:device-changed', 'audio:device-reset'] as const) {
|
||||
void listen(ev, () => {
|
||||
void refreshFollowingSystemDefault();
|
||||
}).then((u) => {
|
||||
if (cancelled) u();
|
||||
else eventUnsubs.push(u);
|
||||
});
|
||||
}
|
||||
|
||||
// Sub 3 — poll while following system default (covers missed events / wpctl lag).
|
||||
const pollId = setInterval(() => {
|
||||
if (cancelled) return;
|
||||
void refreshFollowingSystemDefault(true);
|
||||
}, SYSTEM_DEFAULT_POLL_MS);
|
||||
|
||||
// Sub 4 — 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).
|
||||
@@ -78,13 +251,26 @@ export function setupEqDeviceSync(): () => void {
|
||||
state.enabled !== prev.enabled ||
|
||||
state.preGain !== prev.preGain ||
|
||||
state.activePreset !== prev.activePreset;
|
||||
if (justEnabled || contentChanged) {
|
||||
if (justEnabled) {
|
||||
void (async () => {
|
||||
const pinned = useAuthStore.getState().audioOutputDevice;
|
||||
const key = pinned ?? await resolveSystemDefaultKey() ?? DEFAULT_DEVICE_KEY;
|
||||
if (cancelled) return;
|
||||
currentKey = key;
|
||||
useEqStore.getState().saveSnapshotFor(key);
|
||||
})();
|
||||
return;
|
||||
}
|
||||
if (contentChanged) {
|
||||
useEqStore.getState().saveSnapshotFor(currentKey);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(pollId);
|
||||
unsubDevice();
|
||||
unsubEq();
|
||||
for (const u of eventUnsubs) u();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,6 +152,13 @@ export const commands = {
|
||||
audioCanonicalizeSelectedDevice: () => __TAURI_INVOKE<string | null>("audio_canonicalize_selected_device"),
|
||||
/** Device id string for the host default output (matches an entry from `audio_list_devices` when present). */
|
||||
audioDefaultOutputDeviceName: () => __TAURI_INVOKE<string | null>("audio_default_output_device_name"),
|
||||
/** Lightweight default query for EQ poll — skips full `output_devices()` scan (#996). */
|
||||
audioDefaultOutputDeviceNameForPoll: () => __TAURI_INVOKE<string | null>("audio_default_output_device_name_for_poll"),
|
||||
/**
|
||||
* Find a stored per-device EQ key that denotes the same sink as `candidate`
|
||||
* (exact or Linux ALSA logical match).
|
||||
*/
|
||||
audioMatchStoredOutputDeviceKey: (candidate: string, storedKeys: string[]) => __TAURI_INVOKE<string | null>("audio_match_stored_output_device_key", { candidate, storedKeys }),
|
||||
/**
|
||||
* Switch the audio output device. `device_name = null` → follow system default.
|
||||
* Reopens the stream immediately; frontend must restart playback via audio:device-changed.
|
||||
|
||||
@@ -113,6 +113,17 @@ export function audioDefaultOutputDeviceName(): Promise<string | null> {
|
||||
return commands.audioDefaultOutputDeviceName();
|
||||
}
|
||||
|
||||
export function audioDefaultOutputDeviceNameForPoll(): Promise<string | null> {
|
||||
return commands.audioDefaultOutputDeviceNameForPoll();
|
||||
}
|
||||
|
||||
export function audioMatchStoredOutputDeviceKey(
|
||||
candidate: string,
|
||||
storedKeys: string[],
|
||||
): Promise<string | null> {
|
||||
return commands.audioMatchStoredOutputDeviceKey(candidate, storedKeys);
|
||||
}
|
||||
|
||||
export function audioCanonicalizeSelectedDevice(): Promise<string | null> {
|
||||
return commands.audioCanonicalizeSelectedDevice();
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ export const settings = {
|
||||
audioOutputDeviceListError: 'Зареждането на списъка с аудио устройства се провали.',
|
||||
audioOutputDeviceNotInCurrentList: 'не е в текущия списък',
|
||||
audioOutputDeviceRememberEq: 'Запомни еквалайзера за устройство',
|
||||
audioOutputDeviceRememberEqDesc: 'Запазва настройките на еквалайзера за всяко аудио изходно устройство и ги възстановява автоматично при смяна на устройства. Профилите се сменят само когато избереш устройство тук, а системата по подразбиране използва един общ профил.',
|
||||
audioOutputDeviceRememberEqDesc: 'Запазва настройките на еквалайзера за всяко аудио изходно устройство и ги възстановява автоматично при смяна на устройства. При избран системен изход по подразбиране профилите следват активния системен изход, включително при промяна извън Psysonic.',
|
||||
hiResTitle: 'Родно Hi-Res възпроизвеждане',
|
||||
hiResEnabled: 'Включи родно hi-res възпроизвеждане',
|
||||
hiResDesc: 'Възпроизвежда всяка песен с оригиналната честота на дискретизация, вместо да преобразува всичко до 44.1 kHz, като превключва аудио устройството да съответства на файла (88.2 kHz и нагоре). Включвай само ако хардуерът и мрежата ти надеждно поддържат високи честоти на дискретизация.',
|
||||
|
||||
@@ -237,7 +237,7 @@ export const settings = {
|
||||
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.',
|
||||
audioOutputDeviceRememberEqDesc: 'Speichert die Equalizer-Einstellungen für jedes Audioausgabegerät und stellt sie beim Wechsel automatisch wieder her. Bei Systemstandard folgen die Profile der aktiven Systemausgabe, auch wenn sie außerhalb von Psysonic gewechselt wird.',
|
||||
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.",
|
||||
|
||||
@@ -240,7 +240,7 @@ export const settings = {
|
||||
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.',
|
||||
audioOutputDeviceRememberEqDesc: 'Save the equalizer settings for each audio output device and restore them automatically when you switch devices. With System Default selected, profiles follow the active system output, including when it changes outside Psysonic.',
|
||||
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.",
|
||||
|
||||
@@ -236,7 +236,7 @@ export const settings = {
|
||||
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.',
|
||||
audioOutputDeviceRememberEqDesc: 'Guarda los ajustes del ecualizador para cada dispositivo de salida de audio y los restaura automáticamente al cambiar de dispositivo. Con Salida del sistema seleccionada, los perfiles siguen la salida activa del sistema, incluso si cambia fuera de Psysonic.',
|
||||
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.",
|
||||
|
||||
@@ -236,7 +236,7 @@ export const settings = {
|
||||
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é.',
|
||||
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. Avec Sortie système sélectionnée, les profils suivent la sortie système active, y compris lorsqu’elle change en dehors de Psysonic.',
|
||||
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.",
|
||||
|
||||
@@ -240,7 +240,7 @@ export const settings = {
|
||||
audioOutputDeviceListError: 'Nem sikerült betölteni a hangeszközök listáját.',
|
||||
audioOutputDeviceNotInCurrentList: 'nincs a jelenlegi listában',
|
||||
audioOutputDeviceRememberEq: 'EQ megjegyzése eszközönként',
|
||||
audioOutputDeviceRememberEqDesc: 'Elmenti a hangszínszabályzó beállításait minden hangkimeneti eszközhöz, és automatikusan visszaállítja őket eszközváltáskor. A profilok csak akkor váltanak, ha itt választasz eszközt, és a rendszer alapértelmezése egyetlen közös profilt használ.',
|
||||
audioOutputDeviceRememberEqDesc: 'Elmenti a hangszínszabályzó beállításait minden hangkimeneti eszközhöz, és automatikusan visszaállítja őket eszközváltáskor. Rendszer alapértelmezés mellett a profilok követik az aktív rendszerkimenetet, beleértve a Psysonic-on kívüli váltásokat is.',
|
||||
hiResTitle: 'Natív Hi-Res lejátszás',
|
||||
hiResEnabled: 'Natív Hi-Res lejátszás engedélyezése',
|
||||
hiResDesc: 'Minden számot az eredeti mintavételezési frekvenciáján játszik le, ahelyett, hogy mindent 44,1 kHz-re mintavételezne újra, és a hangeszközt a fájlhoz igazítja (88,2 kHz-től felfelé). Csak akkor engedélyezd, ha a hardvered és a hálózatod megbízhatóan kezeli a magas mintavételezési frekvenciákat.',
|
||||
|
||||
@@ -240,7 +240,7 @@ export const settings = {
|
||||
audioOutputDeviceListError: 'Impossibile caricare l\'elenco dei dispositivi audio.',
|
||||
audioOutputDeviceNotInCurrentList: 'non nell\'elenco attuale',
|
||||
audioOutputDeviceRememberEq: 'Ricorda EQ per dispositivo',
|
||||
audioOutputDeviceRememberEqDesc: 'Salva le impostazioni dell\'equalizzatore per ogni dispositivo di uscita audio e ripristinale automaticamente quando cambi dispositivo. I profili cambiano solo quando scegli un dispositivo qui, mentre l\'uscita predefinita di sistema usa un unico profilo condiviso.',
|
||||
audioOutputDeviceRememberEqDesc: 'Salva le impostazioni dell\'equalizzatore per ogni dispositivo di uscita audio e ripristinale automaticamente quando cambi dispositivo. Con Uscita di sistema selezionata, i profili seguono l\'uscita di sistema attiva, anche se cambiata al di fuori di Psysonic.',
|
||||
hiResTitle: 'Riproduzione Hi-Res nativa',
|
||||
hiResEnabled: 'Attiva riproduzione hi-res nativa',
|
||||
hiResDesc: 'Riproduce ogni brano al suo sample rate originale invece di ricampionare tutto a 44,1 kHz, cambiando il dispositivo audio in base al file (da 88,2 kHz in su). Attivalo solo se il tuo hardware e la tua rete gestiscono in modo affidabile sample rate elevati.',
|
||||
|
||||
@@ -240,7 +240,7 @@ export const settings = {
|
||||
audioOutputDeviceListError: '音声デバイス一覧を読み込めませんでした。',
|
||||
audioOutputDeviceNotInCurrentList: '現在の一覧にありません',
|
||||
audioOutputDeviceRememberEq: 'デバイスごとにイコライザーを記憶',
|
||||
audioOutputDeviceRememberEqDesc: 'オーディオ出力デバイスごとにイコライザー設定を保存し、デバイスを切り替えたときに自動的に復元します。プロファイルが切り替わるのはここでデバイスを選んだときだけで、システム既定の出力は1つの共有プロファイルを使います。',
|
||||
audioOutputDeviceRememberEqDesc: 'オーディオ出力デバイスごとにイコライザー設定を保存し、デバイスを切り替えたときに自動的に復元します。システム既定を選択している場合、Psysonic外で変更された場合も含め、アクティブなシステム出力にプロファイルが追従します。',
|
||||
hiResTitle: 'ネイティブ Hi-Res 再生',
|
||||
hiResEnabled: 'ネイティブ Hi-Res 再生を有効化',
|
||||
hiResDesc: 'すべてを 44.1 kHz にリサンプリングせず、各トラックを元のサンプルレートで再生し、ファイルに合わせて音声デバイスを切り替えます (88.2 kHz 以上)。高サンプルレートをハードウェアとネットワークが安定して扱える場合のみ有効にしてください。',
|
||||
|
||||
@@ -237,7 +237,7 @@ export const settings = {
|
||||
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.',
|
||||
audioOutputDeviceRememberEqDesc: 'Lagrer equalizer-innstillingene for hver lydutgangsenhet og gjenoppretter dem automatisk når du bytter enhet. Med systemstandard følger profilene den aktive systemutgangen, også når den endres utenfor Psysonic.',
|
||||
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.",
|
||||
|
||||
@@ -236,7 +236,7 @@ export const settings = {
|
||||
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.',
|
||||
audioOutputDeviceRememberEqDesc: 'Slaat de equalizer-instellingen op voor elk audio-uitvoerapparaat en herstelt ze automatisch wanneer je van apparaat wisselt. Bij Systeemstandaard volgen profielen de actieve systeemuitvoer, ook wanneer die buiten Psysonic wordt gewijzigd.',
|
||||
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.",
|
||||
|
||||
@@ -240,7 +240,7 @@ export const settings = {
|
||||
audioOutputDeviceListError: 'Nie można było załadować listy urządzeń audio.',
|
||||
audioOutputDeviceNotInCurrentList: 'nie ma go na aktualnej liście',
|
||||
audioOutputDeviceRememberEq: 'Zapamiętaj EQ dla każdego urządzenia',
|
||||
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.', // Propozycja: Zapisuj ustawienia korektora dla każdego urządzenia wyjściowego audio i automatycznie przywracaj je po przełączeniu urządzenia. Profile przełączają się tylko wtedy, gdy wybierzesz urządzenie tutaj, a domyślne urządzenie systemowe używa jednego wspólnego profilu.
|
||||
audioOutputDeviceRememberEqDesc: 'Zapisuje ustawienia korektora dla każdego urządzenia wyjściowego audio i automatycznie przywraca je po przełączeniu urządzenia. Przy wybranej domyślnej systemowej konfiguracja podąża za aktywnym wyjściem systemowym, także gdy zmieni się poza Psysonic.',
|
||||
hiResTitle: 'Natywne odtwarzanie w wysokiej rozdzielczości',
|
||||
hiResEnabled: 'Włącz natywne odtwarzanie w wysokiej rozdzielczości',
|
||||
hiResDesc: "Wymusza 44.1 kHz dla maksymalnej stabilności. Włącz tylko wtedy, gdy sprzęt i sieć niezawodnie obsługują wysokie częstotliwości próbkowania (88.2 kHz+).",
|
||||
|
||||
@@ -239,7 +239,7 @@ export const settings = {
|
||||
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.',
|
||||
audioOutputDeviceRememberEqDesc: 'Salvează setările egalizatorului pentru fiecare dispozitiv de ieșire audio și le restaurează automat la schimbarea dispozitivului. Cu Ieșirea sistemului selectată, profilele urmăresc ieșirea activă a sistemului, inclusiv când se schimbă în afara Psysonic.',
|
||||
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.",
|
||||
|
||||
@@ -240,7 +240,7 @@ export const settings = {
|
||||
audioOutputDeviceListError: 'Не удалось загрузить список аудиоустройств.',
|
||||
audioOutputDeviceNotInCurrentList: 'нет в текущем списке',
|
||||
audioOutputDeviceRememberEq: 'Запоминать эквалайзер для каждого устройства',
|
||||
audioOutputDeviceRememberEqDesc: 'Сохраняет настройки эквалайзера для каждого устройства вывода звука и восстанавливает их автоматически при переключении устройств. Профиль меняется только когда вы выбираете устройство здесь; системный вывод по умолчанию использует один общий профиль.',
|
||||
audioOutputDeviceRememberEqDesc: 'Сохраняет настройки эквалайзера для каждого устройства вывода звука и восстанавливает их автоматически при переключении устройств. При выборе «Системный по умолчанию» профили следуют за активным системным выходом, в том числе при смене извне Psysonic.',
|
||||
hiResTitle: 'Нативное воспроизведение Hi-Res',
|
||||
hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
|
||||
hiResDesc: "Воспроизводит каждый трек с его исходной частотой дискретизации вместо передискретизации всего в 44,1 кГц, переключая устройство вывода под частоту файла (88,2 кГц и выше). Включайте только если оборудование и сеть надёжно справляются с высокими частотами.",
|
||||
|
||||
@@ -236,7 +236,7 @@ export const settings = {
|
||||
audioOutputDeviceListError: '无法加载音频设备列表。',
|
||||
audioOutputDeviceNotInCurrentList: '不在当前列表中',
|
||||
audioOutputDeviceRememberEq: '为每个设备记住均衡器',
|
||||
audioOutputDeviceRememberEqDesc: '为每个音频输出设备保存均衡器设置,并在切换设备时自动恢复。仅当你在此处选择设备时配置才会切换;系统默认输出共用一个配置。',
|
||||
audioOutputDeviceRememberEqDesc: '为每个音频输出设备保存均衡器设置,并在切换设备时自动恢复。选择系统默认时,配置会跟随当前系统输出,包括在 Psysonic 外部切换时。',
|
||||
hiResTitle: '原生高清晰度播放',
|
||||
hiResEnabled: '启用原生高清晰度播放',
|
||||
hiResDesc: "以每个曲目的原始采样率播放,而不是将所有内容重采样到 44.1 kHz,并将输出设备切换为与文件匹配(88.2 kHz 及以上)。仅在硬件和网络能可靠处理高采样率时启用。",
|
||||
|
||||
Reference in New Issue
Block a user