audiorouter 0.1.3

Cross-platform command-line audio router with a terminal UI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
# audiorouter v0.1 Implementation Plan

This plan is written for a low-cost implementation model. Follow the steps in order. Do not skip validation or tests. Keep v0.1 small: only `devices` and `routes` as routing concepts, and use a subcommand-free CLI.

## Current desired v0.1 behavior

Build a Rust CLI that:

1. Reads TOML config from:
   - positional `CONFIG`, if supplied;
   - else `$XDG_CONFIG_HOME/audiorouter/config.toml`;
   - else `~/.config/audiorouter/config.toml`.
2. Provides no subcommands.
3. Uses mode flags:
   - default mode: run audio routing;
   - `--check` / `-n`: validate config and devices, then exit;
   - `--list-devices` / `-l`: list devices, then exit without reading config;
   - `--print-config-path`: print resolved config path, then exit without reading config.
4. Uses config sections:
   - `[engine]`
   - `[[devices]]`
   - `[[routes]]`
5. Infers device input/output roles from route direction.
6. Routes and mixes audio from physical input channels to physical output channels.
7. Uses BlackHole or another output device as the sink.

Read `SPEC.md` before coding. If this plan conflicts with `SPEC.md`, treat `SPEC.md` as authoritative.

---

## Phase 0: Project setup

### 0.1 Update `Cargo.toml`

Add dependencies with bounded versions:

```toml
[dependencies]
anyhow = ">=1,<2"
clap = { version = ">=4,<5", features = ["derive"] }
serde = { version = ">=1,<2", features = ["derive"] }
toml = ">=0.8,<0.9"
cpal = ">=0.15,<0.16"
ringbuf = ">=0.4,<0.5"
ctrlc = ">=3,<4"
dirs = ">=5,<7"
tracing = ">=0.1,<0.2"
tracing-subscriber = ">=0.3,<0.4"
```

Do not add `rubato` or another resampler in v0.1.

`ringbuf` is the preferred shared-memory ring buffer between input callbacks and output callbacks. If `ringbuf` 0.4 API proves too complex, you may substitute a simpler `Arc<Mutex<Vec<f32>>>` latest-buffer approach for an initial working version, but document the limitation clearly.

### 0.2 Create module files

Create these files:

```text
src/cli.rs
src/config.rs
src/validate.rs
src/devices.rs
src/audio.rs
src/mixer.rs
```

Keep `src/main.rs` very small.

Suggested `main.rs` responsibility:

- initialize tracing/logging:
  - `--quiet`: ERROR level only
  - default (no flag): WARN level
  - `-v`: DEBUG level
  - `-vv` or more: TRACE level
- parse CLI args
- dispatch selected mode
- convert errors into process exit code (1 for validation, 2 for runtime/device)

---

## Phase 1: CLI skeleton

### 1.1 Implement clap structures in `src/cli.rs`

There are no subcommands.

Define:

```rust
use std::path::PathBuf;
use clap::Parser;

#[derive(Debug, Parser)]
#[command(author, version, about)]
pub struct Cli {
    /// TOML configuration file to read.
    ///
    /// If omitted, audiorouter reads the default XDG config path.
    pub config: Option<PathBuf>,

    /// Validate configuration and device availability, then exit.
    #[arg(short = 'n', long)]
    pub check: bool,

    /// List available audio input/output devices, then exit.
    /// Does not read CONFIG.
    #[arg(short = 'l', long)]
    pub list_devices: bool,

    /// Print the resolved configuration path, then exit.
    #[arg(long)]
    pub print_config_path: bool,

    /// Suppress non-error output.
    #[arg(short, long)]
    pub quiet: bool,

    /// Print extra diagnostics. Repeat for more detail.
    #[arg(short, long, action = clap::ArgAction::Count)]
    pub verbose: u8,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
    Run,
    Check,
    ListDevices,
    PrintConfigPath,
}
```

### 1.2 Implement mode selection

Implement a method or function:

