Skip to content
Open
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
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,15 +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}")

# 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)
Expand Down
2 changes: 1 addition & 1 deletion src/fish_audio_sdk/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ class ASRSegment(BaseModel):

class ASRResponse(BaseModel):
text: str
# Duration in milliseconds
# Duration in seconds
duration: float
segments: list[ASRSegment]

Expand Down
26 changes: 22 additions & 4 deletions src/fishaudio/resources/asr.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,28 @@ 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
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}")
Expand Down Expand Up @@ -88,19 +97,28 @@ 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
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}")
Expand Down
3 changes: 2 additions & 1 deletion src/fishaudio/types/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -30,6 +30,7 @@
# ASR types
"ASRResponse",
"ASRSegment",
"SpeakerTurn",
# Shared types
"AudioFormat",
"LatencyMode",
Expand Down
30 changes: 28 additions & 2 deletions src/fishaudio/types/asr.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""ASR (Automatic Speech Recognition) related types."""

from typing import Optional

from pydantic import BaseModel


Expand All @@ -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
45 changes: 41 additions & 4 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
106 changes: 104 additions & 2 deletions tests/unit/test_asr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand All @@ -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
Expand Down Expand Up @@ -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
Loading
Loading