Skip to content

feat: add Volcengine TTS V3 Create API provider - #9661

Open
zxzxovo wants to merge 1 commit into
AstrBotDevs:masterfrom
zxzxovo:feat/volcengine-tts-v3
Open

feat: add Volcengine TTS V3 Create API provider#9661
zxzxovo wants to merge 1 commit into
AstrBotDevs:masterfrom
zxzxovo:feat/volcengine-tts-v3

Conversation

@zxzxovo

@zxzxovo zxzxovo commented Aug 13, 2026

Copy link
Copy Markdown

Closes #9657

This PR adds support for Volcengine's TTS V3 Create API while preserving full compatibility with the existing V1 provider.

Modifications / 改动点

  • Added a new, independent volcengine_tts_v3 provider based on:

    • POST /api/v3/tts/create
    • seed-audio-1.0
  • Added authentication using the X-Api-Key and X-Api-Request-Id headers.

  • Added support for optional reference audio and references[].speaker.

  • Added configurable:

    • Audio format
    • Sample rate
    • Speech rate
    • Loudness
    • Pitch
    • Request timeout
    • HTTP proxy
  • Added support for audio returned through:

    • The audio Base64 field
    • The example-compatible data field
    • A downloadable url
  • Added validation and detailed error handling for:

    • Invalid configuration values
    • HTTP and API errors
    • Invalid JSON or Base64 responses
    • Audio download failures
    • Network errors
    • File write failures
  • Included X-Tt-Logid in diagnostic messages when available.

  • Ensured API keys are redacted from error messages.

  • Registered the provider and added configuration metadata for:

    • Simplified Chinese
    • English
    • Russian
  • Added focused unit tests covering successful responses, validation, compatibility fields, downloads, and failure scenarios.

  • Kept the existing volcengine_tts V1 provider and all of its configuration fields unchanged.

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Difference from #8566

PR #8566 targets /api/v3/tts/unidirectional, which is a chunked streaming endpoint.

This PR targets /api/v3/tts/create, the complete-audio Create API documented for seed-audio-1.0.

It also adds V3 as a separate provider instead of replacing the existing V1 implementation, preserving backward compatibility. The implementation is focused on the Create API and includes dedicated unit tests.

Screenshots or Test Results / 运行截图或测试结果

image image image image

Automated verification:

  • Focused Volcengine TTS tests: 57 passed
  • Full TTS source regression suite: 68 passed, 2129 deselected
  • Ruff formatting check: passed
  • Ruff lint check: passed
  • Dashboard production build: passed
  • Simplified Chinese, English, and Russian locale JSON validation: passed
  • git diff --check: passed

Manual WebChat verification:

  1. Added the volcengine_tts_v3 provider and passed the provider connectivity test.
  2. Enabled TTS and selected volcengine_tts_v3 as the default TTS provider.
  3. Set the TTS trigger probability to 1.
  4. Disabled WebChat streaming output because the current TTS result decoration runs on non-streaming responses.
  5. Sent an LLM message through WebChat.
  6. Successfully generated an MP3 file and received it as a voice message.

Relevant runtime logs:

TTS request: 你好,这是火山引擎 TTS V3 接口测试。
TTS result: ...volcengine_tts_v3_....mp3
Prepare to send: [ComponentType.Record]

Summary by Sourcery

Add a new Volcengine TTS v3 audio generation provider and integrate it into AstrBot’s provider system and configuration metadata without altering the existing v1 provider.

New Features:

  • Introduce a Volcengine TTS v3 provider for the /api/v3/tts/create audio generation endpoint, supporting configurable format, sample rate, speech rate, loudness, pitch, timeout, and proxy.
  • Expose Volcengine TTS v3 configuration options in the default config and dashboard metadata, including speaker selection and audio output parameters.

Enhancements:

  • Register the Volcengine TTS v3 provider in the provider manager and provider registry so it can be dynamically imported and used like other TTS providers.

Tests:

  • Add a dedicated test suite for Volcengine Volcengine TTS v1 and v3 providers covering payload construction, configuration validation, error handling, audio download paths, and file generation behavior.

@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. labels Aug 13, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="astrbot/core/provider/sources/volcengine_tts_v3.py" line_range="161-170" />
<code_context>
+            async with self._session_factory() as session:
</code_context>
<issue_to_address>
**issue (bug_risk):** The `session.get` call happens outside the `ClientSession` context manager, which will raise at runtime.

Because the `async with self._session_factory() as session:` block is exited before the `elif isinstance(response_data.get("url"), str)` branch runs, `session.get(...)` is invoked on a closed `ClientSession`, causing a `RuntimeError`/aiohttp error. Keep the URL-based download logic inside the `async with session` block, or create a new session specifically for that path.
</issue_to_address>

