audiorouter 0.2.0

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
# audiorouter v0.1 Specification

`audiorouter` is a macOS-first command-line audio router. It reads a TOML configuration file, opens named CoreAudio devices, remaps/mixes audio channels in real time, and writes the mixed result into output devices such as BlackHole.

The v0.1 scope is intentionally small: **named devices + routes only**. There are no explicit buses, sends, scenes, plugins, UI, daemon mode, subcommands, or HAL driver in v0.1.

## 1. Goals

v0.1 must support:

1. A subcommand-free UNIX-style CLI:
   - `audiorouter` runs with the default config.
   - `audiorouter CONFIG` runs with a specified config file.
   - `audiorouter --check [CONFIG]` validates config and device availability.
   - `audiorouter --list-devices` lists available devices and does not read config.
   - `audiorouter --print-config-path [CONFIG]` prints the resolved config path and exits.
2. A simple TOML model:
   - `[[devices]]`: named real audio devices.
   - `[[routes]]`: channel mappings from one device to another.
3. Multiple inputs mixed into one output.
4. One input routed to multiple outputs.
5. Channel remapping, including mono-to-stereo by repeating `from_channels`.
6. Per-route gain in decibels.
7. Optional per-output limiter flag.
8. Clean Ctrl-C (SIGINT) shutdown while running.
9. Clear error message when the default config path does not exist and no `CONFIG` is supplied.

v0.1 does **not** need to support:

- CLI subcommands such as `run`, `check`, `devices`, or `init`.
- `--config`; config selection is the positional `CONFIG` argument.
- Generating or printing example configs from the CLI, including `--example-config`.
- Creating or modifying config files.
- HAL AudioServerPlugIn virtual device implementation.
- Creating or destroying Aggregate Devices.
- Installing BlackHole.
- Capturing per-application audio via Audio Process Tap.
- Config hot reload.
- Recording to file.
- Network streaming.
- GUI/menu bar app.
- Persistent daemon/service mode.
- Per-route EQ/compressor/noise gate.
- Arbitrary graph cycles.
- Sample-rate conversion beyond fail-fast validation.

## 2. Platform

v0.1 targets macOS.

The implementation may compile on other platforms if CPAL supports them, but the product contract for v0.1 is macOS/CoreAudio. If platform-specific assumptions are needed, guard them with `cfg(target_os = "macos")`. On non-macOS platforms, the binary must either refuse to start with a clear error message at runtime or produce a compile-time error — do not silently produce wrong behavior.

## 3. CLI interface

Use `clap` derive or an equivalent clear command parser.

There are no subcommands in v0.1.

```text
Usage:
  audiorouter [OPTIONS] [CONFIG]

Arguments:
  [CONFIG]
      TOML configuration file to read.
      If omitted, audiorouter reads the default XDG config path.

Options:
  -n, --check
      Validate configuration and device availability, then exit.

  -l, --list-devices
      List available audio input/output devices, then exit.
      Does not read CONFIG.

      --print-config-path
      Print the resolved configuration path, then exit.
      If CONFIG is supplied, print the resolved CONFIG path.

  -q, --quiet
      Suppress non-error output.

  -v, --verbose
      Print extra diagnostics. Repeat for more detail.
      -v:   debug-level tracing (open/close device, route resolution)
      -vv:  trace-level (per-callback timing, underrun counts)

  -h, --help
      Print help.

  -V, --version
      Print version.
```

### 3.1 Mode selection

The program has four modes:

| Invocation | Mode | Reads config? | Starts audio? |
|---|---|---:|---:|
| `audiorouter` | run | yes | yes |
| `audiorouter CONFIG` | run | yes | yes |
| `audiorouter --check` | check | yes | no |
| `audiorouter --check CONFIG` | check | yes | no |
| `audiorouter --list-devices` | list devices | no | no |
| `audiorouter --print-config-path` | print config path | no | no |
| `audiorouter --print-config-path CONFIG` | print config path | no | no |

Mode flags are mutually exclusive:

- `--check`
- `--list-devices`
- `--print-config-path`

Invalid:

