voxora 1.0.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.
- voxora-1.0.0/LICENSE +19 -0
- voxora-1.0.0/PKG-INFO +158 -0
- voxora-1.0.0/README.md +134 -0
- voxora-1.0.0/pyproject.toml +54 -0
- voxora-1.0.0/setup.cfg +4 -0
- voxora-1.0.0/src/voxora/__init__.py +25 -0
- voxora-1.0.0/src/voxora/audio.py +198 -0
- voxora-1.0.0/src/voxora/config.py +133 -0
- voxora-1.0.0/src/voxora/exceptions.py +33 -0
- voxora-1.0.0/src/voxora/formatter.py +72 -0
- voxora-1.0.0/src/voxora/groq.py +76 -0
- voxora-1.0.0/src/voxora/stt.py +262 -0
- voxora-1.0.0/src/voxora.egg-info/PKG-INFO +158 -0
- voxora-1.0.0/src/voxora.egg-info/SOURCES.txt +19 -0
- voxora-1.0.0/src/voxora.egg-info/dependency_links.txt +1 -0
- voxora-1.0.0/src/voxora.egg-info/requires.txt +7 -0
- voxora-1.0.0/src/voxora.egg-info/top_level.txt +1 -0
- voxora-1.0.0/tests/test_audio.py +185 -0
- voxora-1.0.0/tests/test_formatter.py +92 -0
- voxora-1.0.0/tests/test_groq.py +113 -0
- voxora-1.0.0/tests/test_stt.py +723 -0
voxora-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2026 Voxora contributors
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
voxora-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: voxora
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A lightweight, microphone-based speech-to-text SDK for Python.
|
|
5
|
+
Author: Prakhar Parikh
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: speech-to-text,speech-recognition,voice-activity-detection,audio,microphone,groq,whisper,python-sdk
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: PyAudio>=0.2.13
|
|
18
|
+
Requires-Dist: webrtcvad-wheels>=2.0.14.post1
|
|
19
|
+
Requires-Dist: groq>=0.31.0
|
|
20
|
+
Requires-Dist: openai>=2.31.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# Voxora
|
|
26
|
+
|
|
27
|
+
**A lightweight, microphone-based speech-to-text SDK for Python.**
|
|
28
|
+
|
|
29
|
+
Voxora makes it easy to add speech recognition to Python applications with automatic voice detection, manual recording controls, and optional AI-powered transcript formatting.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- **Automatic recording:** Detect speech and automatically stop after silence.
|
|
34
|
+
- **Manual recording:** Control recording with `start()` and `stop()`.
|
|
35
|
+
- **Speech-to-text:** Transcribe audio using Groq Whisper.
|
|
36
|
+
- **Transcript formatting:** Optionally improve punctuation, capitalization, and paragraph structure.
|
|
37
|
+
- **In-memory processing:** Audio is processed in memory without writing recordings to disk.
|
|
38
|
+
- **Simple API:** A single `STT` class for both recording modes.
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install voxora
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Set your Groq API key:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
export GROQ_API_KEY="your-groq-api-key"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Voxora requires Python 3.10 or newer and microphone/audio dependencies. PyAudio may require platform-specific system libraries.
|
|
53
|
+
|
|
54
|
+
## Quick Start
|
|
55
|
+
|
|
56
|
+
### Automatic recording (VAD)
|
|
57
|
+
|
|
58
|
+
Voxora automatically detects speech and stops recording after the configured silence duration.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from voxora import STT
|
|
62
|
+
|
|
63
|
+
stt = STT(
|
|
64
|
+
api_key="YOUR_GROQ_API_KEY",
|
|
65
|
+
vad=True
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
text = stt.listen()
|
|
69
|
+
|
|
70
|
+
print(text)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Manual recording
|
|
74
|
+
|
|
75
|
+
For applications that need explicit recording controls:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from voxora import STT
|
|
79
|
+
|
|
80
|
+
stt = STT(
|
|
81
|
+
api_key="YOUR_GROQ_API_KEY",
|
|
82
|
+
vad=False
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
stt.start()
|
|
86
|
+
|
|
87
|
+
input("Press Enter to stop recording...")
|
|
88
|
+
|
|
89
|
+
text = stt.stop()
|
|
90
|
+
|
|
91
|
+
print(text)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Transcript Formatting
|
|
95
|
+
|
|
96
|
+
Voxora can optionally format transcribed text using a compatible chat-completions API.
|
|
97
|
+
|
|
98
|
+
Formatting improves punctuation, capitalization, and paragraph breaks while preserving the original spoken words and meaning.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from voxora import STT
|
|
102
|
+
|
|
103
|
+
stt = STT(
|
|
104
|
+
api_key="YOUR_GROQ_API_KEY",
|
|
105
|
+
vad=True,
|
|
106
|
+
format=True,
|
|
107
|
+
format_model="openai/gpt-oss-120b",
|
|
108
|
+
format_base_url="https://api.groq.com/openai/v1"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
text = stt.listen()
|
|
112
|
+
|
|
113
|
+
print(text)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Formatting is disabled by default. The formatter receives the transcript after speech recognition; it does not receive the audio.
|
|
117
|
+
|
|
118
|
+
## Configuration
|
|
119
|
+
|
|
120
|
+
| Parameter | Description |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `api_key` | Groq API key. Defaults to `GROQ_API_KEY`. |
|
|
123
|
+
| `stt_model` | Groq transcription model. |
|
|
124
|
+
| `language` | Optional ISO-639-1 language hint. |
|
|
125
|
+
| `vad` | Enables automatic voice activity detection. |
|
|
126
|
+
| `vad_mode` | WebRTC VAD aggressiveness (0–3). |
|
|
127
|
+
| `silence_duration` | Silence duration before automatic recording stops. |
|
|
128
|
+
| `pre_speech_duration` | Audio retained before speech detection. |
|
|
129
|
+
| `max_recording_duration` | Maximum recording duration. |
|
|
130
|
+
| `speech_start_timeout` | Maximum wait for speech to begin. |
|
|
131
|
+
| `format` | Enables optional transcript formatting. |
|
|
132
|
+
| `format_base_url` | Compatible formatting API endpoint. |
|
|
133
|
+
| `format_model` | Formatting model. |
|
|
134
|
+
| `format_api_key` | Formatting API key; defaults to `api_key`. |
|
|
135
|
+
|
|
136
|
+
## Error Handling
|
|
137
|
+
|
|
138
|
+
Voxora exposes exceptions for configuration, recording, transcription, and formatting failures.
|
|
139
|
+
|
|
140
|
+
Provider authentication, network, rate-limit, and other API errors may propagate from the underlying SDK.
|
|
141
|
+
|
|
142
|
+
## Requirements
|
|
143
|
+
|
|
144
|
+
- Python 3.10+
|
|
145
|
+
- PyAudio
|
|
146
|
+
- WebRTC VAD
|
|
147
|
+
- Groq API key
|
|
148
|
+
- Internet connection for transcription and optional formatting
|
|
149
|
+
|
|
150
|
+
Voxora uses Groq's cloud transcription API and is not an offline speech recognition engine.
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
See [LICENSE](LICENSE).
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
**Voxora** — Speech recognition, made simple.
|
voxora-1.0.0/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Voxora
|
|
2
|
+
|
|
3
|
+
**A lightweight, microphone-based speech-to-text SDK for Python.**
|
|
4
|
+
|
|
5
|
+
Voxora makes it easy to add speech recognition to Python applications with automatic voice detection, manual recording controls, and optional AI-powered transcript formatting.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Automatic recording:** Detect speech and automatically stop after silence.
|
|
10
|
+
- **Manual recording:** Control recording with `start()` and `stop()`.
|
|
11
|
+
- **Speech-to-text:** Transcribe audio using Groq Whisper.
|
|
12
|
+
- **Transcript formatting:** Optionally improve punctuation, capitalization, and paragraph structure.
|
|
13
|
+
- **In-memory processing:** Audio is processed in memory without writing recordings to disk.
|
|
14
|
+
- **Simple API:** A single `STT` class for both recording modes.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install voxora
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Set your Groq API key:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
export GROQ_API_KEY="your-groq-api-key"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Voxora requires Python 3.10 or newer and microphone/audio dependencies. PyAudio may require platform-specific system libraries.
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
### Automatic recording (VAD)
|
|
33
|
+
|
|
34
|
+
Voxora automatically detects speech and stops recording after the configured silence duration.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from voxora import STT
|
|
38
|
+
|
|
39
|
+
stt = STT(
|
|
40
|
+
api_key="YOUR_GROQ_API_KEY",
|
|
41
|
+
vad=True
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
text = stt.listen()
|
|
45
|
+
|
|
46
|
+
print(text)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Manual recording
|
|
50
|
+
|
|
51
|
+
For applications that need explicit recording controls:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from voxora import STT
|
|
55
|
+
|
|
56
|
+
stt = STT(
|
|
57
|
+
api_key="YOUR_GROQ_API_KEY",
|
|
58
|
+
vad=False
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
stt.start()
|
|
62
|
+
|
|
63
|
+
input("Press Enter to stop recording...")
|
|
64
|
+
|
|
65
|
+
text = stt.stop()
|
|
66
|
+
|
|
67
|
+
print(text)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Transcript Formatting
|
|
71
|
+
|
|
72
|
+
Voxora can optionally format transcribed text using a compatible chat-completions API.
|
|
73
|
+
|
|
74
|
+
Formatting improves punctuation, capitalization, and paragraph breaks while preserving the original spoken words and meaning.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from voxora import STT
|
|
78
|
+
|
|
79
|
+
stt = STT(
|
|
80
|
+
api_key="YOUR_GROQ_API_KEY",
|
|
81
|
+
vad=True,
|
|
82
|
+
format=True,
|
|
83
|
+
format_model="openai/gpt-oss-120b",
|
|
84
|
+
format_base_url="https://api.groq.com/openai/v1"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
text = stt.listen()
|
|
88
|
+
|
|
89
|
+
print(text)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Formatting is disabled by default. The formatter receives the transcript after speech recognition; it does not receive the audio.
|
|
93
|
+
|
|
94
|
+
## Configuration
|
|
95
|
+
|
|
96
|
+
| Parameter | Description |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `api_key` | Groq API key. Defaults to `GROQ_API_KEY`. |
|
|
99
|
+
| `stt_model` | Groq transcription model. |
|
|
100
|
+
| `language` | Optional ISO-639-1 language hint. |
|
|
101
|
+
| `vad` | Enables automatic voice activity detection. |
|
|
102
|
+
| `vad_mode` | WebRTC VAD aggressiveness (0–3). |
|
|
103
|
+
| `silence_duration` | Silence duration before automatic recording stops. |
|
|
104
|
+
| `pre_speech_duration` | Audio retained before speech detection. |
|
|
105
|
+
| `max_recording_duration` | Maximum recording duration. |
|
|
106
|
+
| `speech_start_timeout` | Maximum wait for speech to begin. |
|
|
107
|
+
| `format` | Enables optional transcript formatting. |
|
|
108
|
+
| `format_base_url` | Compatible formatting API endpoint. |
|
|
109
|
+
| `format_model` | Formatting model. |
|
|
110
|
+
| `format_api_key` | Formatting API key; defaults to `api_key`. |
|
|
111
|
+
|
|
112
|
+
## Error Handling
|
|
113
|
+
|
|
114
|
+
Voxora exposes exceptions for configuration, recording, transcription, and formatting failures.
|
|
115
|
+
|
|
116
|
+
Provider authentication, network, rate-limit, and other API errors may propagate from the underlying SDK.
|
|
117
|
+
|
|
118
|
+
## Requirements
|
|
119
|
+
|
|
120
|
+
- Python 3.10+
|
|
121
|
+
- PyAudio
|
|
122
|
+
- WebRTC VAD
|
|
123
|
+
- Groq API key
|
|
124
|
+
- Internet connection for transcription and optional formatting
|
|
125
|
+
|
|
126
|
+
Voxora uses Groq's cloud transcription API and is not an offline speech recognition engine.
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
See [LICENSE](LICENSE).
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
**Voxora** — Speech recognition, made simple.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "voxora"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "A lightweight, microphone-based speech-to-text SDK for Python."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Prakhar Parikh" }
|
|
15
|
+
]
|
|
16
|
+
keywords = [
|
|
17
|
+
"speech-to-text",
|
|
18
|
+
"speech-recognition",
|
|
19
|
+
"voice-activity-detection",
|
|
20
|
+
"audio",
|
|
21
|
+
"microphone",
|
|
22
|
+
"groq",
|
|
23
|
+
"whisper",
|
|
24
|
+
"python-sdk"
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"Development Status :: 4 - Beta",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
30
|
+
"Programming Language :: Python :: 3",
|
|
31
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
32
|
+
"Programming Language :: Python :: 3.10",
|
|
33
|
+
]
|
|
34
|
+
dependencies = [
|
|
35
|
+
"PyAudio>=0.2.13",
|
|
36
|
+
"webrtcvad-wheels>=2.0.14.post1",
|
|
37
|
+
"groq>=0.31.0",
|
|
38
|
+
"openai>=2.31.0",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.optional-dependencies]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=7",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.setuptools]
|
|
47
|
+
package-dir = { "" = "src" }
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.packages.find]
|
|
50
|
+
where = ["src"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
addopts = "-q"
|
voxora-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Public package interface for Voxora."""
|
|
2
|
+
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
AudioCaptureError,
|
|
5
|
+
ConfigurationError,
|
|
6
|
+
FormattingError,
|
|
7
|
+
NoSpeechError,
|
|
8
|
+
RecordingStateError,
|
|
9
|
+
RecordingTimeoutError,
|
|
10
|
+
TranscriptionError,
|
|
11
|
+
VoxoraError,
|
|
12
|
+
)
|
|
13
|
+
from .stt import STT
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"STT",
|
|
17
|
+
"VoxoraError",
|
|
18
|
+
"ConfigurationError",
|
|
19
|
+
"RecordingStateError",
|
|
20
|
+
"AudioCaptureError",
|
|
21
|
+
"NoSpeechError",
|
|
22
|
+
"RecordingTimeoutError",
|
|
23
|
+
"FormattingError",
|
|
24
|
+
"TranscriptionError",
|
|
25
|
+
]
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Internal microphone capture, buffering, and WebRTC VAD."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections import deque
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Callable, Deque, List
|
|
11
|
+
|
|
12
|
+
from .exceptions import AudioCaptureError, NoSpeechError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class AudioResult:
|
|
17
|
+
"""Internal PCM recording result for the future transcription layer."""
|
|
18
|
+
|
|
19
|
+
pcm_data: bytes
|
|
20
|
+
sample_rate: int
|
|
21
|
+
channels: int = 1
|
|
22
|
+
sample_width: int = 2
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AudioRecorder:
|
|
26
|
+
"""Capture mono 16-bit PCM from the default microphone."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, config, logger, *, pyaudio_module=None, vad_factory=None, clock: Callable[[], float] = time.monotonic):
|
|
29
|
+
self.config = config
|
|
30
|
+
self.logger = logger
|
|
31
|
+
self._pyaudio_module = pyaudio_module
|
|
32
|
+
self._vad_factory = vad_factory
|
|
33
|
+
self._clock = clock
|
|
34
|
+
self._audio = None
|
|
35
|
+
self._stream = None
|
|
36
|
+
self.samples_per_frame = config.sample_rate * config.frame_duration_ms // 1000
|
|
37
|
+
if self.samples_per_frame * 1000 != config.sample_rate * config.frame_duration_ms:
|
|
38
|
+
raise AudioCaptureError("frame duration does not produce a whole number of samples")
|
|
39
|
+
self.frame_bytes = self.samples_per_frame * 2
|
|
40
|
+
|
|
41
|
+
def _load_pyaudio(self):
|
|
42
|
+
if self._pyaudio_module is None:
|
|
43
|
+
try:
|
|
44
|
+
import pyaudio
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
raise AudioCaptureError("PyAudio is required for microphone recording") from exc
|
|
47
|
+
self._pyaudio_module = pyaudio
|
|
48
|
+
return self._pyaudio_module
|
|
49
|
+
|
|
50
|
+
def _load_vad(self):
|
|
51
|
+
if self._vad_factory is None:
|
|
52
|
+
try:
|
|
53
|
+
import webrtcvad
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
raise AudioCaptureError("WebRTC VAD is required for VAD recording") from exc
|
|
56
|
+
self._vad_factory = webrtcvad.Vad
|
|
57
|
+
return self._vad_factory
|
|
58
|
+
|
|
59
|
+
def open(self) -> None:
|
|
60
|
+
if self._stream is not None:
|
|
61
|
+
raise AudioCaptureError("microphone stream is already open")
|
|
62
|
+
pyaudio = self._load_pyaudio()
|
|
63
|
+
try:
|
|
64
|
+
self._audio = pyaudio.PyAudio()
|
|
65
|
+
self._stream = self._audio.open(
|
|
66
|
+
format=pyaudio.paInt16,
|
|
67
|
+
channels=1,
|
|
68
|
+
rate=self.config.sample_rate,
|
|
69
|
+
input=True,
|
|
70
|
+
frames_per_buffer=self.samples_per_frame,
|
|
71
|
+
)
|
|
72
|
+
self.logger.debug("microphone initialized")
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
self.close()
|
|
75
|
+
raise AudioCaptureError("could not initialize microphone") from exc
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
stream, audio = self._stream, self._audio
|
|
79
|
+
self._stream = None
|
|
80
|
+
self._audio = None
|
|
81
|
+
cleanup_errors = []
|
|
82
|
+
try:
|
|
83
|
+
if stream is not None:
|
|
84
|
+
try:
|
|
85
|
+
stream.stop_stream()
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
cleanup_errors.append(exc)
|
|
88
|
+
try:
|
|
89
|
+
stream.close()
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
cleanup_errors.append(exc)
|
|
92
|
+
finally:
|
|
93
|
+
if audio is not None:
|
|
94
|
+
try:
|
|
95
|
+
audio.terminate()
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
cleanup_errors.append(exc)
|
|
98
|
+
self.logger.debug("microphone resources cleaned up")
|
|
99
|
+
if cleanup_errors:
|
|
100
|
+
self.logger.error("microphone cleanup encountered an error")
|
|
101
|
+
|
|
102
|
+
def _read_frame(self) -> bytes:
|
|
103
|
+
if self._stream is None:
|
|
104
|
+
raise AudioCaptureError("microphone stream is not open")
|
|
105
|
+
try:
|
|
106
|
+
frame = self._stream.read(self.samples_per_frame, exception_on_overflow=False)
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
raise AudioCaptureError("microphone read failed") from exc
|
|
109
|
+
if not isinstance(frame, bytes) or len(frame) != self.frame_bytes:
|
|
110
|
+
raise AudioCaptureError("microphone returned an invalid audio frame")
|
|
111
|
+
return frame
|
|
112
|
+
|
|
113
|
+
def _new_vad(self):
|
|
114
|
+
try:
|
|
115
|
+
return self._load_vad()(self.config.vad_mode)
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
raise AudioCaptureError("invalid WebRTC VAD configuration") from exc
|
|
118
|
+
|
|
119
|
+
def record_vad(self) -> AudioResult:
|
|
120
|
+
"""Record until speech followed by configured silence is detected."""
|
|
121
|
+
self.open()
|
|
122
|
+
frames: List[bytes] = []
|
|
123
|
+
pre_speech_frames: Deque[bytes] = deque(
|
|
124
|
+
maxlen=max(0, math.ceil(self.config.pre_speech_duration * 1000 / self.config.frame_duration_ms))
|
|
125
|
+
)
|
|
126
|
+
frame_seconds = self.config.frame_duration_ms / 1000.0
|
|
127
|
+
silence_frames_needed = max(1, math.ceil(self.config.silence_duration / frame_seconds))
|
|
128
|
+
speech_started = False
|
|
129
|
+
consecutive_silence = 0
|
|
130
|
+
started_at = self._clock()
|
|
131
|
+
vad = None
|
|
132
|
+
try:
|
|
133
|
+
vad = self._new_vad()
|
|
134
|
+
self.logger.debug("recording started with VAD")
|
|
135
|
+
while True:
|
|
136
|
+
frame = self._read_frame()
|
|
137
|
+
elapsed = self._clock() - started_at
|
|
138
|
+
if self.config.max_recording_duration is not None and elapsed >= self.config.max_recording_duration:
|
|
139
|
+
self.logger.debug("maximum recording duration reached")
|
|
140
|
+
if not speech_started:
|
|
141
|
+
raise NoSpeechError("no speech detected before maximum recording duration")
|
|
142
|
+
break
|
|
143
|
+
|
|
144
|
+
if not speech_started and (
|
|
145
|
+
self.config.speech_start_timeout is not None
|
|
146
|
+
and elapsed >= self.config.speech_start_timeout
|
|
147
|
+
):
|
|
148
|
+
raise NoSpeechError("no speech detected before speech_start_timeout")
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
speaking = vad.is_speech(frame, self.config.sample_rate)
|
|
152
|
+
except Exception as exc:
|
|
153
|
+
raise AudioCaptureError("WebRTC VAD processing failed") from exc
|
|
154
|
+
if not speech_started:
|
|
155
|
+
if speaking:
|
|
156
|
+
speech_started = True
|
|
157
|
+
frames.extend(pre_speech_frames)
|
|
158
|
+
frames.append(frame)
|
|
159
|
+
self.logger.debug("speech detected")
|
|
160
|
+
else:
|
|
161
|
+
pre_speech_frames.append(frame)
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
frames.append(frame)
|
|
165
|
+
if speaking:
|
|
166
|
+
consecutive_silence = 0
|
|
167
|
+
else:
|
|
168
|
+
consecutive_silence += 1
|
|
169
|
+
if consecutive_silence >= silence_frames_needed:
|
|
170
|
+
self.logger.debug("silence detected; recording stopped")
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
if not frames:
|
|
174
|
+
raise NoSpeechError("no usable audio was captured")
|
|
175
|
+
return AudioResult(b"".join(frames), self.config.sample_rate)
|
|
176
|
+
finally:
|
|
177
|
+
self.close()
|
|
178
|
+
self.logger.debug("audio preparation completed")
|
|
179
|
+
|
|
180
|
+
def record_manual(self, stop_event: threading.Event) -> AudioResult:
|
|
181
|
+
"""Record until stopped or the configured maximum duration is reached."""
|
|
182
|
+
if self._stream is None:
|
|
183
|
+
raise AudioCaptureError("microphone stream is not open")
|
|
184
|
+
frames: List[bytes] = []
|
|
185
|
+
started_at = self._clock()
|
|
186
|
+
self.logger.debug("manual recording started")
|
|
187
|
+
try:
|
|
188
|
+
while not stop_event.is_set():
|
|
189
|
+
if self.config.max_recording_duration is not None and self._clock() - started_at >= self.config.max_recording_duration:
|
|
190
|
+
self.logger.debug("maximum recording duration reached")
|
|
191
|
+
break
|
|
192
|
+
frames.append(self._read_frame())
|
|
193
|
+
if not frames:
|
|
194
|
+
raise NoSpeechError("no usable audio was captured")
|
|
195
|
+
return AudioResult(b"".join(frames), self.config.sample_rate)
|
|
196
|
+
finally:
|
|
197
|
+
self.close()
|
|
198
|
+
self.logger.debug("manual recording stopped")
|