```rust
impl Cli {
    pub fn mode(&self) -> anyhow::Result<Mode> {
        let selected = [self.check, self.list_devices, self.print_config_path]
            .into_iter()
            .filter(|selected| *selected)
            .count();

        if selected > 1 {
            anyhow::bail!(
                "--check, --list-devices, and --print-config-path are mutually exclusive"
            );
        }

        let mode = if self.check {
            Mode::Check
        } else if self.list_devices {
            Mode::ListDevices
        } else if self.print_config_path {
            Mode::PrintConfigPath
        } else {
            Mode::Run
        };

        if self.config.is_some() && matches!(mode, Mode::ListDevices) {
            anyhow::bail!("CONFIG cannot be used with --list-devices");
        }

        Ok(mode)
    }
}
```

`CONFIG` is allowed with:

- `Run`
- `Check`
- `PrintConfigPath`

`CONFIG` is not allowed with:

- `ListDevices`

### 1.3 Main dispatch

`main.rs` should dispatch:

```rust
match cli.mode()? {
    Mode::Run => ...,
    Mode::Check => ...,
    Mode::ListDevices => ...,
    Mode::PrintConfigPath => ...,
}
```

For now, modes may print placeholder messages until later phases are implemented.

Exit codes: `1` means config/validation failure, `2` means runtime/device error
(see SPEC.md section 5 and 10.1). A plain `fn main() -> anyhow::Result<()>` always
exits `1` on error, so do not rely on it for the `1` vs `2` split. Instead have
`main` compute the code and call `std::process::exit(code)` explicitly — e.g. run
the real logic in `fn run() -> Result<()>`, then in `main` match the error
category to `1` or `2`.

### 1.4 Verify

Run:

```sh
cargo fmt
cargo run -- --help
cargo run -- --check --help
cargo run -- --list-devices --help
cargo run -- --print-config-path --help
```

Note: `--check --help` just prints global help because there are no subcommands. This is fine.

Confirm help shows a usage shaped like:

```text
audiorouter [OPTIONS] [CONFIG]
```

### 1.5 CLI tests

Add tests for mode selection:

- no flags => `Mode::Run`
- `--check` => `Mode::Check`
- `--list-devices` => `Mode::ListDevices`
- `--print-config-path` => `Mode::PrintConfigPath`
- `--check --list-devices` fails
- `--check --print-config-path` fails
- `--list-devices --print-config-path` fails
- `CONFIG` with `--list-devices` fails
- `CONFIG` with `--check` is allowed
- `CONFIG` with `--print-config-path` is allowed

---

## Phase 2: Config structs and path resolution

### 2.1 Implement config structs in `src/config.rs`

Use serde deserialize.

Required structs:

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
    pub engine: EngineConfig,
    #[serde(default)]
    pub devices: Vec<DeviceConfig>,
    #[serde(default)]
    pub routes: Vec<RouteConfig>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct EngineConfig {
    pub sample_rate: u32,
    pub buffer_size: u32,
}

#[derive(Debug, Clone, Deserialize)]
pub struct DeviceConfig {
    pub name: String,
    pub device: String,
    #[serde(default)]
    pub limiter: bool,
}

#[derive(Debug, Clone, Deserialize)]
pub struct RouteConfig {
    pub from: String,
    pub to: String,
    pub from_channels: Vec<usize>,
    pub to_channels: Vec<usize>,
    #[serde(default)]
    pub gain_db: f32,
    #[serde(default)]
    pub mute: bool,
}
```

Channel numbers are stored as `usize` but represent **1-based physical channel numbers**.

### 2.2 Config path resolution

Implement:

```rust
pub fn default_config_path() -> anyhow::Result<PathBuf>
pub fn resolve_config_path(config_arg: Option<&Path>) -> anyhow::Result<PathBuf>
```

Rules:

1. If `config_arg` is Some:
   - If absolute, return it as a `PathBuf`.
   - If relative, return `std::env::current_dir()?.join(config_arg)`.
   - Do not call `canonicalize()` here, because `--print-config-path` must work even if the file does not exist.
2. Else if `XDG_CONFIG_HOME` is set and non-empty, return `$XDG_CONFIG_HOME/audiorouter/config.toml`.
3. Else return `dirs::home_dir().ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?.join(".config/audiorouter/config.toml")`.

Note: `dirs::home_dir()` returns `Option<PathBuf>` in all current versions of the `dirs` crate. Do not use any deprecated `home_dir` function from the standard library.

### 2.3 Read config

Implement:

```rust
pub fn read_config(path: &Path) -> anyhow::Result<Config>
```

Behavior:

- Read file as UTF-8 text.
- Parse as TOML.
- Include path in error context.
- If the file does not exist, return an error whose message includes:
  - the resolved path
  - the hint `Run 'audiorouter --print-config-path' to see the expected location.`

### 2.4 No config-writing or config-template command

Do not implement config generation in v0.1.

Specifically:

- No `init` command.
- No `--example-config` option.
- No command or option that writes config files.
- No command or option that prints a config template.

Users create TOML files manually, using `SPEC.md` section 13 as a copyable sample.

### 2.5 Tests

Add tests in `config.rs`:

- parses the sample config from `SPEC.md` copied into a test string
- `XDG_CONFIG_HOME` path resolution
- fallback home path if practical
- positional absolute `CONFIG` path is returned unchanged
- positional relative `CONFIG` path is joined with current working directory
- missing config path produces a clear error from `read_config`

Use `tempfile` only if you add it as a dev-dependency. Otherwise write tests that do not require filesystem temp dirs except via standard library.

---

## Phase 3: Validation and role inference

### 3.1 Create validation types in `src/validate.rs`

Define a validated plan that is independent of CPAL streams.

Suggested structs:

```rust
pub struct ValidatedConfig {
    pub config: Config,
    pub devices: Vec<ResolvedDeviceRole>,
    pub routes: Vec<ValidatedRoute>,
    pub warnings: Vec<String>,
}

