Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ EXTENSION_NAME=${EXT_NAME:-elevenlabs_tts_python}

export PYTHONPATH=.:ten_packages/system/ten_runtime_python/lib:ten_packages/system/ten_runtime_python/interface:ten_packages/system/ten_ai_base/interface:ten_packages/extension/${EXTENSION_NAME}:$PYTHONPATH
# some tts extension does not support sample rate comparison, so we need to set the enable_sample_rate parameter
if [[ "${EXTENSION_NAME}" == "humeai_tts_python" || "${EXTENSION_NAME}" == "openai_tts_python" || "${EXTENSION_NAME}" == "openai_tts2_python" || "${EXTENSION_NAME}" == "mistral_tts_python" || "${EXTENSION_NAME}" == "ezai_tw_tts_python" ]]; then
if [[ "${EXTENSION_NAME}" == "humeai_tts_python" || "${EXTENSION_NAME}" == "openai_tts_python" || "${EXTENSION_NAME}" == "openai_tts2_python" || "${EXTENSION_NAME}" == "mistral_tts_python" || "${EXTENSION_NAME}" == "ezai_tw_tts_python" || "${EXTENSION_NAME}" == "typecast_tts_python" ]]; then
export ENABLE_SAMPLE_RATE="False"
else
export ENABLE_SAMPLE_RATE="True"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Typecast TTS Extension

Typecast text-to-speech extension for TEN Framework.

The extension uses the official `typecast-python` SDK and Typecast's
`/v1/text-to-speech/stream` endpoint. Typecast streams WAV audio at 32 kHz,
16-bit, mono. The extension strips the initial WAV header and forwards PCM16
mono audio to TEN.

## Configuration

```json
{
"params": {
"api_key": "<typecast-api-key>",
"url": "https://api.typecast.ai",
"voice_id": "tc_60e5426de8b95f1d3000d7b5",
"model": "ssfm-v30",
"output": {
"audio_format": "wav"
}
},
"chunk_size": 8192
}
```

`output.audio_format` is forced to `wav` because TEN consumes PCM audio frames.
MP3 streaming would require an additional decoder and is intentionally not used.

## Validation

