claude-ssh-voice 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claude_ssh_voice-0.1.0/.gitignore +6 -0
- claude_ssh_voice-0.1.0/CHANGELOG.md +11 -0
- claude_ssh_voice-0.1.0/LICENSE +21 -0
- claude_ssh_voice-0.1.0/PKG-INFO +143 -0
- claude_ssh_voice-0.1.0/README.md +119 -0
- claude_ssh_voice-0.1.0/pyproject.toml +43 -0
- claude_ssh_voice-0.1.0/src/claude_voice/__init__.py +1 -0
- claude_ssh_voice-0.1.0/src/claude_voice/__main__.py +3 -0
- claude_ssh_voice-0.1.0/src/claude_voice/bridge.py +38 -0
- claude_ssh_voice-0.1.0/src/claude_voice/cli.py +210 -0
- claude_ssh_voice-0.1.0/src/claude_voice/shim/claude-wrap +14 -0
- claude_ssh_voice-0.1.0/src/claude_voice/shim/rec +13 -0
- claude_ssh_voice-0.1.0/src/claude_voice/shim/setup.sh +40 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-10-09
|
|
4
|
+
|
|
5
|
+
First public release.
|
|
6
|
+
|
|
7
|
+
- `claude-voice` CLI: `devices`, `test`, `install-remote`, `uninstall-remote`, `daemon`, `autostart`, `connect`.
|
|
8
|
+
- Autostart on Windows (Startup folder), macOS (LaunchAgent) and Linux (systemd user unit), one per host.
|
|
9
|
+
- Server hook for bash, zsh and fish; `install-remote` works whatever the remote login shell is.
|
|
10
|
+
- Extra ssh options are passed after `--`.
|
|
11
|
+
- Published on PyPI as `claude-ssh-voice`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matteo Donato
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: claude-ssh-voice
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Use Claude Code's native /voice over SSH by bridging your local microphone to the remote server.
|
|
5
|
+
Project-URL: Homepage, https://github.com/MatteoSid/Claude-SSH-Voice-Tunnel
|
|
6
|
+
Project-URL: Issues, https://github.com/MatteoSid/Claude-SSH-Voice-Tunnel/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/MatteoSid/Claude-SSH-Voice-Tunnel/blob/main/CHANGELOG.md
|
|
8
|
+
Author: Matteo Donato
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: claude,claude-code,microphone,remote,ssh,voice,vscode
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Capture/Recording
|
|
20
|
+
Classifier: Topic :: Software Development
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: sounddevice>=0.4.6
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# claude-voice
|
|
26
|
+
|
|
27
|
+
[](https://github.com/MatteoSid/Claude-SSH-Voice-Tunnel/actions/workflows/ci.yml)
|
|
28
|
+
[](https://pypi.org/project/claude-ssh-voice/)
|
|
29
|
+
[](LICENSE)
|
|
30
|
+
|
|
31
|
+
Use Claude Code's native **`/voice`** dictation when Claude Code runs on a remote server over SSH
|
|
32
|
+
(plain SSH or VS Code Remote-SSH terminals).
|
|
33
|
+
|
|
34
|
+
Claude Code records audio on the machine where `claude` runs. Over SSH that machine has no microphone, so `/voice`
|
|
35
|
+
fails with `could not open an audio capture device`. `claude-voice` streams the microphone of **your computer** to the
|
|
36
|
+
server through the SSH connection:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
your mic -> claude-voice daemon -> ssh -R tunnel -> server: fake `arecord`/`rec` -> claude /voice
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
No WSL, PulseAudio, sox or root access needed. Nothing listens on the network: everything goes through SSH.
|
|
43
|
+
|
|
44
|
+
## Requirements
|
|
45
|
+
|
|
46
|
+
| Your computer (client) | Server |
|
|
47
|
+
|-----------------------------------------------------------|----------------------------------------------------------|
|
|
48
|
+
| Windows, macOS or Linux | Linux with `bash`, `ss`, `tar` (standard on most distros) |
|
|
49
|
+
| Python 3.9+ and [uv](https://docs.astral.sh/uv/) or pipx | [Claude Code](https://docs.claude.com/en/docs/claude-code) logged in with a Claude.ai account |
|
|
50
|
+
| OpenSSH client with **key-based** login to the server | Shell: bash, zsh or fish |
|
|
51
|
+
| Linux only: `sudo apt install libportaudio2` | |
|
|
52
|
+
|
|
53
|
+
## Install (on your computer)
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
uv tool install claude-ssh-voice
|
|
57
|
+
# or: pipx install claude-ssh-voice
|
|
58
|
+
# or the latest development version:
|
|
59
|
+
uv tool install git+https://github.com/MatteoSid/Claude-SSH-Voice-Tunnel
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This gives you the `claude-voice` command.
|
|
63
|
+
|
|
64
|
+
## Quick start
|
|
65
|
+
|
|
66
|
+
`HOST` is anything `ssh` accepts: `user@server`, or better an alias from `~/.ssh/config`.
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
claude-voice test # 1. records 3 s into mic-test.wav and checks it isn't silent
|
|
70
|
+
claude-voice install-remote HOST # 2. installs the server side (once per server)
|
|
71
|
+
claude-voice autostart HOST # 3. keeps the mic tunnel running, now and at every login
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Then, in a **new** terminal on the server (VS Code included): run `claude`, type `/voice`, and hold space to talk.
|
|
75
|
+
Set the dictation language with `/config` inside Claude Code.
|
|
76
|
+
|
|
77
|
+
That's it. To check it's working: on the server `ss -ltn | grep 48713` should show a listening socket.
|
|
78
|
+
|
|
79
|
+
## Commands
|
|
80
|
+
|
|
81
|
+
| Command | What it does |
|
|
82
|
+
|--------------------------------------|----------------------------------------------------------------------------------------------|
|
|
83
|
+
| `claude-voice devices` | List microphones (use the index or name with `--device`) |
|
|
84
|
+
| `claude-voice test` | Record a short clip to check the mic (`--seconds`, `--out`, `--device`) |
|
|
85
|
+
| `claude-voice install-remote HOST` | Copy the helper scripts to `~/.claude-voice` on the server and hook `claude` in your shell rc |
|
|
86
|
+
| `claude-voice uninstall-remote HOST` | Remove everything `install-remote` added |
|
|
87
|
+
| `claude-voice autostart HOST` | Run the daemon at login: Startup folder (Windows), LaunchAgent (macOS), systemd user unit (Linux). `--remove` to undo |
|
|
88
|
+
| `claude-voice daemon HOST` | Run the tunnel in the foreground; reconnects automatically. Log: `~/.claude-voice/daemon.log` |
|
|
89
|
+
| `claude-voice connect HOST` | One-off interactive SSH session with the tunnel, instead of the daemon |
|
|
90
|
+
|
|
91
|
+
Options for `daemon`, `autostart`, `connect`: `--port` (remote port, default `48713`), `--device`.
|
|
92
|
+
Extra `ssh` options go after `--`, e.g. `claude-voice autostart myserver -- -p 2222 -i ~/.ssh/id_work`.
|
|
93
|
+
You can autostart one daemon per server.
|
|
94
|
+
|
|
95
|
+
If you change `--port`, also set `export CLAUDE_VOICE_PORT=<port>` in your shell rc on the server.
|
|
96
|
+
|
|
97
|
+
## How it works
|
|
98
|
+
|
|
99
|
+
- **Client**: a small Python bridge opens the microphone only while the server is reading from it, and streams
|
|
100
|
+
16 kHz mono PCM to `127.0.0.1`. The daemon runs `ssh -N -R 48713:127.0.0.1:<bridge>` so the stream appears on
|
|
101
|
+
the server at `127.0.0.1:48713`.
|
|
102
|
+
- **Server**: `install-remote` puts a fake `arecord`/`rec` in `~/.claude-voice/shim` that just reads that socket,
|
|
103
|
+
and defines `claude` as a shell function calling `~/.claude-voice/bin/claude-wrap`. When the tunnel is up, the
|
|
104
|
+
wrapper puts the shim first in `PATH` for that `claude` process only; when it's down, it runs plain `claude`.
|
|
105
|
+
`CLAUDE_VOICE_OFF=1 claude` forces plain mode.
|
|
106
|
+
|
|
107
|
+
### Why the wrapper hides `/proc/asound/cards`
|
|
108
|
+
Claude Code uses native ALSA capture whenever `/proc/asound/cards` lists any sound card, even a playback-only one
|
|
109
|
+
(e.g. GPU HDMI), and then never calls `arecord` (symptom: `ALSA lib ... cannot find card '0'`). In that case the
|
|
110
|
+
wrapper starts `claude` in an unprivileged user+mount namespace (same uid) where that file is empty.
|
|
111
|
+
Side effect: no `sudo` inside that `claude` session. If unprivileged user namespaces are disabled, it runs without.
|
|
112
|
+
|
|
113
|
+
## Troubleshooting
|
|
114
|
+
|
|
115
|
+
| Symptom | Fix |
|
|
116
|
+
|-------------------------------------------------------|-----|
|
|
117
|
+
| `could not open an audio capture device` | The tunnel isn't up. Check `~/.claude-voice/daemon.log` on your computer, and `ss -ltn \| grep 48713` on the server. Open a **new** server terminal after `install-remote`. |
|
|
118
|
+
| Daemon log shows `Permission denied (publickey)` | The daemon can't type a password: set up an SSH key (`ssh-copy-id HOST`) and make sure `ssh HOST` works without prompts. |
|
|
119
|
+
| `remote port forwarding failed for listen port 48713` | Another daemon (maybe another computer) already holds the port. Stop it, or use a different `--port`. |
|
|
120
|
+
| Transcription is empty | Run `claude-voice test` and check the level; pick the right mic with `--device`. macOS: allow your terminal (or Python) to use the microphone in System Settings › Privacy. |
|
|
121
|
+
| `PortAudio library not found` (Linux client) | `sudo apt install libportaudio2` |
|
|
122
|
+
| `ALSA lib ... cannot find card '0'` | Unprivileged user namespaces are disabled on the server (see above). |
|
|
123
|
+
|
|
124
|
+
## Security
|
|
125
|
+
|
|
126
|
+
The bridge listens on `127.0.0.1` only and opens the microphone only while a recorder is connected. While the tunnel
|
|
127
|
+
is up, **any process on the server** that connects to the forwarded port can hear your microphone (including other
|
|
128
|
+
users on multi-user machines). Only use this with servers you trust.
|
|
129
|
+
|
|
130
|
+
## Limitations
|
|
131
|
+
|
|
132
|
+
- Server must be Linux. The VS Code **Claude Code extension** (its own bundled binary) is not supported; the
|
|
133
|
+
`claude` CLI in a VS Code terminal is.
|
|
134
|
+
- Tested: Windows 11 client, Ubuntu 22.04 server, bash, VS Code Remote-SSH terminal. macOS/Linux autostart and
|
|
135
|
+
zsh/fish hooks are newer and less tested: reports welcome.
|
|
136
|
+
|
|
137
|
+
## Contributing
|
|
138
|
+
|
|
139
|
+
Issues and pull requests are welcome, see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
[MIT](LICENSE). Not affiliated with Anthropic.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# claude-voice
|
|
2
|
+
|
|
3
|
+
[](https://github.com/MatteoSid/Claude-SSH-Voice-Tunnel/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/claude-ssh-voice/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Use Claude Code's native **`/voice`** dictation when Claude Code runs on a remote server over SSH
|
|
8
|
+
(plain SSH or VS Code Remote-SSH terminals).
|
|
9
|
+
|
|
10
|
+
Claude Code records audio on the machine where `claude` runs. Over SSH that machine has no microphone, so `/voice`
|
|
11
|
+
fails with `could not open an audio capture device`. `claude-voice` streams the microphone of **your computer** to the
|
|
12
|
+
server through the SSH connection:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
your mic -> claude-voice daemon -> ssh -R tunnel -> server: fake `arecord`/`rec` -> claude /voice
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
No WSL, PulseAudio, sox or root access needed. Nothing listens on the network: everything goes through SSH.
|
|
19
|
+
|
|
20
|
+
## Requirements
|
|
21
|
+
|
|
22
|
+
| Your computer (client) | Server |
|
|
23
|
+
|-----------------------------------------------------------|----------------------------------------------------------|
|
|
24
|
+
| Windows, macOS or Linux | Linux with `bash`, `ss`, `tar` (standard on most distros) |
|
|
25
|
+
| Python 3.9+ and [uv](https://docs.astral.sh/uv/) or pipx | [Claude Code](https://docs.claude.com/en/docs/claude-code) logged in with a Claude.ai account |
|
|
26
|
+
| OpenSSH client with **key-based** login to the server | Shell: bash, zsh or fish |
|
|
27
|
+
| Linux only: `sudo apt install libportaudio2` | |
|
|
28
|
+
|
|
29
|
+
## Install (on your computer)
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
uv tool install claude-ssh-voice
|
|
33
|
+
# or: pipx install claude-ssh-voice
|
|
34
|
+
# or the latest development version:
|
|
35
|
+
uv tool install git+https://github.com/MatteoSid/Claude-SSH-Voice-Tunnel
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
This gives you the `claude-voice` command.
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
`HOST` is anything `ssh` accepts: `user@server`, or better an alias from `~/.ssh/config`.
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
claude-voice test # 1. records 3 s into mic-test.wav and checks it isn't silent
|
|
46
|
+
claude-voice install-remote HOST # 2. installs the server side (once per server)
|
|
47
|
+
claude-voice autostart HOST # 3. keeps the mic tunnel running, now and at every login
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Then, in a **new** terminal on the server (VS Code included): run `claude`, type `/voice`, and hold space to talk.
|
|
51
|
+
Set the dictation language with `/config` inside Claude Code.
|
|
52
|
+
|
|
53
|
+
That's it. To check it's working: on the server `ss -ltn | grep 48713` should show a listening socket.
|
|
54
|
+
|
|
55
|
+
## Commands
|
|
56
|
+
|
|
57
|
+
| Command | What it does |
|
|
58
|
+
|--------------------------------------|----------------------------------------------------------------------------------------------|
|
|
59
|
+
| `claude-voice devices` | List microphones (use the index or name with `--device`) |
|
|
60
|
+
| `claude-voice test` | Record a short clip to check the mic (`--seconds`, `--out`, `--device`) |
|
|
61
|
+
| `claude-voice install-remote HOST` | Copy the helper scripts to `~/.claude-voice` on the server and hook `claude` in your shell rc |
|
|
62
|
+
| `claude-voice uninstall-remote HOST` | Remove everything `install-remote` added |
|
|
63
|
+
| `claude-voice autostart HOST` | Run the daemon at login: Startup folder (Windows), LaunchAgent (macOS), systemd user unit (Linux). `--remove` to undo |
|
|
64
|
+
| `claude-voice daemon HOST` | Run the tunnel in the foreground; reconnects automatically. Log: `~/.claude-voice/daemon.log` |
|
|
65
|
+
| `claude-voice connect HOST` | One-off interactive SSH session with the tunnel, instead of the daemon |
|
|
66
|
+
|
|
67
|
+
Options for `daemon`, `autostart`, `connect`: `--port` (remote port, default `48713`), `--device`.
|
|
68
|
+
Extra `ssh` options go after `--`, e.g. `claude-voice autostart myserver -- -p 2222 -i ~/.ssh/id_work`.
|
|
69
|
+
You can autostart one daemon per server.
|
|
70
|
+
|
|
71
|
+
If you change `--port`, also set `export CLAUDE_VOICE_PORT=<port>` in your shell rc on the server.
|
|
72
|
+
|
|
73
|
+
## How it works
|
|
74
|
+
|
|
75
|
+
- **Client**: a small Python bridge opens the microphone only while the server is reading from it, and streams
|
|
76
|
+
16 kHz mono PCM to `127.0.0.1`. The daemon runs `ssh -N -R 48713:127.0.0.1:<bridge>` so the stream appears on
|
|
77
|
+
the server at `127.0.0.1:48713`.
|
|
78
|
+
- **Server**: `install-remote` puts a fake `arecord`/`rec` in `~/.claude-voice/shim` that just reads that socket,
|
|
79
|
+
and defines `claude` as a shell function calling `~/.claude-voice/bin/claude-wrap`. When the tunnel is up, the
|
|
80
|
+
wrapper puts the shim first in `PATH` for that `claude` process only; when it's down, it runs plain `claude`.
|
|
81
|
+
`CLAUDE_VOICE_OFF=1 claude` forces plain mode.
|
|
82
|
+
|
|
83
|
+
### Why the wrapper hides `/proc/asound/cards`
|
|
84
|
+
Claude Code uses native ALSA capture whenever `/proc/asound/cards` lists any sound card, even a playback-only one
|
|
85
|
+
(e.g. GPU HDMI), and then never calls `arecord` (symptom: `ALSA lib ... cannot find card '0'`). In that case the
|
|
86
|
+
wrapper starts `claude` in an unprivileged user+mount namespace (same uid) where that file is empty.
|
|
87
|
+
Side effect: no `sudo` inside that `claude` session. If unprivileged user namespaces are disabled, it runs without.
|
|
88
|
+
|
|
89
|
+
## Troubleshooting
|
|
90
|
+
|
|
91
|
+
| Symptom | Fix |
|
|
92
|
+
|-------------------------------------------------------|-----|
|
|
93
|
+
| `could not open an audio capture device` | The tunnel isn't up. Check `~/.claude-voice/daemon.log` on your computer, and `ss -ltn \| grep 48713` on the server. Open a **new** server terminal after `install-remote`. |
|
|
94
|
+
| Daemon log shows `Permission denied (publickey)` | The daemon can't type a password: set up an SSH key (`ssh-copy-id HOST`) and make sure `ssh HOST` works without prompts. |
|
|
95
|
+
| `remote port forwarding failed for listen port 48713` | Another daemon (maybe another computer) already holds the port. Stop it, or use a different `--port`. |
|
|
96
|
+
| Transcription is empty | Run `claude-voice test` and check the level; pick the right mic with `--device`. macOS: allow your terminal (or Python) to use the microphone in System Settings › Privacy. |
|
|
97
|
+
| `PortAudio library not found` (Linux client) | `sudo apt install libportaudio2` |
|
|
98
|
+
| `ALSA lib ... cannot find card '0'` | Unprivileged user namespaces are disabled on the server (see above). |
|
|
99
|
+
|
|
100
|
+
## Security
|
|
101
|
+
|
|
102
|
+
The bridge listens on `127.0.0.1` only and opens the microphone only while a recorder is connected. While the tunnel
|
|
103
|
+
is up, **any process on the server** that connects to the forwarded port can hear your microphone (including other
|
|
104
|
+
users on multi-user machines). Only use this with servers you trust.
|
|
105
|
+
|
|
106
|
+
## Limitations
|
|
107
|
+
|
|
108
|
+
- Server must be Linux. The VS Code **Claude Code extension** (its own bundled binary) is not supported; the
|
|
109
|
+
`claude` CLI in a VS Code terminal is.
|
|
110
|
+
- Tested: Windows 11 client, Ubuntu 22.04 server, bash, VS Code Remote-SSH terminal. macOS/Linux autostart and
|
|
111
|
+
zsh/fish hooks are newer and less tested: reports welcome.
|
|
112
|
+
|
|
113
|
+
## Contributing
|
|
114
|
+
|
|
115
|
+
Issues and pull requests are welcome, see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
[MIT](LICENSE). Not affiliated with Anthropic.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "claude-ssh-voice"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Use Claude Code's native /voice over SSH by bridging your local microphone to the remote server."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Matteo Donato" }]
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
dependencies = ["sounddevice>=0.4.6"]
|
|
11
|
+
keywords = ["claude", "claude-code", "voice", "ssh", "microphone", "remote", "vscode"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Operating System :: Microsoft :: Windows",
|
|
17
|
+
"Operating System :: MacOS",
|
|
18
|
+
"Operating System :: POSIX :: Linux",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Multimedia :: Sound/Audio :: Capture/Recording",
|
|
21
|
+
"Topic :: Software Development",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/MatteoSid/Claude-SSH-Voice-Tunnel"
|
|
26
|
+
Issues = "https://github.com/MatteoSid/Claude-SSH-Voice-Tunnel/issues"
|
|
27
|
+
Changelog = "https://github.com/MatteoSid/Claude-SSH-Voice-Tunnel/blob/main/CHANGELOG.md"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
claude-voice = "claude_voice.cli:main"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["hatchling>=1.27"]
|
|
34
|
+
build-backend = "hatchling.build"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.version]
|
|
37
|
+
path = "src/claude_voice/__init__.py"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/claude_voice"]
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.sdist]
|
|
43
|
+
include = ["src/", "README.md", "LICENSE", "CHANGELOG.md"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Local microphone -> TCP (127.0.0.1) bridge. Streams raw 16 kHz mono s16le PCM."""
|
|
2
|
+
import socketserver
|
|
3
|
+
import threading
|
|
4
|
+
|
|
5
|
+
import sounddevice as sd
|
|
6
|
+
|
|
7
|
+
RATE = 16000
|
|
8
|
+
BLOCK = 1600 # 100 ms
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _Handler(socketserver.BaseRequestHandler):
|
|
12
|
+
def handle(self):
|
|
13
|
+
# The mic is only open while a remote recorder is connected.
|
|
14
|
+
try:
|
|
15
|
+
with sd.RawInputStream(samplerate=RATE, channels=1, dtype="int16",
|
|
16
|
+
blocksize=BLOCK, device=self.server.device) as stream:
|
|
17
|
+
while True:
|
|
18
|
+
data, _ = stream.read(BLOCK)
|
|
19
|
+
self.request.sendall(bytes(data))
|
|
20
|
+
except (BrokenPipeError, ConnectionError, OSError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Bridge(socketserver.ThreadingTCPServer):
|
|
25
|
+
allow_reuse_address = True
|
|
26
|
+
daemon_threads = True
|
|
27
|
+
|
|
28
|
+
def __init__(self, port=0, device=None):
|
|
29
|
+
self.device = device
|
|
30
|
+
super().__init__(("127.0.0.1", port), _Handler) # loopback only, never 0.0.0.0
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def port(self):
|
|
34
|
+
return self.server_address[1]
|
|
35
|
+
|
|
36
|
+
def start(self):
|
|
37
|
+
threading.Thread(target=self.serve_forever, daemon=True).start()
|
|
38
|
+
return self
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import io
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tarfile
|
|
8
|
+
import time
|
|
9
|
+
import wave
|
|
10
|
+
from importlib import resources
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
|
|
15
|
+
DEFAULT_PORT = 48713
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _sd():
|
|
19
|
+
"""Import sounddevice with a helpful message when the PortAudio library is missing (Linux)."""
|
|
20
|
+
try:
|
|
21
|
+
import sounddevice
|
|
22
|
+
except OSError as e:
|
|
23
|
+
sys.exit(f"claude-voice: {e}\nOn Debian/Ubuntu install it with: sudo apt install libportaudio2")
|
|
24
|
+
return sounddevice
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _device(v):
|
|
28
|
+
return int(v) if v is not None and v.isdigit() else v
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_devices(_):
|
|
32
|
+
print(_sd().query_devices())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_test(a):
|
|
36
|
+
sd = _sd()
|
|
37
|
+
from .bridge import RATE
|
|
38
|
+
print(f"Recording {a.seconds}s from the microphone... speak now.")
|
|
39
|
+
data = sd.rec(int(a.seconds * RATE), samplerate=RATE, channels=1, dtype="int16",
|
|
40
|
+
device=_device(a.device))
|
|
41
|
+
sd.wait()
|
|
42
|
+
with wave.open(a.out, "wb") as w:
|
|
43
|
+
w.setnchannels(1); w.setsampwidth(2); w.setframerate(RATE)
|
|
44
|
+
w.writeframes(data.tobytes())
|
|
45
|
+
peak = int(abs(data).max())
|
|
46
|
+
print(f"Saved {a.out} (peak level {peak}/32767){' - looks silent!' if peak < 200 else ''}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _remote_bundle():
|
|
50
|
+
"""tar of ~/.claude-voice: bin/claude-wrap, shim/rec, shim/arecord -> rec, setup.sh."""
|
|
51
|
+
buf = io.BytesIO()
|
|
52
|
+
with tarfile.open(fileobj=buf, mode="w") as t:
|
|
53
|
+
for name, dest in (("claude-wrap", "bin/claude-wrap"), ("rec", "shim/rec"), ("setup.sh", "setup.sh")):
|
|
54
|
+
data = resources.files("claude_voice").joinpath("shim", name).read_bytes().replace(b"\r\n", b"\n")
|
|
55
|
+
ti = tarfile.TarInfo(dest); ti.size = len(data); ti.mode = 0o755
|
|
56
|
+
t.addfile(ti, io.BytesIO(data))
|
|
57
|
+
ln = tarfile.TarInfo("shim/arecord"); ln.type = tarfile.SYMTYPE; ln.linkname = "rec"
|
|
58
|
+
t.addfile(ln)
|
|
59
|
+
return buf.getvalue()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _run_setup(a, action):
|
|
63
|
+
# Works whatever the remote login shell is (bash, zsh, fish): the real logic lives in setup.sh.
|
|
64
|
+
script = f"mkdir -p ~/.claude-voice && tar -xf - -C ~/.claude-voice && sh ~/.claude-voice/setup.sh {action}"
|
|
65
|
+
sys.exit(subprocess.run(["ssh", *a.ssh_args, a.host, script], input=_remote_bundle()).returncode)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cmd_install_remote(a):
|
|
69
|
+
_run_setup(a, "install")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_uninstall_remote(a):
|
|
73
|
+
_run_setup(a, "uninstall")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cmd_daemon(a):
|
|
77
|
+
"""Keep a mic bridge + reverse SSH tunnel alive in the background (reconnects forever)."""
|
|
78
|
+
_sd()
|
|
79
|
+
from .bridge import Bridge
|
|
80
|
+
bridge = Bridge(device=_device(a.device)).start()
|
|
81
|
+
log = Path.home() / ".claude-voice" / "daemon.log"
|
|
82
|
+
log.parent.mkdir(exist_ok=True)
|
|
83
|
+
cmd = ["ssh", "-N", "-T", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes",
|
|
84
|
+
"-o", "ServerAliveInterval=30", "-o", "ServerAliveCountMax=3",
|
|
85
|
+
"-R", f"{a.port}:127.0.0.1:{bridge.port}", *a.ssh_args, a.host]
|
|
86
|
+
flags = 0x08000000 if sys.platform == "win32" else 0 # CREATE_NO_WINDOW
|
|
87
|
+
while True:
|
|
88
|
+
with open(log, "ab") as f:
|
|
89
|
+
f.write(f"[{time.ctime()}] starting tunnel to {a.host}\n".encode())
|
|
90
|
+
subprocess.call(cmd, stderr=f, stdin=subprocess.DEVNULL, creationflags=flags)
|
|
91
|
+
time.sleep(5)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cmd_autostart(a):
|
|
95
|
+
"""Register `claude-voice daemon HOST` to start at login (one entry per host)."""
|
|
96
|
+
slug = re.sub(r"[^A-Za-z0-9_.-]", "_", a.host)
|
|
97
|
+
argv = ["-m", "claude_voice", "daemon", a.host, "--port", str(a.port)]
|
|
98
|
+
if a.device is not None:
|
|
99
|
+
argv += ["--device", a.device]
|
|
100
|
+
argv += ["--", *a.ssh_args]
|
|
101
|
+
if sys.platform == "win32":
|
|
102
|
+
path = Path(os.environ["APPDATA"]) / f"Microsoft/Windows/Start Menu/Programs/Startup/claude-voice-{slug}.vbs"
|
|
103
|
+
exe = Path(sys.executable).with_name("pythonw.exe") # no console window
|
|
104
|
+
cmdline = subprocess.list2cmdline([str(exe), *argv]).replace('"', '""')
|
|
105
|
+
content = f'CreateObject("Wscript.Shell").Run "{cmdline}", 0, False\n'
|
|
106
|
+
start, stop = [], []
|
|
107
|
+
hint = f"Starts at next login. Start it now with: claude-voice daemon {a.host} (or log out/in)"
|
|
108
|
+
elif sys.platform == "darwin":
|
|
109
|
+
label = f"com.claude-voice.{slug}"
|
|
110
|
+
path = Path.home() / f"Library/LaunchAgents/{label}.plist"
|
|
111
|
+
args = "".join(f"<string>{_xml(x)}</string>" for x in (sys.executable, *argv))
|
|
112
|
+
content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
113
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
114
|
+
<plist version="1.0"><dict>
|
|
115
|
+
<key>Label</key><string>{label}</string>
|
|
116
|
+
<key>ProgramArguments</key><array>{args}</array>
|
|
117
|
+
<key>RunAtLoad</key><true/>
|
|
118
|
+
<key>KeepAlive</key><true/>
|
|
119
|
+
</dict></plist>
|
|
120
|
+
"""
|
|
121
|
+
start = [["launchctl", "load", "-w", str(path)]]
|
|
122
|
+
stop = [["launchctl", "unload", "-w", str(path)]]
|
|
123
|
+
hint = "Running now and at every login."
|
|
124
|
+
else:
|
|
125
|
+
unit = f"claude-voice-{slug}.service"
|
|
126
|
+
path = Path.home() / ".config/systemd/user" / unit
|
|
127
|
+
content = f"""[Unit]
|
|
128
|
+
Description=claude-voice microphone tunnel to {a.host}
|
|
129
|
+
|
|
130
|
+
[Service]
|
|
131
|
+
ExecStart={" ".join(_systemd_quote(x) for x in (sys.executable, *argv))}
|
|
132
|
+
Restart=always
|
|
133
|
+
RestartSec=5
|
|
134
|
+
|
|
135
|
+
[Install]
|
|
136
|
+
WantedBy=default.target
|
|
137
|
+
"""
|
|
138
|
+
start = [["systemctl", "--user", "daemon-reload"], ["systemctl", "--user", "enable", "--now", unit]]
|
|
139
|
+
stop = [["systemctl", "--user", "disable", "--now", unit]]
|
|
140
|
+
hint = f"Running now and at every login. Status: systemctl --user status {unit}"
|
|
141
|
+
|
|
142
|
+
if a.remove:
|
|
143
|
+
if path.exists():
|
|
144
|
+
for c in stop:
|
|
145
|
+
subprocess.call(c)
|
|
146
|
+
path.unlink()
|
|
147
|
+
return print("removed", path)
|
|
148
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
path.write_text(content)
|
|
150
|
+
for c in start:
|
|
151
|
+
subprocess.check_call(c)
|
|
152
|
+
print(f"Installed {path}\n{hint}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _xml(s):
|
|
156
|
+
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _systemd_quote(s):
|
|
160
|
+
return '"' + s.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%").replace("$", "$$") + '"'
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def cmd_connect(a):
|
|
164
|
+
"""One-off interactive SSH with the tunnel (alternative to the daemon)."""
|
|
165
|
+
_sd()
|
|
166
|
+
from .bridge import Bridge
|
|
167
|
+
bridge = Bridge(device=_device(a.device)).start()
|
|
168
|
+
print(f"Mic bridge on 127.0.0.1:{bridge.port} -> remote port {a.port}")
|
|
169
|
+
cmd = ["ssh", "-t", "-o", "ExitOnForwardFailure=yes", "-R", f"{a.port}:127.0.0.1:{bridge.port}",
|
|
170
|
+
*a.ssh_args, a.host, f"export CLAUDE_VOICE_PORT={a.port}; exec \"$SHELL\" -l"]
|
|
171
|
+
try:
|
|
172
|
+
sys.exit(subprocess.call(cmd))
|
|
173
|
+
finally:
|
|
174
|
+
bridge.shutdown()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def main():
|
|
178
|
+
p = argparse.ArgumentParser(
|
|
179
|
+
prog="claude-voice", description="Use Claude Code /voice over SSH.",
|
|
180
|
+
epilog="Pass extra ssh options after `--`, e.g.: claude-voice daemon myserver -- -p 2222")
|
|
181
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
182
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
183
|
+
sub.add_parser("devices", help="list audio input devices").set_defaults(fn=cmd_devices)
|
|
184
|
+
|
|
185
|
+
t = sub.add_parser("test", help="record a short local clip to check the mic")
|
|
186
|
+
t.add_argument("--seconds", type=float, default=3)
|
|
187
|
+
t.add_argument("--out", default="mic-test.wav")
|
|
188
|
+
t.add_argument("--device")
|
|
189
|
+
t.set_defaults(fn=cmd_test)
|
|
190
|
+
|
|
191
|
+
for name, fn, h in (("install-remote", cmd_install_remote, "set up the server (shim + `claude` wrapper)"),
|
|
192
|
+
("uninstall-remote", cmd_uninstall_remote, "remove everything claude-voice put on the server"),
|
|
193
|
+
("daemon", cmd_daemon, "background mic tunnel to HOST, auto-reconnecting"),
|
|
194
|
+
("autostart", cmd_autostart, "run the daemon at login (Windows, macOS, Linux/systemd)"),
|
|
195
|
+
("connect", cmd_connect, "one-off SSH session with the mic tunnel")):
|
|
196
|
+
s = sub.add_parser(name, help=h, description=h)
|
|
197
|
+
s.add_argument("host", help="[user@]host or ~/.ssh/config alias")
|
|
198
|
+
s.add_argument("ssh_args", nargs="*", help="extra ssh args, after `--`")
|
|
199
|
+
s.set_defaults(fn=fn)
|
|
200
|
+
if name in ("daemon", "autostart", "connect"):
|
|
201
|
+
s.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"remote tunnel port (default {DEFAULT_PORT})")
|
|
202
|
+
s.add_argument("--device", help="input device index or name (see `devices`)")
|
|
203
|
+
if name == "autostart":
|
|
204
|
+
s.add_argument("--remove", action="store_true", help="unregister instead")
|
|
205
|
+
a = p.parse_args()
|
|
206
|
+
a.fn(a)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
main()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# claude-voice: run the real `claude`; if the microphone tunnel is up, enable voice for this process.
|
|
3
|
+
D="$HOME/.claude-voice"; PORT="${CLAUDE_VOICE_PORT:-48713}"
|
|
4
|
+
REAL="$(type -P claude)" || { echo "claude-voice: claude not found in PATH" >&2; exit 127; }
|
|
5
|
+
if [ -n "$CLAUDE_VOICE_OFF" ] || ! ss -ltn 2>/dev/null | grep -q "127.0.0.1:$PORT "; then
|
|
6
|
+
exec "$REAL" "$@"
|
|
7
|
+
fi
|
|
8
|
+
export PATH="$D/shim:$PATH" CLAUDE_VOICE_PORT="$PORT"
|
|
9
|
+
# Claude prefers native ALSA capture if /proc/asound/cards lists any card (even playback-only HDMI),
|
|
10
|
+
# bypassing the shim. Hide that file from this process only (unprivileged mount namespace, same uid).
|
|
11
|
+
if grep -q . /proc/asound/cards 2>/dev/null && unshare -rm sh -c 'mount --bind /dev/null /proc/asound/cards' 2>/dev/null; then
|
|
12
|
+
exec unshare -rm sh -c 'mount --bind /dev/null /proc/asound/cards && u=$0 g=$1 r=$2 && shift 2 && exec unshare -U --map-user="$u" --map-group="$g" "$r" "$@"' "$(id -u)" "$(id -g)" "$REAL" "$@"
|
|
13
|
+
fi
|
|
14
|
+
exec "$REAL" "$@"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# claude-voice shim: stands in for sox `rec` / `arecord` on the remote server.
|
|
3
|
+
# Streams raw 16 kHz mono s16le PCM from the microphone bridge (SSH reverse tunnel).
|
|
4
|
+
case "$1" in
|
|
5
|
+
--version|-V) echo "rec (claude-voice shim)"; exit 0 ;;
|
|
6
|
+
esac
|
|
7
|
+
PORT="${CLAUDE_VOICE_PORT:-48713}"
|
|
8
|
+
if ! (exec 3<>"/dev/tcp/127.0.0.1/$PORT") 2>/dev/null; then
|
|
9
|
+
echo "claude-voice: no microphone tunnel on 127.0.0.1:$PORT (run 'claude-voice daemon HOST' on your computer)" >&2
|
|
10
|
+
exit 1
|
|
11
|
+
fi
|
|
12
|
+
exec 3<>"/dev/tcp/127.0.0.1/$PORT"
|
|
13
|
+
exec cat <&3
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# claude-voice: hook (or unhook) the `claude` wrapper into the server's shell configs.
|
|
3
|
+
# Usage: sh ~/.claude-voice/setup.sh install|uninstall
|
|
4
|
+
set -e
|
|
5
|
+
D="$HOME/.claude-voice"
|
|
6
|
+
BEGIN="# >>> claude-voice >>>"
|
|
7
|
+
END="# <<< claude-voice <<<"
|
|
8
|
+
FISH="$HOME/.config/fish/functions/claude.fish"
|
|
9
|
+
|
|
10
|
+
strip() { # remove our block from file $1, keeping the file itself (symlinks, permissions)
|
|
11
|
+
[ -f "$1" ] && grep -qF "$BEGIN" "$1" || return 0
|
|
12
|
+
sed "/^$BEGIN\$/,/^$END\$/d" "$1" > "$1.claude-voice.tmp"
|
|
13
|
+
cat "$1.claude-voice.tmp" > "$1" && rm -f "$1.claude-voice.tmp"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
case "$1" in
|
|
17
|
+
install)
|
|
18
|
+
for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do
|
|
19
|
+
[ "$rc" = "$HOME/.bashrc" ] || [ -f "$rc" ] || continue
|
|
20
|
+
strip "$rc"
|
|
21
|
+
# shellcheck disable=SC2016 # written literally, expanded by the user's shell
|
|
22
|
+
printf '%s\n' "$BEGIN" 'claude() { "$HOME/.claude-voice/bin/claude-wrap" "$@"; }' "$END" >> "$rc"
|
|
23
|
+
echo "claude-voice: hooked $rc"
|
|
24
|
+
done
|
|
25
|
+
if command -v fish >/dev/null 2>&1; then
|
|
26
|
+
mkdir -p "${FISH%/*}"
|
|
27
|
+
printf '%s\n' "$BEGIN" 'function claude' ' ~/.claude-voice/bin/claude-wrap $argv' 'end' > "$FISH"
|
|
28
|
+
echo "claude-voice: hooked $FISH"
|
|
29
|
+
fi
|
|
30
|
+
echo "claude-voice: installed in $D (open a new terminal on the server)"
|
|
31
|
+
;;
|
|
32
|
+
uninstall)
|
|
33
|
+
strip "$HOME/.bashrc"
|
|
34
|
+
strip "$HOME/.zshrc"
|
|
35
|
+
if [ -f "$FISH" ] && grep -qF "$BEGIN" "$FISH"; then rm -f "$FISH"; fi
|
|
36
|
+
rm -rf "$D"
|
|
37
|
+
echo "claude-voice: removed from this server"
|
|
38
|
+
;;
|
|
39
|
+
*) echo "usage: $0 install|uninstall" >&2; exit 2 ;;
|
|
40
|
+
esac
|