```sh
audiorouter --check --list-devices
audiorouter --check --print-config-path
audiorouter --list-devices --print-config-path
```

`--quiet` and `--verbose` are not mode flags and are not mutually exclusive with
each other at the parser level. If both are supplied, `--quiet` takes precedence
and the effective log level is ERROR.

### 3.2 Positional `CONFIG`

`CONFIG` is an optional path to a TOML config file.

Rules:

1. `CONFIG` is a file path, not a profile name.
2. No `.toml` suffix inference is performed.
3. No lookup under `~/.config/audiorouter/` is performed for bare names.
4. If `CONFIG` is relative, resolve it relative to the current working directory.
5. Do not use `std::fs::canonicalize()` for `--print-config-path`, because the file may not exist yet.
6. For `run` and `--check`, reading the file naturally fails if it does not exist.

Examples:

```sh
audiorouter
audiorouter ./vt4.toml
audiorouter --check
audiorouter --check ./vt4.toml
audiorouter --print-config-path
audiorouter --print-config-path ./vt4.toml
```

`CONFIG` is not allowed with `--list-devices`, because that mode does not read configuration:

```sh
audiorouter --list-devices ./vt4.toml
```

must fail with a clear error.

## 4. Configuration path resolution

Resolved config path rules:

1. If positional `CONFIG` is passed, use that path.
   - If relative, convert it to an absolute path by joining it with the current working directory.
   - Do not require the path to exist when only printing it.
2. Else if `XDG_CONFIG_HOME` is set and non-empty, use:

   ```text
   $XDG_CONFIG_HOME/audiorouter/config.toml
   ```

3. Else use:

   ```text
   ~/.config/audiorouter/config.toml
   ```

The program never creates or modifies the config file in v0.1. Users create and edit the TOML file themselves.

## 5. Mode behavior

### 5.1 Default run mode

Invocation:

```sh
audiorouter [CONFIG]
```

Behavior:

1. Resolve config path.
2. Read config. If the file does not exist, print a clear error including the resolved path and a hint such as `Run 'audiorouter --print-config-path' to see the expected location.`
3. Validate config exactly as `--check` does.
4. Open required input streams and output streams.
5. Route/mix audio until SIGINT (Ctrl-C) or a fatal audio error.
6. On SIGINT, set running flag to false; wait for the main loop to exit; drop streams; exit 0.
   SIGTERM is not handled in v0.1; the OS will deliver the default termination.
7. On a fatal stream error, set running flag to false, exit 2.
8. Print a short startup summary unless `--quiet` is set:
   - config path
   - engine sample rate
   - buffer size
   - devices opened as input/output
   - route count

Exit codes:

- `0`: clean Ctrl-C or normal shutdown.
- `1`: config validation failed.
- `2`: runtime/audio device error.

On a fatal stream error during run (CPAL error callback that is unrecoverable):

- Log the error to stderr.
- Signal the main loop to stop (set the running flag to false).
- Allow streams to be dropped and exit with code `2`.
- Do not panic inside the callback.

### 5.2 Check mode

Invocation:

```sh
audiorouter --check [CONFIG]
audiorouter -n [CONFIG]
```

Behavior:

1. Resolve config path.
2. Read and parse config (TOML parse + struct deserialization in one step).
3. Run pure config validation (no CPAL).
4. Resolve device names via CPAL.
5. Validate channel counts and sample-rate support when CPAL reports enough information.
6. Print either a success summary or all discovered validation errors.
7. Do not open long-running audio streams.

Exit codes:

- `0`: valid config.
- `1`: config validation failed.
- `2`: unexpected I/O/runtime/device-query error.

### 5.3 List-devices mode

Invocation:

```sh
audiorouter --list-devices
audiorouter -l
```

Behavior:

- Do not read config.
- Show input devices and output devices separately.
- For each device, show:
  - display name
  - max input channels if available
  - max output channels if available
  - supported sample rates when available
  - default input/output marker if available

Human-readable output is enough for v0.1. JSON output is not required.

Exit codes:

- `0`: success.
- `2`: device-query error.

### 5.4 Print-config-path mode

