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 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,3 @@
1
+ """fala package."""
2
+
3
+ __all__ = []
@@ -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()