Skip to content

Commit 5a271d5

Browse files
authored
Move FLAC decode pipeline off the event loop (#154)
## Summary - move FLAC decode and chunk submission into a dedicated synchronous worker (`_AudioSyncWorker`) so decode no longer runs in the event loop - make `AudioPlayer` synchronous (`submit`, sync `stop`) and switch its internal queue/timing calls to stdlib `queue.Queue` + `time.monotonic()` - route all audio chunks (PCM + FLAC), clears, and software volume updates through the worker thread - remove the unused `janus` dependency ## Validation - `python -m py_compile sendspin/audio.py sendspin/audio_connector.py sendspin/decoder.py` - `python -m compileall -q sendspin` - `python -m pytest -q` was not run in this environment (`No module named pytest`)
1 parent 9eba0be commit 5a271d5

5 files changed

Lines changed: 484 additions & 306 deletions

File tree

‎AGENTS.md‎

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
## Project Overview
2+
3+
Sendspin CLI is a synchronized audio player client for the [Sendspin Protocol](https://github.com/Sendspin/website/blob/main/src/spec.md). It connects to Sendspin servers via WebSocket, receives time-synchronized audio streams, and plays them back with precise timing to enable multi-room synchronized audio.
4+
5+
**Note**: If uncertain about how something in Sendspin is supposed to work, fetch and refer to the [protocol specification](https://github.com/Sendspin/website/blob/main/src/spec.md) for authoritative implementation details.
6+
7+
## Architecture
8+
9+
```
10+
cli.py (main entry)
11+
│
12+
┌───────────────┼───────────────┐
13+
│ │
14+
▼ ▼
15+
┌──────────┐ ┌──────────┐
16+
│ tui/ │ │ daemon/ │
17+
└──────────┘ └──────────┘
18+
│ │
19+
┌───────────┼───────────┐ │
20+
│ │ │ │
21+
▼ ▼ ▼ ▼
22+
┌────────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────┐
23+
│ keyboard │ │ ui │ │ app │ │ daemon │
24+
│ │ │ │ │ │ │ │
25+
│ - Keys │ │ - TUI │ │ - State │ │ - Headless │
26+
│ - Commands │ │ - Panel │ │ - Tasks │ │ - Signals │
27+
└────────────┘ └─────────┘ └─────────┘ └─────────────┘
28+
│ │ │ │
29+
└───────────┼───────────┴───────────────────┘
30+
│
31+
┌───────────┼───────────────────┐
32+
│ │ │
33+
▼ ▼ ▼
34+
┌─────────────┐ ┌───────────────┐ ┌──────────────┐
35+
│ audio.py │ │ discovery.py │ │ aiosendspin │
36+
│ │ │ │ │ (external) │
37+
│ - Playback │ │ - mDNS │ │ │
38+
│ - Time sync │ │ - Servers │ │ - WebSocket │
39+
└─────────────┘ └───────────────┘ └──────────────┘
40+
```
41+
42+
## File Responsibilities
43+
44+
### `README.md`
45+
Project documentation, installation instructions, usage guide (including command line arguments), configuration options.
46+
47+
### `sendspin/cli.py`
48+
Main entry point and orchestrator. Responsibilities:
49+
- **Argument parsing**: Commands (`serve`, `daemon`), flags (`--url`, `--name`, `--id`, `--audio-device`, `--static-delay-ms`)
50+
- **Command routing**: Routes to appropriate mode (TUI app, daemon, or server)
51+
- **Device enumeration**: Lists audio devices and servers via `--list-audio-devices` and `--list-servers`
52+
- **Backward compatibility**: Handles deprecated `--headless` flag with warning
53+
54+
### `sendspin/tui/` (TUI Mode Package)
55+
Interactive terminal UI mode with keyboard control.
56+
57+
#### `sendspin/tui/app.py`
58+
TUI mode application logic. Responsibilities:
59+
- **Service discovery**: mDNS discovery of Sendspin servers
60+
- **Connection management**: `ConnectionManager` handles reconnection with exponential backoff
61+
- **State management**: `AppState` dataclass mirrors server state (playback, metadata, volume)
62+
- **Audio stream handling**: `AudioStreamHandler` bridges between client and `AudioPlayer`
63+
- **Event callbacks**: Routes server messages to appropriate handlers (metadata, group updates, commands)
64+
- **UI coordination**: Manages `SendspinUI` and keyboard input loop
65+
66+
#### `sendspin/tui/keyboard.py`
67+
Keyboard input handling for interactive control. Responsibilities:
68+
- **Key capture**: Uses `readchar` library for single-keypress detection
69+
- **Command parsing**: `CommandHandler` class parses and executes commands
70+
- **Media commands**: play, pause, stop, next, previous, shuffle, repeat modes, switch group
71+
- **Volume control**: Group volume (`vol+`/`vol-`/`mute`) and player volume (`pvol+`/`pvol-`/`pmute`)
72+
- **Delay adjustment**: Real-time static delay adjustment via `delay` command
73+
- **Keyboard shortcuts**: Arrow keys (prev/next/volume), space (toggle), m (mute), g (switch group), q (quit)
74+
75+
#### `sendspin/tui/ui.py`
76+
Rich-based terminal UI for visual feedback. Responsibilities:
77+
- **Now Playing panel**: Track title, artist, album with playback shortcuts
78+
- **Volume panel**: Group and player volume with mute indicators
79+
- **Progress bar**: Real-time track progress with interpolation during playback
80+
- **Status line**: Connection status and quit shortcut
81+
- **Shortcut highlighting**: Visual feedback when shortcuts are pressed (0.15s highlight)
82+
- **State management**: `UIState` dataclass for all display state
83+
84+
### `sendspin/daemon/` (Daemon Mode Package)
85+
Headless operation mode for background services.
86+
87+
#### `sendspin/daemon/daemon.py`
88+
Daemon mode for headless operation (via `sendspin daemon` or deprecated `--headless`). Responsibilities:
89+
- **Headless operation**: Runs without UI or keyboard input
90+
- **Service discovery**: Same mDNS discovery as TUI mode
91+
- **Connection management**: Reuses `ConnectionManager` from `tui/app.py`
92+
- **Audio playback**: Same synchronized audio playback
93+
- **Event logging**: Prints events to stdout instead of UI
94+
- **Signal handling**: SIGINT/SIGTERM for graceful shutdown
95+
96+
### `sendspin/audio.py`
97+
Time-synchronized audio playback engine. Responsibilities:
98+
- **Audio output**: Uses `sounddevice` (PortAudio) for low-latency playback
99+
- **Time synchronization**: DAC-to-loop time calibration for precise playback timing
100+
- **Buffer management**: Queue-based buffering with gap/overlap handling
101+
- **Sync correction**: Sample drop/insert for drift correction (Kalman-filtered)
102+
- **Playback state machine**: INITIALIZING → WAITING_FOR_START → PLAYING → REANCHORING
103+
- **Volume control**: Software volume with power curve for natural control
104+
105+
### `sendspin/__init__.py`
106+
Package entry point, exports `main` from `cli.py`.
107+
108+
## Key Dependencies
109+
110+
- **aiosendspin**: Async Sendspin protocol client (WebSocket, time sync, messages)
111+
- **sounddevice**: PortAudio wrapper for audio I/O
112+
- **rich**: Terminal UI rendering
113+
- **readchar**: Cross-platform keyboard input
114+
- **zeroconf**: mDNS service discovery
115+
- **numpy**: Audio sample processing
116+
117+
## Common Tasks
118+
119+
### Running the player
120+
```bash
121+
uv run sendspin # TUI mode with auto-discovery
122+
uv run sendspin --url ws://host:port/sendspin # TUI mode with direct connection
123+
uv run sendspin daemon # Daemon mode (headless) with auto-discovery
124+
uv run sendspin daemon --url ws://host:port/sendspin # Daemon mode with direct connection
125+
```
126+
127+
### Development commands
128+
```bash
129+
uv run ruff check --fix . # Lint and auto-fix
130+
uv run ruff format . # Format
131+
uv run mypy sendspin # Type check
132+
```
133+
134+
### Before pushing commits
135+
Run pre-commit before pushing any commit to ensure it is valid:
136+
```bash
137+
uv run pre-commit run --all-files
138+
```
139+
140+
### Adding a new keyboard shortcut
141+
1. Add key handler in `tui/keyboard.py` `keyboard_loop()` function
142+
2. Add UI highlight call: `ui.highlight_shortcut("shortcut_name")`
143+
3. Execute command via `handler.execute("command")`
144+
4. Update shortcut display in `tui/ui.py` `_build_now_playing_panel()` or relevant panel
145+
146+
### Adding a new media command
147+
1. Check if `MediaCommand` enum in aiosendspin supports it
148+
2. Add command alias in `CommandHandler.execute()` in `tui/keyboard.py`
149+
3. Command is sent via `client.send_group_command(MediaCommand.XXX)`
150+
151+
### Adding a new configuration option (CLI + settings file)
152+
Options should be configurable via both CLI flags and the settings file, with CLI taking precedence.
153+
154+
1. **Add to `Settings` dataclass** in `sendspin/settings.py`:
155+
- Add field with default value (e.g., `use_mpris: bool = True`)
156+
- Add to `to_dict()` return value
157+
- Add to `from_dict()` with `data.get("field_name", default)`
158+
159+
2. **Add property to `SettingsManager`** in `sendspin/settings.py`:
160+
- Add `@property` that returns `self._settings.field_name`
161+
- Optionally add to `update()` method if it should be changeable at runtime
162+
163+
3. **Add CLI argument** in `sendspin/cli.py` `parse_args()`:
164+
- Add to both `daemon_parser` and main `parser` if applicable to both modes
165+
- Use `action="store_true"` for boolean flags
166+
167+
4. **Apply settings default** in `_apply_settings_defaults()`:
168+
- Compute final value: `args.option = <cli_value> or settings.option`
169+
- For disable flags: `args.use_x = not args.disable_x and settings.use_x`
170+
171+
5. **Add to Args dataclasses**:
172+
- Add field to `DaemonArgs` in `daemon/daemon.py`
173+
- Add field to `AppArgs` in `tui/app.py`
174+
175+
6. **Pass to apps** in `cli.py`:
176+
- Add `option=args.option` when creating `DaemonArgs` and `AppArgs`
177+
178+
7. **Use in daemon/TUI**:
179+
- Access via `self._args.option`
180+
181+
8. **Document** in `README.md` (required for user-facing features):
182+
- Add to example config JSON
183+
- Add to settings table
184+
- Add usage section if the feature warrants explanation
185+
186+
**Important:** All new user-facing features (CLI options, settings, commands) must be documented in `README.md`.

‎CLAUDE.md‎

Lines changed: 0 additions & 180 deletions
This file was deleted.

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AGENTS.md

‎pyproject.toml‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ dependencies = [
1717
"aiosendspin~=4.3",
1818
"aiosendspin-mpris~=2.1.1",
1919
"av>=14.0.0",
20-
"janus>=2.0.0",
2120
"numpy>=1.24.0",
2221
"pychromecast>=14.0.0",
2322
"pulsectl-asyncio>=1.2.2; platform_system == 'Linux'",

0 commit comments

Comments
 (0)