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.
- vox_cli-0.1.0/.gitignore +23 -0
- vox_cli-0.1.0/LICENSE +21 -0
- vox_cli-0.1.0/PKG-INFO +79 -0
- vox_cli-0.1.0/README.md +50 -0
- vox_cli-0.1.0/pyproject.toml +56 -0
- vox_cli-0.1.0/tests/__init__.py +0 -0
- vox_cli-0.1.0/tests/test_cleaner.py +108 -0
- vox_cli-0.1.0/tests/test_cli.py +58 -0
- vox_cli-0.1.0/tests/test_hotkey.py +81 -0
- vox_cli-0.1.0/tests/test_mcp_server.py +81 -0
- vox_cli-0.1.0/tests/test_output.py +55 -0
- vox_cli-0.1.0/tests/test_recorder.py +101 -0
- vox_cli-0.1.0/tests/test_transcriber.py +72 -0
- vox_cli-0.1.0/vox/__init__.py +3 -0
- vox_cli-0.1.0/vox/cleaner.py +198 -0
- vox_cli-0.1.0/vox/cli.py +187 -0
- vox_cli-0.1.0/vox/hotkey.py +122 -0
- vox_cli-0.1.0/vox/mcp_server.py +80 -0
- vox_cli-0.1.0/vox/output.py +67 -0
- vox_cli-0.1.0/vox/recorder.py +141 -0
- vox_cli-0.1.0/vox/transcriber.py +73 -0
vox_cli-0.1.0/.gitignore
ADDED
|
@@ -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
|
vox_cli-0.1.0/README.md
ADDED
|
@@ -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")
|