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
107 changes: 107 additions & 0 deletions .agents/skills/fish-audio-tts/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
---
name: fish-audio-tts
description: Generate expressive, multilingual narration with fish.audio (S1 / S2-generation models) and reuse cloned voices via reference_id. Use when the user prefers fish.audio/Fish Audio TTS, wants a specific playground voice model, or needs high-emotion voice-clone narration.
---

# fish.audio TTS

Requires `FISH_AUDIO_API_KEY` in `.env` (create one at https://fish.audio/go-api/api-keys/).
Create voice models in the fish.audio playground and pass their id as `reference_id` to reuse a cloned voice.

## Current API

Single synchronous call returning raw audio bytes:

```text
POST https://api.fish.audio/v1/tts
Authorization: Bearer ${FISH_AUDIO_API_KEY}
Content-Type: application/json
model: <backend model> # HTTP header selects the backend, e.g. s1
```

The backend model is chosen with the `model` **HTTP header**, not a body field. In OpenMontage this maps to the tool's `model` input.

## Backend models

`model` is **required — there is no default**. Pass one of:

- `s2.1-pro` — latest generation. Best quality: inline emotion tags, 80+ languages, multi-speaker. Hero narration.
- `s2.1-pro-free` — free tier of s2.1-pro. Drafts, samples, and validation runs at $0.
- `s2-pro` — first S2 generation. Stable high quality with emotion-tag support.
- `s1` — previous flagship. Kept for compatibility with existing integrations.

Billing is **per UTF-8 byte of input text** (not per character). CJK text and emoji cost 3-4x an ASCII character of the same visible length. Approximate: `s1` / `s2-pro` / `s2.1-pro` ≈ $15 per 1M bytes, `s2.1-pro-free` = $0. Verify current pricing at https://fish.audio before large batches.

## Inline emotion tags (S2 models only)

`s2-pro` / `s2.1-pro` / `s2.1-pro-free` interpret inline emotion tags embedded in the text:

- Tags like `[laugh]`, `[whispers]` change the delivery mid-sentence.
- Example: `"That's hilarious [laugh] but let me explain seriously."`
- `s1` does not interpret emotion tags — they may be read out as plain text, so strip them when targeting s1.

## Voice selection (reference_id)

- Build or pick a voice in the fish.audio playground, then copy its model id.
- Pass it as `reference_id`. The selector's generic `voice_id` is accepted as an alias when `reference_id` is absent.
- Without a `reference_id`, fish.audio uses its default voice for the chosen model.

Inline on-the-fly cloning (uploading reference audio + text per request) is **not** supported by this tool — create a voice model in the playground first.

## OpenMontage Usage

Generate with the TTS selector:

```python
from tools.audio.tts_selector import TTSSelector

result = TTSSelector().execute({
"preferred_provider": "fish_audio",
"text": "Here's why compound interest quietly beats every get-rich-quick scheme.",
"model": "s1",
"reference_id": "<playground voice model id>",
"output_path": "projects/my-video/assets/audio/narration.mp3",
})
```

Or call the provider directly:

```python
from tools.audio.fish_audio_tts import FishAudioTTS

result = FishAudioTTS().execute({
"text": "Short sample line for approval.",
"model": "s1",
"reference_id": "<playground voice model id>",
"output_path": "projects/my-video/assets/audio/fish_sample.mp3",
})
```

The provider writes the audio to `output_path` and returns `data.output` plus the resolved `model` and `reference_id`.

## Quality & latency tuning

- `latency`: `normal` (default, best quality), `balanced` (a little faster), or `low` (fastest, slight quality cost).
- `normalize`: default `true`; keep it on so numbers, dates, and currency read naturally.
- `prosody`: optional `{ "speed": 1.0, "volume": 0 }` to nudge pace/loudness.
- `mp3_bitrate`: `128` is a good default; raise to `192` for music-bed-heavy mixes.
- `temperature`: default `0.7`. Raise toward `0.9` for more expressive reads (recommended when leaning on emotion tags); lower for a steadier, more predictable delivery.
- `top_p` / `repetition_penalty`: usually leave at the defaults (`0.7` / `1.2`).

## Recommended Workflow

1. Generate a 10-15 second sample with the chosen `model` + `reference_id` before a full paid narration.
2. Ask the user to approve voice naturalness, emotion, and pace.
3. Generate the full narration only after approval.
4. For batch/localization variants where cost matters, prototype on `s2.1-pro-free` ($0) and upgrade the final to `s2.1-pro`.

## Troubleshooting

- `401 Unauthorized`: wrong or missing `FISH_AUDIO_API_KEY`.
- `402` / payment errors: account credit exhausted.
- `404` / bad voice: the `reference_id` is wrong or not owned by this account.
- Empty/short audio: check that `text` is non-empty and `normalize` is not stripping the whole input.

## Safety

Never print or write the API key to logs, metadata, patches, or project artifacts. `.env.example` should contain only empty variable names.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ OPENAI_API_KEY= # OpenAI TTS fallback and GPT Image 2 image generat
XAI_API_KEY= # Grok image generation/editing and Grok video generation
DOUBAO_SPEECH_API_KEY= # Volcengine Doubao Speech TTS (new console API Key)
DOUBAO_SPEECH_VOICE_TYPE= # Default Doubao speaker/voice type, e.g. zh_female_vv_uranus_bigtts
FISH_AUDIO_API_KEY= # fish.audio TTS (S1 / speech-1.x, reference_id voice cloning)
# Piper local voices do not require env vars; install `piper-tts` via pip

# --- DashScope (Alibaba Cloud Bailian) ---
Expand Down
2 changes: 1 addition & 1 deletion AGENT_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ Three selector tools abstract multi-provider capabilities. **Selectors auto-disc

| Selector | Routes to | How it discovers |
|----------|-----------|-----------------|
| `tts_selector` | All tools with `capability="tts"` (ElevenLabs, Google TTS, OpenAI, Piper) | `registry.get_by_capability("tts")` |
| `tts_selector` | All tools with `capability="tts"` (ElevenLabs, Google TTS, OpenAI, Piper, fish.audio) | `registry.get_by_capability("tts")` |
| `image_selector` | All tools with `capability="image_generation"` (FLUX, Google Imagen, GPT Image, Recraft, etc.) | `registry.get_by_capability("image_generation")` |
| `video_selector` | All tools with `capability="video_generation"` | `registry.get_by_capability("video_generation")` |

Expand Down
204 changes: 204 additions & 0 deletions tests/tools/test_fish_audio_tts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
"""Unit tests for the fish.audio TTS provider (tools/audio/fish_audio_tts.py).

The real API is never called — requests.post is patched to return synthetic
audio bytes. Covers status gating, the required-model contract, the
voice_id -> reference_id alias, request shape, output writing, cost, and
API-key redaction on error.
"""

from __future__ import annotations

from unittest.mock import patch

import pytest

from tools.audio.fish_audio_tts import FishAudioTTS
from tools.base_tool import ToolStatus


class _FakeResponse:
def __init__(self, content: bytes = b"ID3fake-audio", status_code: int = 200):
self.content = content
self.status_code = status_code

def raise_for_status(self) -> None:
if self.status_code >= 400:
raise RuntimeError(f"HTTP {self.status_code}")


@pytest.fixture
def api_key(monkeypatch):
monkeypatch.setenv("FISH_AUDIO_API_KEY", "sk-fish-secret")
return "sk-fish-secret"


@pytest.fixture
def no_api_key(monkeypatch):
monkeypatch.delenv("FISH_AUDIO_API_KEY", raising=False)


# ----------------------------------------------------------------------
# Status gating
# ----------------------------------------------------------------------


class TestStatus:
def test_unavailable_without_key(self, no_api_key):
assert FishAudioTTS().get_status() == ToolStatus.UNAVAILABLE

def test_available_with_key(self, api_key):
assert FishAudioTTS().get_status() == ToolStatus.AVAILABLE

def test_execute_fails_without_key(self, no_api_key):
result = FishAudioTTS().execute({"text": "hello", "model": "s1"})
assert result.success is False
assert "fish.audio API key" in result.error


# ----------------------------------------------------------------------
# Required-model contract
# ----------------------------------------------------------------------


class TestModelContract:
def test_missing_model_errors_with_valid_values(self, api_key):
result = FishAudioTTS().execute({"text": "hello"})
assert result.success is False
assert "requires an explicit 'model'" in result.error
assert "s1" in result.error

def test_unknown_model_errors(self, api_key):
result = FishAudioTTS().execute({"text": "hello", "model": "gpt-voice"})
assert result.success is False
assert "Unknown fish.audio model" in result.error

def test_retired_legacy_model_rejected(self, api_key):
# speech-1.x / s1-mini are gone from the current fish.audio lineup.
result = FishAudioTTS().execute({"text": "hello", "model": "speech-1.5"})
assert result.success is False
assert "Unknown fish.audio model" in result.error


# ----------------------------------------------------------------------
# Happy path
# ----------------------------------------------------------------------


class TestGenerate:
def test_writes_audio_and_reports_metadata(self, api_key, tmp_path):
out = tmp_path / "narration.mp3"
with patch("requests.post", return_value=_FakeResponse(b"AUDIOBYTES")) as mock_post:
result = FishAudioTTS().execute(
{"text": "hello world", "model": "s1", "output_path": str(out)}
)

assert result.success is True
assert out.read_bytes() == b"AUDIOBYTES"
assert result.data["provider"] == "fish_audio"
assert result.data["model"] == "s1"
assert result.data["format"] == "mp3"
assert str(out) in result.artifacts
assert result.cost_usd > 0
assert result.model == "fish-audio/s1"

# model goes in the HTTP header, not the body
_, kwargs = mock_post.call_args
assert kwargs["headers"]["model"] == "s1"
assert kwargs["headers"]["Authorization"] == "Bearer sk-fish-secret"
assert kwargs["json"]["text"] == "hello world"

def test_s2_pro_model_sent_in_header(self, api_key, tmp_path):
out = tmp_path / "s2.mp3"
with patch("requests.post", return_value=_FakeResponse()) as mock_post:
result = FishAudioTTS().execute(
{"text": "hello", "model": "s2-pro", "output_path": str(out)}
)

assert result.success is True
assert result.model == "fish-audio/s2-pro"
_, kwargs = mock_post.call_args
assert kwargs["headers"]["model"] == "s2-pro"

def test_temperature_and_top_p_sent_in_body(self, api_key, tmp_path):
out = tmp_path / "expressive.mp3"
with patch("requests.post", return_value=_FakeResponse()) as mock_post:
FishAudioTTS().execute(
{
"text": "hello",
"model": "s2.1-pro",
"temperature": 0.9,
"top_p": 0.5,
"output_path": str(out),
}
)
_, kwargs = mock_post.call_args
assert kwargs["json"]["temperature"] == 0.9
assert kwargs["json"]["top_p"] == 0.5

def test_voice_id_maps_to_reference_id(self, api_key, tmp_path):
out = tmp_path / "clone.mp3"
with patch("requests.post", return_value=_FakeResponse()) as mock_post:
result = FishAudioTTS().execute(
{
"text": "cloned voice",
"model": "s1",
"voice_id": "voice-abc123",
"output_path": str(out),
}
)

assert result.success is True
assert result.data["reference_id"] == "voice-abc123"
_, kwargs = mock_post.call_args
assert kwargs["json"]["reference_id"] == "voice-abc123"

def test_reference_id_takes_precedence_over_voice_id(self, api_key, tmp_path):
out = tmp_path / "clone.mp3"
with patch("requests.post", return_value=_FakeResponse()) as mock_post:
FishAudioTTS().execute(
{
"text": "x",
"model": "s1",
"reference_id": "primary",
"voice_id": "fallback",
"output_path": str(out),
}
)
_, kwargs = mock_post.call_args
assert kwargs["json"]["reference_id"] == "primary"


# ----------------------------------------------------------------------
# Cost — byte-based, not char-based
# ----------------------------------------------------------------------


class TestCost:
def test_cost_uses_utf8_bytes(self):
tool = FishAudioTTS()
# 3 CJK chars = 9 UTF-8 bytes, larger than a 3-char ASCII cost.
cjk = tool.estimate_cost({"text": "你好吗", "model": "s1"})
ascii_cost = tool.estimate_cost({"text": "abc", "model": "s1"})
assert cjk > ascii_cost

def test_s2_1_pro_free_costs_zero(self):
tool = FishAudioTTS()
assert tool.estimate_cost({"text": "same text here", "model": "s2.1-pro-free"}) == 0.0


# ----------------------------------------------------------------------
# Safety — never leak the API key
# ----------------------------------------------------------------------


class TestKeyRedaction:
def test_error_does_not_leak_key(self, api_key):
def _boom(*args, **kwargs):
raise RuntimeError("upstream error for key sk-fish-secret at host")

with patch("requests.post", side_effect=_boom):
result = FishAudioTTS().execute({"text": "hello", "model": "s1"})

assert result.success is False
assert "sk-fish-secret" not in result.error
assert "***" in result.error
Loading