pub struct ResolvedDeviceRole {
    pub name: String,
    pub device: String,
    pub limiter: bool,
    pub needs_input: bool,
    pub needs_output: bool,
    pub required_input_channels: usize,
    pub required_output_channels: usize,
}

pub struct ValidatedRoute {
    pub from: String,
    pub to: String,
    pub from_channels: Vec<usize>,
    pub to_channels: Vec<usize>,
    pub gain_db: f32,
    pub mute: bool,
}
```

### 3.2 Implement pure config validation

Implement:

```rust
pub fn validate_config(config: Config) -> Result<ValidatedConfig, Vec<String>>
```

This function should not touch CPAL. It only validates internal config consistency.

Validation rules:

1. `engine.sample_rate > 0`.
2. `engine.buffer_size > 0`.
3. At least one device.
4. At least one route.
5. Device `name` (alias) non-empty.
6. Device `device` (CoreAudio name) non-empty.
7. Device `name` values unique.
8. Every route `from` exists.
9. Every route `to` exists.
10. Reject `from == to` in v0.1.
11. `from_channels` non-empty.
12. `to_channels` non-empty.
13. `from_channels.len() == to_channels.len()`.
14. Every channel number is >= 1.
15. `gain_db.is_finite()`.
16. Infer roles and required channel counts.
17. Warn for unused devices.

Collect as many errors as possible rather than returning after the first error.

### 3.3 Error wording

Use explicit messages. Include route index and device alias where possible.

Examples:

```text
route[0].from references unknown device alias "foo"
route[1] has from_channels length 1 but to_channels length 2
route[2].from_channels contains invalid channel 0; channels are 1-based
```

### 3.4 Check mode implementation

Implement check mode:

1. Resolve path from positional `CONFIG` or default path.
2. Read config.
3. Run pure validation.
4. If pure validation fails, print all errors and exit 1.
5. Resolve actual audio devices and validate channel counts in Phase 4.
6. For now, print pure validation success if Phase 4 is not done.

### 3.5 Tests

Add unit tests for:

- duplicate device names fail
- unknown route `from` fails
- unknown route `to` fails
- mismatched channel lengths fail
- zero channel fails
- `from == to` fails
- non-finite `gain_db` (NaN/inf) fails
- role inference from route direction
- required channel count inference
- unused device warning

Run:

```sh
cargo test
```

---

## Phase 4: Device enumeration and resolution

### 4.1 Implement `src/devices.rs`

Use CPAL.

Functions:

```rust
pub fn print_devices() -> anyhow::Result<()>
pub fn resolve_devices(plan: &ValidatedConfig) -> anyhow::Result<ResolvedAudioDevices>
```

You can adjust type names, but keep responsibilities clear.

### 4.2 Device listing

`audiorouter --list-devices` should:

1. Get default host: `cpal::default_host()`.
2. Print input devices.
3. Print output devices.
4. For each, print device name and supported configs if available.
5. Do not read config.

CPAL APIs to inspect:

- `host.input_devices()`
- `host.output_devices()`
- `device.name()`
- `device.supported_input_configs()`
- `device.supported_output_configs()`
- `host.default_input_device()`
- `host.default_output_device()`

Make output robust: if supported configs cannot be queried, print a warning for that device and continue.

### 4.3 Exact device matching

v0.1 matching rule:

- A config `device = "..."` must exactly match `cpal::Device::name()`.

For a device used as input, search input devices.
For a device used as output, search output devices.
For a device used as both, search both lists.

If not found, error:

```text
device alias "vt4" uses CoreAudio device "VT-4" as input, but no matching input device was found. Run `audiorouter --list-devices`.
```

### 4.4 Channel-count validation against actual devices

For each resolved input:

- Determine maximum supported input channels across supported input configs.
- Ensure `required_input_channels <= max_input_channels`.

For each resolved output:

- Determine maximum supported output channels across supported output configs.
- Ensure `required_output_channels <= max_output_channels`.

If supported configs cannot be queried, allow run mode to attempt opening but print warning during `--check`.

### 4.5 Sample-rate validation

For each required input/output device:

- Verify at least one supported config range includes `engine.sample_rate`.
- If unavailable, warn or defer to stream opening.
- Prefer clear error if configs are available and sample rate is unsupported.

### 4.6 Integrate with check mode

`audiorouter --check [CONFIG]` should run:

1. TOML parse.
2. Pure validation.
3. CPAL device resolution.
4. Channel/sample-rate validation.

Print warnings, then success summary:

```text
Config OK: 4 devices, 3 routes, sample_rate=48000, buffer_size=256
Inputs: vt4 -> VT-4, mic -> MacBook Pro Microphone
Outputs: blackhole -> BlackHole 2ch, speaker -> MacBook Pro Speakers
```

---

## Phase 5: Mixer math

### 5.1 Implement `src/mixer.rs`

Pure functions first. Do not involve CPAL here.

Required functions:

```rust
pub fn db_to_linear(db: f32) -> f32        // 10^(db/20)
pub fn hard_limit_sample(x: f32) -> f32    // clamp(x, -1.0, 1.0)
pub fn hard_limit_buffer(buf: &mut [f32])  // applies hard_limit_sample to every element
```

Then implement a small channel mapping helper.

Suggested function shape:

```rust
pub fn mix_route_interleaved(
    input: &[f32],
    input_channels: usize,
    output: &mut [f32],
    output_channels: usize,
    from_channels_1based: &[usize],
    to_channels_1based: &[usize],
    gain: f32,
)
```

Assumptions:

- Buffers are interleaved.
- Frame count is `min(input.len() / input_channels, output.len() / output_channels)`.
- Channel numbers are 1-based.
- Caller already validated channel numbers.

### 5.2 Tests

Test:

1. `db_to_linear(0.0) == 1.0` approximately.
2. `db_to_linear(-6.0)` approximately `0.501`.
3. hard limiter clamps above 1 and below -1.
4. stereo passthrough:
   - input L/R -> output L/R.
5. mono-to-stereo:
   - input ch1 repeated to output ch1/ch2.
6. summing:
   - call mix twice into same output and confirm values add.
7. fan-out:
   - mix one input into two separate output buffers; both receive the source
     (a non-destructive read; confirms 1-input -> N-output works).

Do this before writing the real audio engine.

---

## Phase 6: Audio engine v0.1

This is the hardest part. Keep it minimal.

### 6.1 Initial implementation strategy

All audio engine code lives in `src/audio.rs`. `src/main.rs` calls into it; `src/mixer.rs` is called from within it.

Read SPEC.md section 8.3 first. The key constraint: one input can fan out to
multiple outputs (the sample config sends `vt4` to both `blackhole` and
`speaker`), and an SPSC ring buffer has a single, destructive consumer. So a
single ring buffer **per input device** does not work — use **one ring buffer
per route**.

Preferred approach — one `ringbuf` SPSC ring per route:

```rust
use ringbuf::{HeapRb, traits::*};

