vox-cli 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,23 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+ *.egg
9
+ .venv/
10
+ venv/
11
+ env/
12
+ .env
13
+ .mypy_cache/
14
+ .pytest_cache/
15
+ .ruff_cache/
16
+ .coverage
17
+ htmlcov/
18
+ *.wav
19
+ *.mp3
20
+ *.ogg
21
+ models/
22
+ *.bin
23
+ *.pt
vox_cli-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 beee003
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.
vox_cli-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: vox-cli
3
+ Version: 0.1.0
4
+ Summary: Voice comments for your terminal. Push-to-talk for Claude Code, Cursor, and any CLI.
5
+ Author: beee003
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: cli,developer-tools,speech-to-text,terminal,voice
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: click>=8.1.0
17
+ Requires-Dist: faster-whisper>=1.0.0
18
+ Requires-Dist: mcp>=1.2.0
19
+ Requires-Dist: numpy>=1.24.0
20
+ Requires-Dist: pynput>=1.7.6
21
+ Requires-Dist: pyperclip>=1.8.2
22
+ Requires-Dist: sounddevice>=0.4.6
23
+ Provides-Extra: dev
24
+ Requires-Dist: mypy>=1.0; extra == 'dev'
25
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
26
+ Requires-Dist: pytest>=7.0; extra == 'dev'
27
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # vox
31
+
32
+ Voice comments for your terminal. Push-to-talk for Claude Code, Cursor, and any CLI.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install vox-cli
38
+ ```
39
+
40
+ ## Quick Start
41
+
42
+ ```bash
43
+ # One-shot: speak and get text
44
+ vox say
45
+
46
+ # Push-to-talk daemon (hold Right Alt to record)
47
+ vox listen
48
+
49
+ # List audio devices
50
+ vox devices
51
+ ```
52
+
53
+ ## Features
54
+
55
+ - **Local-first** — runs Whisper locally, no cloud API calls
56
+ - **Code-aware cleaning** — fixes capitalization of `API`, `JSON`, `None`, etc.
57
+ - **Voice casing** — say "snake case my variable name" → `my_variable_name`
58
+ - **Multiple outputs** — clipboard (default), stdout, or simulated paste
59
+ - **Push-to-talk** — configurable hotkey, silence detection auto-stops
60
+
61
+ ## Options
62
+
63
+ ```
64
+ vox listen --model small # tiny|base|small|medium
65
+ vox listen --output stdout # clipboard|stdout|paste
66
+ vox listen --key f5 # any modifier or function key
67
+ vox say --duration 15 # max recording seconds
68
+ vox --verbose listen # debug logging
69
+ ```
70
+
71
+ ## Requirements
72
+
73
+ - Python 3.10+
74
+ - A microphone
75
+ - macOS: grant Terminal Accessibility permission for hotkey support
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,50 @@
1
+ # vox
2
+
3
+ Voice comments for your terminal. Push-to-talk for Claude Code, Cursor, and any CLI.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install vox-cli
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ # One-shot: speak and get text
15
+ vox say
16
+
17
+ # Push-to-talk daemon (hold Right Alt to record)
18
+ vox listen
19
+
20
+ # List audio devices
21
+ vox devices
22
+ ```
23
+
24
+ ## Features
25
+
26
+ - **Local-first** — runs Whisper locally, no cloud API calls
27
+ - **Code-aware cleaning** — fixes capitalization of `API`, `JSON`, `None`, etc.
28
+ - **Voice casing** — say "snake case my variable name" → `my_variable_name`
29
+ - **Multiple outputs** — clipboard (default), stdout, or simulated paste
30
+ - **Push-to-talk** — configurable hotkey, silence detection auto-stops
31
+
32
+ ## Options
33
+
34
+ ```
35
+ vox listen --model small # tiny|base|small|medium
36
+ vox listen --output stdout # clipboard|stdout|paste
37
+ vox listen --key f5 # any modifier or function key
38
+ vox say --duration 15 # max recording seconds
39
+ vox --verbose listen # debug logging
40
+ ```
41
+
42
+ ## Requirements
43
+
44
+ - Python 3.10+
45
+ - A microphone
46
+ - macOS: grant Terminal Accessibility permission for hotkey support
47
+
48
+ ## License
49
+
50
+ MIT
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "vox-cli"
7
+ version = "0.1.0"
8
+ description = "Voice comments for your terminal. Push-to-talk for Claude Code, Cursor, and any CLI."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "beee003" }]
13
+ keywords = ["voice", "terminal", "cli", "speech-to-text", "developer-tools"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Libraries :: Python Modules",
21
+ ]
22
+ dependencies = [
23
+ "faster-whisper>=1.0.0",
24
+ "sounddevice>=0.4.6",
25
+ "numpy>=1.24.0",
26
+ "pyperclip>=1.8.2",
27
+ "pynput>=1.7.6",
28
+ "click>=8.1.0",
29
+ "mcp>=1.2.0",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=7.0",
35
+ "pytest-cov>=4.0",
36
+ "ruff>=0.1.0",
37
+ "mypy>=1.0",
38
+ ]
39
+
40
+ [project.scripts]
41
+ vox = "vox.cli:main"
42
+ vox-mcp = "vox.mcp_server:main"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["vox"]
46
+
47
+ [tool.ruff]
48
+ target-version = "py310"
49
+ line-length = 100
50
+
51
+ [tool.ruff.lint]
52
+ select = ["E", "F", "I", "N", "W", "UP", "S", "B", "A", "C4", "PT"]
53
+ ignore = ["S101"] # allow assert in tests
54
+
55
+ [tool.pytest.ini_options]
56
+ testpaths = ["tests"]
File without changes
@@ -0,0 +1,108 @@
1
+ """Tests for vox.cleaner — code-aware text cleaning."""
2
+
3
+ from vox.cleaner import clean
4
+
5
+
6
+ class TestFillerRemoval:
7
+ def test_removes_um_uh(self):
8
+ assert clean("um so the function uh returns") == "So the function returns"
9
+
10
+ def test_removes_multi_word_fillers(self):
11
+ assert clean("you know the API is broken") == "The API is broken"
12
+
13
+ def test_keeps_like_after_keeper(self):
14
+ assert "like" in clean("it looks like a bug").lower()
15
+
16
+ def test_removes_like_as_filler(self):
17
+ result = clean("so like the function like returns none")
18
+ assert result.count("like") == 0
19
+
20
+ def test_empty_input(self):
21
+ assert clean("") == ""
22
+ assert clean(" ") == ""
23
+
24
+
25
+ class TestCodeKeywords:
26
+ def test_capitalizes_none(self):
27
+ assert "None" in clean("it returns none")
28
+
29
+ def test_capitalizes_true_false(self):
30
+ result = clean("set it to true or false")
31
+ assert "True" in result
32
+ assert "False" in result
33
+
34
+ def test_preserves_surrounding_text(self):
35
+ result = clean("check if the value is none then return")
36
+ assert "None" in result
37
+ assert "return" in result
38
+
39
+
40
+ class TestTechTerms:
41
+ def test_capitalizes_api(self):
42
+ assert "API" in clean("the api is down")
43
+
44
+ def test_capitalizes_json(self):
45
+ assert "JSON" in clean("parse the json response")
46
+
47
+ def test_capitalizes_python(self):
48
+ assert "Python" in clean("write it in python")
49
+
50
+ def test_capitalizes_github(self):
51
+ assert "GitHub" in clean("push to github")
52
+
53
+
54
+ class TestCasingCommands:
55
+ def test_snake_case(self):
56
+ result = clean("define snake case my variable name.")
57
+ assert "my_variable_name" in result
58
+
59
+ def test_camel_case(self):
60
+ result = clean("call camel case get user data.")
61
+ assert "getUserData" in result
62
+
63
+ def test_pascal_case(self):
64
+ result = clean("pascal case user service")
65
+ assert "UserService" in result
66
+
67
+ def test_kebab_case(self):
68
+ result = clean("use kebab case my component.")
69
+ assert "my-component" in result
70
+
71
+ def test_all_caps(self):
72
+ result = clean("all caps max retries")
73
+ assert "MAX_RETRIES" in result
74
+
75
+
76
+ class TestFormatCommands:
77
+ def test_new_line(self):
78
+ result = clean("first line new line second line")
79
+ assert "\n" in result
80
+
81
+ def test_period(self):
82
+ result = clean("end of sentence period")
83
+ assert "." in result
84
+
85
+ def test_open_close_paren(self):
86
+ result = clean("call open paren close paren")
87
+ assert "(" in result
88
+ assert ")" in result
89
+
90
+ def test_arrow(self):
91
+ result = clean("returns arrow string")
92
+ assert "->" in result
93
+
94
+
95
+ class TestWhitespace:
96
+ def test_collapses_spaces(self):
97
+ assert " " not in clean("too many spaces here")
98
+
99
+ def test_strips_leading_trailing(self):
100
+ result = clean(" hello world ")
101
+ assert not result.startswith(" ")
102
+ assert not result.endswith(" ")
103
+
104
+
105
+ class TestCapitalizeFirst:
106
+ def test_first_letter_capitalized(self):
107
+ result = clean("the function works")
108
+ assert result[0] == "T"
@@ -0,0 +1,58 @@
1
+ """Tests for vox.cli — CLI entry point."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ from click.testing import CliRunner
6
+
7
+ from vox.cli import main
8
+
9
+
10
+ class TestCLI:
11
+ def test_version(self):
12
+ runner = CliRunner()
13
+ result = runner.invoke(main, ["--version"])
14
+ assert result.exit_code == 0
15
+ assert "0.1.0" in result.output
16
+
17
+ def test_help(self):
18
+ runner = CliRunner()
19
+ result = runner.invoke(main, ["--help"])
20
+ assert result.exit_code == 0
21
+ assert "voice comments" in result.output.lower()
22
+
23
+ def test_listen_help(self):
24
+ runner = CliRunner()
25
+ result = runner.invoke(main, ["listen", "--help"])
26
+ assert result.exit_code == 0
27
+ assert "--model" in result.output
28
+ assert "--output" in result.output
29
+ assert "--key" in result.output
30
+
31
+ def test_say_help(self):
32
+ runner = CliRunner()
33
+ result = runner.invoke(main, ["say", "--help"])
34
+ assert result.exit_code == 0
35
+ assert "--duration" in result.output
36
+
37
+ def test_devices_help(self):
38
+ runner = CliRunner()
39
+ result = runner.invoke(main, ["devices", "--help"])
40
+ assert result.exit_code == 0
41
+
42
+ @patch("vox.recorder.get_input_devices")
43
+ def test_devices_command(self, mock_devices):
44
+ mock_devices.return_value = [
45
+ {"index": 0, "name": "Built-in Mic", "channels": 2, "default_samplerate": 44100.0},
46
+ ]
47
+ runner = CliRunner()
48
+ result = runner.invoke(main, ["devices"])
49
+ assert result.exit_code == 0
50
+ assert "Built-in Mic" in result.output
51
+
52
+ @patch("vox.recorder.get_input_devices")
53
+ def test_devices_empty(self, mock_devices):
54
+ mock_devices.return_value = []
55
+ runner = CliRunner()
56
+ result = runner.invoke(main, ["devices"])
57
+ assert result.exit_code == 0
58
+ assert "No audio input devices found" in result.output
@@ -0,0 +1,81 @@
1
+ """Tests for vox.hotkey — push-to-talk hotkey listener."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ from vox.hotkey import HotkeyError, PushToTalk
8
+
9
+
10
+ class TestPushToTalk:
11
+ def test_init_defaults(self):
12
+ ptt = PushToTalk()
13
+ assert ptt._trigger_key_name == "alt_r"
14
+ assert ptt.is_active is False
15
+
16
+ def test_init_custom_key(self):
17
+ ptt = PushToTalk(trigger_key="f5")
18
+ assert ptt._trigger_key_name == "f5"
19
+
20
+ def test_invalid_key_raises(self):
21
+ ptt = PushToTalk(trigger_key="nonexistent_key_xyz")
22
+ with pytest.raises(HotkeyError, match="Unknown key"):
23
+ ptt._resolve_key()
24
+
25
+ @patch("vox.hotkey.Listener", create=True)
26
+ def test_on_press_activates(self, _mock_listener):
27
+ callback_called = []
28
+ ptt = PushToTalk(
29
+ trigger_key="alt_r",
30
+ on_start=lambda: callback_called.append("start"),
31
+ )
32
+ # Manually resolve key and simulate press
33
+ with patch("vox.hotkey.Key", create=True) as mock_key:
34
+ mock_key.alt_r = "ALT_R_KEY"
35
+ ptt._trigger_key = "ALT_R_KEY"
36
+ ptt._on_press("ALT_R_KEY")
37
+
38
+ assert ptt.is_active is True
39
+ assert callback_called == ["start"]
40
+
41
+ @patch("vox.hotkey.Listener", create=True)
42
+ def test_on_release_deactivates(self, _mock_listener):
43
+ callback_called = []
44
+ ptt = PushToTalk(
45
+ trigger_key="alt_r",
46
+ on_stop=lambda: callback_called.append("stop"),
47
+ )
48
+ ptt._trigger_key = "ALT_R_KEY"
49
+ # Activate first
50
+ ptt._active = True
51
+ ptt._on_release("ALT_R_KEY")
52
+
53
+ assert ptt.is_active is False
54
+ assert callback_called == ["stop"]
55
+
56
+ def test_press_wrong_key_ignored(self):
57
+ ptt = PushToTalk()
58
+ ptt._trigger_key = "ALT_R_KEY"
59
+ ptt._on_press("OTHER_KEY")
60
+ assert ptt.is_active is False
61
+
62
+ def test_double_press_only_fires_once(self):
63
+ count = []
64
+ ptt = PushToTalk(on_start=lambda: count.append(1))
65
+ ptt._trigger_key = "KEY"
66
+ ptt._on_press("KEY")
67
+ ptt._on_press("KEY") # Should not fire again
68
+ assert len(count) == 1
69
+
70
+ def test_double_release_only_fires_once(self):
71
+ count = []
72
+ ptt = PushToTalk(on_stop=lambda: count.append(1))
73
+ ptt._trigger_key = "KEY"
74
+ ptt._active = True
75
+ ptt._on_release("KEY")
76
+ ptt._on_release("KEY")
77
+ assert len(count) == 1
78
+
79
+ def test_stop_without_start(self):
80
+ ptt = PushToTalk()
81
+ ptt.stop() # Should not raise
@@ -0,0 +1,81 @@
1
+ """Tests for the vox MCP server tool functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from unittest.mock import patch, MagicMock
7
+
8
+ import numpy as np
9
+ import pytest
10
+
11
+ from vox.mcp_server import record_voice, list_microphones
12
+
13
+
14
+ @pytest.fixture()
15
+ def fake_audio():
16
+ return np.zeros(16000, dtype=np.float32)
17
+
18
+
19
+ class TestRecordVoice:
20
+ """Tests for the record_voice tool."""
21
+
22
+ def test_basic_transcription(self, fake_audio):
23
+ with (
24
+ patch("vox.recorder.record_until_silence", return_value=fake_audio) as mock_rec,
25
+ patch("vox.transcriber.transcribe", return_value="hello world") as mock_trans,
26
+ patch("vox.cleaner.clean", return_value="Hello world") as mock_clean,
27
+ ):
28
+ result = asyncio.run(record_voice(max_duration=5, model="tiny"))
29
+
30
+ mock_rec.assert_called_once_with(max_duration=5, device=None)
31
+ mock_trans.assert_called_once_with(fake_audio, "tiny")
32
+ mock_clean.assert_called_once_with("hello world")
33
+ assert result == "Hello world"
34
+
35
+ def test_skip_cleaning(self, fake_audio):
36
+ with (
37
+ patch("vox.recorder.record_until_silence", return_value=fake_audio),
38
+ patch("vox.transcriber.transcribe", return_value="um hello world"),
39
+ patch("vox.cleaner.clean") as mock_clean,
40
+ ):
41
+ result = asyncio.run(record_voice(clean_text=False))
42
+
43
+ mock_clean.assert_not_called()
44
+ assert result == "um hello world"
45
+
46
+ def test_passes_device(self, fake_audio):
47
+ with (
48
+ patch("vox.recorder.record_until_silence", return_value=fake_audio) as mock_rec,
49
+ patch("vox.transcriber.transcribe", return_value="test"),
50
+ patch("vox.cleaner.clean", return_value="Test"),
51
+ ):
52
+ asyncio.run(record_voice(device=3))
53
+ mock_rec.assert_called_once_with(max_duration=15, device=3)
54
+
55
+ def test_recorder_error_propagates(self):
56
+ with patch(
57
+ "vox.recorder.record_until_silence",
58
+ side_effect=RuntimeError("no mic"),
59
+ ):
60
+ with pytest.raises(RuntimeError, match="no mic"):
61
+ asyncio.run(record_voice())
62
+
63
+
64
+ class TestListMicrophones:
65
+ """Tests for the list_microphones tool."""
66
+
67
+ def test_devices_listed(self):
68
+ devices = [
69
+ {"index": 0, "name": "Built-in Microphone", "channels": 1, "sample_rate": 16000},
70
+ {"index": 2, "name": "USB Mic", "channels": 2, "sample_rate": 44100},
71
+ ]
72
+ with patch("vox.recorder.get_input_devices", return_value=devices):
73
+ result = asyncio.run(list_microphones())
74
+
75
+ assert "[0] Built-in Microphone" in result
76
+ assert "[2] USB Mic" in result
77
+
78
+ def test_no_devices(self):
79
+ with patch("vox.recorder.get_input_devices", return_value=[]):
80
+ result = asyncio.run(list_microphones())
81
+ assert result == "No input devices found."
@@ -0,0 +1,55 @@
1
+ """Tests for vox.output — text delivery module."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ from vox.output import OutputError, deliver, to_clipboard, to_stdout
8
+
9
+
10
+ class TestToStdout:
11
+ def test_writes_to_stdout(self, capsys):
12
+ to_stdout("hello world")
13
+ captured = capsys.readouterr()
14
+ assert captured.out == "hello world"
15
+
16
+
17
+ class TestToClipboard:
18
+ @patch("vox.output.pyperclip.copy")
19
+ def test_copies_text(self, mock_copy):
20
+ to_clipboard("test text")
21
+ mock_copy.assert_called_once_with("test text")
22
+
23
+ @patch("vox.output.pyperclip.copy")
24
+ def test_raises_on_clipboard_error(self, mock_copy):
25
+ import pyperclip
26
+ mock_copy.side_effect = pyperclip.PyperclipException("no clipboard")
27
+ with pytest.raises(OutputError, match="Clipboard not available"):
28
+ to_clipboard("text")
29
+
30
+
31
+ class TestDeliver:
32
+ def test_stdout_mode(self, capsys):
33
+ deliver("hello", mode="stdout")
34
+ assert capsys.readouterr().out == "hello"
35
+
36
+ @patch("vox.output.pyperclip.copy")
37
+ def test_clipboard_mode(self, mock_copy):
38
+ deliver("test", mode="clipboard")
39
+ mock_copy.assert_called_once_with("test")
40
+
41
+ def test_invalid_mode_raises(self):
42
+ with pytest.raises(ValueError, match="Invalid output mode"):
43
+ deliver("text", mode="email")
44
+
45
+
46
+ class TestToPaste:
47
+ @patch("vox.output.pyperclip.copy")
48
+ def test_copies_before_paste(self, mock_copy):
49
+ from vox.output import to_paste
50
+ # Will fail on paste simulation in test env, but clipboard copy should work
51
+ try:
52
+ to_paste("text")
53
+ except (OutputError, Exception):
54
+ pass
55
+ mock_copy.assert_called_with("text")