Invocation:

```sh
audiorouter --print-config-path
audiorouter --print-config-path CONFIG
```

Behavior:

- Do not read config.
- Print exactly one path to stdout.
- If `CONFIG` is supplied, print the resolved absolute path for that argument.
- If `CONFIG` is omitted, print the resolved default config path.
- Do not create directories or files.

Exit codes:

- `0`: success.
- `2`: path resolution error, such as no discoverable home directory for the default path.

## 6. TOML schema

### 6.1 Full example

```toml
[engine]
sample_rate = 48000
buffer_size = 256

[[devices]]
name = "vt4"
device = "VT-4"

[[devices]]
name = "mic"
device = "MacBook Pro Microphone"

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

[[devices]]
name = "speaker"
device = "MacBook Pro Speakers"

[[routes]]
from = "vt4"
to = "blackhole"
from_channels = [3, 4]
to_channels = [1, 2]
gain_db = 0.0

[[routes]]
from = "mic"
to = "blackhole"
from_channels = [1, 1]
to_channels = [1, 2]
gain_db = -8.0

[[routes]]
from = "vt4"
to = "speaker"
from_channels = [3, 4]
to_channels = [1, 2]
gain_db = -12.0
```

### 6.2 `[engine]`

Optional table. If omitted, the engine uses the defaults below.

| Field | Type | Required | Default | Description |
|---|---:|---:|---:|---|
| `sample_rate` | integer | no | `48000` | Engine sample rate in Hz. v0.1 requires all opened devices to support this rate. |
| `buffer_size` | integer | no | `256` | Desired audio buffer size in frames. |

Validation:

- `sample_rate` must be positive.
- Recommended sample rates are `44100` and `48000`, but do not hard-code only these if the device reports others.
- `buffer_size` must be positive.
- Recommended `buffer_size` range is `64..=2048`.
- v0.1 may warn outside the recommended range instead of failing.

### 6.3 `[[devices]]`

Array of named device definitions. Entries may be omitted for devices referenced directly by routes; missing route endpoints are treated as implicit devices with `name = device = <route string>` and `limiter = false`.

| Field | Type | Required | Default | Description |
|---|---:|---:|---:|---|
| `name` | string | no | value of `device` | Stable config-local alias used by routes. |
| `device` | string | yes | none | Human-readable CoreAudio device name to match. |
| `limiter` | bool | no | `false` | Applies only when this device is used as an output. Enables simple output limiter. |

No `type` field exists in v0.1. A device's role is inferred from routes:

- Appears in `route.from` => opened as input.
- Appears in `route.to` => opened as output.
- Appears in both => opened as both input and output if the underlying device supports duplex use.

Validation:

- `name` must be non-empty.
- `name` must be unique.
- `device` must be non-empty.
- A defined device not used by any route should produce a warning, not an error.
- Matching by exact device name is required for v0.1.
- Optional future improvement: unique case-insensitive substring matching. Do not implement substring matching unless ambiguity handling is also implemented.

### 6.4 `[[routes]]`

Array of channel mappings.

| Field | Type | Required | Default | Description |
|---|---:|---:|---:|---|
| `from` | string | yes | none | Source device alias. |
| `to` | string | yes | none | Destination device alias. |
| `from_channels` | array of positive integers | yes | none | Source physical input channels, 1-based. |
| `to_channels` | array of positive integers | yes | none | Destination physical output channels, 1-based. |
| `gain_db` | float | no | `0.0` | Gain applied to this route before mixing. |
| `mute` | bool | no | `false` | If true, route contributes silence. Optional in v0.1; easy to support. |

Channel arrays use **1-based physical channel numbers**. There is no hidden internal channel renumbering in the config.

Examples:

```toml
from_channels = [3, 4]
to_channels = [1, 2]
```

means:

```text
source physical ch3 -> output physical ch1
source physical ch4 -> output physical ch2
```

Mono-to-stereo is expressed by repeating the source channel:

```toml
from_channels = [1, 1]
to_channels = [1, 2]
```

means:

```text
source physical ch1 -> output physical ch1
source physical ch1 -> output physical ch2
```