// channels = physical channel count of route.from's input stream (NOT
// from_channels.len()). The route buffer carries the source device's full
// physical frames so the mixer can index by 1-based physical channel.
// Capacity is in f32 elements (samples), so multiply by channels.
let capacity = engine.buffer_size as usize * channels * 4;
let rb = HeapRb::<f32>::new(capacity);
let (prod, cons) = rb.split();
// prod: written by the input callback of route.from (single producer)
// cons: read by the output callback of route.to   (single consumer)
```

- The input callback for device `D` writes its full physical interleaved frames
  into the producer of **every** route where `D` is `from`.
- The output callback for device `O` reads from the consumer of **every** route
  where `O` is `to`, then mixes (see `src/mixer.rs`).
- On overrun (producer full), drop oldest/skip; never block the callback.
- On underrun (consumer empty), fill with silence.

Fallback — if the `ringbuf` 0.4 split/producer API proves too fiddly, use a
non-destructive shared latest-buffer keyed by input device:

```rust
struct LatestInputBuffer {
    channels: usize,
    frames: Vec<f32>,  // interleaved, len = buffer_size * channels
}
// Arc<Mutex<LatestInputBuffer>> per input device.
// Output callbacks lock and COPY (non-destructive) so fan-out still works.
```

If using the fallback, document clearly that v0.1 uses latest-buffer mixing and may not be sample-accurate under load. The fallback must still not block for long inside callbacks.

### 6.2 Stream config

For each input device (open it **once**, even if it is `from` in many routes):

- Open one input stream.
- Use `engine.sample_rate`.
- Open with the device's **full physical channel count** (the channel count of
  the chosen supported config), not `required_input_channels`. Physical channel
  `N` maps to interleaved index `N-1` only when all channels are present; see
  SPEC.md 8.1. `required_input_channels` is only the minimum the device must
  expose, used during validation.
- Convert incoming samples to f32.
- Write frames into each outgoing route's buffer.

For each output device (open it **once**, even if many routes target it):

- Open one output stream.
- Use `engine.sample_rate`.
- Open with the device's full physical channel count, same reasoning as inputs.
- In callback:
  - clear output to zero
  - collect routes targeting this output
  - read needed source data
  - call mixer mapping for each route
  - apply limiter if configured

Important: open each physical input and output device only once, then share its
stream across all routes that reference it.

### 6.3 CPAL sample formats

CPAL streams may use `f32`, `i16`, `u16`, etc.

Implement helpers to convert input samples to f32 and f32 output to the output sample type.

CPAL examples usually use match on `SampleFormat` and generic callback functions. Follow that pattern.

### 6.4 Shutdown

Use `ctrlc` crate:

- Create an `Arc<AtomicBool>` running flag (also used by fatal-error path in 6.5).
- Ctrl-C (SIGINT) handler sets running to false.
- Run mode starts streams and then sleeps in a loop while running is true.
- Streams are dropped at the end of the `run_audio` function (natural drop when the `Vec<Stream>` goes out of scope).
- SIGTERM is not handled in v0.1; the OS default applies.

Pseudo-code:

```rust
// streams: Vec<cpal::Stream> — keep alive here
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || { r.store(false, Ordering::SeqCst); })?;

