From 8313fc95deb19075da23a091e9e9f5d5e0830773 Mon Sep 17 00:00:00 2001 From: Carter Campbell Date: Wed, 12 Aug 2026 16:33:40 -0600 Subject: [PATCH 1/2] feat!: remove the extras module and the [extras] install option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop assemblyai/extras.py (MicrophoneStream, stream_file, file_from_stream) and the pyaudio extras_require for the major release. The SDK no longer captures audio — callers supply PCM chunks themselves. README streaming examples now pace files with a plain generator; tox no longer installs pyaudio. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 8 +- README.md | 44 ++------ assemblyai/__init__.py | 3 - assemblyai/extras.py | 215 ------------------------------------- setup.py | 3 - tests/unit/test_extras.py | 170 ----------------------------- tests/unit/test_imports.py | 92 ---------------- tox.ini | 4 - 8 files changed, 15 insertions(+), 524 deletions(-) delete mode 100644 assemblyai/extras.py delete mode 100644 tests/unit/test_extras.py delete mode 100644 tests/unit/test_imports.py diff --git a/CLAUDE.md b/CLAUDE.md index a1ed95f..0c9b60b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -311,7 +311,7 @@ async def main(): asyncio.run(main()) ``` -**Microphone streaming** (both clients): `pip install "assemblyai[extras]"` for `pyaudio`, then pass `aai.extras.MicrophoneStream(sample_rate=16000)` to `client.stream(...)`. +**Microphone streaming**: the SDK does not capture audio — supply 16-bit PCM chunks yourself (e.g. via `pyaudio` or `sounddevice`) and pass the iterable to `client.stream(...)`. **Voice-agent tuning** (knobs that matter most when building a voice agent): ```python @@ -372,7 +372,7 @@ async with AsyncStreamingClient(StreamingClientOptions(token=token_from_server)) ``` **Other gotchas**: -- Don't pass `aai.extras.stream_file(...)` to `AsyncStreamingClient.stream()` — it uses blocking `time.sleep` and starves the read task. Use an `async def` generator with `await asyncio.sleep(...)` instead. +- Don't feed `AsyncStreamingClient.stream()` from a sync generator that blocks (e.g. `time.sleep` pacing) — it starves the read task. Use an `async def` generator with `await asyncio.sleep(...)` instead. - `format_turns=True` enables punctuation/casing on confirmed end-of-turns. Toggle mid-session via `client.set_params(StreamingSessionParameters(format_turns=True))`. - `AsyncStreamingClient` used as `async with` calls `disconnect(terminate=True)` on normal block exit and `disconnect(terminate=False)` on exception — no explicit `disconnect()` needed inside the block. @@ -382,14 +382,14 @@ async with AsyncStreamingClient(StreamingClientOptions(token=token_from_server)) - **`speech_models` takes a list** with fallback ordering: `["universal-3-5-pro", "universal-2"]` - **PII redaction uses `set_redact_pii()`**, not a constructor parameter - **Streaming v3 lives in its own module**: `assemblyai.streaming.v3` (there is no other streaming API in this SDK). See the "Streaming (real-time)" section above. -- **Microphone streaming needs extras**: `pip install "assemblyai[extras]"` for `pyaudio` +- **The SDK does not capture microphone audio**: bring your own capture (e.g. `pyaudio`, `sounddevice`) and stream the PCM chunks - **`transcribe_async()` returns a `concurrent.futures.Future`**, not an asyncio coroutine. In asyncio code use `aai.AsyncTranscriber` (see "Asyncio transcription" above) - **Timestamps are in milliseconds** throughout the SDK - **Minimum Python**: 3.8+ ## Dependencies -`httpx`, `pydantic`, `typing-extensions`, `websockets`. Optional: `pyaudio` via `[extras]`. +`httpx`, `pydantic`, `typing-extensions`, `websockets`. ## Docs diff --git a/README.md b/README.md index 85bc760..b6bd511 100644 --- a/README.md +++ b/README.md @@ -992,12 +992,20 @@ Real-time speech-to-text via WebSocket against the `universal-3-5-pro` model. Th Stream a local file (sync) ```python -import assemblyai as aai +import time + from assemblyai.streaming.v3 import ( BeginEvent, StreamingClient, StreamingClientOptions, StreamingError, StreamingEvents, StreamingParameters, TerminationEvent, TurnEvent, ) +def stream_file(path: str, sample_rate: int, chunk_duration: float = 0.3): + bytes_per_chunk = int(sample_rate * chunk_duration) * 2 + with open(path, "rb") as f: + while chunk := f.read(bytes_per_chunk): + yield chunk + time.sleep(chunk_duration) + def on_begin(client, event: BeginEvent): print(f"Session started: {event.id}") @@ -1020,37 +1028,7 @@ client.connect(StreamingParameters( sample_rate=16000, speech_model="universal-3-5-pro", )) try: - client.stream(aai.extras.stream_file(filepath="audio.wav", sample_rate=16000)) -finally: - client.disconnect(terminate=True) -``` - - - -
- Stream your microphone (sync) - -`MicrophoneStream` requires PyAudio: - -```bash -pip install -U "assemblyai[extras]" -``` - -```python -import assemblyai as aai -from assemblyai.streaming.v3 import ( - StreamingClient, StreamingClientOptions, StreamingEvents, StreamingParameters, -) - -def on_turn(client, event): - print(f"{event.transcript} (end_of_turn={event.end_of_turn})") - -client = StreamingClient(StreamingClientOptions(api_key="")) -client.on(StreamingEvents.Turn, on_turn) -client.connect(StreamingParameters(sample_rate=16000, speech_model="universal-3-5-pro")) - -try: - client.stream(aai.extras.MicrophoneStream(sample_rate=16000)) + client.stream(stream_file("audio.wav", sample_rate=16000)) finally: client.disconnect(terminate=True) ``` @@ -1124,7 +1102,7 @@ See [`examples/streaming_dual_channel.py`](./examples/streaming_dual_channel.py)
Stream a local file (async) -`AsyncStreamingClient` mirrors `StreamingClient` with async methods. It's safe to use as an async context manager — `disconnect()` runs on block exit even if user code raises. Don't pass `extras.stream_file` directly (it uses blocking `time.sleep`); pace from an async generator instead. +`AsyncStreamingClient` mirrors `StreamingClient` with async methods. It's safe to use as an async context manager — `disconnect()` runs on block exit even if user code raises. Pace the audio from an async generator so the event loop is never blocked. ```python import asyncio diff --git a/assemblyai/__init__.py b/assemblyai/__init__.py index c40bbac..c1207c6 100644 --- a/assemblyai/__init__.py +++ b/assemblyai/__init__.py @@ -1,4 +1,3 @@ -from . import extras from .__version__ import __version__ from .async_client import AsyncClient from .client import Client @@ -180,8 +179,6 @@ "WordSearchMatch", # package globals "settings", - # packages - "extras", # version "__version__", ] diff --git a/assemblyai/extras.py b/assemblyai/extras.py deleted file mode 100644 index df78f27..0000000 --- a/assemblyai/extras.py +++ /dev/null @@ -1,215 +0,0 @@ -import threading -import time -from typing import BinaryIO, Generator, Optional -from warnings import warn - -from . import api -from .client import Client - - -class AssemblyAIExtrasNotInstalledError(ImportError): - def __init__( - self, - msg=""" - You must install the extras for this SDK to use this feature. - Run `pip install "assemblyai[extras]"` to install the extras. - Make sure to install `apt install portaudio19-dev` (Debian/Ubuntu) or - `brew install portaudio` (MacOS) before installing the extras - """, - *args, - **kwargs, - ): - super().__init__(msg, *args, **kwargs) - - -class MicrophoneStream: - """ - An iterator of raw microphone audio chunks (~100ms each), suitable for - passing to a streaming client's ``stream()`` method. - - :meth:`pause`, :meth:`resume`, and :meth:`close` are thread-safe: they may - be called from another thread while a read is in flight. - """ - - def __init__(self, sample_rate: int = 44_100, device_index: Optional[int] = None): - """ - Creates a stream of audio from the microphone. - - Args: - sample_rate: The sample rate to record audio at. - device_index: The index of the input device to use. If None, uses the default device. - """ - try: - import pyaudio - except ImportError: - raise AssemblyAIExtrasNotInstalledError - - self._pyaudio = pyaudio.PyAudio() - self.sample_rate = sample_rate - - self._chunk_size = int(self.sample_rate * 0.1) - self._stream = self._pyaudio.open( - format=pyaudio.paInt16, - channels=1, - rate=sample_rate, - input=True, - frames_per_buffer=self._chunk_size, - input_device_index=device_index, - ) - - self._open = True - self._paused = False - self._closed = False - # Serializes device reads against teardown: closing the PortAudio - # stream while another thread is blocked in read() deadlocks, so - # close() waits for any in-flight read to finish first. - self._lock = threading.Lock() - - def __iter__(self): - """ - Returns the iterator object. - """ - - return self - - def __next__(self): - """ - Reads a chunk of audio from the microphone. - - While paused (see :meth:`pause`), the microphone is still drained so its - input buffer doesn't overflow, but silence is yielded in place of the - captured audio. This keeps the streaming session alive (avoiding an idle - timeout) without forwarding what the mic picks up. - """ - if not self._open: - raise StopIteration - - with self._lock: - # Re-check after acquiring: a concurrent close() may have torn the - # stream down while this thread waited for the lock. - if not self._open: - raise StopIteration - - try: - data = self._stream.read(self._chunk_size) - except KeyboardInterrupt: - raise StopIteration - - if self._paused: - return b"\x00" * len(data) - - return data - - def pause(self) -> None: - """ - Pause forwarding microphone audio. - - The stream stays open and keeps reading from the microphone so its - internal buffer doesn't overflow, but :meth:`__next__` yields silence - instead of the captured audio. Useful for muting the mic while a voice - agent is speaking so its own output (e.g. TTS played back through the - speakers) isn't transcribed and fed into a feedback loop. - - Thread-safe: may be called from any thread while another thread is - reading from the stream. Takes effect within one chunk (~100ms). - """ - self._paused = True - - def resume(self) -> None: - """ - Resume forwarding live microphone audio after :meth:`pause`. - - Thread-safe, like :meth:`pause`. - """ - self._paused = False - - @property - def paused(self) -> bool: - """Whether the stream is currently yielding silence (see :meth:`pause`).""" - return self._paused - - def close(self): - """ - Closes the stream. - - Thread-safe and idempotent: may be called from any thread, including - while another thread is blocked in a read. Teardown waits for the - in-flight chunk (~100ms) to finish first — closing the PortAudio - stream mid-read deadlocks the reading thread. After close(), the - iterator raises ``StopIteration``. - """ - - # Stop new reads before waiting on the in-flight one, so the reader - # can't re-acquire the lock ahead of teardown. - self._open = False - - with self._lock: - if self._closed: - return - self._closed = True - - if self._stream.is_active(): - self._stream.stop_stream() - - self._stream.close() - self._pyaudio.terminate() - - -def stream_file( - filepath: str, - sample_rate: int, -) -> Generator[bytes, None, None]: - """ - Mimics a stream of audio data by reading it chunk by chunk from a file. - - NOTE: Only supports WAV/PCM16 files as of now. - - Args: - filepath: The path to the file to stream. - sample_rate: The sample rate of the audio file. - - Returns: A generator that yields chunks of audio data. - """ - chunk_duration = 0.3 - with open(filepath, "rb") as f: - while True: - # send in 300ms segments (2 bytes per frame) - data = f.read(int(sample_rate * chunk_duration) * 2) - - if not data: - break - - yield data - - time.sleep(chunk_duration) - - -def file_from_stream(data: BinaryIO) -> str: - """ - DeprecationWarning: `file_from_stream()` is deprecated and will be removed in 1.0.0. Use `Transcriber.upload_file()` instead. - - Uploads the given stream and returns the uploaded audio url. - - This function can be used to transcribe data that's already - available in memory. - - Example: - ``` - upload_url = aai.extras.file_from_stream(data) - - transcriber = aai.Transcriber() - transcript = transcriber.transcribe(upload_url) - ``` - - Args: - `data`: A file-like object (in binary mode) - """ - warn( - "`file_from_stream()` is deprecated and will be removed in 1.0.0. Use `Transcriber.upload_file()` instead.", - DeprecationWarning, - stacklevel=2, - ) - return api.upload_file( - client=Client.get_default().http_client, - audio_file=data, - ) diff --git a/setup.py b/setup.py index 6657b90..48f0d10 100644 --- a/setup.py +++ b/setup.py @@ -27,9 +27,6 @@ def get_version() -> str: "typing-extensions>=3.7", "websockets>=11.0", ], - extras_require={ - "extras": ["pyaudio>=0.2.13"], - }, classifiers=[ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", diff --git a/tests/unit/test_extras.py b/tests/unit/test_extras.py deleted file mode 100644 index 74d8517..0000000 --- a/tests/unit/test_extras.py +++ /dev/null @@ -1,170 +0,0 @@ -from unittest.mock import mock_open, patch - -import pytest_mock - -import assemblyai as aai - - -def test_stream_file_empty_file(): - """ - Test streaming of an empty file. - """ - - data = b"" - sample_rate = 44100 - - m = mock_open(read_data=data) - - with patch("builtins.open", m), patch("time.sleep", return_value=None): - chunks = list(aai.extras.stream_file("fake_path", sample_rate)) - - # Expect no chunk - assert len(chunks) == 0 - - -def test_stream_file_small_file(): - """ - Tests streaming a file smaller than 300ms. - """ - - data = b"\x00" * int(0.2 * 44100) * 2 - sample_rate = 44100 - - m = mock_open(read_data=data) - - with patch("builtins.open", m), patch("time.sleep", return_value=None): - chunks = list(aai.extras.stream_file("fake_path", sample_rate)) - - # Expecting one chunks because of no padding at the end - expected_chunk_length = int(0.2 * sample_rate * 2) - assert len(chunks) == 1 - assert len(chunks[0]) == expected_chunk_length - assert chunks[0] == b"\x00" * expected_chunk_length - - -def test_stream_file_large_file(): - """ - Test streaming a file larger than 300ms. - """ - - data = b"\x00" * int(0.6 * 44100) * 2 - sample_rate = 44100 - - m = mock_open(read_data=data) - - with patch("builtins.open", m), patch("time.sleep", return_value=None): - chunks = list(aai.extras.stream_file("fake_path", sample_rate)) - - # Expecting two chunks - assert len(chunks) == 2 - - -def test_stream_file_exact_file(): - """ - Test streaming a file exactly 300ms long. - """ - - data = b"\x00" * int(0.3 * 44100) * 2 - sample_rate = 44100 - - m = mock_open(read_data=data) - - with patch("builtins.open", m), patch("time.sleep", return_value=None): - chunks = list(aai.extras.stream_file("fake_path", sample_rate)) - - # Expecting one chunk - assert len(chunks) == 1 - - -def test_microphone_stream_pause_resume(mocker: pytest_mock.MockerFixture): - """ - A paused MicrophoneStream keeps draining the device but yields silence in - place of captured audio, and resumes forwarding live audio afterwards. - """ - import pyaudio - - live_chunk = b"\x01\x02\x03\x04" * 8 - fake_stream = mocker.MagicMock() - fake_stream.read.return_value = live_chunk - mocker.patch.object(pyaudio.PyAudio, "open", return_value=fake_stream) - - mic = aai.extras.MicrophoneStream(sample_rate=16000) - - # Live: captured audio is forwarded unchanged. - assert mic.paused is False - assert next(mic) == live_chunk - - # Paused: yields silence of the same length, but still reads from the device - # (so the input buffer keeps draining and the session stays alive). - mic.pause() - assert mic.paused is True - reads_before = fake_stream.read.call_count - assert next(mic) == b"\x00" * len(live_chunk) - assert fake_stream.read.call_count == reads_before + 1 - - # Resume: live audio is forwarded again. - mic.resume() - assert mic.paused is False - assert next(mic) == live_chunk - - -def test_microphone_stream_close_during_read_is_thread_safe( - mocker: pytest_mock.MockerFixture, -): - """ - close() may be called from another thread while a read is in flight (the - natural companion to pause()/resume() in voice agents). Teardown must wait - for the in-flight chunk instead of closing the PortAudio stream mid-read, - which deadlocks the reading thread. - """ - import threading - import time - - import pyaudio - - chunk = b"\x00" * 16 - read_started = threading.Event() - - def slow_read(num_frames, *args, **kwargs): - read_started.set() - time.sleep(0.05) # simulate the ~100ms blocking device read - return chunk - - fake_stream = mocker.MagicMock() - fake_stream.read.side_effect = slow_read - fake_stream.is_active.return_value = True - mocker.patch.object(pyaudio.PyAudio, "open", return_value=fake_stream) - terminate = mocker.patch.object(pyaudio.PyAudio, "terminate") - - mic = aai.extras.MicrophoneStream(sample_rate=16000) - - def consume(): - for _ in mic: - pass - - reader = threading.Thread(target=consume) - reader.start() - assert read_started.wait(timeout=2), "reader never reached the device read" - - started = time.monotonic() - mic.close() # concurrent with an in-flight read - close_duration = time.monotonic() - started - - reader.join(timeout=2) - assert not reader.is_alive(), "reader thread did not exit after close()" - assert close_duration < 2, "close() blocked far longer than one chunk read" - # close() was called mid-read (after read_started, during the 50ms sleep), - # so a thread-safe close must have waited out the in-flight read rather - # than tearing the stream down underneath it. - assert close_duration >= 0.03, "close() did not wait for the in-flight read" - - # Teardown ran exactly once, and only after the in-flight read finished. - fake_stream.stop_stream.assert_called_once() - fake_stream.close.assert_called_once() - terminate.assert_called_once() - - # Idempotent: a second close() is a no-op, and iteration stays ended. - mic.close() - fake_stream.close.assert_called_once() - terminate.assert_called_once() - assert next(mic, None) is None diff --git a/tests/unit/test_imports.py b/tests/unit/test_imports.py deleted file mode 100644 index e75225c..0000000 --- a/tests/unit/test_imports.py +++ /dev/null @@ -1,92 +0,0 @@ -import os -import sys -from importlib import reload -from unittest.mock import mock_open, patch - -import httpx -import pytest -import pytest_mock -from pytest_httpx import HTTPXMock - -import assemblyai as aai -from assemblyai.api import ENDPOINT_UPLOAD - - -class ImportFailureMocker: - def __init__(self, module: str): - self.module = module - - def find_spec(self, fullname, path, target=None): - if fullname == self.module: - raise ImportError - - def __enter__(self): - # Remove module if already imported - if self.module in sys.modules: - del sys.modules[self.module] - - # Add self as first importer - sys.meta_path.insert(0, self) - return self - - def __exit__(self, type, value, traceback): - # Remove self as importer - sys.meta_path.pop(0) - - -def __reload_assesmblyai_module(): - reload(aai) - aai.settings.api_key = "test" - - -def test_import_sdk_without_extras_installed(): - with ImportFailureMocker("pyaudio"): - __reload_assesmblyai_module() - # Test succeeds if no failures - - -def test_import_sdk_and_use_extra_functions_without_extras_installed( - httpx_mock: HTTPXMock, -): - with ImportFailureMocker("pyaudio"): - __reload_assesmblyai_module() - - local_file = os.urandom(10) - expected_upload_url = "https://example.org/audio.wav" - - # patch the reading of a local file - with patch("builtins.open", mock_open(read_data=local_file)): - _ = aai.extras.stream_file(filepath="audio.wav", sample_rate=44_100) - - # mock the upload endpoint - httpx_mock.add_response( - url=f"{aai.settings.base_url}{ENDPOINT_UPLOAD}", - status_code=httpx.codes.OK, - method="POST", - json={"upload_url": expected_upload_url}, - match_content=local_file, - ) - - upload_url = aai.extras.file_from_stream(local_file) - assert upload_url == expected_upload_url - - -def test_import_sdk_and_use_MicrophoneStream_without_extras_installed(): - with ImportFailureMocker("pyaudio"): - __reload_assesmblyai_module() - - with pytest.raises(aai.extras.AssemblyAIExtrasNotInstalledError): - aai.extras.MicrophoneStream() - - -def test_import_sdk_and_use_MicrophoneStream_with_extras_installed( - mocker: pytest_mock.MockerFixture, -): - import pyaudio - - __reload_assesmblyai_module() - - mocker.patch.object(pyaudio.PyAudio, "open", return_value=None) - aai.extras.MicrophoneStream() - - # Test succeeds if no failures diff --git a/tox.ini b/tox.ini index b0adc6f..0500a7a 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,6 @@ envlist = py311-httpx{0.22,0.24} py311-pydantic1.10 py311-websockets11.0 - py311-pyaudio0.2 [testenv] deps = @@ -17,9 +16,6 @@ deps = httpx0.22: httpx>=0.22.0,<0.23.0 httpx0.24: httpx>=0.24.0,<0.25.0 pydantic1.10: pydantic>=1.10.17,<1.11.0 - # pyaudio is an extra, not in install_requires — install for all envs - pyaudio>=0.2.13 - pyaudio0.2: pyaudio>=0.2.13,<0.3.0 # test dependencies pytest pytest-httpx From ff4aaf08cc15f00c24ca948c2e2b0754d2900845 Mon Sep 17 00:00:00 2001 From: Carter Campbell Date: Wed, 12 Aug 2026 17:27:42 -0600 Subject: [PATCH 2/2] ci: stop installing portaudio19-dev pyaudio is no longer a test dependency now that the extras are gone. Co-Authored-By: Claude Fable 5 --- .github/workflows/test.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b4d2469..1c7273f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,7 +43,6 @@ jobs: python-version: ${{ matrix.py }} - name: Setup test suite run: | - sudo apt-get update && sudo apt-get install -y portaudio19-dev python_version="${{ matrix.py }}" python_version="${python_version/./}" tox -f "py$python_version" -vvvv --notest