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:
12
README.md
12
README.md
@@ -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.
|
||||||
|
|||||||
58
src/cli.rs
58
src/cli.rs
@@ -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\
|
||||||
|
|||||||
143
src/config.rs
143
src/config.rs
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user