diff --git a/assemblyai/__version__.py b/assemblyai/__version__.py index 27729bb..0348df7 100644 --- a/assemblyai/__version__.py +++ b/assemblyai/__version__.py @@ -1 +1 @@ -__version__ = "0.64.25" +__version__ = "0.64.26" diff --git a/assemblyai/streaming/v3/models.py b/assemblyai/streaming/v3/models.py index 879e4a7..55794f2 100644 --- a/assemblyai/streaming/v3/models.py +++ b/assemblyai/streaming/v3/models.py @@ -2,7 +2,16 @@ from enum import Enum from typing import Any, List, Literal, Optional, Union -from pydantic import BaseModel +try: + # pydantic v2 import + from pydantic import BaseModel, model_validator + + pydantic_v2 = True +except ImportError: + # pydantic v1 import (fallback for Python < 3.14) + from pydantic import BaseModel, root_validator + + pydantic_v2 = False class LLMGatewayMessage(BaseModel): @@ -153,11 +162,12 @@ class StreamingSessionParameters(BaseModel): class Encoding(str, Enum): pcm_s16le = "pcm_s16le" pcm_mulaw = "pcm_mulaw" - # Raw Opus packets, one packet per binary WS message. `sample_rate` is - # optional and ignored — the Opus stream is self-describing. + # Raw Opus packets, one packet per binary WS message. `sample_rate` may be + # omitted — the Opus stream is self-describing and the server ignores it. opus = "opus" # Ogg-encapsulated Opus byte stream (ffmpeg, gstreamer, opusenc, browser - # MediaRecorder output). `sample_rate` is optional and ignored. + # MediaRecorder output). `sample_rate` may be omitted — the Opus stream is + # self-describing and the server ignores it. ogg_opus = "ogg_opus" def __str__(self): @@ -263,7 +273,10 @@ def __str__(self): class StreamingParameters(StreamingSessionParameters): - sample_rate: int + # Required for PCM encodings. May be omitted for Opus encodings + # (opus, ogg_opus) — the stream is self-describing and the server + # ignores the value. + sample_rate: Optional[int] = None encoding: Optional[Encoding] = None speech_model: Optional[SpeechModel] = None # Deprecated: use language_codes instead (pass a single-element list, e.g. @@ -291,6 +304,36 @@ class StreamingParameters(StreamingSessionParameters): redact_pii_sub: Optional[StreamingPiiSubstitution] = None mode: Optional[StreamingMode] = None + if pydantic_v2: + + @model_validator(mode="after") + def _require_sample_rate_for_non_opus(self): + if self.sample_rate is None and self.encoding not in ( + Encoding.opus, + Encoding.ogg_opus, + ): + raise ValueError( + "sample_rate is required; it may only be omitted when " + "encoding is 'opus' or 'ogg_opus' (the Opus stream is " + "self-describing)." + ) + return self + + else: + + @root_validator(skip_on_failure=True) + def _require_sample_rate_for_non_opus(cls, values): + if values.get("sample_rate") is None and values.get("encoding") not in ( + Encoding.opus, + Encoding.ogg_opus, + ): + raise ValueError( + "sample_rate is required; it may only be omitted when " + "encoding is 'opus' or 'ogg_opus' (the Opus stream is " + "self-describing)." + ) + return values + class UpdateConfiguration(StreamingSessionParameters): type: Literal["UpdateConfiguration"] = "UpdateConfiguration" diff --git a/tests/unit/test_streaming.py b/tests/unit/test_streaming.py index 154b7e8..cfc6ca4 100644 --- a/tests/unit/test_streaming.py +++ b/tests/unit/test_streaming.py @@ -3,6 +3,7 @@ import threading import time from types import SimpleNamespace +from typing import Optional from urllib.parse import urlencode import pytest @@ -412,6 +413,53 @@ def mocked_websocket_connect( assert f"encoding={encoding.value}" in actual_url +@pytest.mark.parametrize("encoding", [Encoding.opus, Encoding.ogg_opus]) +def test_client_connect_opus_without_sample_rate( + mocker: MockFixture, encoding: Encoding +): + # Given: client + an Opus encoding and no sample_rate (the Opus stream is + # self-describing, so the parameter may be omitted) + actual_url = None + + def mocked_websocket_connect( + url: str, additional_headers: dict, open_timeout: float + ): + nonlocal actual_url + actual_url = url + + mocker.patch( + "assemblyai.streaming.v3.client.websocket_connect", + new=mocked_websocket_connect, + ) + _disable_rw_threads(mocker) + client = StreamingClient( + StreamingClientOptions(api_key="test", api_host="api.example.com") + ) + params = StreamingParameters( + speech_model=SpeechModel.universal_3_5_pro, + encoding=encoding, + ) + + # When: connect + client.connect(params) + + # Then: the encoding is forwarded and sample_rate is absent from the URL + assert f"encoding={encoding.value}" in actual_url + assert "sample_rate" not in actual_url + + +@pytest.mark.parametrize("encoding", [None, Encoding.pcm_s16le, Encoding.pcm_mulaw]) +def test_sample_rate_required_for_non_opus_encodings(encoding: Optional[Encoding]): + # Given/When: constructing parameters without sample_rate for a PCM (or + # unset) encoding + # Then: validation rejects it + with pytest.raises(ValueError, match="sample_rate is required"): + StreamingParameters( + speech_model=SpeechModel.universal_3_5_pro, + encoding=encoding, + ) + + def test_noise_suppression_deprecated_alias_migrates_to_voice_focus( mocker: MockFixture, caplog: pytest.LogCaptureFixture ):