while running.load(Ordering::SeqCst) {
    std::thread::sleep(Duration::from_millis(100));
}
// streams dropped here -> CPAL stops callbacks
```

### 6.5 Error callbacks

CPAL stream error callbacks should print errors to stderr. For fatal/unrecoverable errors:

- Store a clone of the `running` `Arc<AtomicBool>` in the error closure.
- Set running to false so the main loop exits.
- Store an `Arc<AtomicBool> fatal_error` flag; if true, main returns exit code 2.

Do not panic inside callbacks.

### 6.6 Startup summary

Before playing streams, print unless `--quiet` is set:

```text
Using config: ...
Engine: 48000 Hz, buffer_size=256
Inputs:
  vt4 -> VT-4, required channels: 4
Outputs:
  blackhole -> BlackHole 2ch, required channels: 2, limiter: true
Routes:
  vt4 [3,4] -> blackhole [1,2], gain=0.0 dB
```

---

## Phase 7: Verification

Run after each meaningful phase:

```sh
cargo fmt --check
cargo test
```

Before considering v0.1 complete, run:

```sh
cargo fmt --check
cargo clippy -- -D warnings
cargo test
cargo run -- --help
cargo run -- --list-devices
cargo run -- --print-config-path
```

Then test config commands with a manually created TOML file:

```sh
cat > /tmp/audiorouter-test.toml <<'EOF'
[engine]
sample_rate = 48000
buffer_size = 256

