Add --refresh to repair stale device IDs, and rename to audioswitch

Windows gives a device a new endpoint ID when it reappears as another
instance, typically a DAC moved to a different USB port, which also
gains a "2- " name prefix. The old config line then silently dropped out
of the cycle. --refresh re-points each stale line at the present output
with the same name (ignoring that prefix), keeps order and comments, adds
unmentioned outputs as commented-out lines, and keeps a .bak of the
previous file.

Also replace every remaining "win-audio-output-switcher" with
"audioswitch": the config now lives in %APPDATA%\audioswitch, and the
autostart Run value and single-instance mutex use the new name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-19 13:43:55 +00:00
parent c2825fa062
commit 70494e96bc
4 changed files with 209 additions and 9 deletions

View File

@@ -1,4 +1,4 @@
# win-audio-output-switcher # audioswitch
A global hotkey that cycles the Windows 11 default audio output, and a flyout that tells A global hotkey that cycles the Windows 11 default audio output, and a flyout that tells
you where you landed. you where you landed.
@@ -76,6 +76,7 @@ $old = [Environment]::GetEnvironmentVariable('Path', 'User')
audioswitch Run in the background (single instance) audioswitch Run in the background (single instance)
audioswitch --list List outputs and their endpoint IDs audioswitch --list List outputs and their endpoint IDs
audioswitch --config Create/open the config file audioswitch --config Create/open the config file
audioswitch --refresh Update config device IDs to match current outputs
audioswitch --quit Stop the running instance audioswitch --quit Stop the running instance
audioswitch --autostart on Start with Windows (off to undo) audioswitch --autostart on Start with Windows (off to undo)
``` ```
@@ -89,7 +90,7 @@ GUI-subsystem binary, a shell may hand back the prompt before the output arrives
## Config ## Config
`%APPDATA%\win-audio-output-switcher\config.ini`, created by `--config` already populated `%APPDATA%\audioswitch\config.ini`, created by `--config` already populated
with the outputs you have right now. Edits apply on the next hotkey press — no restart. with the outputs you have right now. Edits apply on the next hotkey press — no restart.
```ini ```ini
@@ -106,6 +107,13 @@ osd_fade = false
{0.0.0.00000000}.{ff879682-...} = Haut-parleurs (Focusrite USB Audio) {0.0.0.00000000}.{ff879682-...} = Haut-parleurs (Focusrite USB Audio)
``` ```
Windows sometimes gives a device a new endpoint ID. Plugging a DAC into a different USB
port is the usual cause, and its name gains a `2- ` prefix as well. The old line then
silently drops out of the cycle. `audioswitch --refresh` fixes this. It moves each stale
line to the present output with the same name (ignoring that prefix), keeps your order and
comments, and adds outputs the file doesn't mention yet as commented-out lines. The
previous file is kept as `config.ini.bak`.
`osd_fade` is off by default. Fading needs `WS_EX_LAYERED`, which on some builds suppresses `osd_fade` is off by default. Fading needs `WS_EX_LAYERED`, which on some builds suppresses
the acrylic backdrop that makes the flyout look like part of the shell. Turn it on and see the acrylic backdrop that makes the flyout look like part of the shell. Turn it on and see
which you prefer on your machine. which you prefer on your machine.

View File