```bash
tman -y install --standalone
./tests/bin/start
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#
# This file is part of TEN Framework, an open source project.
# Licensed under the Apache License, Version 2.0.
# See the LICENSE file for more information.
#
from . import addon
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#
# This file is part of TEN Framework, an open source project.
# Licensed under the Apache License, Version 2.0.
# See the LICENSE file for more information.
#
from ten_runtime import Addon, TenEnv, register_addon_as_extension

from .extension import TypecastTTSExtension


@register_addon_as_extension("typecast_tts_python")
class TypecastTTSExtensionAddon(Addon):
def on_create_instance(self, ten_env: TenEnv, name: str, context) -> None:
ten_env.log_info("TypecastTTSExtensionAddon on_create_instance")
ten_env.on_create_instance_done(TypecastTTSExtension(name), context)
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#
# This file is part of TEN Framework, an open source project.
# Licensed under the Apache License, Version 2.0.
# See the LICENSE file for more information.
#
from pathlib import Path
from typing import Any
import copy

from pydantic import Field
from ten_ai_base import utils
from ten_ai_base.tts2_http import AsyncTTS2HttpConfig


class TypecastTTSConfig(AsyncTTS2HttpConfig):
"""Typecast TTS config."""

dump: bool = Field(default=False, description="Typecast TTS dump")
dump_path: str = Field(
default_factory=lambda: str(
Path(__file__).parent / "typecast_tts_in.pcm"
),
description="Typecast TTS dump path",
)
url: str = Field(
default="https://api.typecast.ai",
description="Typecast API URL",
)
params: dict[str, Any] = Field(
default_factory=dict,
description="Typecast TTS params",
)
chunk_size: int = Field(
default=8192,
ge=1,
description="Maximum bytes read from each Typecast streaming chunk",
)

def update_params(self) -> None:
if "url" in self.params:
self.url = str(
self.params.pop("url") # pylint: disable=no-member
).rstrip("/")
else:
self.url = self.url.rstrip("/")

if "model" not in self.params:
self.params["model"] = "ssfm-v30"

output = dict(
self.params.get("output") or {} # pylint: disable=no-member
)
output["audio_format"] = "wav"
self.params["output"] = output

def validate(self) -> None:
if not self.params.get("api_key"): # pylint: disable=no-member
raise ValueError("api_key is required for Typecast TTS")
if not self.params.get("voice_id"): # pylint: disable=no-member
raise ValueError("voice_id is required for Typecast TTS")
if not self.params.get("model"): # pylint: disable=no-member
raise ValueError("model is required for Typecast TTS")

def to_str(self, sensitive_handling: bool = True) -> str:
if not sensitive_handling:
return f"{self}"

config = copy.deepcopy(self)
if config.params and "api_key" in config.params:
config.params["api_key"] = utils.encrypt(config.params["api_key"])
return f"{config}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#
# This file is part of TEN Framework, an open source project.
# Licensed under the Apache License, Version 2.0.
# See the LICENSE file for more information.
#
from ten_ai_base.tts2_http import (
AsyncTTS2HttpClient,
AsyncTTS2HttpConfig,
AsyncTTS2HttpExtension,
)
from ten_ai_base.message import TTSAudioEndReason
from ten_runtime import AsyncTenEnv

from .config import TypecastTTSConfig
from .typecast_tts import TYPECAST_STREAM_SAMPLE_RATE, TypecastTTSClient


class TypecastTTSExtension(AsyncTTS2HttpExtension):
"""Typecast TTS extension using Typecast streaming TTS."""

def __init__(self, name: str) -> None:
super().__init__(name)
self.config: TypecastTTSConfig = None
self.client: TypecastTTSClient = None

async def create_config(self, config_json_str: str) -> AsyncTTS2HttpConfig:
return TypecastTTSConfig.model_validate_json(config_json_str)

async def create_client(
self, config: AsyncTTS2HttpConfig, ten_env: AsyncTenEnv
) -> AsyncTTS2HttpClient:
return TypecastTTSClient(config=config, ten_env=ten_env)

def vendor(self) -> str:
return "typecast"

def synthesize_audio_sample_rate(self) -> int:
return TYPECAST_STREAM_SAMPLE_RATE

async def _send_audio_end_and_finish(
self,
request_id: str,
reason: TTSAudioEndReason,
log_message: str | None = None,
) -> None:
# The base PCMWriter may retain tail bytes while a write is in flight.
# Its cleanup flush then writes those bytes on this second pass.
recorder = self.recorder_map.get(request_id)
if recorder:
await recorder.flush()

await super()._send_audio_end_and_finish(
request_id=request_id,
reason=reason,
log_message=log_message,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
{
"type": "extension",
"name": "typecast_tts_python",
"version": "0.1.0",
"dependencies": [
{
"type": "system",
"name": "ten_runtime_python",
"version": "0.11"
},
{
"type": "system",
"name": "ten_ai_base",
"version": "0.7"
}
],
"package": {
"include": [
"manifest.json",
"property.json",
"BUILD.gn",
"**.tent",
"**.py",
"README.md",
"pyproject.toml",
"requirements.txt"
]
},
"api": {
"interface": [
{
"import_uri": "../../system/ten_ai_base/api/tts-interface.json"
}
],
"property": {
"properties": {
"params": {
"type": "object",
"properties": {
"api_key": {
"type": "string"
},
"url": {
"type": "string"
},
"voice_id": {
"type": "string"
},
"model": {
"type": "string"
},
"language": {
"type": "string"
},
"output": {
"type": "object",
"properties": {
"audio_pitch": {
"type": "int64"
},
"audio_tempo": {
"type": "float64"
},
"audio_format": {
"type": "string"
},
"target_lufs": {
"type": "float64"
}
}
},
"seed": {
"type": "int64"
}
}
},
"chunk_size": {
"type": "int64"
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#
# This file is part of TEN Framework, an open source project.
# Licensed under the Apache License, Version 2.0.
# See the LICENSE file for more information.
#
BYTES_PER_SAMPLE = 2
NUMBER_OF_CHANNELS = 1
WAV_HEADER_BYTES = 44


class StreamingWavToPcm16:
"""Strip the first WAV header and return PCM16-aligned chunks."""

def __init__(self) -> None:
self._header = bytearray()
self._remainder = bytearray()
self._header_stripped = False

def feed(self, chunk: bytes) -> bytes:
if not chunk:
return b""

if not self._header_stripped:
needed = WAV_HEADER_BYTES - len(self._header)
self._header.extend(chunk[:needed])
chunk = chunk[needed:]
if len(self._header) < WAV_HEADER_BYTES:
return b""
self._header_stripped = True

if self._remainder:
chunk = bytes(self._remainder) + chunk
self._remainder.clear()

frame_size = BYTES_PER_SAMPLE * NUMBER_OF_CHANNELS
left_size = len(chunk) % frame_size
if left_size:
self._remainder.extend(chunk[-left_size:])
chunk = chunk[:-left_size]

return bytes(chunk)
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"params": {
"api_key": "",
"voice_id": "tc_60e5426de8b95f1d3000d7b5",
"model": "ssfm-v30",
"output": {
"audio_format": "wav"
}
},
"chunk_size": 8192,
"dump": false,
"dump_path": "/tmp/typecast_tts_audio_dump"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[project]
name = "typecast-tts-python"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"typecast-python==0.3.9",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
typecast-python==0.3.9
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/bin/bash

set -e

cd "$(dirname "${BASH_SOURCE[0]}")/../.."

export PYTHONPATH=.ten/app:.ten/app/ten_packages/system/ten_runtime_python/lib:.ten/app/ten_packages/system/ten_runtime_python/interface:.ten/app/ten_packages/system/ten_ai_base/interface:$PYTHONPATH

pytest -s tests/ "$@"
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"dump": false,
"dump_path": "./tests/keep_dump_output/",
"params": {
"api_key": "${env:TYPECAST_API_KEY}",
"voice_id": "tc_60e5426de8b95f1d3000d7b5",
"model": "ssfm-v30"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"dump": false,
"dump_path": "./tests/keep_dump_output/",
"params": {
"api_key": "${env:TYPECAST_API_KEY}",
"voice_id": "tc_60e5426de8b95f1d3000d7b5",
"model": "ssfm-v30"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"dump": true,
"dump_path": "./dump/",
"params": {
"api_key": "${env:TYPECAST_API_KEY}",
"voice_id": "tc_60e5426de8b95f1d3000d7b5",
"model": "ssfm-v30"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"params": {
"api_key": "invalid",
"voice_id": "tc_60e5426de8b95f1d3000d7b5",
"model": "ssfm-v30"
}
}
Loading
Loading