Validation:

- If `from` does not refer to an explicit `devices.name`, it is treated as an implicit device with `name = device = from`.
- If `to` does not refer to an explicit `devices.name`, it is treated as an implicit device with `name = device = to`.
- `from_channels` and `to_channels` must have the same length.
- Both arrays must be non-empty.
- Every channel number must be `>= 1`.
- `from` and `to` may be the same only if the implementation can safely avoid feedback; for v0.1, reject same-device routes with a clear error.
- `gain_db` should be finite. Reject NaN and infinity.
- If `mute = true`, still validate all device/channel references.

## 7. Device role inference

After parsing config:

1. Build a map of `devices.name -> DeviceConfig`.
2. Initialize role flags for every device:
   - `needs_input = false`
   - `needs_output = false`
3. For each route:
   - set `route.from.needs_input = true`
   - set `route.to.needs_output = true`
4. A device may have both flags.
5. A device with neither flag is unused and should warn.

Required channel counts are derived from routes:

- For each input device, required input channel count is the max of all `from_channels` where it appears as `from`.
- For each output device, required output channel count is the max of all `to_channels` where it appears as `to`.

## 8. Audio processing model

### 8.1 Internal sample format

Use `f32` internally.

If CPAL provides other formats, convert to/from `f32` at the stream boundary.

