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/.pre-commit-config.yaml b/.pre-commit-config.yaml index 817a5ba2..75455951 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=py310] - - 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/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/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 d5f00939..3eec1062 100644 --- a/sdk/agent_stt/pyproject.toml +++ b/sdk/agent_stt/pyproject.toml @@ -39,8 +39,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 dcac20e5..017d68c8 100644 --- a/sdk/batch/pyproject.toml +++ b/sdk/batch/pyproject.toml @@ -28,8 +28,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 f4bad066..b1e5626b 100644 --- a/sdk/batch/speechmatics/batch/_sync_transport.py +++ b/sdk/batch/speechmatics/batch/_sync_transport.py @@ -312,9 +312,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 d5efb2a2..6afb8aba 100644 --- a/sdk/batch/speechmatics/batch/_transport.py +++ b/sdk/batch/speechmatics/batch/_transport.py @@ -321,9 +321,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 f7b3d174..5a16142a 100644 --- a/sdk/flow/pyproject.toml +++ b/sdk/flow/pyproject.toml @@ -35,8 +35,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 ab1c53f1..8047b2ee 100644 --- a/sdk/rt/pyproject.toml +++ b/sdk/rt/pyproject.toml @@ -35,8 +35,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 799c98d5..cfb83766 100644 --- a/sdk/tts/pyproject.toml +++ b/sdk/tts/pyproject.toml @@ -28,8 +28,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/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 diff --git a/sdk/voice/pyproject.toml b/sdk/voice/pyproject.toml index 354f62f9..b2fca322 100644 --- a/sdk/voice/pyproject.toml +++ b/sdk/voice/pyproject.toml @@ -46,8 +46,8 @@ smart = [ "transformers>=4.57.0,<5", ] dev = [ - "black", - "ruff", + "black==26.5.1", + "ruff==0.16.8", "mypy", "pre-commit", "pytest",