fala 0.0.1__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.
Potentially problematic release.
This version of fala might be problematic. Click here for more details.
- fala-0.0.1/LICENSE +21 -0
- fala-0.0.1/PKG-INFO +205 -0
- fala-0.0.1/README.md +173 -0
- fala-0.0.1/pyproject.toml +107 -0
- fala-0.0.1/src/fala/__init__.py +3 -0
- fala-0.0.1/src/fala/audio_capture.py +156 -0
- fala-0.0.1/src/fala/cli.py +1395 -0
- fala-0.0.1/src/fala/config.py +583 -0
- fala-0.0.1/src/fala/deps.py +104 -0
- fala-0.0.1/src/fala/record.py +65 -0
- fala-0.0.1/src/fala/script_templates.py +532 -0
- fala-0.0.1/src/fala/stats.py +328 -0
- fala-0.0.1/src/fala/streaming.py +480 -0
- fala-0.0.1/src/fala/transcribe.py +100 -0
fala-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ivar Soares Urdalen
|
|
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.
|
fala-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fala
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Voice dictation for Ubuntu/Wayland — hotkey-driven speech-to-text that types anywhere
|
|
5
|
+
Author: Ivar Soares Urdalen
|
|
6
|
+
Author-email: Ivar Soares Urdalen <ivar.urdalen@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Environment :: Console
|
|
17
|
+
Requires-Dist: assemblyai
|
|
18
|
+
Requires-Dist: peewee
|
|
19
|
+
Requires-Dist: questionary
|
|
20
|
+
Requires-Dist: openai
|
|
21
|
+
Requires-Dist: rich
|
|
22
|
+
Requires-Dist: secretstorage
|
|
23
|
+
Requires-Dist: typer
|
|
24
|
+
Requires-Dist: websockets>=14.0
|
|
25
|
+
Requires-Dist: pyaudio>=0.2.11
|
|
26
|
+
Requires-Dist: numpy>=1.24.0
|
|
27
|
+
Requires-Python: >=3.11
|
|
28
|
+
Project-URL: Source, https://github.com/ivarurdalen/fala
|
|
29
|
+
Project-URL: Documentation, https://ivarurdalen.github.io/fala
|
|
30
|
+
Project-URL: Changelog, https://github.com/ivarurdalen/fala/blob/main/CHANGELOG.md
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# fala
|
|
34
|
+
|
|
35
|
+
*fala* means "speak" in Portuguese.
|
|
36
|
+
|
|
37
|
+
Voice dictation for Ubuntu/Wayland. Press a hotkey to start recording, press it again to stop — your speech is transcribed via the OpenAI API and typed wherever your cursor is.
|
|
38
|
+
|
|
39
|
+
Works system-wide: terminals, browsers, text editors, anything.
|
|
40
|
+
|
|
41
|
+
See [docs/architecture.md](docs/architecture.md) for a full diagram of how the components connect.
|
|
42
|
+
|
|
43
|
+
## How it works
|
|
44
|
+
|
|
45
|
+
1. Press your hotkey → recording starts (`parecord`)
|
|
46
|
+
2. Speak
|
|
47
|
+
3. Press hotkey again → recording stops, audio is sent to OpenAI for transcription, text is typed at the cursor via `ydotool`
|
|
48
|
+
|
|
49
|
+
## System requirements
|
|
50
|
+
|
|
51
|
+
- Python 3.11+
|
|
52
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
sudo apt install -y ffmpeg portaudio19-dev pulseaudio-utils
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`ydotool` needs access to the input subsystem. Add your user to the `input` group and create the udev rule:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
sudo usermod -aG input $USER
|
|
62
|
+
echo 'KERNEL=="uinput", MODE="0660", GROUP="input"' \
|
|
63
|
+
| sudo tee /etc/udev/rules.d/60-uinput.rules
|
|
64
|
+
sudo udevadm control --reload-rules && sudo udevadm trigger
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Then log out and back in for the group change to take effect.
|
|
68
|
+
|
|
69
|
+
### ydotool daemon
|
|
70
|
+
|
|
71
|
+
The Ubuntu apt package for `ydotool` is too old to include `ydotoold`. Build and install the current version from source:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
bash scripts/install_ydotool.sh
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
This clones, builds, installs, and starts the daemon as a systemd user service. Safe to re-run.
|
|
78
|
+
|
|
79
|
+
## Installation
|
|
80
|
+
|
|
81
|
+
Build and install `fala` as a global CLI tool using a wheel:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
uv build
|
|
85
|
+
uv tool install --force dist/*.whl
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
This makes `fala` available in your PATH everywhere, no virtualenv activation needed.
|
|
89
|
+
|
|
90
|
+
To update after pulling changes:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
uv build
|
|
94
|
+
uv tool install --force dist/*.whl
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## API key setup
|
|
98
|
+
|
|
99
|
+
Store your OpenAI API key in the GNOME Keyring (encrypted at rest, unlocked automatically at login):
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
fala setup
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
You will be prompted to enter your key. It is stored securely and never written to disk in plaintext.
|
|
106
|
+
|
|
107
|
+
Alternatively, set it as an environment variable (e.g. for CI or headless use):
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
export OPENAI_API_KEY="sk-..."
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The env var takes precedence over the keyring if both are set.
|
|
114
|
+
|
|
115
|
+
## Hotkey setup
|
|
116
|
+
|
|
117
|
+
Install/update helper scripts directly:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
fala install-scripts
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Or via guided setup:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
fala setup
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Register a GNOME keyboard shortcut. The script will ask you to physically press the key combination you want:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
bash scripts/setup_keybinding.sh
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
To remove the shortcut:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
bash scripts/setup_keybinding.sh --revert
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`fala setup` is idempotent and safe to re-run.
|
|
142
|
+
|
|
143
|
+
## CLI usage
|
|
144
|
+
|
|
145
|
+
Record from microphone (press Enter to start, press Enter to stop):
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
fala record
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Transcribe an existing audio file:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
fala transcribe path/to/audio.wav
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Live streaming transcription to terminal:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
fala stream
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
By default, `fala stream` prints transcript deltas directly in the terminal and redirects noisy stderr output (for example ALSA/JACK warnings) to `~/.config/fala/stream.log`.
|
|
164
|
+
Use `--type-into-active-window` to also inject text into the focused GUI input via `ydotool`.
|
|
165
|
+
|
|
166
|
+
Create a high-clarity test fixture recording and generate a pytest integration test with the expected transcript baked into the test:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
fala create-test-fixture clear_en_sentence --text "The quick brown fox jumps over the lazy dog."
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Run integration tests (including realtime streaming integration with local WAV fixture):
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
just test-integration
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Manual equivalent for all integration tests:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
RUN_STREAMING_INTEGRATION=1 uv run pytest -m integration
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Notes:
|
|
185
|
+
- The integration test uses `tests/fixtures/clear_en_sentence.wav`.
|
|
186
|
+
- It requires provider API keys to be available (`OPENAI_API_KEY` and/or `ASSEMBLYAI_API_KEY`); the `just` command loads both from keyring automatically if stored via `fala setup`.
|
|
187
|
+
|
|
188
|
+
Show usage analytics from your transcription log:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
fala stats
|
|
192
|
+
fala stats --weeks 16
|
|
193
|
+
fala stats --typing-wpm 45 --speaking-wpm 130
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`fala stats` prints rich tables with weekly usage, average characters per transcription, provider mix, streaks, and activity trends. It also estimates time saved by speaking vs typing.
|
|
197
|
+
|
|
198
|
+
Set defaults once (persisted in `~/.config/fala/config.toml`):
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
fala config --typing-wpm 40 --speaking-wpm 120 --avg-characters-per-word 5
|
|
202
|
+
fala config --assemblyai-model u3-rt-pro
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
These defaults are used by `fala stats` unless you override them with command flags.
|
fala-0.0.1/README.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# fala
|
|
2
|
+
|
|
3
|
+
*fala* means "speak" in Portuguese.
|
|
4
|
+
|
|
5
|
+
Voice dictation for Ubuntu/Wayland. Press a hotkey to start recording, press it again to stop — your speech is transcribed via the OpenAI API and typed wherever your cursor is.
|
|
6
|
+
|
|
7
|
+
Works system-wide: terminals, browsers, text editors, anything.
|
|
8
|
+
|
|
9
|
+
See [docs/architecture.md](docs/architecture.md) for a full diagram of how the components connect.
|
|
10
|
+
|
|
11
|
+
## How it works
|
|
12
|
+
|
|
13
|
+
1. Press your hotkey → recording starts (`parecord`)
|
|
14
|
+
2. Speak
|
|
15
|
+
3. Press hotkey again → recording stops, audio is sent to OpenAI for transcription, text is typed at the cursor via `ydotool`
|
|
16
|
+
|
|
17
|
+
## System requirements
|
|
18
|
+
|
|
19
|
+
- Python 3.11+
|
|
20
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
sudo apt install -y ffmpeg portaudio19-dev pulseaudio-utils
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`ydotool` needs access to the input subsystem. Add your user to the `input` group and create the udev rule:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
sudo usermod -aG input $USER
|
|
30
|
+
echo 'KERNEL=="uinput", MODE="0660", GROUP="input"' \
|
|
31
|
+
| sudo tee /etc/udev/rules.d/60-uinput.rules
|
|
32
|
+
sudo udevadm control --reload-rules && sudo udevadm trigger
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then log out and back in for the group change to take effect.
|
|
36
|
+
|
|
37
|
+
### ydotool daemon
|
|
38
|
+
|
|
39
|
+
The Ubuntu apt package for `ydotool` is too old to include `ydotoold`. Build and install the current version from source:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
bash scripts/install_ydotool.sh
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
This clones, builds, installs, and starts the daemon as a systemd user service. Safe to re-run.
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
Build and install `fala` as a global CLI tool using a wheel:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
uv build
|
|
53
|
+
uv tool install --force dist/*.whl
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This makes `fala` available in your PATH everywhere, no virtualenv activation needed.
|
|
57
|
+
|
|
58
|
+
To update after pulling changes:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uv build
|
|
62
|
+
uv tool install --force dist/*.whl
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## API key setup
|
|
66
|
+
|
|
67
|
+
Store your OpenAI API key in the GNOME Keyring (encrypted at rest, unlocked automatically at login):
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
fala setup
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
You will be prompted to enter your key. It is stored securely and never written to disk in plaintext.
|
|
74
|
+
|
|
75
|
+
Alternatively, set it as an environment variable (e.g. for CI or headless use):
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
export OPENAI_API_KEY="sk-..."
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The env var takes precedence over the keyring if both are set.
|
|
82
|
+
|
|
83
|
+
## Hotkey setup
|
|
84
|
+
|
|
85
|
+
Install/update helper scripts directly:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
fala install-scripts
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Or via guided setup:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
fala setup
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Register a GNOME keyboard shortcut. The script will ask you to physically press the key combination you want:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
bash scripts/setup_keybinding.sh
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
To remove the shortcut:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
bash scripts/setup_keybinding.sh --revert
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`fala setup` is idempotent and safe to re-run.
|
|
110
|
+
|
|
111
|
+
## CLI usage
|
|
112
|
+
|
|
113
|
+
Record from microphone (press Enter to start, press Enter to stop):
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
fala record
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Transcribe an existing audio file:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
fala transcribe path/to/audio.wav
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Live streaming transcription to terminal:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
fala stream
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
By default, `fala stream` prints transcript deltas directly in the terminal and redirects noisy stderr output (for example ALSA/JACK warnings) to `~/.config/fala/stream.log`.
|
|
132
|
+
Use `--type-into-active-window` to also inject text into the focused GUI input via `ydotool`.
|
|
133
|
+
|
|
134
|
+
Create a high-clarity test fixture recording and generate a pytest integration test with the expected transcript baked into the test:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
fala create-test-fixture clear_en_sentence --text "The quick brown fox jumps over the lazy dog."
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Run integration tests (including realtime streaming integration with local WAV fixture):
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
just test-integration
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Manual equivalent for all integration tests:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
RUN_STREAMING_INTEGRATION=1 uv run pytest -m integration
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Notes:
|
|
153
|
+
- The integration test uses `tests/fixtures/clear_en_sentence.wav`.
|
|
154
|
+
- It requires provider API keys to be available (`OPENAI_API_KEY` and/or `ASSEMBLYAI_API_KEY`); the `just` command loads both from keyring automatically if stored via `fala setup`.
|
|
155
|
+
|
|
156
|
+
Show usage analytics from your transcription log:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
fala stats
|
|
160
|
+
fala stats --weeks 16
|
|
161
|
+
fala stats --typing-wpm 45 --speaking-wpm 130
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`fala stats` prints rich tables with weekly usage, average characters per transcription, provider mix, streaks, and activity trends. It also estimates time saved by speaking vs typing.
|
|
165
|
+
|
|
166
|
+
Set defaults once (persisted in `~/.config/fala/config.toml`):
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
fala config --typing-wpm 40 --speaking-wpm 120 --avg-characters-per-word 5
|
|
170
|
+
fala config --assemblyai-model u3-rt-pro
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
These defaults are used by `fala stats` unless you override them with command flags.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fala"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Voice dictation for Ubuntu/Wayland — hotkey-driven speech-to-text that types anywhere"
|
|
5
|
+
license = "MIT"
|
|
6
|
+
license-files = ["LICENSE"]
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Ivar Soares Urdalen", email = "ivar.urdalen@gmail.com" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Environment :: Console",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"assemblyai",
|
|
24
|
+
"peewee",
|
|
25
|
+
"questionary",
|
|
26
|
+
"openai",
|
|
27
|
+
"rich",
|
|
28
|
+
"secretstorage",
|
|
29
|
+
"typer",
|
|
30
|
+
"websockets>=14.0",
|
|
31
|
+
"pyaudio>=0.2.11",
|
|
32
|
+
"numpy>=1.24.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
fala = "fala.cli:main"
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Source = "https://github.com/ivarurdalen/fala"
|
|
40
|
+
Documentation = "https://ivarurdalen.github.io/fala"
|
|
41
|
+
Changelog = "https://github.com/ivarurdalen/fala/blob/main/CHANGELOG.md"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["uv_build>=0.10.0,<0.11.0"]
|
|
45
|
+
build-backend = "uv_build"
|
|
46
|
+
|
|
47
|
+
[dependency-groups]
|
|
48
|
+
dev = [
|
|
49
|
+
"ty",
|
|
50
|
+
"coverage",
|
|
51
|
+
"pytest",
|
|
52
|
+
"pytest-cov",
|
|
53
|
+
"ruff",
|
|
54
|
+
"zensical>=0.0.24",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[tool.ruff]
|
|
58
|
+
line-length = 100
|
|
59
|
+
indent-width = 4
|
|
60
|
+
|
|
61
|
+
[tool.ruff.lint]
|
|
62
|
+
select = [
|
|
63
|
+
"E", # pycodestyle errors (style issues)
|
|
64
|
+
"F", # Pyflakes (code errors)
|
|
65
|
+
"B", # flake8-bugbear (bug prevention)
|
|
66
|
+
"S", # bandit (security issues)
|
|
67
|
+
"RUF", # ruff-specific rules
|
|
68
|
+
"N", # pep8-naming (naming conventions)
|
|
69
|
+
"W", # pycodestyle warnings
|
|
70
|
+
"I", # isort (import formatting)
|
|
71
|
+
"C90",
|
|
72
|
+
"DOC",
|
|
73
|
+
"D",
|
|
74
|
+
"D213",
|
|
75
|
+
"PLE",
|
|
76
|
+
"UP",
|
|
77
|
+
"PERF", # performance issues
|
|
78
|
+
"PIE", # flake8-pie (unnecessary code)
|
|
79
|
+
"TID", # flake8-tidy-imports (import best practices)
|
|
80
|
+
"ASYNC", # async/await best practices
|
|
81
|
+
"RET", # flake8-return (return improvements)
|
|
82
|
+
]
|
|
83
|
+
extend-ignore = [
|
|
84
|
+
"B008",
|
|
85
|
+
"D212",
|
|
86
|
+
"E501",
|
|
87
|
+
"S603",
|
|
88
|
+
"S101",
|
|
89
|
+
"C901", # stream function is inherently complex due to async/audio handling
|
|
90
|
+
"S607", # ydotool and wl-copy are standard system tools
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
fixable = ["ALL"]
|
|
94
|
+
|
|
95
|
+
[tool.ruff.lint.pydocstyle]
|
|
96
|
+
convention = "google"
|
|
97
|
+
|
|
98
|
+
[tool.ruff.format]
|
|
99
|
+
quote-style = "double"
|
|
100
|
+
indent-style = "space"
|
|
101
|
+
skip-magic-trailing-comma = false
|
|
102
|
+
line-ending = "auto"
|
|
103
|
+
|
|
104
|
+
[tool.pytest.ini_options]
|
|
105
|
+
markers = [
|
|
106
|
+
"integration: marks tests that call external transcription providers",
|
|
107
|
+
]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Audio capture from microphone in real-time."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Self
|
|
8
|
+
|
|
9
|
+
import pyaudio
|
|
10
|
+
|
|
11
|
+
# Audio configuration for transcription APIs
|
|
12
|
+
SAMPLE_RATE = 16000
|
|
13
|
+
CHANNELS = 1
|
|
14
|
+
FORMAT = pyaudio.paInt16
|
|
15
|
+
FRAMES_PER_BUFFER = 800 # 50ms at 16kHz
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AudioCapture:
|
|
19
|
+
"""Captures audio from microphone and yields chunks in real-time."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
device_index: int | None = None,
|
|
24
|
+
sample_rate: int = SAMPLE_RATE,
|
|
25
|
+
channels: int = CHANNELS,
|
|
26
|
+
frames_per_buffer: int = FRAMES_PER_BUFFER,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Initialize audio capture with device and format settings."""
|
|
29
|
+
self.sample_rate = sample_rate
|
|
30
|
+
self.channels = channels
|
|
31
|
+
self.frames_per_buffer = frames_per_buffer
|
|
32
|
+
self.device_index = device_index
|
|
33
|
+
self._pyaudio: pyaudio.PyAudio | None = None
|
|
34
|
+
self._stream: pyaudio.Stream | None = None
|
|
35
|
+
self._stop_event = threading.Event()
|
|
36
|
+
|
|
37
|
+
def __enter__(self) -> Self:
|
|
38
|
+
"""Context manager entry: start capture."""
|
|
39
|
+
self.start()
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
43
|
+
"""Context manager exit: stop capture."""
|
|
44
|
+
self.stop()
|
|
45
|
+
|
|
46
|
+
def start(self) -> None:
|
|
47
|
+
"""Open the audio stream for capture."""
|
|
48
|
+
self._pyaudio = pyaudio.PyAudio()
|
|
49
|
+
self._stream = self._pyaudio.open(
|
|
50
|
+
format=FORMAT,
|
|
51
|
+
channels=self.channels,
|
|
52
|
+
rate=self.sample_rate,
|
|
53
|
+
input=True,
|
|
54
|
+
frames_per_buffer=self.frames_per_buffer,
|
|
55
|
+
input_device_index=self.device_index,
|
|
56
|
+
)
|
|
57
|
+
self._stop_event.clear()
|
|
58
|
+
|
|
59
|
+
def stop(self) -> None:
|
|
60
|
+
"""Stop capture and release resources."""
|
|
61
|
+
self.request_stop()
|
|
62
|
+
if self._stream:
|
|
63
|
+
try:
|
|
64
|
+
self._stream.stop_stream()
|
|
65
|
+
self._stream.close()
|
|
66
|
+
except Exception: # noqa: S110
|
|
67
|
+
pass
|
|
68
|
+
finally:
|
|
69
|
+
self._stream = None
|
|
70
|
+
if self._pyaudio:
|
|
71
|
+
try:
|
|
72
|
+
self._pyaudio.terminate()
|
|
73
|
+
except Exception: # noqa: S110
|
|
74
|
+
pass
|
|
75
|
+
finally:
|
|
76
|
+
self._pyaudio = None
|
|
77
|
+
|
|
78
|
+
def request_stop(self) -> None:
|
|
79
|
+
"""Signal the capture loop to stop without tearing down audio resources."""
|
|
80
|
+
self._stop_event.set()
|
|
81
|
+
|
|
82
|
+
def chunks(self) -> Iterator[bytes]:
|
|
83
|
+
"""Yield audio chunks from the microphone."""
|
|
84
|
+
if self._stream is None:
|
|
85
|
+
raise RuntimeError("AudioCapture not started. Call start() first.")
|
|
86
|
+
# Capture local reference to avoid None during iteration
|
|
87
|
+
stream = self._stream
|
|
88
|
+
while not self._stop_event.is_set():
|
|
89
|
+
try:
|
|
90
|
+
assert stream is not None # for type checker
|
|
91
|
+
data = stream.read(self.frames_per_buffer, exception_on_overflow=False)
|
|
92
|
+
yield data
|
|
93
|
+
except Exception as e:
|
|
94
|
+
print(f"Audio capture error: {e}", flush=True)
|
|
95
|
+
if self._stop_event.is_set():
|
|
96
|
+
break
|
|
97
|
+
time.sleep(0.01)
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def list_devices() -> list[dict]:
|
|
101
|
+
"""Return list of available audio input devices."""
|
|
102
|
+
p = pyaudio.PyAudio()
|
|
103
|
+
devices = []
|
|
104
|
+
try:
|
|
105
|
+
for i in range(p.get_device_count()):
|
|
106
|
+
info = p.get_device_info_by_index(i)
|
|
107
|
+
# Cast to int to satisfy type checker
|
|
108
|
+
if int(info["maxInputChannels"]) > 0:
|
|
109
|
+
devices.append(
|
|
110
|
+
{
|
|
111
|
+
"index": i,
|
|
112
|
+
"name": info["name"],
|
|
113
|
+
"channels": int(info["maxInputChannels"]),
|
|
114
|
+
"sample_rate": int(info["defaultSampleRate"]),
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
finally:
|
|
118
|
+
p.terminate()
|
|
119
|
+
return devices
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def record_to_file(
|
|
123
|
+
device_index: int | None,
|
|
124
|
+
duration_seconds: float,
|
|
125
|
+
output_path: Path,
|
|
126
|
+
sample_rate: int = SAMPLE_RATE,
|
|
127
|
+
channels: int = CHANNELS,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Record audio for a fixed duration and save to WAV file."""
|
|
130
|
+
import wave
|
|
131
|
+
|
|
132
|
+
p = pyaudio.PyAudio()
|
|
133
|
+
try:
|
|
134
|
+
stream = p.open(
|
|
135
|
+
format=FORMAT,
|
|
136
|
+
channels=channels,
|
|
137
|
+
rate=sample_rate,
|
|
138
|
+
input=True,
|
|
139
|
+
frames_per_buffer=FRAMES_PER_BUFFER,
|
|
140
|
+
input_device_index=device_index,
|
|
141
|
+
)
|
|
142
|
+
frames = []
|
|
143
|
+
chunks_needed = int(duration_seconds * sample_rate / FRAMES_PER_BUFFER)
|
|
144
|
+
for _ in range(chunks_needed):
|
|
145
|
+
data = stream.read(FRAMES_PER_BUFFER, exception_on_overflow=False)
|
|
146
|
+
frames.append(data)
|
|
147
|
+
stream.stop_stream()
|
|
148
|
+
stream.close()
|
|
149
|
+
|
|
150
|
+
with wave.open(str(output_path), "wb") as wf:
|
|
151
|
+
wf.setnchannels(channels)
|
|
152
|
+
wf.setsampwidth(p.get_sample_size(FORMAT))
|
|
153
|
+
wf.setframerate(sample_rate)
|
|
154
|
+
wf.writeframes(b"".join(frames))
|
|
155
|
+
finally:
|
|
156
|
+
p.terminate()
|