Config channel numbers are **1-based physical channel indices**. For the mapping
`physical channel N -> interleaved index N-1` to be correct, each stream must be
opened with the device's full physical channel count, **not** the route's
`required_*_channels`. Opening a 4-channel device with only 2 channels would make
channels 3 and 4 unaddressable and silently misroute audio. Choose the supported
config whose channel count exposes every channel the routes reference (typically
the device's max channel count), then index into it.

### 8.2 Gain conversion

Route gain in dB converts to linear gain as:

```text
linear_gain = 10^(gain_db / 20)
```

For muted routes, effective gain is `0.0`.

### 8.3 Buffer topology and fan-out

A single input device may feed multiple output devices (goal 4), and the headline
example in section 6.1 routes `vt4` to both `blackhole` and `speaker`. This
constrains the shared-buffer design:

- A standard SPSC ring buffer has **one** consumer and reads are **destructive**.
  A single ring buffer per input device therefore **cannot** fan out to multiple
  outputs: whichever output callback reads first consumes the frames.

v0.1 must use one of these fan-out-safe designs:

1. **Per-route ring buffer (preferred).** Allocate one SPSC ring buffer per
   route (per `from -> to` edge). The input callback for device `D` is the single
   producer for every route where `D` is `from`, and writes the source device's
   **full physical interleaved frames** into each of those routes' ring buffers.
   The output callback for device `O` is the single consumer of every route ring
   buffer where `O` is `to`. Each route buffer therefore carries all of
   `route.from`'s physical channels, so the mixer can index by 1-based physical
   channel number (consistent with 8.1; no internal channel renumbering). This
   keeps SPSC discipline, supports fan-out, and is real-time friendly.
2. **Non-destructive shared latest-buffer (acceptable fallback).** Each input
   callback writes its latest block into an `Arc<Mutex<Vec<f32>>>`; each output
   callback locks and *copies* (non-destructive) the frames it needs. Fan-out
   works naturally because reads do not consume. Document that this is not
   sample-accurate under load and may briefly block on the mutex.

Do not use a single ring buffer per input device with multiple consumers.

### 8.4 Mixing rule

For each output callback buffer:

1. Clear the output buffer to silence.
2. Find all routes targeting that output device.
3. For each route:
   - Read up to `frame_count` source frames for `route.from` from that route's
     buffer (per the topology in 8.3).
   - For each mapping pair `(from_channel, to_channel)`:
     - Convert channel numbers from 1-based config to 0-based buffer indices.
     - Multiply source sample by route gain.
     - Add into destination output sample.
4. If output device has `limiter = true`, apply limiter to final mixed output.
5. Write output buffer.

Multiple routes writing to the same output channel are summed.

### 8.5 Missing, late, or excess input data

Independent input and output streams run on independent device clocks and drift
over time, so a steady stream of underruns and overruns is expected, not
exceptional.

If a route buffer has **insufficient** frames for an output callback (underrun):

- Fill the missing portion with silence.
- Do not panic.
- Count underruns for diagnostics if easy.

If a route buffer is **full** when the input callback tries to write (overrun):

- Drop the oldest frames (or skip the write) so the producer never blocks.
- Do not panic or grow the buffer unboundedly.
- Count overruns for diagnostics if easy.

### 8.6 Limiter v0.1

A simple limiter is acceptable:

```text
sample = clamp(sample, -1.0, 1.0)
```

This is technically a hard clipper, not a production limiter. Name the implementation clearly as `hard_limit_sample` (per-sample) and `hard_limit_buffer` (slice variant), and document that v0.1 uses hard clipping.

A better look-ahead limiter can be added later without changing the config schema.

### 8.7 Sample rate handling

v0.1 should fail fast if any required device cannot run at `engine.sample_rate`.

Do not silently resample in v0.1 unless a proper resampler is implemented and tested.

### 8.8 Buffer size handling

Use `engine.buffer_size` as the requested stream buffer size when CPAL supports it.

If the host/device refuses the requested fixed buffer size, either:

- fall back to the device default and print a warning, or
- fail with a clear error.

For v0.1, prefer fail-fast during `--check`/run if validation can detect unsupported buffer size; otherwise report the actual stream config during run.

### 8.9 Ring buffer sizing

With the per-route ring buffer design (8.3, option 1), each route owns one ring
buffer carrying the full physical interleaved frames of its `route.from` device.

Ring buffer capacity for v0.1: at least `engine.buffer_size * channels * 4`
**f32 elements**, where `channels` is the physical channel count of the
`route.from` device (the channel count the input stream is opened with, per 8.1),
**not** `from_channels.len()`. The `* 4` gives roughly four output-period headroom
against scheduling jitter and clock drift. Capacity is in samples (f32 elements),
not frames — do not forget the `channels` factor.

If the non-destructive latest-buffer design (8.3, option 2) is used instead, size
each input's shared buffer to one block: `engine.buffer_size * channels` f32
elements, and document the not-sample-accurate limitation. See section 9 for the
module layout.

## 9. Runtime architecture

Suggested modules:

```text
src/main.rs        CLI entrypoint: parse args, dispatch mode, convert errors to exit codes
src/cli.rs         clap option definitions, mode selection, argument validation
src/config.rs      config path resolution, TOML parsing, structs
src/validate.rs    config validation and route/device role inference (no CPAL)
src/devices.rs     CPAL device enumeration and resolution
src/audio.rs       stream startup/shutdown orchestration:
                     - open CPAL input/output streams per device
                     - allocate and wire shared ring buffers
                     - hold stream handles alive for the process lifetime
                     - handle SIGINT/fatal-stream-error shutdown
src/mixer.rs       gain, channel mapping, summing, limiter helpers (no CPAL)
```

Keep logic testable:

- CLI mode selection should be unit-testable without audio devices.
- Config parsing should not touch audio devices.
- Validation should be separable from stream startup.
- Mixer math should be unit-testable without CPAL.

## 10. Error messages

Errors should be specific enough that users can fix config files quickly.

Good examples:

```text
error: CONFIG cannot be used with --list-devices
error: --check, --list-devices, and --print-config-path are mutually exclusive
error: route[1].from references unknown device alias "vt-4"; known devices: vt4, mic, blackhole
error: route[2] maps from_channels length 1 to to_channels length 2; lengths must match. Use from_channels = [1, 1] for mono-to-stereo.
error: device "vt4" is used as input and route requires channel 4, but CoreAudio reports only 2 input channels for "VT-4".
error: output device "blackhole" resolved to "BlackHole 2ch", but route requires output channel 3.
```

Avoid vague errors like:

```text
invalid config
stream failed
```

### 10.1 Exit code mechanism

The modes above distinguish exit code `1` (config/validation failure) from `2`
(runtime/device/I-O error). A bare `fn main() -> anyhow::Result<()>` cannot
express this: it returns `1` for every `Err`. The implementation must map errors
to codes explicitly, for example:

- keep `main` returning a normal `Result` to a thin wrapper, and have the wrapper
  call `std::process::exit(code)` with an explicit code, or
- carry a category on the error (a small enum or an `anyhow` downcast) and select
  the code in one place before exiting.

Whatever the mechanism, the codes in section 5 are the contract; tests or manual
checks should confirm at least the `1` vs `2` distinction for a validation error
versus a missing/unavailable device.

## 11. Testing requirements

v0.1 should include unit tests for non-audio logic:

1. CLI mode selection:
   - no flags => run
   - `--check` => check
   - `--list-devices` => list devices
   - `--print-config-path` => print config path
   - multiple mode flags fail
   - `CONFIG` with `--list-devices` fails
2. Config path resolution with and without `XDG_CONFIG_HOME`.
3. Positional relative `CONFIG` is resolved against current working directory without requiring the file to exist for `--print-config-path`.
4. TOML parsing of the sample config.
5. Duplicate device names fail validation.
6. Unknown route device fails validation.
7. Channel array length mismatch fails validation.
8. Zero channel number fails validation.
9. Same-device route (`from == to`) fails validation.
10. Non-finite `gain_db` (NaN/inf) fails validation.
11. Device role inference works:
    - from-only => input
    - to-only => output
    - both => input and output
12. Required channel counts are computed from routes.
13. dB to linear gain conversion.
14. Mixer channel mapping:
    - stereo passthrough
    - mono-to-stereo via `[1, 1] -> [1, 2]`
    - multiple routes summed into one output
    - one input fanned out to two outputs (both receive the source)
    - hard limiter clamps output

Manual/ad-hoc verification on macOS:

1. `cargo fmt --check`
2. `cargo clippy -- -D warnings`
3. `cargo test`
4. `cargo run -- --help`
5. `cargo run -- --list-devices`
6. `cargo run -- --print-config-path`
7. Create a temporary TOML config manually using the sample in this spec.
8. `cargo run -- --check /tmp/audiorouter-test.toml`
9. With BlackHole installed and a valid input device configured:
   - `cargo run -- /tmp/audiorouter-real.toml`
   - confirm receiving app sees audio on BlackHole.

## 12. Dependency guidance

Recommended crates:

```toml
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"
ctrlc = ">=3,<4"
dirs = ">=5,<7"
tracing = ">=0.1,<0.2"
tracing-subscriber = ">=0.3,<0.4"
```

Do not add a resampling dependency in v0.1 unless resampling is actually implemented.

## 13. Sample config

Users create the config file manually. v0.1 does not provide an `init` command or `--example-config`; the sample below exists only in this specification and can be copied into the default config path or another TOML file path passed as positional `CONFIG`:

```toml
[engine]
sample_rate = 48000
buffer_size = 256

# Run `audiorouter --list-devices` to find the exact device names on your system.

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

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

# Stereo route: source ch1/ch2 -> BlackHole ch1/ch2
[[routes]]
from = "source"
to = "blackhole"
from_channels = [1, 2]
to_channels = [1, 2]
gain_db = 0.0
```

## 14. v0.1 acceptance criteria

The v0.1 implementation is acceptable when:

- `cargo fmt --check` passes.
- `cargo clippy -- -D warnings` passes.
- `cargo test` passes.
- `audiorouter --help` documents `audiorouter [OPTIONS] [CONFIG]` with no subcommands.
- `audiorouter --list-devices` lists CoreAudio devices on macOS.
- `audiorouter --print-config-path` prints the default config path.
- `audiorouter --check CONFIG` catches invalid aliases and invalid channel mappings.
- `audiorouter CONFIG` can route at least one stereo input to BlackHole 2ch on a machine where BlackHole is installed.
- Ctrl-C exits without panic and with exit code 0.
- Running with a missing config file produces a clear error message including the resolved path.
- `audiorouter --quiet CONFIG` suppresses the startup summary.
- Config syntax remains only `devices` + `routes` for routing concepts.