From 9fc082c1dba9c172528187a735d1b071d1dd94ef Mon Sep 17 00:00:00 2001 From: liujiahua123123 Date: Fri, 2 Oct 2026 00:46:27 +0000 Subject: [PATCH 1/2] fix(asr): keep language, request_id and speaker_turns in ASRResponse ASRResponse declared only text, duration and segments, so pydantic's default extra="ignore" silently dropped the language, language_code, request_id and speaker_turns fields that /v1/asr returns. Add them as optional fields (None when the API does not return them) with a new SpeakerTurn model, exported next to ASRSegment. Also correct the duration unit to seconds in the docstrings and the legacy fish_audio_sdk schema comment, and use seconds in the ASR test fixtures. The change is backward compatible: no existing field or request parameter changes. Co-Authored-By: Claude Opus 5.5 --- README.md | 19 ++++++ src/fish_audio_sdk/schemas.py | 2 +- src/fishaudio/resources/asr.py | 8 ++- src/fishaudio/types/__init__.py | 3 +- src/fishaudio/types/asr.py | 30 ++++++++- tests/unit/conftest.py | 45 ++++++++++++-- tests/unit/test_asr.py | 106 +++++++++++++++++++++++++++++++- tests/unit/test_types.py | 63 ++++++++++++++++++- 8 files changed, 261 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index f732655..6181828 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,25 @@ print(result.text) # Access timestamped segments for segment in result.segments: print(f"[{segment.start:.2f}s - {segment.end:.2f}s] {segment.text}") + +# Language of the transcript, when the API returns it +print(result.language_code) # e.g. "en" +``` + +**Speaker turns:** + +```python +from fishaudio.core import RequestOptions + +# transcribe-1-pro returns speaker turns along with timestamps +with open("meeting.wav", "rb") as f: + result = client.asr.transcribe( + audio=f.read(), + request_options=RequestOptions(additional_headers={"model": "transcribe-1-pro"}), + ) + +for turn in result.speaker_turns or []: + print(f"[{turn.start:.2f}s - {turn.end:.2f}s] {turn.speaker}: {turn.text}") ``` [Learn more](https://docs.fish.audio/developer-guide/sdk-guide/python/speech-to-text) diff --git a/src/fish_audio_sdk/schemas.py b/src/fish_audio_sdk/schemas.py index af40743..a9dcba3 100644 --- a/src/fish_audio_sdk/schemas.py +++ b/src/fish_audio_sdk/schemas.py @@ -56,7 +56,7 @@ class ASRSegment(BaseModel): class ASRResponse(BaseModel): text: str - # Duration in milliseconds + # Duration in seconds duration: float segments: list[ASRSegment] diff --git a/src/fishaudio/resources/asr.py b/src/fishaudio/resources/asr.py index e3e38f6..f29bfe3 100644 --- a/src/fishaudio/resources/asr.py +++ b/src/fishaudio/resources/asr.py @@ -29,10 +29,12 @@ def transcribe( audio: Audio file bytes language: Language code (e.g., "en", "zh"). Auto-detected if not provided. include_timestamps: Whether to include timestamp information for segments + (and speaker turns, on models that return them) request_options: Request-level overrides Returns: - ASRResponse with transcription text, duration, and segments + ASRResponse with transcription text, duration (in seconds), and segments, + plus language, request ID and speaker turns when the API returns them Example: ```python @@ -88,10 +90,12 @@ async def transcribe( audio: Audio file bytes language: Language code (e.g., "en", "zh"). Auto-detected if not provided. include_timestamps: Whether to include timestamp information for segments + (and speaker turns, on models that return them) request_options: Request-level overrides Returns: - ASRResponse with transcription text, duration, and segments + ASRResponse with transcription text, duration (in seconds), and segments, + plus language, request ID and speaker turns when the API returns them Example: ```python diff --git a/src/fishaudio/types/__init__.py b/src/fishaudio/types/__init__.py index 9c4994e..584bc38 100644 --- a/src/fishaudio/types/__init__.py +++ b/src/fishaudio/types/__init__.py @@ -1,7 +1,7 @@ """Type definitions for the Fish Audio SDK.""" from .account import Credits, Package -from .asr import ASRResponse, ASRSegment +from .asr import ASRResponse, ASRSegment, SpeakerTurn from .shared import ( AudioFormat, LatencyMode, @@ -30,6 +30,7 @@ # ASR types "ASRResponse", "ASRSegment", + "SpeakerTurn", # Shared types "AudioFormat", "LatencyMode", diff --git a/src/fishaudio/types/asr.py b/src/fishaudio/types/asr.py index 41bec1c..83c6ed7 100644 --- a/src/fishaudio/types/asr.py +++ b/src/fishaudio/types/asr.py @@ -1,5 +1,7 @@ """ASR (Automatic Speech Recognition) related types.""" +from typing import Optional + from pydantic import BaseModel @@ -17,15 +19,39 @@ class ASRSegment(BaseModel): end: float +class SpeakerTurn(BaseModel): + """A stretch of the transcript spoken by one speaker. + + Attributes: + speaker: Speaker label (e.g., "speaker:0"). Labels identify speakers within one response only + text: What the speaker said in this turn, without inline speaker markers + start: Turn start time in seconds + end: Turn end time in seconds + """ + + speaker: str + text: str + start: float + end: float + + class ASRResponse(BaseModel): """Response from speech-to-text transcription. Attributes: text: Complete transcription of the entire audio - duration: Total audio duration in milliseconds + duration: Total audio duration in seconds segments: List of timestamped text segments. Empty if include_timestamps=False + language: Display name of the transcript's language (e.g., "English"). None if not returned + language_code: Language code of the transcript (e.g., "en"): the detected language, or the requested language if none was detected. None if not returned + request_id: Identifier of this request; quote it when reporting a problem with the request. None if not returned + speaker_turns: Speaker turns in order, with times in seconds. Returned only by models with speaker diarization, and only if include_timestamps=True; empty if no speech was found. None otherwise """ text: str - duration: float # Duration in milliseconds + duration: float # Duration in seconds segments: list[ASRSegment] + language: Optional[str] = None + language_code: Optional[str] = None + request_id: Optional[str] = None + speaker_turns: Optional[list[SpeakerTurn]] = None diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 58c6a58..a5b1c2c 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -58,17 +58,54 @@ def sample_paginated_voices_response(sample_voice_response): @pytest.fixture def sample_asr_response(): - """Sample ASR API response.""" + """Sample ASR API response (times in seconds).""" return { "text": "Hello world", - "duration": 1500.0, + "duration": 1.5, "segments": [ - {"text": "Hello", "start": 0.0, "end": 500.0}, - {"text": "world", "start": 500.0, "end": 1500.0}, + {"text": "Hello", "start": 0.0, "end": 0.5}, + {"text": "world", "start": 0.5, "end": 1.5}, ], } +@pytest.fixture +def sample_asr_standard_response(): + """Sample transcribe-1 ASR API response with timestamps requested.""" + return { + "language": "English", + "language_code": "en", + "text": "Hello, world.", + "segments": [ + {"text": "Hello", "start": 0.24, "end": 0.64}, + {"text": "world", "start": 0.64, "end": 1.2}, + ], + "duration": 1.62, + } + + +@pytest.fixture +def sample_asr_pro_response(): + """Sample transcribe-1-pro ASR API response with timestamps requested.""" + return { + "duration": 3.5, + "language": "English", + "language_code": "en", + "request_id": "0b9e4f5c-7d2a-4f1e-9c3b-5a8d6e2f1c07", + "segments": [ + {"end": 0.64, "start": 0.24, "text": "Hello"}, + {"end": 1.2, "start": 0.64, "text": "there"}, + {"end": 2.1, "start": 1.8, "text": "Hi"}, + {"end": 3.1, "start": 2.1, "text": "back"}, + ], + "speaker_turns": [ + {"speaker": "speaker:0", "text": "Hello there.", "start": 0.24, "end": 1.2}, + {"speaker": "speaker:1", "text": "Hi back.", "start": 1.8, "end": 3.1}, + ], + "text": "<|speaker:0|>Hello there.<|speaker:1|>Hi back.", + } + + @pytest.fixture def sample_credits_response(): """Sample credits API response.""" diff --git a/tests/unit/test_asr.py b/tests/unit/test_asr.py index 62469dd..dabc60c 100644 --- a/tests/unit/test_asr.py +++ b/tests/unit/test_asr.py @@ -53,8 +53,10 @@ def test_transcribe_basic( assert isinstance(result, ASRResponse) assert result.text == "Hello world" - assert result.duration == 1500.0 + assert result.duration == 1.5 assert len(result.segments) == 2 + assert result.language_code is None + assert result.speaker_turns is None mock_client_wrapper.request.assert_called_once() call_args = mock_client_wrapper.request.call_args @@ -123,6 +125,54 @@ def test_transcribe_language_none( payload = ormsgpack.unpackb(call_args[1]["content"]) assert payload["language"] is None + def test_transcribe_standard_model_response( + self, asr_client, mock_client_wrapper, sample_asr_standard_response + ): + """Test that a transcribe-1 response keeps its language fields.""" + mock_response = Mock() + mock_response.json.return_value = sample_asr_standard_response + mock_client_wrapper.request.return_value = mock_response + + result = asr_client.transcribe(audio=b"fake_audio") + + assert result.text == "Hello, world." + assert result.duration == 1.62 + assert result.language == "English" + assert result.language_code == "en" + assert result.request_id is None + assert result.speaker_turns is None + + def test_transcribe_pro_model_response( + self, asr_client, mock_client_wrapper, sample_asr_pro_response + ): + """Test that a transcribe-1-pro response keeps request_id and speaker_turns.""" + mock_response = Mock() + mock_response.json.return_value = sample_asr_pro_response + mock_client_wrapper.request.return_value = mock_response + + request_options = RequestOptions( + additional_headers={"model": "transcribe-1-pro"} + ) + result = asr_client.transcribe( + audio=b"fake_audio", request_options=request_options + ) + + assert result.duration == 3.5 + assert result.language == "English" + assert result.language_code == "en" + assert result.request_id == "0b9e4f5c-7d2a-4f1e-9c3b-5a8d6e2f1c07" + assert result.speaker_turns is not None + assert [turn.speaker for turn in result.speaker_turns] == [ + "speaker:0", + "speaker:1", + ] + assert result.speaker_turns[1].text == "Hi back." + assert result.speaker_turns[1].start == 1.8 + assert result.speaker_turns[1].end == 3.1 + + call_args = mock_client_wrapper.request.call_args + assert call_args[1]["request_options"] is request_options + class TestAsyncASRClient: """Test asynchronous AsyncASRClient.""" @@ -140,8 +190,10 @@ async def test_transcribe_basic( assert isinstance(result, ASRResponse) assert result.text == "Hello world" - assert result.duration == 1500.0 + assert result.duration == 1.5 assert len(result.segments) == 2 + assert result.language_code is None + assert result.speaker_turns is None async_mock_client_wrapper.request.assert_called_once() call_args = async_mock_client_wrapper.request.call_args @@ -199,3 +251,53 @@ async def test_transcribe_with_request_options( call_args = async_mock_client_wrapper.request.call_args assert call_args[1]["request_options"] == request_options + + @pytest.mark.asyncio + async def test_transcribe_standard_model_response( + self, async_asr_client, async_mock_client_wrapper, sample_asr_standard_response + ): + """Test that a transcribe-1 response keeps its language fields (async).""" + mock_response = Mock() + mock_response.json.return_value = sample_asr_standard_response + async_mock_client_wrapper.request = AsyncMock(return_value=mock_response) + + result = await async_asr_client.transcribe(audio=b"fake_audio") + + assert result.text == "Hello, world." + assert result.duration == 1.62 + assert result.language == "English" + assert result.language_code == "en" + assert result.request_id is None + assert result.speaker_turns is None + + @pytest.mark.asyncio + async def test_transcribe_pro_model_response( + self, async_asr_client, async_mock_client_wrapper, sample_asr_pro_response + ): + """Test that a transcribe-1-pro response keeps request_id and speaker_turns (async).""" + mock_response = Mock() + mock_response.json.return_value = sample_asr_pro_response + async_mock_client_wrapper.request = AsyncMock(return_value=mock_response) + + request_options = RequestOptions( + additional_headers={"model": "transcribe-1-pro"} + ) + result = await async_asr_client.transcribe( + audio=b"fake_audio", request_options=request_options + ) + + assert result.duration == 3.5 + assert result.language == "English" + assert result.language_code == "en" + assert result.request_id == "0b9e4f5c-7d2a-4f1e-9c3b-5a8d6e2f1c07" + assert result.speaker_turns is not None + assert [turn.speaker for turn in result.speaker_turns] == [ + "speaker:0", + "speaker:1", + ] + assert result.speaker_turns[0].text == "Hello there." + assert result.speaker_turns[0].start == 0.24 + assert result.speaker_turns[0].end == 1.2 + + call_args = async_mock_client_wrapper.request.call_args + assert call_args[1]["request_options"] is request_options diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index cfc8446..e06b482 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -10,6 +10,7 @@ PaginatedResponse, Prosody, ReferenceAudio, + SpeakerTurn, TTSConfig, TTSRequest, Voice, @@ -51,17 +52,73 @@ class TestASRTypes: """Test ASR-related types.""" def test_asr_segment(self): - segment = ASRSegment(text="Hello", start=0.0, end=500.0) + segment = ASRSegment(text="Hello", start=0.0, end=0.5) assert segment.text == "Hello" assert segment.start == 0.0 - assert segment.end == 500.0 + assert segment.end == 0.5 + + def test_speaker_turn(self): + turn = SpeakerTurn(speaker="speaker:0", text="Hello", start=0.24, end=0.64) + assert turn.speaker == "speaker:0" + assert turn.text == "Hello" + assert turn.start == 0.24 + assert turn.end == 0.64 def test_asr_response(self, sample_asr_response): response = ASRResponse.model_validate(sample_asr_response) assert response.text == "Hello world" - assert response.duration == 1500.0 + assert response.duration == 1.5 assert len(response.segments) == 2 assert isinstance(response.segments[0], ASRSegment) + # Fields the API did not return default to None + assert response.language is None + assert response.language_code is None + assert response.request_id is None + assert response.speaker_turns is None + + def test_asr_response_standard_model(self, sample_asr_standard_response): + """transcribe-1 returns the language but no request_id or speaker_turns.""" + response = ASRResponse.model_validate(sample_asr_standard_response) + assert response.text == "Hello, world." + assert response.duration == 1.62 + assert response.language == "English" + assert response.language_code == "en" + assert response.request_id is None + assert response.speaker_turns is None + assert response.model_dump(exclude_none=True) == sample_asr_standard_response + + def test_asr_response_pro_model(self, sample_asr_pro_response): + """transcribe-1-pro also returns request_id and speaker_turns.""" + response = ASRResponse.model_validate(sample_asr_pro_response) + assert response.duration == 3.5 + assert response.language == "English" + assert response.language_code == "en" + assert response.request_id == "0b9e4f5c-7d2a-4f1e-9c3b-5a8d6e2f1c07" + assert len(response.segments) == 4 + assert response.speaker_turns is not None + assert len(response.speaker_turns) == 2 + assert all(isinstance(turn, SpeakerTurn) for turn in response.speaker_turns) + first, second = response.speaker_turns + assert (first.speaker, first.text, first.start, first.end) == ( + "speaker:0", + "Hello there.", + 0.24, + 1.2, + ) + assert (second.speaker, second.text, second.start, second.end) == ( + "speaker:1", + "Hi back.", + 1.8, + 3.1, + ) + # Every field of the API response is kept + assert response.model_dump() == sample_asr_pro_response + + def test_asr_response_empty_speaker_turns(self, sample_asr_pro_response): + """An empty speaker_turns list (no speech) stays a list, not None.""" + sample_asr_pro_response.update(text="", segments=[], speaker_turns=[]) + response = ASRResponse.model_validate(sample_asr_pro_response) + assert response.speaker_turns == [] class TestAccountTypes: From 8d2f6b5eb4781cae137d01073e449b88191a7ef8 Mon Sep 17 00:00:00 2001 From: liujiahua123123 Date: Fri, 2 Oct 2026 05:07:54 +0000 Subject: [PATCH 2/2] docs(asr): select transcribe-1-pro in the README and transcribe() examples transcribe-1-pro is the recommended speech-to-text model, and the API serves a request without the model header on transcribe-1. The README and the transcribe() docstring examples (published to the docs site) now select it with RequestOptions(additional_headers={"model": "transcribe-1-pro"}), and the README shows speaker turns in the same example. Co-Authored-By: Claude Opus 5.5 --- README.md | 33 +++++++++++++-------------------- src/fishaudio/resources/asr.py | 18 ++++++++++++++++-- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 6181828..c244ed9 100644 --- a/README.md +++ b/README.md @@ -147,34 +147,27 @@ audio = client.tts.stream(text="Hello!").collect() ### Speech-to-Text ```python -# Transcribe audio -with open("audio.wav", "rb") as f: - result = client.asr.transcribe(audio=f.read(), language="en") +from fishaudio.core import RequestOptions + +# transcribe-1-pro is the recommended model. Select it with the model header; +# without it, the request runs on transcribe-1. +pro = RequestOptions(additional_headers={"model": "transcribe-1-pro"}) + +with open("meeting.wav", "rb") as f: + result = client.asr.transcribe(audio=f.read(), request_options=pro) print(result.text) -# Access timestamped segments +# Word-level timestamps for segment in result.segments: print(f"[{segment.start:.2f}s - {segment.end:.2f}s] {segment.text}") -# Language of the transcript, when the API returns it -print(result.language_code) # e.g. "en" -``` - -**Speaker turns:** - -```python -from fishaudio.core import RequestOptions - -# transcribe-1-pro returns speaker turns along with timestamps -with open("meeting.wav", "rb") as f: - result = client.asr.transcribe( - audio=f.read(), - request_options=RequestOptions(additional_headers={"model": "transcribe-1-pro"}), - ) - +# Speaker turns (transcribe-1-pro, returned with timestamps) for turn in result.speaker_turns or []: print(f"[{turn.start:.2f}s - {turn.end:.2f}s] {turn.speaker}: {turn.text}") + +# Language of the transcript, when the API returns it +print(result.language_code) # e.g. "en" ``` [Learn more](https://docs.fish.audio/developer-guide/sdk-guide/python/speech-to-text) diff --git a/src/fishaudio/resources/asr.py b/src/fishaudio/resources/asr.py index f29bfe3..c13a8fc 100644 --- a/src/fishaudio/resources/asr.py +++ b/src/fishaudio/resources/asr.py @@ -38,12 +38,19 @@ def transcribe( Example: ```python + from fishaudio.core import RequestOptions + client = FishAudio(api_key="...") with open("audio.mp3", "rb") as f: audio_bytes = f.read() - result = client.asr.transcribe(audio=audio_bytes, language="en") + result = client.asr.transcribe( + audio=audio_bytes, + request_options=RequestOptions( + additional_headers={"model": "transcribe-1-pro"} + ), + ) print(result.text) for segment in result.segments: print(f"{segment.start}-{segment.end}: {segment.text}") @@ -99,12 +106,19 @@ async def transcribe( Example: ```python + from fishaudio.core import RequestOptions + client = AsyncFishAudio(api_key="...") async with aiofiles.open("audio.mp3", "rb") as f: audio_bytes = await f.read() - result = await client.asr.transcribe(audio=audio_bytes, language="en") + result = await client.asr.transcribe( + audio=audio_bytes, + request_options=RequestOptions( + additional_headers={"model": "transcribe-1-pro"} + ), + ) print(result.text) for segment in result.segments: print(f"{segment.start}-{segment.end}: {segment.text}")