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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ *.wav
5
+ dist/
6
+ build/
@@ -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
+ [![CI](https://github.com/MatteoSid/Claude-SSH-Voice-Tunnel/actions/workflows/ci.yml/badge.svg)](https://github.com/MatteoSid/Claude-SSH-Voice-Tunnel/actions/workflows/ci.yml)
28
+ [![PyPI](https://img.shields.io/pypi/v/claude-ssh-voice)](https://pypi.org/project/claude-ssh-voice/)
29
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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
+ [![CI](https://github.com/MatteoSid/Claude-SSH-Voice-Tunnel/actions/workflows/ci.yml/badge.svg)](https://github.com/MatteoSid/Claude-SSH-Voice-Tunnel/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/claude-ssh-voice)](https://pypi.org/project/claude-ssh-voice/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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