import io import os import struct import threading import time from concurrent.futures import ThreadPoolExecutor import aiohttp import pytest from livekit.agents import inference from livekit.agents.stt import SpeechEventType from livekit.agents.utils.codecs import AudioStreamDecoder, StreamBuffer from livekit.agents.utils.misc import is_cloud from .utils import wer # Decodes audio on background threads / executors with blocking waits; it deadlocks when forced # to share one event loop with other tests. pytestmark = [pytest.mark.unit, pytest.mark.no_concurrent] TEST_AUDIO_FILEPATH = os.path.join(os.path.dirname(__file__), "change-sophie.opus") @pytest.mark.asyncio @pytest.mark.skipif( os.getenv("LIVEKIT_API_KEY") is None or os.getenv("LIVEKIT_API_SECRET") is None or not is_cloud(os.getenv("LIVEKIT_URL", "")), reason="LiveKit cloud credentials are invalid", ) async def test_decode_and_transcribe(): # Skip if test file doesn't exist if not os.path.exists(TEST_AUDIO_FILEPATH): pytest.skip(f"Test file not found: {TEST_AUDIO_FILEPATH}") decoder = AudioStreamDecoder() with open(TEST_AUDIO_FILEPATH, "rb") as f: opus_data = f.read() decoder.push(opus_data) decoder.end_input() session = aiohttp.ClientSession() stt = inference.STT(model="deepgram/nova-3", http_session=session) stream = stt.stream() # Push frames to STT async for frame in decoder: stream.push_frame(frame) # Mark end of input stream.end_input() # Collect results final_text = "" async for event in stream: if event.type == SpeechEventType.FINAL_TRANSCRIPT: if event.alternatives: if final_text: final_text += " " final_text += event.alternatives[0].text await decoder.aclose() await stream.aclose() await session.close() # Verify the transcription expected_text = ( "the people that are crazy enough to think they can change the world are the ones who do" ) assert wer(final_text, expected_text) < 0.2 def test_stream_buffer(): buffer = StreamBuffer() data_chunks = [b"hello", b"world", b"test", b"data"] received_data = bytearray() write_completed = threading.Event() def writer(): for chunk in data_chunks: buffer.write(chunk) time.sleep(0.01) # Simulate some processing time buffer.end_input() write_completed.set() def reader(): while True: data = buffer.read(4) # Read in small chunks if not data: # EOF break received_data.extend(data) # Run writer and reader in separate threads with ThreadPoolExecutor(max_workers=2) as executor: reader_future = executor.submit(reader) writer_future = executor.submit(writer) # Wait for both threads to complete writer_future.result() reader_future.result() # Verify that all data was received correctly expected_data = b"".join(data_chunks) assert bytes(received_data) == expected_data def test_stream_buffer_large_chunks(): import hashlib buffer = StreamBuffer() large_chunk = os.urandom(1024 * 1024) # 1MB of random bytes num_chunks = 5 total_size = 0 write_completed = threading.Event() input_hasher = hashlib.sha256() def writer(): nonlocal total_size for _ in range(num_chunks): buffer.write(large_chunk) total_size += len(large_chunk) input_hasher.update(large_chunk) buffer.end_input() write_completed.set() received_size = 0 output_hasher = hashlib.sha256() def reader(): nonlocal received_size # allow writer to start first time.sleep(0.1) while True: chunk = buffer.read(8192) # Read in 8KB chunks if not chunk: break received_size += len(chunk) output_hasher.update(chunk) # Run writer and reader in separate threads with ThreadPoolExecutor(max_workers=2) as executor: reader_future = executor.submit(reader) writer_future = executor.submit(writer) # Wait for both threads to complete writer_future.result() reader_future.result() assert received_size == total_size assert total_size == num_chunks * len(large_chunk) assert input_hasher.hexdigest() == output_hasher.hexdigest() def test_stream_buffer_early_close(): buffer = StreamBuffer() # Write some data buffer.write(b"test data") # Close the buffer buffer.close() # Reading from closed buffer should return empty bytes assert buffer.read() == b"" def test_stream_buffer_slow_writer_fast_reader(): """Reader calls read(256) in a tight loop while writer pushes small chunks with delays.""" buffer = StreamBuffer() chunk_size = 48 num_chunks = 50 chunks = [os.urandom(chunk_size) for _ in range(num_chunks)] received = bytearray() def writer(): for chunk in chunks: buffer.write(chunk) time.sleep(0.005) buffer.end_input() def reader(): while True: data = buffer.read(256) if not data: break received.extend(data) with ThreadPoolExecutor(max_workers=2) as pool: rf = pool.submit(reader) wf = pool.submit(writer) wf.result(timeout=10) rf.result(timeout=10) assert bytes(received) == b"".join(chunks) def test_stream_buffer_reader_starts_before_writer(): """Reader blocks on read() before any data exists, then writer starts.""" buffer = StreamBuffer() payload = os.urandom(1024) received = bytearray() reader_started = threading.Event() def reader(): reader_started.set() while True: data = buffer.read(256) if not data: break received.extend(data) def writer(): reader_started.wait() time.sleep(0.05) # ensure reader is blocking in read() buffer.write(payload) buffer.end_input() with ThreadPoolExecutor(max_workers=2) as pool: rf = pool.submit(reader) wf = pool.submit(writer) wf.result(timeout=10) rf.result(timeout=10) assert bytes(received) == payload def test_stream_buffer_end_input_with_pending_data(): """Writer pushes data then immediately calls end_input(). Reader must get all data.""" buffer = StreamBuffer() payload = os.urandom(2048) # write everything and signal EOF before reader starts buffer.write(payload) buffer.end_input() received = bytearray() while True: data = buffer.read(256) if not data: break received.extend(data) assert bytes(received) == payload def test_stream_buffer_compaction(): """Verify that compaction preserves unread data after _COMPACT_THRESHOLD bytes are consumed.""" import hashlib buffer = StreamBuffer() chunk_size = 512 * 1024 # 512KB per write # write enough to push read_pos past the 5MB threshold with leftover unread data num_writer_chunks = 12 # 6MB total total_written = num_writer_chunks * chunk_size read_size = 4096 chunks = [os.urandom(chunk_size) for _ in range(num_writer_chunks)] input_hasher = hashlib.sha256() for c in chunks: input_hasher.update(c) received = bytearray() output_hasher = hashlib.sha256() def writer(): for chunk in chunks: buffer.write(chunk) time.sleep(0.002) buffer.end_input() def reader(): while True: data = buffer.read(read_size) if not data: break received.extend(data) output_hasher.update(data) with ThreadPoolExecutor(max_workers=2) as pool: wf = pool.submit(writer) rf = pool.submit(reader) wf.result(timeout=30) rf.result(timeout=30) assert len(received) == total_written assert input_hasher.hexdigest() == output_hasher.hexdigest() # confirm compaction actually fired: after reading 6MB in 4KB chunks, # _read_pos would have exceeded the 5MB threshold at least once. # The data integrity check above is the real proof — if compaction # dropped or duplicated bytes, the hash would mismatch. def test_stream_buffer_compaction_boundary(): """Compaction must not lose the tail bytes sitting between read_pos and write_pos.""" buffer = StreamBuffer() threshold = StreamBuffer._COMPACT_THRESHOLD # 5MB # 1. Write exactly threshold + extra bytes, read exactly threshold bytes, # then verify the extra bytes survive compaction. extra = b"HELLO_AFTER_COMPACT" big_block = os.urandom(threshold) + extra buffer.write(big_block) buffer.end_input() # drain exactly `threshold` bytes in small reads drained = 0 while drained < threshold: chunk = buffer.read(8192) assert chunk # should not be empty yet drained += len(chunk) # the next read triggers compaction (read_pos >= threshold) and must return the extra remainder = bytearray() while True: data = buffer.read(8192) if not data: break remainder.extend(data) assert bytes(remainder) == extra def test_stream_buffer_close_while_reading(): """Reader is blocked in read(), then close() is called. Must unblock promptly.""" buffer = StreamBuffer() reader_started = threading.Event() result = [] def reader(): reader_started.set() data = buffer.read(256) result.append(data) with ThreadPoolExecutor(max_workers=1) as pool: rf = pool.submit(reader) reader_started.wait() time.sleep(0.05) # ensure reader is blocking buffer.close() rf.result(timeout=2) assert result == [b""] def _make_wav(sample_rate: int, num_channels: int, num_samples: int) -> bytes: """Generate a valid PCM16 WAV file in memory.""" bits_per_sample = 16 byte_rate = sample_rate * num_channels * bits_per_sample // 8 block_align = num_channels * bits_per_sample // 8 data_size = num_samples * num_channels * (bits_per_sample // 8) buf = io.BytesIO() # RIFF header buf.write(b"RIFF") buf.write(struct.pack("