Skip to content

Commit 5b334af

Browse files
committed
chore: improve channel mapping
1 parent 8361297 commit 5b334af

2 files changed

Lines changed: 97 additions & 6 deletions

File tree

.github/copilot-instructions.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# MUC Soundboard AI Instructions
2+
3+
## 🏗 Architecture & Data Flow
4+
5+
- **Core Components**:
6+
- `CLI` (`src/cli.py`): Entry point using `rich-click`. Orchestrates `Soundboard` and `AudioManager`.
7+
- `Config` (`src/config.py`): Persists state (device ID, volume) to `~/.muc/config.json`.
8+
- `Soundboard` (`src/soundboard.py`): Manages sound library (scanning `sounds/`) and `pynput` hotkey bindings.
9+
- `AudioManager` (`src/audio_manager.py`): Low-level audio I/O via `sounddevice` and `soundfile`.
10+
11+
- **Audio Routing Strategy**:
12+
- The app outputs audio to a **Virtual Audio Cable** (e.g., VB-Cable).
13+
- **Critical**: The app must output to the "Input" side of the virtual cable (e.g., `CABLE Input`).
14+
- Games/Apps read from the "Output" side (e.g., `CABLE Output`) as a microphone.
15+
- `AudioManager.find_virtual_cable()` auto-detects devices with keywords: "cable", "virtual", "vb-audio".
16+
17+
- **Data Flow**:
18+
`CLI Command/Hotkey` -> `Soundboard` -> `AudioManager.play_audio()` -> `soundfile.read()` -> `numpy` processing (volume/channels) -> `sounddevice.play()` -> `Virtual Device`.
19+
20+
## 🛠 Developer Workflows
21+
22+
- **Dependency Management**: Use `uv` for package management.
23+
- Install: `uv add muc` or `uv sync`
24+
- Run: `uv run muc`
25+
- **Linting**: Run `make lint` to execute pre-commit hooks (ruff, etc.).
26+
- **Testing Audio**:
27+
- Use `muc devices` to list IDs.
28+
- Use `muc play [name]` to test playback without hotkeys.
29+
- `AudioManager.play_audio(..., blocking=True)` is used for sequential playback (e.g., `muc auto`).
30+
31+
## 🧩 Patterns & Conventions
32+
33+
- **UI/UX**:
34+
- **ALWAYS** use `rich.console.Console` for output. Never use `print()`.
35+
- Use `rich.table.Table` for listing data (devices, sounds).
36+
- Use `rich.panel.Panel` for welcome messages/headers.
37+
- Style errors with `[red]✗[/red]` and successes with `[green]✓[/green]`.
38+
39+
- **Audio Handling**:
40+
- **Channel Mapping**: `AudioManager` automatically upmixes mono files to match the output device's channel count using `numpy.tile`.
41+
- **Volume**: Applied as a scalar multiplication on the numpy array *before* playback.
42+
- **Non-blocking by default**: `play_audio` is fire-and-forget unless `blocking=True` is passed.
43+
44+
- **Configuration**:
45+
- Config is auto-loaded on instantiation of `Config()`.
46+
- `Config.save()` must be called explicitly after modifying settings.
47+
- Paths are handled with `pathlib.Path`.
48+
49+
- **Error Handling**:
50+
- Catch `OSError` and `RuntimeError` from `sounddevice`/`pynput`.
51+
- Display user-friendly messages via `console.print` instead of letting the app crash.
52+
- Example:
53+
```python
54+
try:
55+
sd.play(data, samplerate)
56+
except (OSError, RuntimeError) as e:
57+
self.console.print(f"[red]Error:[/red] {e}")
58+
```
59+
60+
## 🔑 Key Integration Points
61+
62+
- **Hotkeys**:
63+
- Uses `pynput.keyboard.GlobalHotKeys`.
64+
- The listener runs in a daemon thread, but the CLI command (`listen`) must keep the main thread alive (often with a `keyboard.Listener` blocking on `join()`).
65+
- **File Discovery**:
66+
- Recursively scans `sounds/` for extensions: `.wav`, `.mp3`, `.ogg`, `.flac`, `.m4a`.
67+
- Uses `pathlib.Path.rglob("*")`.

src/audio_manager.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,35 @@ def set_volume(self, volume: float) -> None:
117117
percentage = int(self.volume * 100)
118118
self.console.print(f"[cyan]♪[/cyan] Volume set to {percentage}%")
119119

120+
@staticmethod
121+
def _adjust_channels(data: np.ndarray, max_channels: int) -> np.ndarray:
122+
"""Adjust audio channels to match the output device.
123+
124+
Args:
125+
data: Audio data array
126+
max_channels: Target number of channels
127+
128+
Returns:
129+
Adjusted audio data array
130+
131+
"""
132+
if data.shape[1] < max_channels:
133+
# Duplicate channels to fill as much as possible
134+
tile_count = max_channels // data.shape[1]
135+
data = np.tile(data, (1, tile_count))
136+
137+
# If we still don't have enough channels (e.g. 2 -> 5), pad with silence
138+
current_channels = data.shape[1]
139+
if current_channels < max_channels:
140+
padding = np.zeros((data.shape[0], max_channels - current_channels))
141+
data = np.hstack((data, padding))
142+
143+
elif data.shape[1] > max_channels:
144+
# Take only the channels we need
145+
data = data[:, :max_channels]
146+
147+
return data
148+
120149
def play_audio(self, audio_file: Path, *, blocking: bool = False) -> bool:
121150
"""Play an audio file through the selected output device.
122151
@@ -155,12 +184,7 @@ def play_audio(self, audio_file: Path, *, blocking: bool = False) -> bool:
155184
max_channels = device_info["max_output_channels"] # pyright: ignore[reportCallIssue, reportArgumentType]
156185

157186
# Adjust channels if needed
158-
if data.shape[1] < max_channels:
159-
# Duplicate channels if we have fewer than device supports
160-
data = np.tile(data, (1, max_channels // data.shape[1]))
161-
elif data.shape[1] > max_channels:
162-
# Take only the channels we need
163-
data = data[:, :max_channels]
187+
data = self._adjust_channels(data, max_channels)
164188

165189
# Apply volume scaling
166190
data *= self.volume

0 commit comments

Comments
 (0)