diff --git a/getstream/base.py b/getstream/base.py index 6a9ff75d..aa877db3 100644 --- a/getstream/base.py +++ b/getstream/base.py @@ -1,6 +1,7 @@ import json import time import uuid +import asyncio from typing import Any, Dict, Optional, Type, get_origin from getstream.models import APIError @@ -355,6 +356,14 @@ async def _request_async( ) as span: call_kwargs = dict(kwargs) call_kwargs.pop("path_params", None) + + if call_kwargs.get("json") is not None: + json_body = call_kwargs.pop("json") + json_str = await asyncio.to_thread(json.dumps, json_body) + call_kwargs["content"] = json_str + call_kwargs["headers"] = call_kwargs.get("headers", {}) + call_kwargs["headers"]["Content-Type"] = "application/json" + response = await getattr(self.client, method.lower())( url_path, params=query_params, *args, **call_kwargs ) @@ -377,7 +386,9 @@ async def _request_async( status_code=getattr(response, "status_code", None), ) record_metrics(duration_ms, attributes=metric_attrs) - return self._parse_response(response, data_type or Dict[str, Any]) + return await asyncio.to_thread( + self._parse_response, response, data_type or Dict[str, Any] + ) async def patch( self, diff --git a/getstream/utils/__init__.py b/getstream/utils/__init__.py index 6abbcb29..f2181954 100644 --- a/getstream/utils/__init__.py +++ b/getstream/utils/__init__.py @@ -126,6 +126,28 @@ def build_query_param(**kwargs): return params +async def build_query_param_async(**kwargs): + """ + Async version that offloads CPU-bound JSON serialization to thread pool. + + Constructs a dictionary of query parameters from keyword arguments. + + This function handles various data types: + - JSON-serializable objects with a `to_json` method will be serialized using that method. + - Booleans are converted to lowercase strings. + - Lists are converted to comma-separated strings with URL-encoded values. + - Other types (strings, integers, dictionaries) are handled appropriately. + + Args: + **kwargs: Arbitrary keyword arguments representing potential query parameters. + + Returns: + dict: A dictionary where keys are parameter names and values are URL-ready strings. + """ + # Use sync version in thread pool to avoid blocking event loop + return await asyncio.to_thread(build_query_param, **kwargs) + + def build_body_dict(**kwargs): """ Constructs a dictionary for the body of a request, handling nested structures. @@ -153,6 +175,24 @@ def handle_value(value): return data +async def build_body_dict_async(**kwargs): + """ + Async version that offloads CPU-bound to_dict() calls to thread pool. + + Constructs a dictionary for the body of a request, handling nested structures. + If an object has a `to_dict` method, it calls this method to serialize the object. + It handles nested dictionaries and lists recursively. + + Args: + **kwargs: Keyword arguments representing keys and values to be included in the body dictionary. + + Returns: + dict: A dictionary with keys corresponding to kwargs keys and values processed, potentially recursively. + """ + # Use sync version in thread pool to avoid blocking event loop + return await asyncio.to_thread(build_body_dict, **kwargs) + + def configure_logging(level=None, handler=None, format=None): """ Configure logging for the Stream library. @@ -232,7 +272,9 @@ async def sync_to_async(func, *args, **kwargs): "encode_datetime", "datetime_from_unix_ns", "build_query_param", + "build_query_param_async", "build_body_dict", + "build_body_dict_async", "validate_and_clean_url", "configure_logging", "UTC", diff --git a/getstream/video/rtc/track_util.py b/getstream/video/rtc/track_util.py index d70a014a..dc4e6148 100644 --- a/getstream/video/rtc/track_util.py +++ b/getstream/video/rtc/track_util.py @@ -1,5 +1,4 @@ import asyncio -import copy import io import wave from enum import Enum @@ -168,6 +167,61 @@ def __init__( self.time_base: Optional[float] = time_base self.channels: int = channels + def __repr__(self) -> str: + """ + Return a string representation of the PcmData object. + + Returns: + str: String representation + """ + return str(self) + + def __str__(self) -> str: + """ + Return a user-friendly string representation of the PcmData object. + + Returns: + str: Human-readable description of the audio data + """ + # Get sample count + if self.samples.ndim == 2: + sample_count = ( + self.samples.shape[1] + if self.samples.shape[0] == self.channels + else self.samples.shape[0] + ) + else: + sample_count = len(self.samples) + + # Get channel description + if self.channels == 1: + channel_desc = "Mono" + elif self.channels == 2: + channel_desc = "Stereo" + else: + channel_desc = f"{self.channels}-channel" + + # Get duration + duration_s = self.duration + if duration_s >= 1.0: + duration_str = f"{duration_s:.2f}s" + else: + duration_str = f"{self.duration_ms:.1f}ms" + + # Format description + format_desc = ( + "16-bit PCM" + if self.format == "s16" + else "32-bit float" + if self.format == "f32" + else self.format + ) + + return ( + f"{channel_desc} audio: {self.sample_rate}Hz, {format_desc}, " + f"{sample_count} samples, {duration_str}" + ) + @property def stereo(self) -> bool: return self.channels == 2 @@ -180,53 +234,27 @@ def duration(self) -> float: Returns: float: Duration in seconds. """ - # The samples field contains a numpy array of audio samples + # The samples field is always a numpy array of audio samples # For s16 format, each element in the array is one sample (int16) # For f32 format, each element in the array is one sample (float32) - if isinstance(self.samples, np.ndarray): - # If array has shape (channels, samples) or (samples, channels), duration uses the samples dimension - if self.samples.ndim == 2: - # Determine which dimension is samples vs channels - # Standard format is (channels, samples), but we need to handle both - ch = self.channels if self.channels else 1 - if self.samples.shape[0] == ch: - # Shape is (channels, samples) - correct format - num_samples = self.samples.shape[1] - elif self.samples.shape[1] == ch: - # Shape is (samples, channels) - transposed format - num_samples = self.samples.shape[0] - else: - # Ambiguous or unknown - assume (channels, samples) and pick larger dimension - # This handles edge cases like (2, 2) arrays - num_samples = max(self.samples.shape[0], self.samples.shape[1]) - else: - num_samples = len(self.samples) - elif isinstance(self.samples, bytes): - # If samples is bytes, calculate based on format - if self.format == "s16": - # For s16 format, each sample is 2 bytes (16 bits) - # For multi-channel, divide by channels to get sample count - num_samples = len(self.samples) // ( - 2 * (self.channels if self.channels else 1) - ) - elif self.format == "f32": - # For f32 format, each sample is 4 bytes (32 bits) - num_samples = len(self.samples) // ( - 4 * (self.channels if self.channels else 1) - ) + # If array has shape (channels, samples) or (samples, channels), duration uses the samples dimension + if self.samples.ndim == 2: + # Determine which dimension is samples vs channels + # Standard format is (channels, samples), but we need to handle both + ch = self.channels if self.channels else 1 + if self.samples.shape[0] == ch: + # Shape is (channels, samples) - correct format + num_samples = self.samples.shape[1] + elif self.samples.shape[1] == ch: + # Shape is (samples, channels) - transposed format + num_samples = self.samples.shape[0] else: - # Default assumption for other formats (treat as raw bytes) - num_samples = len(self.samples) + # Ambiguous or unknown - assume (channels, samples) and pick larger dimension + # This handles edge cases like (2, 2) arrays + num_samples = max(self.samples.shape[0], self.samples.shape[1]) else: - # Fallback: try to get length - try: - num_samples = len(self.samples) - except TypeError: - logger.warning( - f"Cannot determine sample count for type {type(self.samples)}" - ) - return 0.0 + num_samples = len(self.samples) # Calculate duration based on sample rate return num_samples / self.sample_rate @@ -607,38 +635,14 @@ def to_float32(self) -> "PcmData": # If already f32 format, return self without modification if self.format in (AudioFormat.F32, "f32", "float32"): # Additional check: verify the samples are actually float32 - if ( - isinstance(self.samples, np.ndarray) - and self.samples.dtype == np.float32 - ): + if self.samples.dtype == np.float32: return self arr = self.samples - # Normalize to a numpy array for conversion - if not isinstance(arr, np.ndarray): - try: - # Round-trip through bytes to reconstruct canonical ndarray shape - arr = PcmData.from_bytes( - self.to_bytes(), - sample_rate=self.sample_rate, - format=self.format, - channels=self.channels, - ).samples - except Exception: - # Fallback to from_data for robustness - arr = PcmData.from_data( - self.samples, - sample_rate=self.sample_rate, - format=self.format, - channels=self.channels, - ).samples - # Convert to float32 and scale if needed fmt = (self.format or "").lower() - if fmt in ("s16", "int16") or ( - isinstance(arr, np.ndarray) and arr.dtype == np.int16 - ): + if fmt in ("s16", "int16") or arr.dtype == np.int16: arr_f32 = arr.astype(np.float32) / 32768.0 else: # Ensure dtype float32; values assumed already in [-1, 1] @@ -672,35 +676,14 @@ def to_int16(self) -> "PcmData": # If already s16 format, return self without modification if self.format in (AudioFormat.S16, "s16", "int16"): # Additional check: verify the samples are actually int16 - if isinstance(self.samples, np.ndarray) and self.samples.dtype == np.int16: + if self.samples.dtype == np.int16: return self arr = self.samples - # Normalize to a numpy array for conversion - if not isinstance(arr, np.ndarray): - try: - # Round-trip through bytes to reconstruct canonical ndarray shape - arr = PcmData.from_bytes( - self.to_bytes(), - sample_rate=self.sample_rate, - format=self.format, - channels=self.channels, - ).samples - except Exception: - # Fallback to from_data for robustness - arr = PcmData.from_data( - self.samples, - sample_rate=self.sample_rate, - format=self.format, - channels=self.channels, - ).samples - # Convert to int16 and scale if needed fmt = (self.format or "").lower() - if fmt in ("f32", "float32") or ( - isinstance(arr, np.ndarray) and arr.dtype == np.float32 - ): + if fmt in ("f32", "float32") or arr.dtype == np.float32: # Convert float32 in [-1, 1] to int16 arr_s16 = (np.clip(arr, -1.0, 1.0) * 32767.0).astype(np.int16) else: @@ -738,16 +721,9 @@ def _is_empty(arr: Any) -> bool: except Exception: return False - # Normalize numpy arrays from bytes-like if needed + # Samples are always numpy arrays def _ensure_ndarray(pcm: "PcmData") -> np.ndarray: - if isinstance(pcm.samples, np.ndarray): - return pcm.samples - return PcmData.from_bytes( - pcm.to_bytes(), - sample_rate=pcm.sample_rate, - format=pcm.format, - channels=pcm.channels, - ).samples + return pcm.samples # Adjust other to match sample rate and channels first other_adj = other @@ -868,9 +844,7 @@ def copy(self) -> "PcmData": return PcmData( sample_rate=self.sample_rate, format=self.format, - samples=self.samples.copy() - if isinstance(self.samples, np.ndarray) - else copy.deepcopy(self.samples), + samples=self.samples.copy(), pts=self.pts, dts=self.dts, time_base=self.time_base, @@ -1055,29 +1029,19 @@ def chunks( >>> len(chunks) # [0:4], [2:6], [4:8], [6:10], [8:10] 5 """ - # Ensure we have a 1D array for simpler chunking - if isinstance(self.samples, np.ndarray): - if self.samples.ndim == 2 and self.channels == 1: - samples = self.samples.flatten() - elif self.samples.ndim == 2: - # For multi-channel, work with channel-major format - samples = self.samples - else: - samples = self.samples + # Normalize sample array shape + if self.samples.ndim == 2 and self.channels == 1: + samples = self.samples.flatten() + elif self.samples.ndim == 2: + # For multi-channel, work with channel-major format + samples = self.samples else: - # Convert bytes/other to ndarray first - temp = PcmData.from_bytes( - self.to_bytes(), - sample_rate=self.sample_rate, - format=self.format, - channels=self.channels, - ) - samples = temp.samples + samples = self.samples # Handle overlap step = max(1, chunk_size - overlap) - if self.channels > 1 and isinstance(samples, np.ndarray) and samples.ndim == 2: + if self.channels > 1 and samples.ndim == 2: # Multi-channel case: chunk along the samples axis num_samples = samples.shape[1] for i in range(0, num_samples, step): @@ -1116,9 +1080,7 @@ def chunks( ) else: # Mono or 1D case - samples_1d = ( - samples.flatten() if isinstance(samples, np.ndarray) else samples - ) + samples_1d = samples.flatten() if samples.ndim > 1 else samples total_samples = len(samples_1d) for i in range(0, total_samples, step): @@ -1152,9 +1114,7 @@ def chunks( pts=chunk_pts, dts=self.dts, time_base=self.time_base, - channels=1 - if isinstance(chunk_samples, np.ndarray) and chunk_samples.ndim == 1 - else self.channels, + channels=1 if chunk_samples.ndim == 1 else self.channels, ) def sliding_window( @@ -1220,16 +1180,8 @@ def tail( if pad_at not in ("start", "end"): raise ValueError(f"pad_at must be 'start' or 'end', got {pad_at!r}") - # Get samples array + # Get samples array (always ndarray) samples = self.samples - if not isinstance(samples, np.ndarray): - # Convert to ndarray first - samples = PcmData.from_bytes( - self.to_bytes(), - sample_rate=self.sample_rate, - format=self.format, - channels=self.channels, - ).samples # Handle multi-channel audio if samples.ndim == 2 and self.channels > 1: @@ -1328,16 +1280,8 @@ def head( if pad_at not in ("start", "end"): raise ValueError(f"pad_at must be 'start' or 'end', got {pad_at!r}") - # Get samples array + # Get samples array (always ndarray) samples = self.samples - if not isinstance(samples, np.ndarray): - # Convert to ndarray first - samples = PcmData.from_bytes( - self.to_bytes(), - sample_rate=self.sample_rate, - format=self.format, - channels=self.channels, - ).samples # Handle multi-channel audio if samples.ndim == 2 and self.channels > 1: diff --git a/tests/rtc/test_pcm_data.py b/tests/rtc/test_pcm_data.py index 5f19ee3b..ff55258e 100644 --- a/tests/rtc/test_pcm_data.py +++ b/tests/rtc/test_pcm_data.py @@ -1560,3 +1560,110 @@ def test_from_audioframe_unsupported_format(): assert "Unsupported audio frame format" in error_msg assert "s32p" in error_msg assert "s16" in error_msg or "flt" in error_msg # Should mention supported formats + + +def test_str_mono_short(): + """Test __str__ with short mono audio.""" + pcm = PcmData( + samples=np.array([100, 200, 300, 400], dtype=np.int16), + sample_rate=16000, + format="s16", + channels=1, + ) + + str_str = str(pcm) + + assert "Mono" in str_str + assert "16000Hz" in str_str + assert "16-bit PCM" in str_str + assert "4 samples" in str_str + assert "ms" in str_str + + +def test_str_stereo_short(): + """Test __str__ with short stereo audio.""" + left = np.random.randint(-1000, 1000, 320, dtype=np.int16) + right = np.random.randint(-1000, 1000, 320, dtype=np.int16) + stereo = np.vstack([left, right]) + + pcm = PcmData( + samples=stereo, + sample_rate=16000, + format="s16", + channels=2, + ) + + str_str = str(pcm) + + assert "Stereo" in str_str + assert "16000Hz" in str_str + assert "16-bit PCM" in str_str + assert "320 samples" in str_str + assert "20.0ms" in str_str + + +def test_str_float32(): + """Test __str__ with float32 format.""" + pcm = PcmData( + samples=np.array([0.1, 0.2, 0.3], dtype=np.float32), + sample_rate=48000, + format="f32", + channels=1, + ) + + str_str = str(pcm) + + assert "Mono" in str_str + assert "48000Hz" in str_str + assert "32-bit float" in str_str + assert "3 samples" in str_str + + +def test_str_long_duration(): + """Test __str__ with long audio (shows seconds instead of ms).""" + # Create 2 seconds of audio + samples = np.zeros(32000, dtype=np.int16) + + pcm = PcmData( + samples=samples, + sample_rate=16000, + format="s16", + channels=1, + ) + + str_str = str(pcm) + + assert "Mono" in str_str + assert "32000 samples" in str_str + assert "2.00s" in str_str # Should show seconds, not ms + + +def test_str_multichannel(): + """Test __str__ with more than 2 channels.""" + # 4-channel audio + samples = np.zeros((4, 100), dtype=np.int16) + + pcm = PcmData( + samples=samples, + sample_rate=16000, + format="s16", + channels=4, + ) + + str_str = str(pcm) + + assert "4-channel" in str_str + assert "16000Hz" in str_str + assert "100 samples" in str_str + + +def test_repr_returns_str(): + """Test that __repr__ returns the same as __str__.""" + pcm = PcmData( + samples=np.array([100, 200, 300], dtype=np.int16), + sample_rate=16000, + format="s16", + channels=1, + ) + + assert repr(pcm) == str(pcm)