### Comment 2
<location path="astrbot/core/provider/sources/volcengine_tts_v3.py" line_range="123" />
<code_context>
+            payload["references"] = [{"speaker": self.speaker}]
+        return payload
+
+    async def get_audio(self, text: str) -> str:
+        """Generate an audio file from text.
+
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring `get_audio` into smaller helpers for validation, error handling, audio retrieval, and file writing so it reads as a clear orchestration function rather than a long procedural block.

You can reduce complexity in `get_audio` without changing behavior by extracting a few focused helpers and centralizing repeated logic.

### 1. Centralize API key masking

Right now `response_text.replace(self.api_key, "***")` (and similar) is repeated in several branches. A small helper keeps error paths simpler:

```python
def _mask_api_key(self, text: str) -> str:
    if not self.api_key or not isinstance(text, str):
        return text
    return text.replace(self.api_key, "***")
```

Usage in `get_audio`:

```python
safe_body = self._mask_api_key(response_text)
...
error_text = self._mask_api_key(await audio_response.text())
...
error_message = self._mask_api_key(str(exc))
```

### 2. Extract text validation

The text validation block at the top of `get_audio` can be moved out so the method focuses on orchestration:

```python
def _validate_text(self, text: str) -> None:
    if not isinstance(text, str):
        raise ValueError("Volcengine TTS v3 text prompt must be a string.")
    if not text.strip():
        raise ValueError("Volcengine TTS v3 text prompt cannot be empty.")
    if len(text) > 3000:
        raise ValueError(
            "Volcengine TTS v3 text prompt cannot exceed 3000 characters."
        )
```

Then at the start of `get_audio`:

```python
if not self.api_key:
    raise ValueError("Volcengine TTS v3 API key is required.")
self._validate_text(text)
```

### 3. Separate response error construction

The verbose `RuntimeError` constructions can be centralized to cut down branching noise:

```python
def _api_error(self, base: str, log_id: str | None, detail: str) -> RuntimeError:
    safe_detail = self._mask_api_key(detail)
    log_suffix = f", log ID: {log_id}" if log_id else ""
    return RuntimeError(f"{base}{log_suffix}: {safe_detail[:200]}")
```

Example usage:

```python
if response.status != 200:
    raise self._api_error(
        f"Volcengine TTS v3 request failed with status {response.status}",
        log_id,
        response_text,
    )

...
if error_code not in (None, 0, "0"):
    error_message = str(response_data.get("message") or "Unknown API error")
    raise self._api_error(
        f"Volcengine TTS v3 API error {error_code}",
        log_id,
        error_message,
    )
```

### 4. Extract audio decoding/download

The audio extraction branch can be factored into helpers to shorten `get_audio`:

```python
def _decode_audio_base64(self, audio_base64: str) -> bytes:
    if not isinstance(audio_base64, str):
        raise RuntimeError(
            "Volcengine TTS v3 returned a non-string audio payload."
        )
    try:
        compact_audio = "".join(audio_base64.split())
        return base64.b64decode(compact_audio, validate=True)
    except (binascii.Error, ValueError) as exc:
        raise RuntimeError(
            "Volcengine TTS v3 returned invalid Base64 audio data."
        ) from exc
```

```python
async def _download_audio_url(
    self,
    session: aiohttp.ClientSession,
    url: str,
    timeout: aiohttp.ClientTimeout,
    proxy: str | None,
) -> bytes:
    async with session.get(url, timeout=timeout, proxy=proxy) as audio_response:
        if audio_response.status != 200:
            error_text = self._mask_api_key(await audio_response.text())
            raise RuntimeError(
                "Volcengine TTS v3 audio download failed with status "
                f"{audio_response.status}: {error_text[:200]}"
            )
        return await audio_response.read()
```

Then in `get_audio`:

```python
audio_base64 = response_data.get("audio") or response_data.get("data")
if audio_base64:
    audio_data = self._decode_audio_base64(audio_base64)
elif isinstance(response_data.get("url"), str) and response_data["url"]:
    audio_data = await self._download_audio_url(
        session, response_data["url"], timeout, proxy
    )
elif "audio" in response_data or "data" in response_data:
    raise RuntimeError("Volcengine TTS v3 returned empty audio data.")
else:
    response_keys = ", ".join(sorted(response_data)) or "none"
    raise RuntimeError(
        "Volcengine TTS v3 returned no audio payload or URL. "
        f"Response keys: {response_keys}."
    )
```

### 5. Extract file writing

The final file-writing `try` block can be moved out so `get_audio` just calls it:

```python
async def _write_audio_file(self, audio_data: bytes) -> str:
    try:
        temp_dir = Path(get_astrbot_temp_path())
        await asyncio.to_thread(temp_dir.mkdir, parents=True, exist_ok=True)
        extension = AUDIO_FILE_EXTENSIONS[self.audio_format]
        file_path = temp_dir / (
            f"volcengine_tts_v3_{generate_timestamp_id()}.{extension}"
        )
        await asyncio.to_thread(file_path.write_bytes, audio_data)
        return str(file_path)
    except OSError as exc:
        raise RuntimeError(
            f"Volcengine TTS v3 failed to write the audio file: {exc}"
        ) from exc
```

Then at the end of `get_audio`:

```python
if not audio_data:
    raise RuntimeError("Volcengine TTS v3 returned empty audio data.")
return await self._write_audio_file(audio_data)
```

---

Applying these small extractions keeps functionality intact but turns `get_audio` into a clearer orchestration of a few well-named steps, reducing nesting and repeated logic while preserving your current behavior and error detail.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread astrbot/core/provider/sources/volcengine_tts_v3.py
Comment thread astrbot/core/provider/sources/volcengine_tts_v3.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] 火山引擎 TTS 支持新版音频生成 HTTP API(/api/v3/tts/create)

1 participant