@@ -23,7 +23,7 @@ use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
use crate::{audio, config}; use crate::{audio, config};
const RUN_KEY: windows::core::PCWSTR = w!(r"Software\Microsoft\Windows\CurrentVersion\Run"); const RUN_KEY: windows::core::PCWSTR = w!(r"Software\Microsoft\Windows\CurrentVersion\Run");
const RUN_VALUE: windows::core::PCWSTR = w!("win-audio-output-switcher"); const RUN_VALUE: windows::core::PCWSTR = w!("audioswitch");
/// True when another instance already holds the single-instance mutex. /// True when another instance already holds the single-instance mutex.
/// ///
@@ -102,6 +102,59 @@ pub fn config() -> i32 {
0 0
} }
pub fn refresh() -> i32 {
attach_console();
let Ok(enumerator) = audio::enumerator() else {
eprintln!("Could not reach the audio device enumerator.");
return 1;
};
let outputs = audio::outputs(&enumerator).unwrap_or_default();
let path = config::path();
let Ok(text) = std::fs::read_to_string(&path) else {
// Nothing to refresh, but a fresh template is exactly what a refresh would produce.
return match config::write_template(&outputs) {
Ok(path) => {
println!("No config yet - created {}", path.display());
0
}
Err(error) => {
eprintln!("Could not write the config file: {error}");
1
}
};
};
let pairs: Vec<(&str, &str)> = outputs
.iter()
.map(|output| (output.id.as_str(), output.name.as_str()))
.collect();
let Some(refreshed) = config::refresh(&text, &pairs) else {
println!("No [devices] section - every active output is already in the cycle.");
return 0;
};
for change in &refreshed.changes {
println!(" {change}");
}
if refreshed.text == text {
println!("Config already up to date: {}", path.display());
return 0;
}
// One level of undo, in case a name match picked the wrong device.
let backup = path.with_extension("ini.bak");
if let Err(error) = std::fs::write(&backup, &text)
.and_then(|()| std::fs::write(&path, &refreshed.text))
{
eprintln!("Could not update the config file: {error}");
return 1;
}
println!("\nUpdated {} (previous version in {})", path.display(), backup.display());
0
}
pub fn quit() -> i32 { pub fn quit() -> i32 {
attach_console(); attach_console();
@@ -158,12 +211,13 @@ fn print_usage() {
.unwrap_or_else(|_| "audioswitch.exe".to_string()); .unwrap_or_else(|_| "audioswitch.exe".to_string());
println!( println!(
"win-audio-output-switcher - cycle the default audio output from a global hotkey\n\ "audioswitch - cycle the default audio output from a global hotkey\n\
\n\ \n\
Usage ({exe}):\n\ Usage ({exe}):\n\
\x20 <exe> Run in the background (single instance)\n\ \x20 <exe> Run in the background (single instance)\n\
\x20 <exe> --list List outputs and their endpoint IDs\n\ \x20 <exe> --list List outputs and their endpoint IDs\n\
\x20 <exe> --config Create/open the config file\n\ \x20 <exe> --config Create/open the config file\n\
\x20 <exe> --refresh Update config device IDs to match current outputs\n\
\x20 <exe> --quit Stop the running instance\n\ \x20 <exe> --quit Stop the running instance\n\
\x20 <exe> --autostart on Start with Windows (off to undo)\n\ \x20 <exe> --autostart on Start with Windows (off to undo)\n\
\x20 <exe> --help This text\n\ \x20 <exe> --help This text\n\

View File

@@ -1,4 +1,4 @@
//! The config file: `%APPDATA%\win-audio-output-switcher\config.ini`. //! The config file: `%APPDATA%\audioswitch\config.ini`.
//! //!
//! Deliberately hand-parsed. The whole file is a handful of `key = value` lines plus one //! Deliberately hand-parsed. The whole file is a handful of `key = value` lines plus one
//! `[devices]` section, which is not worth a serde + toml dependency — and endpoint IDs //! `[devices]` section, which is not worth a serde + toml dependency — and endpoint IDs
@@ -52,7 +52,7 @@ impl Default for Config {
pub fn path() -> PathBuf { pub fn path() -> PathBuf {
let base = std::env::var("APPDATA").unwrap_or_default(); let base = std::env::var("APPDATA").unwrap_or_default();
PathBuf::from(base) PathBuf::from(base)
.join("win-audio-output-switcher") .join("audioswitch")
.join("config.ini") .join("config.ini")
} }
@@ -158,7 +158,7 @@ pub fn write_template(outputs: &[crate::audio::Endpoint]) -> std::io::Result<Pat
} }
let mut text = String::from( let mut text = String::from(
"; win-audio-output-switcher\n\ "; audioswitch\n\
; Edits take effect on the next hotkey press - no restart needed.\n\ ; Edits take effect on the next hotkey press - no restart needed.\n\
\n\ \n\
hotkey = ctrl+alt+a\n\ hotkey = ctrl+alt+a\n\
@@ -183,3 +183,140 @@ pub fn write_template(outputs: &[crate::audio::Endpoint]) -> std::io::Result<Pat
fs::write(&path, text)?; fs::write(&path, text)?;
Ok(path) Ok(path)
} }
/// Outcome of [`refresh`]: the rewritten file and a line per change, for the user.
pub struct Refresh {
pub text: String,
pub changes: Vec<String>,
}
/// Rewrite the `[devices]` section against the outputs present right now.
///
/// Windows hands a device a new endpoint ID when it reappears as a different instance -
/// typically the same DAC in another USB port, which also gains a `2- ` in its name. The
/// old ID then silently drops out of the cycle. This re-points each stale line at the
/// present output with the same name, keeping the user's order and comments, and lists
/// outputs the file does not mention yet as commented-out lines.
///
/// `outputs` is `(id, name)` pairs. Returns `None` when there is no `[devices]` section,
/// since then every output is already cycled and there is nothing to refresh.
pub fn refresh(text: &str, outputs: &[(&str, &str)]) -> Option<Refresh> {
struct Entry {
line: usize,
/// Everything before the ID: empty, or the comment marker that disabled the line.
prefix: String,
id: String,
label: String,
}
let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
let mut section: Option<(usize, usize)> = None;
let mut entries = Vec::new();
for (index, raw) in lines.iter().enumerate() {
let line = raw.trim();
if line.starts_with('[') && line.ends_with(']') {
if let Some((_, end)) = section.as_mut() {
if *end == usize::MAX {
*end = index;
}
}
if line[1..line.len() - 1].trim().eq_ignore_ascii_case("devices") {
section = Some((index, usize::MAX));
}
continue;
}
if !matches!(section, Some((_, usize::MAX))) {
continue;
}
// A disabled device line is still a device line: remap it too, or it would be
// listed again below as a new output.
let body = line.trim_start_matches([';', '#']).trim_start();
let commented = body.len() != line.len();
let Some((key, value)) = body.split_once('=') else {
continue;
};
let key = key.trim();
if key.is_empty() || (commented && !key.starts_with('{')) {
continue;
}
entries.push(Entry {
line: index,
prefix: line[..line.len() - body.len()].to_string(),
id: key.to_string(),
label: value.trim().to_string(),
});
}
let (header, end) = section?;
let end = end.min(lines.len());
let present = |id: &str| outputs.iter().find(|(output, _)| *output == id);
let mut claimed: Vec<&str> = entries
.iter()
.filter_map(|entry| present(&entry.id).map(|(id, _)| *id))
.collect();
let mut changes = Vec::new();
for entry in &entries {
let (id, name) = match present(&entry.id) {
Some(&(id, name)) => (id, name),
None => {
let wanted = normalize(&entry.label);
let found = outputs
.iter()
.find(|(id, name)| !claimed.contains(id) && normalize(name) == wanted);
let Some(&(id, name)) = found else {
changes.push(format!("not present, left as is: {}", entry.label));
continue;
};
claimed.push(id);
changes.push(format!("new ID for {name}: {} -> {id}", entry.id));
(id, name)
}
};
let rewritten = format!("{}{id} = {name}", entry.prefix);
if lines[entry.line] != rewritten {
if entry.id == id && entry.label != name {
changes.push(format!("renamed: {} -> {name}", entry.label));
}
lines[entry.line] = rewritten;
}
}
// New outputs go at the end of the section, disabled, so refreshing never changes
// what the hotkey cycles through beyond fixing stale IDs.
let insert_at = (header + 1..end)
.rev()
.find(|&index| !lines[index].trim().is_empty())
.map_or(header + 1, |index| index + 1);
let added: Vec<String> = outputs
.iter()
.filter(|(id, _)| !claimed.contains(id))
.map(|(id, name)| {
changes.push(format!("added (commented out): {name}"));
format!("; {id} = {name}")
})
.collect();
lines.splice(insert_at..insert_at, added);
let newline = if text.contains("\r\n") { "\r\n" } else { "\n" };
let mut text = lines.join(newline);
text.push_str(newline);
Some(Refresh { text, changes })
}
/// A device name without the instance number Windows inserts for a second copy of the
/// same hardware: `Casque (2- FiiO USB DAC-E07K)` -> `casque (fiio usb dac-e07k)`.
fn normalize(name: &str) -> String {
if let Some(open) = name.find('(') {
let rest = &name[open + 1..];
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
if digits > 0 && rest[digits..].starts_with("- ") {
return format!("{}{}", &name[..=open], &rest[digits + 2..]).to_lowercase();
}
}
name.to_lowercase()
}

View File

@@ -1,4 +1,4 @@
//! win-audio-output-switcher //! audioswitch
//! //!
//! A global hotkey that cycles the Windows default audio output, and a flyout that says //! A global hotkey that cycles the Windows default audio output, and a flyout that says
//! which one you landed on. One hidden window, one message loop, no polling: the process //! which one you landed on. One hidden window, one message loop, no polling: the process
@@ -41,7 +41,7 @@ mod cli;
/// Asks a running instance to exit. /// Asks a running instance to exit.
const WM_QUIT_REQUEST: u32 = WM_APP + 2; const WM_QUIT_REQUEST: u32 = WM_APP + 2;
const MUTEX_NAME: PCWSTR = w!("Local\\win-audio-output-switcher"); const MUTEX_NAME: PCWSTR = w!("Local\\audioswitch");
struct App { struct App {
enumerator: IMMDeviceEnumerator, enumerator: IMMDeviceEnumerator,
@@ -71,6 +71,7 @@ fn main() {
let code = match args.first().map(String::as_str) { let code = match args.first().map(String::as_str) {
Some("--list" | "-l") => cli::list(), Some("--list" | "-l") => cli::list(),
Some("--config" | "-c") => cli::config(), Some("--config" | "-c") => cli::config(),
Some("--refresh" | "-r") => cli::refresh(),
Some("--quit" | "-q") => cli::quit(), Some("--quit" | "-q") => cli::quit(),
Some("--autostart") => cli::autostart(args.get(1).map(String::as_str)), Some("--autostart") => cli::autostart(args.get(1).map(String::as_str)),
Some("--help" | "-h") => cli::help(), Some("--help" | "-h") => cli::help(),