Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions examples/agent_stt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- |
Expand Down
47 changes: 30 additions & 17 deletions examples/agent_stt/client_vad/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,45 +6,58 @@

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


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)
Expand Down
40 changes: 28 additions & 12 deletions examples/agent_stt/realtime_file/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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


Expand All @@ -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(
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -120,15 +133,16 @@ 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()

while frame := wav.readframes(frames_per_chunk):
# 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)
Expand All @@ -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)
Expand Down
20 changes: 17 additions & 3 deletions sdk/agent_stt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,23 @@ 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** (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
Comment thread
LArmstrongDev marked this conversation as resolved.
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

Expand Down
2 changes: 2 additions & 0 deletions sdk/agent_stt/speechmatics/agent_stt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -59,6 +60,7 @@
"DEFAULT_SAMPLE_RATE",
"DEFAULT_WORD_DELIMITER",
"SEGMENT_MESSAGES",
"SUPPORTED_SAMPLE_RATES",
"TIMED_MESSAGES",
"__version__",
# Client
Expand Down
24 changes: 20 additions & 4 deletions sdk/agent_stt/speechmatics/agent_stt/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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`.

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -344,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:
Expand All @@ -362,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:
Expand Down
1 change: 1 addition & 0 deletions sdk/agent_stt/speechmatics/agent_stt/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = " "

Expand Down
43 changes: 43 additions & 0 deletions tests/agent_stt/test_client.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import io
import json

import pytest

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
Expand Down Expand Up @@ -96,6 +98,47 @@ 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)
)
assert client._audio_format.sample_rate == 16000


@pytest.mark.asyncio
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(
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)
Expand Down
Loading