From edddbf57749c749de2eac842a8d7ee0992b718b3 Mon Sep 17 00:00:00 2001 From: Lorna Armstrong Date: Fri, 25 Sep 2026 09:06:09 +0100 Subject: [PATCH 1/6] Add 8kHz sample rate support --- sdk/agent_stt/README.md | 8 +++-- .../speechmatics/agent_stt/__init__.py | 2 ++ .../speechmatics/agent_stt/_client.py | 19 ++++++++++-- .../speechmatics/agent_stt/_models.py | 1 + tests/agent_stt/test_client.py | 30 +++++++++++++++++++ 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/sdk/agent_stt/README.md b/sdk/agent_stt/README.md index 04b7d538..9f2cdb3d 100644 --- a/sdk/agent_stt/README.md +++ b/sdk/agent_stt/README.md @@ -145,9 +145,11 @@ segment is appended when it is missing. ## Audio -The service requires **16 kHz raw PCM**, `pcm_s16le` or `pcm_f32le`, which is what the client -defaults to. Audio sent before the session is ready, or after it closes, is dropped rather than -raising, so an audio callback does not have to track session state. +The service accepts raw PCM (`pcm_s16le` or `pcm_f32le`) at **16 kHz** (default)or **8 kHz**, set +using `audio_format=AudioFormat(sample_rate=8000, ...)`. +Any other sample rate raises `ValueError`. Audio sent before the session is ready, or after it +closes, is dropped rather than raising, so an audio callback does not have to track session +state. ## Examples diff --git a/sdk/agent_stt/speechmatics/agent_stt/__init__.py b/sdk/agent_stt/speechmatics/agent_stt/__init__.py index c1bc8e19..e7c4e36d 100644 --- a/sdk/agent_stt/speechmatics/agent_stt/__init__.py +++ b/sdk/agent_stt/speechmatics/agent_stt/__init__.py @@ -38,6 +38,7 @@ from ._models import DEFAULT_SAMPLE_RATE from ._models import DEFAULT_WORD_DELIMITER from ._models import SEGMENT_MESSAGES +from ._models import SUPPORTED_SAMPLE_RATES from ._models import TIMED_MESSAGES from ._models import AdditionalVocabEntry from ._models import ClientMessageType @@ -59,6 +60,7 @@ "DEFAULT_SAMPLE_RATE", "DEFAULT_WORD_DELIMITER", "SEGMENT_MESSAGES", + "SUPPORTED_SAMPLE_RATES", "TIMED_MESSAGES", "__version__", # Client diff --git a/sdk/agent_stt/speechmatics/agent_stt/_client.py b/sdk/agent_stt/speechmatics/agent_stt/_client.py index b404d2a9..55a36493 100644 --- a/sdk/agent_stt/speechmatics/agent_stt/_client.py +++ b/sdk/agent_stt/speechmatics/agent_stt/_client.py @@ -18,6 +18,7 @@ from ._logging import get_logger from ._models import DEFAULT_CHUNK_SIZE from ._models import DEFAULT_SAMPLE_RATE +from ._models import SUPPORTED_SAMPLE_RATES from ._models import TIMED_MESSAGES from ._models import ClientMessageType from ._models import LanguagePackInfo @@ -36,6 +37,14 @@ DISCONNECT_TIMEOUT_S = 5.0 +def _validate_sample_rate(sample_rate: int) -> None: + """Raise if `sample_rate` is not one the Agent STT service accepts.""" + if sample_rate not in SUPPORTED_SAMPLE_RATES: + raise ValueError( + f"sample_rate must be one of {SUPPORTED_SAMPLE_RATES}, got {sample_rate}", + ) + + class AgentSttAsyncClient(RTAsyncClient): """ Asynchronous client for the Speechmatics Agent STT service. @@ -56,8 +65,8 @@ class AgentSttAsyncClient(RTAsyncClient): transcription_config: Transcription config for the session, normally an `agent_stt.TranscriptionConfig`. turn_config: Turn-taking config for the session. Defaults to the service's VAD. - audio_format: Audio format. Defaults to 16 kHz signed 16-bit PCM, which is what the - service requires. + audio_format: Audio format. Defaults to 16 kHz signed 16-bit PCM. The service also + accepts 8 kHz; no other sample rate is supported. conn_config: WebSocket connection configuration. record_events: Whether to keep every raw server message in `events`. @@ -109,6 +118,7 @@ def __init__( sample_rate=DEFAULT_SAMPLE_RATE, chunk_size=DEFAULT_CHUNK_SIZE, ) + _validate_sample_rate(self._audio_format.sample_rate) self._session_info = SessionInfo(request_id=self._session.request_id) self._transcript = Transcript(record_events=record_events) @@ -224,18 +234,21 @@ async def start_session( Args: transcription_config: Transcription config for the session. turn_config: Turn-taking config for the session. - audio_format: Audio format. Must be 16 kHz raw PCM for the Agent STT service. + audio_format: Audio format. Must be 8 kHz or 16 kHz raw PCM for the Agent STT + service. ws_headers: Additional WebSocket handshake headers. Raises: ConnectionError: If the WebSocket connection fails. TimeoutError: If the service does not accept the session in time. + ValueError: If `audio_format.sample_rate` is not 8000 or 16000. """ if transcription_config is not None: self._transcription_config = transcription_config if turn_config is not None: self._turn_config = turn_config if audio_format is not None: + _validate_sample_rate(audio_format.sample_rate) self._audio_format = audio_format await super().start_session( diff --git a/sdk/agent_stt/speechmatics/agent_stt/_models.py b/sdk/agent_stt/speechmatics/agent_stt/_models.py index aa75f855..a9b8606f 100644 --- a/sdk/agent_stt/speechmatics/agent_stt/_models.py +++ b/sdk/agent_stt/speechmatics/agent_stt/_models.py @@ -12,6 +12,7 @@ from speechmatics.rt import TranscriptionConfig as RTTranscriptionConfig DEFAULT_SAMPLE_RATE = 16000 +SUPPORTED_SAMPLE_RATES = (8000, 16000) DEFAULT_CHUNK_SIZE = 1024 DEFAULT_WORD_DELIMITER = " " diff --git a/tests/agent_stt/test_client.py b/tests/agent_stt/test_client.py index 043aeb2a..7a086dc5 100644 --- a/tests/agent_stt/test_client.py +++ b/tests/agent_stt/test_client.py @@ -4,6 +4,7 @@ from speechmatics.agent_stt import AgentSttAsyncClient from speechmatics.agent_stt import AudioEncoding +from speechmatics.agent_stt import AudioFormat from speechmatics.agent_stt import ClientMessageType from speechmatics.agent_stt import ServerMessageType from speechmatics.agent_stt import TranscriptionConfig @@ -96,6 +97,35 @@ async def test_audio_format_defaults_to_16k_pcm(client): assert client._audio_format.sample_rate == 16000 +@pytest.mark.asyncio +async def test_audio_format_accepts_8khz(monkeypatch): + monkeypatch.delenv("SPEECHMATICS_RT_URL", raising=False) + client = AgentSttAsyncClient( + api_key=API_KEY, + audio_format=AudioFormat(encoding=AudioEncoding.PCM_S16LE, sample_rate=8000), + ) + assert client._audio_format.sample_rate == 8000 + + +@pytest.mark.asyncio +async def test_audio_format_rejects_unsupported_sample_rate(monkeypatch): + monkeypatch.delenv("SPEECHMATICS_RT_URL", raising=False) + with pytest.raises(ValueError, match="sample_rate"): + AgentSttAsyncClient( + api_key=API_KEY, + audio_format=AudioFormat(encoding=AudioEncoding.PCM_S16LE, sample_rate=44100), + ) + + +@pytest.mark.asyncio +async def test_start_session_rejects_unsupported_sample_rate(client): + start_session(client) + with pytest.raises(ValueError, match="sample_rate"): + await client.start_session( + audio_format=AudioFormat(encoding=AudioEncoding.PCM_S16LE, sample_rate=44100) + ) + + @pytest.mark.asyncio async def test_recognition_started_sets_session_state(client): start_session(client) From c635cc59072e8bccd3d60cbfd5b47a0bc4935d13 Mon Sep 17 00:00:00 2001 From: Lorna Armstrong Date: Fri, 2 Oct 2026 16:27:58 +0100 Subject: [PATCH 2/6] Update examples --- examples/agent_stt/README.md | 5 ++- examples/agent_stt/client_vad/main.py | 47 +++++++++++++++--------- examples/agent_stt/realtime_file/main.py | 40 ++++++++++++++------ 3 files changed, 61 insertions(+), 31 deletions(-) diff --git a/examples/agent_stt/README.md b/examples/agent_stt/README.md index 77e51a6c..86403a53 100644 --- a/examples/agent_stt/README.md +++ b/examples/agent_stt/README.md @@ -4,8 +4,9 @@ Set `SPEECHMATICS_API_KEY` first. To point at a local Voice Agent Service, set `SPEECHMATICS_RT_URL` (for example `ws://localhost:8000/v2`); the `/agent` segment is appended when it is missing. -The service needs 16 kHz raw PCM, so the file examples take a 16 kHz WAV and default to -`tests/voice/assets/audio_01_16kHz.wav`. +The service accepts 16 kHz or 8 kHz raw PCM. The file examples read the rate from the WAV and +default to `tests/voice/assets/audio_01_16kHz.wav`; pass `tests/voice/assets/audio_02_8kHz.wav` +to try 8 kHz. | Example | What it shows | | --- | --- | diff --git a/examples/agent_stt/client_vad/main.py b/examples/agent_stt/client_vad/main.py index d7542558..0d232634 100644 --- a/examples/agent_stt/client_vad/main.py +++ b/examples/agent_stt/client_vad/main.py @@ -6,21 +6,24 @@ The fixed interval below stands in for the host framework's own end-of-speech signal. -Run with: python examples/agent_stt/client_vad/main.py [path/to/16kHz.wav] +Run with: python examples/agent_stt/client_vad/main.py [path/to/8kHz-or-16kHz.wav] """ import asyncio import sys import wave +from speechmatics.agent_stt import DEFAULT_CHUNK_SIZE +from speechmatics.agent_stt import SUPPORTED_SAMPLE_RATES from speechmatics.agent_stt import AgentSttAsyncClient +from speechmatics.agent_stt import AudioEncoding +from speechmatics.agent_stt import AudioFormat from speechmatics.agent_stt import ServerMessageType from speechmatics.agent_stt import TranscriptionConfig from speechmatics.agent_stt import TurnConfig from speechmatics.agent_stt import TurnDetectionMode DEFAULT_AUDIO_FILE = "./tests/voice/assets/audio_01_16kHz.wav" -CHUNK_SIZE = 1024 TURN_SECONDS = 5.0 @@ -28,23 +31,33 @@ async def main(path: str) -> None: transcription_config = TranscriptionConfig(language="en", enable_partials=True) turn_config = TurnConfig(turn_detection_mode=TurnDetectionMode.EXTERNAL) - # Uses SPEECHMATICS_API_KEY from the environment - client = AgentSttAsyncClient(transcription_config=transcription_config, turn_config=turn_config) - - # Registered before the session opens, so no message can arrive unhandled - @client.on(ServerMessageType.ADD_SEGMENT) - def handle_segment(message): - print(f"[final] {message['segment']['transcript']}") - - async with client: - with wave.open(path, "rb") as wav: - if wav.getframerate() != 16000: - print(f"{path} is {wav.getframerate()} Hz; the Agent STT service needs 16 kHz audio") - return - + with wave.open(path, "rb") as wav: + sample_rate = wav.getframerate() + if sample_rate not in SUPPORTED_SAMPLE_RATES: + print(f"{path} is {sample_rate} Hz; the Agent STT service needs 8 kHz or 16 kHz audio") + return + audio_format = AudioFormat( + encoding=AudioEncoding.PCM_S16LE, + sample_rate=sample_rate, + chunk_size=DEFAULT_CHUNK_SIZE, + ) + + # Uses SPEECHMATICS_API_KEY from the environment + client = AgentSttAsyncClient( + transcription_config=transcription_config, + turn_config=turn_config, + audio_format=audio_format, + ) + + # Registered before the session opens, so no message can arrive unhandled + @client.on(ServerMessageType.ADD_SEGMENT) + def handle_segment(message): + print(f"[final] {message['segment']['transcript']}") + + async with client: next_turn_end = TURN_SECONDS while True: - frame = wav.readframes(CHUNK_SIZE // wav.getsampwidth()) + frame = wav.readframes(DEFAULT_CHUNK_SIZE // wav.getsampwidth()) if not frame: break await client.send_audio(frame) diff --git a/examples/agent_stt/realtime_file/main.py b/examples/agent_stt/realtime_file/main.py index eb910dc2..1823eae3 100644 --- a/examples/agent_stt/realtime_file/main.py +++ b/examples/agent_stt/realtime_file/main.py @@ -1,11 +1,11 @@ -"""Stream a 16 kHz WAV file to the Agent STT service at wall-clock speed. +"""Stream an 8 kHz or 16 kHz WAV file to the Agent STT service at wall-clock speed. A file read at full speed reaches the service far ahead of real time, so VAD windows, turn boundaries and latency all read wrong. This paces the send loop so each frame leaves at the moment its audio would have been captured live, and prints how far behind the audio position every message arrives - the number worth watching when testing locally. -Run with: python examples/agent_stt/realtime_file/main.py [path/to/16kHz.wav] +Run with: python examples/agent_stt/realtime_file/main.py [path/to/8kHz-or-16kHz.wav] """ import argparse @@ -14,14 +14,17 @@ import wave from typing import Optional +from speechmatics.agent_stt import DEFAULT_CHUNK_SIZE +from speechmatics.agent_stt import SUPPORTED_SAMPLE_RATES from speechmatics.agent_stt import AgentSttAsyncClient +from speechmatics.agent_stt import AudioEncoding +from speechmatics.agent_stt import AudioFormat from speechmatics.agent_stt import ServerMessageType from speechmatics.agent_stt import TranscriptionConfig from speechmatics.agent_stt import TurnConfig from speechmatics.agent_stt import TurnDetectionMode DEFAULT_AUDIO_FILE = "./tests/voice/assets/audio_01_16kHz.wav" -SAMPLE_RATE = 16000 BYTES_PER_SAMPLE = 2 @@ -30,7 +33,7 @@ def parse_args() -> argparse.Namespace: description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) - parser.add_argument("audio", nargs="?", default=DEFAULT_AUDIO_FILE, help="16 kHz mono WAV file") + parser.add_argument("audio", nargs="?", default=DEFAULT_AUDIO_FILE, help="8 kHz or 16 kHz mono WAV file") parser.add_argument("--language", default="en") parser.add_argument("--chunk-ms", type=float, default=20.0, help="audio frame size in milliseconds") parser.add_argument( @@ -71,15 +74,25 @@ def log(self, tag: str, text: str, *, audio_time: Optional[float] = None, record print(f"[{self.elapsed:6.2f}s] {tag:<17}{lag:<10} {text}") -def build_client(args: argparse.Namespace, clock: Clock) -> AgentSttAsyncClient: +def build_client(args: argparse.Namespace, clock: Clock, sample_rate: int) -> AgentSttAsyncClient: transcription_config = TranscriptionConfig( language=args.language, enable_partials=not args.no_partials, ) turn_config = TurnConfig(turn_detection_mode=TurnDetectionMode(args.turn_detection)) + audio_format = AudioFormat( + encoding=AudioEncoding.PCM_S16LE, + sample_rate=sample_rate, + chunk_size=DEFAULT_CHUNK_SIZE, + ) + # Uses SPEECHMATICS_API_KEY, and SPEECHMATICS_RT_URL to point at a local service - client = AgentSttAsyncClient(transcription_config=transcription_config, turn_config=turn_config) + client = AgentSttAsyncClient( + transcription_config=transcription_config, + turn_config=turn_config, + audio_format=audio_format, + ) @client.on(ServerMessageType.ADD_PARTIAL_SEGMENT) def handle_partial_segment(message): @@ -120,7 +133,8 @@ def handle_error(message): async def stream(client: AgentSttAsyncClient, wav: wave.Wave_read, args: argparse.Namespace, clock: Clock) -> None: """Send the file frame by frame, releasing each frame no earlier than its capture time.""" - frames_per_chunk = int(SAMPLE_RATE * args.chunk_ms / 1000) + sample_rate = wav.getframerate() + frames_per_chunk = int(sample_rate * args.chunk_ms / 1000) next_turn_end = args.turn_seconds clock.start() @@ -128,7 +142,7 @@ async def stream(client: AgentSttAsyncClient, wav: wave.Wave_read, args: argpars # Hold each frame until the wall clock reaches the end of the audio it carries, so a # frame leaves exactly when a live capture would have finished recording it. Paced off # the session clock rather than per-frame sleeps, so the send does not drift. - frame_end = client.audio_seconds_sent + len(frame) / (SAMPLE_RATE * BYTES_PER_SAMPLE) + frame_end = client.audio_seconds_sent + len(frame) / (sample_rate * BYTES_PER_SAMPLE) early = frame_end - clock.elapsed if early > 0: await asyncio.sleep(early) @@ -144,13 +158,15 @@ async def stream(client: AgentSttAsyncClient, wav: wave.Wave_read, args: argpars async def main() -> None: args = parse_args() clock = Clock() - client = build_client(args, clock) with wave.open(args.audio, "rb") as wav: - if (wav.getframerate(), wav.getnchannels(), wav.getsampwidth()) != (SAMPLE_RATE, 1, BYTES_PER_SAMPLE): - print(f"{args.audio} must be 16 kHz mono 16-bit PCM for the Agent STT service") + sample_rate = wav.getframerate() + is_supported_rate = sample_rate in SUPPORTED_SAMPLE_RATES + if not is_supported_rate or (wav.getnchannels(), wav.getsampwidth()) != (1, BYTES_PER_SAMPLE): + print(f"{args.audio} must be 8 kHz or 16 kHz mono 16-bit PCM for the Agent STT service") return - duration = wav.getnframes() / SAMPLE_RATE + duration = wav.getnframes() / sample_rate + client = build_client(args, clock, sample_rate) async with client: await stream(client, wav, args, clock) From c00beb0ffdbc7d4755c88be2d0b563f67a115224 Mon Sep 17 00:00:00 2001 From: Lorna Armstrong Date: Fri, 2 Oct 2026 16:28:13 +0100 Subject: [PATCH 3/6] Update readme --- sdk/agent_stt/README.md | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/sdk/agent_stt/README.md b/sdk/agent_stt/README.md index 9f2cdb3d..38396581 100644 --- a/sdk/agent_stt/README.md +++ b/sdk/agent_stt/README.md @@ -145,11 +145,23 @@ segment is appended when it is missing. ## Audio -The service accepts raw PCM (`pcm_s16le` or `pcm_f32le`) at **16 kHz** (default)or **8 kHz**, set -using `audio_format=AudioFormat(sample_rate=8000, ...)`. -Any other sample rate raises `ValueError`. Audio sent before the session is ready, or after it -closes, is dropped rather than raising, so an audio callback does not have to track session -state. +The service accepts raw PCM (`pcm_s16le` or `pcm_f32le`) at **16 kHz** (the default) or +**8 kHz**. Any other sample rate raises `ValueError`. To send 8 kHz audio, pass an +`AudioFormat`: + +```python +from speechmatics.agent_stt import AgentSttAsyncClient, AudioEncoding, AudioFormat + +client = AgentSttAsyncClient( + audio_format=AudioFormat(encoding=AudioEncoding.PCM_S16LE, sample_rate=8000, chunk_size=1024), +) +``` + +Set `chunk_size` explicitly: `AudioFormat` defaults to 4096 bytes, not the client's default of +1024. + +Audio sent before the session is ready, or after it closes, is dropped rather than raising, so +an audio callback does not have to track session state. ## Examples From daa7790809a9ebccb1f9ec67ec64846f522a64cf Mon Sep 17 00:00:00 2001 From: Lorna Armstrong Date: Fri, 2 Oct 2026 16:28:27 +0100 Subject: [PATCH 4/6] Update test client --- tests/agent_stt/test_client.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/agent_stt/test_client.py b/tests/agent_stt/test_client.py index 7a086dc5..d702be49 100644 --- a/tests/agent_stt/test_client.py +++ b/tests/agent_stt/test_client.py @@ -1,3 +1,4 @@ +import io import json import pytest @@ -126,6 +127,17 @@ async def test_start_session_rejects_unsupported_sample_rate(client): ) +@pytest.mark.asyncio +async def test_transcribe_rejects_unsupported_sample_rate_without_keeping_it(client): + start_session(client) + with pytest.raises(ValueError, match="sample_rate"): + await client.transcribe( + io.BytesIO(b""), + audio_format=AudioFormat(encoding=AudioEncoding.PCM_S16LE, sample_rate=44100), + ) + assert client._audio_format.sample_rate == 16000 + + @pytest.mark.asyncio async def test_recognition_started_sets_session_state(client): start_session(client) From c9f0e6a7ed0c3496d452bea5118be28d65a8dd5d Mon Sep 17 00:00:00 2001 From: Lorna Armstrong Date: Fri, 2 Oct 2026 16:28:44 +0100 Subject: [PATCH 5/6] Update client handling for 8kHz --- sdk/agent_stt/speechmatics/agent_stt/_client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sdk/agent_stt/speechmatics/agent_stt/_client.py b/sdk/agent_stt/speechmatics/agent_stt/_client.py index 55a36493..bf875ac5 100644 --- a/sdk/agent_stt/speechmatics/agent_stt/_client.py +++ b/sdk/agent_stt/speechmatics/agent_stt/_client.py @@ -357,13 +357,15 @@ async def transcribe( audio format. transcription_config: Transcription config for the session. turn_config: Turn-taking config for the session. - audio_format: Audio format. Must be 16 kHz raw PCM for the Agent STT service. + audio_format: Audio format. Must be 8 kHz or 16 kHz raw PCM for the Agent STT + service. ws_headers: Additional WebSocket handshake headers. timeout: Maximum time in seconds to wait for the stream to finish. Raises: TimeoutError: If streaming exceeds the timeout. TranscriptionError: If the service reports an error. + ValueError: If `audio_format.sample_rate` is not 8000 or 16000. Examples: >>> with open("speech.raw", "rb") as audio: @@ -375,6 +377,7 @@ async def transcribe( if turn_config is not None: self._turn_config = turn_config if audio_format is not None: + _validate_sample_rate(audio_format.sample_rate) self._audio_format = audio_format if not self._is_connected: From b27f1ed64a1aa47c81591e3fab55a0213825316a Mon Sep 17 00:00:00 2001 From: Lorna Armstrong Date: Fri, 9 Oct 2026 16:39:47 +0100 Subject: [PATCH 6/6] Clarify test name --- tests/agent_stt/test_client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/agent_stt/test_client.py b/tests/agent_stt/test_client.py index d702be49..ffe842cb 100644 --- a/tests/agent_stt/test_client.py +++ b/tests/agent_stt/test_client.py @@ -125,10 +125,11 @@ async def test_start_session_rejects_unsupported_sample_rate(client): await client.start_session( audio_format=AudioFormat(encoding=AudioEncoding.PCM_S16LE, sample_rate=44100) ) + assert client._audio_format.sample_rate == 16000 @pytest.mark.asyncio -async def test_transcribe_rejects_unsupported_sample_rate_without_keeping_it(client): +async def test_transcribe_keeps_previous_format_after_rejecting_sample_rate(client): start_session(client) with pytest.raises(ValueError, match="sample_rate"): await client.transcribe(