[[devices]]
name = "source"
device = "Your Input Device"

[[devices]]
name = "blackhole"
device = "BlackHole 2ch"
limiter = true

[[routes]]
from = "source"
to = "blackhole"
from_channels = [1, 2]
to_channels = [1, 2]
gain_db = 0.0
EOF

cargo run -- --check /tmp/audiorouter-test.toml
```

The placeholder device names may fail device resolution until edited. That is acceptable. Also test a syntactically valid config with actual device names from `cargo run -- --list-devices`.

For real routing verification on macOS:

1. Install or confirm BlackHole 2ch exists.
2. Run `cargo run -- --list-devices` and copy exact names.
3. Create a real config, e.g. `/tmp/audiorouter-real.toml`.
4. Run:

   ```sh
   cargo run -- --check /tmp/audiorouter-real.toml
   cargo run -- /tmp/audiorouter-real.toml
   ```

5. Select BlackHole in a receiving app or observe levels in Audio MIDI Setup/OBS/DAW.
6. Press Ctrl-C and confirm clean exit.

---

## Recommended implementation order checklist

Use this as a task list.

- [ ] Add dependencies to `Cargo.toml` (including `ringbuf`).
- [ ] Create module files.
- [ ] Implement CLI parser and mode selection.
- [ ] Implement CLI mode validation.
- [ ] Implement config path resolution for positional `CONFIG`.
- [ ] Implement config structs and TOML parsing.
- [ ] Add CLI/config parsing/path tests.
- [ ] Implement pure validation and role inference.
- [ ] Add validation tests.
- [ ] Implement `--list-devices`.
- [ ] Implement exact device resolution.
- [ ] Integrate device/channel/sample-rate validation into `--check`.
- [ ] Implement `--print-config-path`.
- [ ] Implement mixer math.
- [ ] Add mixer tests.
- [ ] Implement minimal audio engine for default run mode.
- [ ] Add Ctrl-C shutdown.
- [ ] Run fmt/clippy/tests.
- [ ] Manually verify `--list-devices`, `--print-config-path`, and `--check` with a hand-written config.
- [ ] Manually verify real routing to BlackHole if available.

---

## Important design constraints

1. Do not implement subcommands in v0.1.
   - No `audiorouter run`.
   - No `audiorouter check`.
   - No `audiorouter devices`.
   - Default run is simply `audiorouter [CONFIG]`.
2. Do not implement `--config` in v0.1.
   - Config selection is positional `CONFIG` only.
3. Do not introduce `type = "input" | "output"` in `[[devices]]`.
   - Role comes from route direction.
4. Do not introduce `buses` in v0.1.
5. Do not introduce `sends` in v0.1.
6. Do not renumber channels internally in config semantics.
   - Config channel numbers are always 1-based physical channel numbers.
7. Do not silently resample in v0.1.
8. Do not open the same physical device once per route.
   - Open one input stream per input device and one output stream per output device.
   - Mix all routes targeting an output into that single output stream.
   - One input may fan out to several outputs; use one ring buffer per route (not
     per input device) so the single-consumer SPSC discipline still holds.
9. Do not panic in audio callbacks.
10. Keep mixer and validation logic unit-testable without audio hardware.
11. Do not implement config generation in v0.1.
    - No `init` command.
    - No `--example-config` option.
    - Config examples live in documentation only.

---

## Known future extensions, not v0.1

These are intentionally out of scope now:

- Subcommands, if a future UI genuinely needs them.
- `--config`; positional `CONFIG` is the only v0.1 config selector.
- Hot reload config.
- `audiorouter status`.
- `--example-config` or any other config-template printing mode.
- JSON output for device listing.
- Audio Process Tap input sources.
- Recording routes to WAV/FLAC.
- Network output.
- Soft limiter/compressor.
- Named presets/scenes.
- HAL virtual device backend.
- LaunchAgent daemon mode.
- Device matching by UID.
- Fuzzy device name matching.
- Sample-rate conversion.

Do not implement these while completing v0.1 unless explicitly requested.