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
diff --git a/CLAUDE.md b/CLAUDE.md
index f2d835c..d4cc595 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -343,7 +343,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
@@ -404,7 +404,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.
@@ -414,14 +414,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) — or `aai.AsyncSyncTranscriber` for the sync API
- **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 c06a7a5..30da654 100644
--- a/README.md
+++ b/README.md
@@ -1017,12 +1017,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}")
@@ -1045,37 +1053,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)
```
@@ -1149,7 +1127,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 d5fe3e9..87d1788 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
@@ -149,8 +148,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