From 32973939a8d3f70c8edce227935d10ce6017ac16 Mon Sep 17 00:00:00 2001 From: Dumitru Gutu Date: Thu, 5 Nov 2026 16:18:45 +0000 Subject: [PATCH 1/3] Pin ruff and black versions across tooling Local runs, pre-commit and CI were using different ruff and black versions (pre-commit: ruff 0.1.9 / black 23.12.1; CI and dev extras: unpinned latest; developer machines: anything on PATH), so formatting and lint results disagreed depending on where they were run. - Pin ruff==0.16.8 and black==26.5.1 in every SDK's dev extras - Use the same versions for the pre-commit hooks (ruff-pre-commit now from astral-sh) - Add a check-tools Makefile guard that fails format-*/lint-* with a clear message when the installed ruff or black differs from the pin - Reformat the repo once with the pinned versions (formatting only) - Fix the SIM115 finding in the multi_chan example by using ExitStack --- .pre-commit-config.yaml | 6 +- Makefile | 40 ++++++---- examples/rt/async/microphone/main.py | 19 ++--- examples/rt/async/multi_chan/main.py | 45 ++++++------ examples/tts/tts_async_example.py | 35 ++++----- .../tts/tts_autoplay/tts_stream_example.py | 73 ++++++++++--------- sdk/agent_stt/pyproject.toml | 4 +- sdk/batch/pyproject.toml | 4 +- .../speechmatics/batch/_sync_transport.py | 6 +- sdk/batch/speechmatics/batch/_transport.py | 6 +- sdk/flow/pyproject.toml | 4 +- sdk/rt/pyproject.toml | 4 +- sdk/tts/pyproject.toml | 4 +- sdk/voice/pyproject.toml | 4 +- tests/agent_stt/test_client.py | 4 +- tests/batch/test_client_parity.py | 1 - tests/batch/test_models.py | 20 ++--- 17 files changed, 140 insertions(+), 139 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b5277c6a..20af9ff6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,15 +15,15 @@ repos: - id: requirements-txt-fixer - repo: https://github.com/psf/black - rev: 23.12.1 + rev: 26.5.1 hooks: - id: black name: black files: ^sdk/.*/speechmatics/ args: [--line-length=120, --target-version=py39] - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.1.9 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.8 hooks: - id: ruff name: ruff diff --git a/Makefile b/Makefile index 61afe473..97319564 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Makefile for Speechmatics Python SDKs -.PHONY: help +.PHONY: help check-tools .PHONY: test-all test-rt test-batch test-agent-stt test-tts test-voice .PHONY: format-all format-rt format-batch format-agent-stt format-flow format-tts format-voice .PHONY: lint-all lint-rt lint-batch lint-agent-stt lint-flow lint-tts lint-voice @@ -9,6 +9,16 @@ .PHONY: clean-all clean-rt clean-batch clean-agent-stt clean-flow clean-tts clean-voice .PHONY: install-dev install-dev-rt install-dev-batch install-dev-agent-stt install-dev-flow install-dev-tts install-dev-voice +# Formatter/linter versions are pinned to keep local runs, pre-commit and CI identical. +# When bumping, update: these two variables, the dev extras in sdk/*/pyproject.toml, +# and the `rev:` entries for black and ruff in .pre-commit-config.yaml. +RUFF_VERSION := 0.16.8 +BLACK_VERSION := 26.5.1 + +check-tools: + @ruff --version | grep -qF "$(RUFF_VERSION)" || { echo "Expected ruff $(RUFF_VERSION) but found: $$(ruff --version 2>&1). Run: pip install 'ruff==$(RUFF_VERSION)' 'black==$(BLACK_VERSION)' (or activate the project .venv)"; exit 1; } + @black --version | grep -qF "$(BLACK_VERSION)" || { echo "Expected black $(BLACK_VERSION) but found: $$(black --version 2>&1 | head -1). Run: pip install 'ruff==$(RUFF_VERSION)' 'black==$(BLACK_VERSION)' (or activate the project .venv)"; exit 1; } + help: @echo "Available commands:" @@ -87,57 +97,57 @@ test-voice: # Formatting targets format-all: format-rt format-batch format-agent-stt format-flow format-tts format-voice format-tests format-examples -format-rt: +format-rt: check-tools cd sdk/rt/speechmatics && black . cd sdk/rt/speechmatics && ruff check --fix . -format-batch: +format-batch: check-tools cd sdk/batch/speechmatics && black . cd sdk/batch/speechmatics && ruff check --fix . -format-agent-stt: +format-agent-stt: check-tools cd sdk/agent_stt/speechmatics && black . cd sdk/agent_stt/speechmatics && ruff check --fix . -format-flow: +format-flow: check-tools cd sdk/flow/speechmatics && black . cd sdk/flow/speechmatics && ruff check --fix . -format-tts: +format-tts: check-tools cd sdk/tts/speechmatics && black . cd sdk/tts/speechmatics && ruff check --fix . -format-voice: +format-voice: check-tools cd sdk/voice/speechmatics && black . cd sdk/voice/speechmatics && ruff check --fix . -format-tests: +format-tests: check-tools cd tests && black . cd tests && ruff check --fix . -format-examples: +format-examples: check-tools cd examples && black . cd examples && ruff check --fix . # Linting targets lint-all: lint-rt lint-batch lint-agent-stt lint-flow lint-tts lint-voice -lint-rt: +lint-rt: check-tools cd sdk/rt/speechmatics && ruff check . -lint-batch: +lint-batch: check-tools cd sdk/batch/speechmatics && ruff check . -lint-agent-stt: +lint-agent-stt: check-tools cd sdk/agent_stt/speechmatics && ruff check . -lint-flow: +lint-flow: check-tools cd sdk/flow/speechmatics && ruff check . -lint-tts: +lint-tts: check-tools cd sdk/tts/speechmatics && ruff check . -lint-voice: +lint-voice: check-tools cd sdk/voice/speechmatics && ruff check . # Type checking targets diff --git a/examples/rt/async/microphone/main.py b/examples/rt/async/microphone/main.py index a5d5756b..1e1bcc43 100644 --- a/examples/rt/async/microphone/main.py +++ b/examples/rt/async/microphone/main.py @@ -1,17 +1,14 @@ import asyncio import logging -from speechmatics.rt import ( - AsyncClient, - AudioEncoding, - AudioFormat, - Microphone, - OperatingPoint, - ServerMessageType, - TranscriptResult, - TranscriptionConfig, -) - +from speechmatics.rt import AsyncClient +from speechmatics.rt import AudioEncoding +from speechmatics.rt import AudioFormat +from speechmatics.rt import Microphone +from speechmatics.rt import OperatingPoint +from speechmatics.rt import ServerMessageType +from speechmatics.rt import TranscriptionConfig +from speechmatics.rt import TranscriptResult logging.basicConfig(level=logging.INFO) diff --git a/examples/rt/async/multi_chan/main.py b/examples/rt/async/multi_chan/main.py index ef042434..d3da3144 100644 --- a/examples/rt/async/multi_chan/main.py +++ b/examples/rt/async/multi_chan/main.py @@ -1,30 +1,33 @@ import asyncio import logging +from contextlib import ExitStack -from speechmatics.rt import AsyncMultiChannelClient, ServerMessageType +from speechmatics.rt import AsyncMultiChannelClient +from speechmatics.rt import ServerMessageType logging.basicConfig(level=logging.INFO) + async def main(): - sources = { - "channel_one": open("channel_one.pcm", "rb"), - "channel_two": open("channel_two.pcm", "rb"), - } - - try: - async with AsyncMultiChannelClient() as client: - @client.on(ServerMessageType.ADD_TRANSCRIPT) - def handle_transcript(msg): - channel = msg.get('channel') - transcript = msg['metadata']['transcript'] - print(f"[{channel}]: {transcript}") - - await client.transcribe(sources) - except Exception as e: - print(f"Transcription error: {e}") - finally: - # Ensure all files are closed - for source in sources.values(): - source.close() + # ExitStack guarantees every opened file is closed, even if a later open() fails + with ExitStack() as stack: + sources = { + "channel_one": stack.enter_context(open("channel_one.pcm", "rb")), + "channel_two": stack.enter_context(open("channel_two.pcm", "rb")), + } + + try: + async with AsyncMultiChannelClient() as client: + + @client.on(ServerMessageType.ADD_TRANSCRIPT) + def handle_transcript(msg): + channel = msg.get("channel") + transcript = msg["metadata"]["transcript"] + print(f"[{channel}]: {transcript}") + + await client.transcribe(sources) + except Exception as e: + print(f"Transcription error: {e}") + asyncio.run(main()) diff --git a/examples/tts/tts_async_example.py b/examples/tts/tts_async_example.py index efa4bec3..9ace9c8a 100644 --- a/examples/tts/tts_async_example.py +++ b/examples/tts/tts_async_example.py @@ -1,55 +1,52 @@ -import os import asyncio - -import wave +import wave from pathlib import Path -from speechmatics.tts import AsyncClient, Voice, OutputFormat +from speechmatics.tts import AsyncClient +from speechmatics.tts import OutputFormat +from speechmatics.tts import Voice - -# Set configuration +# Set configuration TEXT = "Welcome to the future of audio generation from text!" VOICE = Voice.SARAH OUTPUT_FORMAT = OutputFormat.RAW_PCM_16000 OUTPUT_FILE = "output.wav" # Set Format Parameters for WAV output file -SAMPLE_RATE = 16000 #Hz -SAMPLE_WIDTH = 2 # 16-bit audio -CHANNELS = 1 # Mono audio +SAMPLE_RATE = 16000 # Hz +SAMPLE_WIDTH = 2 # 16-bit audio +CHANNELS = 1 # Mono audio + # Save audio to WAV file -async def save_audio_to_wav(audio_data: bytes, - output_file_name: str) -> None: +async def save_audio_to_wav(audio_data: bytes, output_file_name: str) -> None: with wave.open(output_file_name, "wb") as wav_file: wav_file.setnchannels(CHANNELS) wav_file.setsampwidth(SAMPLE_WIDTH) wav_file.setframerate(SAMPLE_RATE) wav_file.writeframes(audio_data) + # Generate speech from text and save to WAV file async def main(): print(f"Generating speech from text: {TEXT}") - + try: async with AsyncClient() as client: - async with await client.generate( - text=TEXT, - voice=VOICE, - output_format=OUTPUT_FORMAT - ) as response: + async with await client.generate(text=TEXT, voice=VOICE, output_format=OUTPUT_FORMAT) as response: # Process the response in chunks and save to WAV audio_chunks = [] async for chunk in response.content.iter_chunked(1024): audio_chunks.append(chunk) - + # Combine chunks and save to WAV - audio_data = b''.join(audio_chunks) + audio_data = b"".join(audio_chunks) await save_audio_to_wav(audio_data, OUTPUT_FILE) print(f"Speech saved to {Path(OUTPUT_FILE).resolve()}") except Exception as e: print(f"An error occurred: {e}") + # Run the async main function if __name__ == "__main__": asyncio.run(main()) diff --git a/examples/tts/tts_autoplay/tts_stream_example.py b/examples/tts/tts_autoplay/tts_stream_example.py index ec4e860e..6c99e61a 100644 --- a/examples/tts/tts_autoplay/tts_stream_example.py +++ b/examples/tts/tts_autoplay/tts_stream_example.py @@ -1,7 +1,11 @@ import asyncio -import sounddevice as sd + import numpy as np -from speechmatics.tts import AsyncClient, Voice, OutputFormat +import sounddevice as sd + +from speechmatics.tts import AsyncClient +from speechmatics.tts import OutputFormat +from speechmatics.tts import Voice # Configuration TEXT = "Welcome to the future of audio generation from text! This audio is a demo of the async streaming Speechmatics' text to speech API." @@ -9,11 +13,11 @@ OUTPUT_FORMAT = OutputFormat.RAW_PCM_16000 # Audio Parameters -SAMPLE_RATE = 16000 #Hz -SAMPLE_WIDTH = 2 # 16-bit audio -CHANNELS = 1 # Mono audio -CHUNK_SIZE = 2048 # Size of audio chunks -BUFFER_SIZE = 4096 # Size of buffer +SAMPLE_RATE = 16000 # Hz +SAMPLE_WIDTH = 2 # 16-bit audio +CHANNELS = 1 # Mono audio +CHUNK_SIZE = 2048 # Size of audio chunks +BUFFER_SIZE = 4096 # Size of buffer # Sentinel value to signal end of stream END_OF_STREAM = None @@ -23,14 +27,14 @@ # 1. Producer: Generates audio and puts chunks into the queue: + async def audio_generator(audio_queue: asyncio.Queue, text: str, voice: str, output_format: str) -> None: try: - async with AsyncClient() as client, await client.generate( - text=text, - voice=voice, - output_format=output_format - ) as response: - buffer=bytearray() + async with ( + AsyncClient() as client, + await client.generate(text=text, voice=voice, output_format=output_format) as response, + ): + buffer = bytearray() async for chunk in response.content.iter_chunked(BUFFER_SIZE): if not chunk: continue @@ -39,54 +43,55 @@ async def audio_generator(audio_queue: asyncio.Queue, text: str, voice: str, out # Process complete frames (2 bytes per sample for 16-bit audio) # Convert little-endian 16-bit signed int to np.int-16 while len(buffer) >= 2: - sample = int.from_bytes(buffer[:2], byteorder='little', signed=True) + sample = int.from_bytes(buffer[:2], byteorder="little", signed=True) await audio_queue.put(sample) buffer = buffer[2:] - + await audio_queue.put(END_OF_STREAM) print("Audio generated and put into queue.") - + except Exception as e: print(f"[{'Generator'}] An error occurred in the audio generator: {e}") await audio_queue.put(END_OF_STREAM) raise + # 2. Consumer: Read audio data from queue and play it in real-time using sounddevice. async def audio_player(play_queue: asyncio.Queue) -> None: try: with sd.OutputStream( samplerate=SAMPLE_RATE, channels=CHANNELS, - dtype='int16', # 16-bit PCM + dtype="int16", # 16-bit PCM blocksize=CHUNK_SIZE, - latency='high', + latency="high", ) as stream: - buffer=[] + buffer = [] while True: try: sample = await asyncio.wait_for(play_queue.get(), timeout=0.1) if sample is END_OF_STREAM: if buffer: - audio_data=np.array(buffer, dtype=np.int16) + audio_data = np.array(buffer, dtype=np.int16) stream.write(audio_data) - buffer=[] + buffer = [] break - + buffer.append(sample) if len(buffer) >= CHUNK_SIZE: - audio_data=np.array(buffer[:CHUNK_SIZE], dtype=np.int16) + audio_data = np.array(buffer[:CHUNK_SIZE], dtype=np.int16) stream.write(audio_data) - buffer=buffer[CHUNK_SIZE:] - + buffer = buffer[CHUNK_SIZE:] + play_queue.task_done() - + except asyncio.TimeoutError: if buffer: - audio_data=np.array(buffer, dtype=np.int16) + audio_data = np.array(buffer, dtype=np.int16) stream.write(audio_data) - buffer=[] + buffer = [] continue - + except Exception as e: print(f"[{'Player'}] An error occurred playing audio chunk {e}") raise @@ -97,6 +102,7 @@ async def audio_player(play_queue: asyncio.Queue) -> None: finally: sd.stop() + # 3. Main Function: Orchestrate audio generation and audio stream async def main() -> None: play_queue = asyncio.Queue() @@ -104,16 +110,17 @@ async def main() -> None: # Create tasks tasks = [ asyncio.create_task(audio_generator(play_queue, TEXT, VOICE, OUTPUT_FORMAT)), - asyncio.create_task(audio_player(play_queue)) + asyncio.create_task(audio_player(play_queue)), ] - + try: await asyncio.gather(*tasks) - - except Exception as e: + + except Exception: for task in tasks: task.cancel() await asyncio.gather(*tasks, return_exceptions=True) + if __name__ == "__main__": asyncio.run(main()) diff --git a/sdk/agent_stt/pyproject.toml b/sdk/agent_stt/pyproject.toml index b0c6bf55..8260e9ce 100644 --- a/sdk/agent_stt/pyproject.toml +++ b/sdk/agent_stt/pyproject.toml @@ -38,8 +38,8 @@ keywords = [ [project.optional-dependencies] jwt = ["aiohttp"] dev = [ - "black", - "ruff", + "black==26.5.1", + "ruff==0.16.8", "mypy", "pre-commit", "pytest", diff --git a/sdk/batch/pyproject.toml b/sdk/batch/pyproject.toml index a579aebb..43c00d99 100644 --- a/sdk/batch/pyproject.toml +++ b/sdk/batch/pyproject.toml @@ -27,8 +27,8 @@ keywords = ["speechmatics", "speech-to-text", "batch", "transcription", "api"] [project.optional-dependencies] dev = [ - "black", - "ruff", + "black==26.5.1", + "ruff==0.16.8", "mypy", "types-aiofiles", "pre-commit", diff --git a/sdk/batch/speechmatics/batch/_sync_transport.py b/sdk/batch/speechmatics/batch/_sync_transport.py index 7cf4ad5d..464b0653 100644 --- a/sdk/batch/speechmatics/batch/_sync_transport.py +++ b/sdk/batch/speechmatics/batch/_sync_transport.py @@ -317,9 +317,9 @@ def _prepare_headers(self) -> dict[str, str]: Headers dictionary with authentication and tracking info """ auth_headers = self._auth.get_auth_headers_sync() - auth_headers[ - "User-Agent" - ] = f"speechmatics-batch-v{get_version()} python/{sys.version_info.major}.{sys.version_info.minor}" + auth_headers["User-Agent"] = ( + f"speechmatics-batch-v{get_version()} python/{sys.version_info.major}.{sys.version_info.minor}" + ) if self._request_id: auth_headers["X-Request-Id"] = self._request_id diff --git a/sdk/batch/speechmatics/batch/_transport.py b/sdk/batch/speechmatics/batch/_transport.py index d5a24458..2a1e22f1 100644 --- a/sdk/batch/speechmatics/batch/_transport.py +++ b/sdk/batch/speechmatics/batch/_transport.py @@ -322,9 +322,9 @@ async def _prepare_headers(self) -> dict[str, str]: Headers dictionary with authentication and tracking info """ auth_headers = await self._auth.get_auth_headers() - auth_headers[ - "User-Agent" - ] = f"speechmatics-batch-v{get_version()} python/{sys.version_info.major}.{sys.version_info.minor}" + auth_headers["User-Agent"] = ( + f"speechmatics-batch-v{get_version()} python/{sys.version_info.major}.{sys.version_info.minor}" + ) if self._request_id: auth_headers["X-Request-Id"] = self._request_id diff --git a/sdk/flow/pyproject.toml b/sdk/flow/pyproject.toml index ea19115e..669e7a4f 100644 --- a/sdk/flow/pyproject.toml +++ b/sdk/flow/pyproject.toml @@ -34,8 +34,8 @@ keywords = [ [project.optional-dependencies] jwt = ["aiohttp"] dev = [ - "black", - "ruff", + "black==26.5.1", + "ruff==0.16.8", "mypy", "pre-commit", "pytest", diff --git a/sdk/rt/pyproject.toml b/sdk/rt/pyproject.toml index 62d2ca9d..3ca81793 100644 --- a/sdk/rt/pyproject.toml +++ b/sdk/rt/pyproject.toml @@ -34,8 +34,8 @@ keywords = [ [project.optional-dependencies] jwt = ["aiohttp"] dev = [ - "black", - "ruff", + "black==26.5.1", + "ruff==0.16.8", "mypy", "pre-commit", "pytest", diff --git a/sdk/tts/pyproject.toml b/sdk/tts/pyproject.toml index ef816d62..3aa3ebdb 100644 --- a/sdk/tts/pyproject.toml +++ b/sdk/tts/pyproject.toml @@ -27,8 +27,8 @@ keywords = ["speechmatics", "speech-to-text", "tts", "transcription", "api"] [project.optional-dependencies] dev = [ - "black", - "ruff", + "black==26.5.1", + "ruff==0.16.8", "mypy", "types-aiofiles", "pre-commit", diff --git a/sdk/voice/pyproject.toml b/sdk/voice/pyproject.toml index 4fdcf081..16e1dd0a 100644 --- a/sdk/voice/pyproject.toml +++ b/sdk/voice/pyproject.toml @@ -45,8 +45,8 @@ smart = [ "transformers>=4.57.0,<5", ] dev = [ - "black", - "ruff", + "black==26.5.1", + "ruff==0.16.8", "mypy", "pre-commit", "pytest", diff --git a/tests/agent_stt/test_client.py b/tests/agent_stt/test_client.py index c663f6bc..a4a90cba 100644 --- a/tests/agent_stt/test_client.py +++ b/tests/agent_stt/test_client.py @@ -122,9 +122,7 @@ async def test_audio_format_rejects_unsupported_sample_rate(monkeypatch): 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) - ) + await client.start_session(audio_format=AudioFormat(encoding=AudioEncoding.PCM_S16LE, sample_rate=44100)) assert client._audio_format.sample_rate == 16000 diff --git a/tests/batch/test_client_parity.py b/tests/batch/test_client_parity.py index c0ac36a3..c032e488 100644 --- a/tests/batch/test_client_parity.py +++ b/tests/batch/test_client_parity.py @@ -336,7 +336,6 @@ def test_non_positive_min_polling_interval_is_rejected(self, client, interval): client.call("wait_for_completion", "job-1", min_polling_interval=interval) - class TestTranscribeParity: def test_single_round_trip_when_wait_succeeds(self, client): with client.patch_transport("post") as post: diff --git a/tests/batch/test_models.py b/tests/batch/test_models.py index fafbe974..6db56c1a 100644 --- a/tests/batch/test_models.py +++ b/tests/batch/test_models.py @@ -9,16 +9,12 @@ class TestTranscriptFilteringConfigToDict: def test_remove_disfluencies_true_serializes_correctly(self): - config = TranscriptionConfig( - transcript_filtering_config=TranscriptFilteringConfig(remove_disfluencies=True) - ) + config = TranscriptionConfig(transcript_filtering_config=TranscriptFilteringConfig(remove_disfluencies=True)) result = config.to_dict() assert result["transcript_filtering_config"] == {"remove_disfluencies": True} def test_remove_disfluencies_false_included_in_output(self): - config = TranscriptionConfig( - transcript_filtering_config=TranscriptFilteringConfig(remove_disfluencies=False) - ) + config = TranscriptionConfig(transcript_filtering_config=TranscriptFilteringConfig(remove_disfluencies=False)) result = config.to_dict() assert result["transcript_filtering_config"] == {"remove_disfluencies": False} @@ -29,9 +25,7 @@ def test_none_excluded_from_output(self): def test_replacements_serialized(self): replacements = [{"from": "um", "to": ""}, {"from": "uh", "to": ""}] - config = TranscriptionConfig( - transcript_filtering_config=TranscriptFilteringConfig(replacements=replacements) - ) + config = TranscriptionConfig(transcript_filtering_config=TranscriptFilteringConfig(replacements=replacements)) result = config.to_dict() assert result["transcript_filtering_config"] == { "remove_disfluencies": False, @@ -39,18 +33,14 @@ def test_replacements_serialized(self): } def test_replacements_absent_when_none(self): - config = TranscriptionConfig( - transcript_filtering_config=TranscriptFilteringConfig(remove_disfluencies=True) - ) + config = TranscriptionConfig(transcript_filtering_config=TranscriptFilteringConfig(remove_disfluencies=True)) result = config.to_dict() assert "replacements" not in result["transcript_filtering_config"] def test_replacements_and_remove_disfluencies_together(self): replacements = [{"from": "gonna", "to": "going to"}] config = TranscriptionConfig( - transcript_filtering_config=TranscriptFilteringConfig( - remove_disfluencies=True, replacements=replacements - ) + transcript_filtering_config=TranscriptFilteringConfig(remove_disfluencies=True, replacements=replacements) ) result = config.to_dict() assert result["transcript_filtering_config"] == { From 379260bf23ca920eda638bc6381bf600586f3b0f Mon Sep 17 00:00:00 2001 From: Dumitru Gutu Date: Thu, 5 Nov 2026 16:22:20 +0000 Subject: [PATCH 2/3] Run pre-commit in CI Pre-commit hooks only run for developers who have run `pre-commit install`, so nothing stopped unformatted or unlinted code from being merged. Add a Pre-commit workflow that runs every hook on all files for pushes to main and for pull requests, so the checks no longer depend on each developer's local setup. The local-only pytest-check hook is skipped because tests already run in the Test and Build workflow. Also commit the fixes the hooks apply to existing files (trailing whitespace in two READMEs, import order in a requirements.txt) so the job passes on its first run. --- .github/workflows/pre-commit.yaml | 31 ++++++++++++++++++++++ examples/tts/tts_autoplay/README.md | 18 ++++++------- examples/tts/tts_autoplay/requirements.txt | 2 +- sdk/tts/README.md | 2 +- 4 files changed, 42 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/pre-commit.yaml diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml new file mode 100644 index 00000000..0dc64e69 --- /dev/null +++ b/.github/workflows/pre-commit.yaml @@ -0,0 +1,31 @@ +name: Pre-commit + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + pre-commit: + name: Pre-commit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} + - name: Install pre-commit + run: python -m pip install pre-commit + # Tests already run in the "Test and Build" workflow, so skip the local-only pytest hook here. + - name: Run pre-commit on all files + run: pre-commit run --all-files --show-diff-on-failure + env: + SKIP: pytest-check diff --git a/examples/tts/tts_autoplay/README.md b/examples/tts/tts_autoplay/README.md index 5d540fb9..3bd9dd68 100644 --- a/examples/tts/tts_autoplay/README.md +++ b/examples/tts/tts_autoplay/README.md @@ -5,21 +5,21 @@ You must have an audio output device configured on their system for this example ## How it Works There are two main components in this example, an audio generator and an audio player. These components are run concurrently using asyncio as tasks, ochestrated by the main() function, to generate and play audio in real-time. -### audio_generator() +### audio_generator() -This producer function connects to the Speechmatics TTS API using the AsyncClient. It calls client.generate() with your text, the voice you want to use, and the output format - RAW_PCM_16000 in this example. -The code iterates over the audio data as it is streamed in chunks (iter_chunked), and accumulates in a bytearray buffer. +This producer function connects to the Speechmatics TTS API using the AsyncClient. It calls client.generate() with your text, the voice you want to use, and the output format - RAW_PCM_16000 in this example. +The code iterates over the audio data as it is streamed in chunks (iter_chunked), and accumulates in a bytearray buffer. The while len(buffer) >= 2 loop reads each audio sample containing 2 bytes, from the buffer, and converts it to a numpy array of int-16 values, which is then put into the audio_queue. -The processed 2 byte sample is then removed from the front of the buffer. +The processed 2 byte sample is then removed from the front of the buffer. END_OF_STREAM is used as a sentinel value to signal the end of the audio stream, with no more audio data to process. If an error occurs during audio generation, the END_OF_STREAM sentinel value is still put into the queue to signal the end of the audio stream to prevent the consumer, audio_player(), from getting stuck in an infinite loop, and raises the exception. -### audio_player() +### audio_player() -This consumer function initialises a sounddevice OutputStream, which is responsible for streaming the audio data to the default audio output device. Within the outputstream, the while True loop means there is continous processing of the incoming audio data. -sample = await asyncio.wait_for(play_queue.get(), timeout=0.1) fetches the next sample from the queue, or waits for 0.1 seconds if the queue is empty. -If the sample is END_OF_STREAM, the while loop breaks and the audio player exits. +This consumer function initialises a sounddevice OutputStream, which is responsible for streaming the audio data to the default audio output device. Within the outputstream, the while True loop means there is continous processing of the incoming audio data. +sample = await asyncio.wait_for(play_queue.get(), timeout=0.1) fetches the next sample from the queue, or waits for 0.1 seconds if the queue is empty. +If the sample is END_OF_STREAM, the while loop breaks and the audio player exits. If the sample is not END_OF_STREAM, it is converted to a numpy array of int-16 values and written to the audio output device using the sounddevice OutputStream. -play_queue.task_done() is called to signal that the sample has been processed. +play_queue.task_done() is called to signal that the sample has been processed. If an error occurs during audio playback, the END_OF_STREAM sentinel value is still put into the queue to signal the end of the audio stream to prevent the audio_player() from getting stuck in an infinite loop, and raises the exception. ## Installation diff --git a/examples/tts/tts_autoplay/requirements.txt b/examples/tts/tts_autoplay/requirements.txt index d4ef65fc..550abbc7 100644 --- a/examples/tts/tts_autoplay/requirements.txt +++ b/examples/tts/tts_autoplay/requirements.txt @@ -1,3 +1,3 @@ -sounddevice>=0.4.6 numpy>=1.24.3 +sounddevice>=0.4.6 speechmatics-tts>=0.1.0 diff --git a/sdk/tts/README.md b/sdk/tts/README.md index 648d210a..9dc4d372 100644 --- a/sdk/tts/README.md +++ b/sdk/tts/README.md @@ -89,7 +89,7 @@ async def main(): async with AsyncClient(conn_config=config) as client: response = await client.generate(text="Hello World") - + asyncio.run(main()) ``` From ef9f5a9c94641544f89312ba8fb8d0bc36408f38 Mon Sep 17 00:00:00 2001 From: Dumitru Gutu Date: Thu, 5 Nov 2026 16:38:47 +0000 Subject: [PATCH 3/3] Reformat tts transport with pinned black --- sdk/tts/speechmatics/tts/_transport.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk/tts/speechmatics/tts/_transport.py b/sdk/tts/speechmatics/tts/_transport.py index 68467490..e8b7c4e8 100644 --- a/sdk/tts/speechmatics/tts/_transport.py +++ b/sdk/tts/speechmatics/tts/_transport.py @@ -263,9 +263,9 @@ async def _prepare_headers(self) -> dict[str, str]: Headers dictionary with authentication and tracking info """ auth_headers = await self._auth.get_auth_headers() - auth_headers[ - "User-Agent" - ] = f"speechmatics-batch-v{get_version()} python/{sys.version_info.major}.{sys.version_info.minor}" + auth_headers["User-Agent"] = ( + f"speechmatics-batch-v{get_version()} python/{sys.version_info.major}.{sys.version_info.minor}" + ) if self._request_id: auth_headers["X-Request-Id"] = self._request_id