feat(transcription): add AssemblyAI as transcription provider

Add AssemblyAI as a third transcription provider option alongside
OpenAI and Groq. AssemblyAI offers better accuracy for certain
audio types (distant voices, noisy environments) and serves as a
reliable fallback when other providers struggle.

Changes:
- Add AssemblyAITranscriptionProvider class in providers/transcription.py
- Add 'assemblyai' option in base channel's transcribe_audio()
- Per-channel configuration via transcriptionProvider in config

Usage:
  Set transcriptionProvider: 'assemblyai' and provide an AssemblyAI
  API key via transcriptionApiKey in the channel config.
This commit is contained in:
comadreja
2026-06-09 05:33:18 +08:00
committed by Xubin Ren
parent f183b37542
commit f3eb2aa08b
17 changed files with 780 additions and 113 deletions
+25 -56
View File
@@ -11,26 +11,20 @@ from __future__ import annotations
from contextlib import suppress
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
from typing import Any
from loguru import logger
from nanobot.audio.transcription_registry import (
get_transcription_provider,
resolve_transcription_provider,
)
from nanobot.config.paths import get_media_dir
from nanobot.utils.media_decode import FileSizeExceeded, save_base64_data_url
TranscriptionProviderName = Literal["groq", "openai", "openrouter", "xiaomi_mimo"]
TranscriptionProviderName = str
_DEFAULT_PROVIDER: TranscriptionProviderName = "groq"
_DEFAULT_MODELS: dict[TranscriptionProviderName, str] = {
"groq": "whisper-large-v3",
"openai": "whisper-1",
"openrouter": "openai/whisper-1",
"xiaomi_mimo": "mimo-v2.5-asr",
}
_PROVIDER_ALIASES: dict[str, TranscriptionProviderName] = {
"mimo": "xiaomi_mimo",
"xiaomi": "xiaomi_mimo",
}
_MAX_AUDIO_BYTES_FALLBACK = 25 * 1024 * 1024
_AUDIO_MIME_ALLOWED: frozenset[str] = frozenset({
"audio/aac",
@@ -72,13 +66,8 @@ class TranscriptionIngressError(Exception):
def _as_provider(value: Any) -> TranscriptionProviderName | None:
if isinstance(value, str):
name = value.strip().lower()
if name in _PROVIDER_ALIASES:
return _PROVIDER_ALIASES[name]
if name in _DEFAULT_MODELS:
return name # type: ignore[return-value]
return None
spec = resolve_transcription_provider(value)
return spec.name if spec else None
def _provider_config(config: Any, provider: str) -> Any:
@@ -101,11 +90,17 @@ def resolve_transcription_config(config: Any) -> EffectiveTranscriptionConfig:
or _as_provider(getattr(channels, "transcription_provider", None))
or _DEFAULT_PROVIDER
)
spec = get_transcription_provider(provider)
if spec is None:
logger.warning("Unknown transcription provider {}; falling back to {}", provider, _DEFAULT_PROVIDER)
provider = _DEFAULT_PROVIDER
spec = get_transcription_provider(provider)
default_model = spec.default_model if spec else ""
provider_cfg = _provider_config(config, provider)
return EffectiveTranscriptionConfig(
enabled=bool(getattr(top, "enabled", True)),
provider=provider,
model=(getattr(top, "model", None) or _DEFAULT_MODELS[provider]).strip(),
model=(getattr(top, "model", None) or default_model).strip(),
language=getattr(top, "language", None) or getattr(channels, "transcription_language", None),
api_key=getattr(provider_cfg, "api_key", None) or "",
api_base=getattr(provider_cfg, "api_base", None) or "",
@@ -170,40 +165,14 @@ async def transcribe_audio_file(
"""Transcribe *file_path* using the already-resolved transcription config."""
if not config.enabled or not config.configured:
return ""
if config.provider == "openai":
from nanobot.providers.transcription import OpenAITranscriptionProvider
provider = OpenAITranscriptionProvider(
api_key=config.api_key,
api_base=config.api_base or None,
language=config.language,
model=config.model,
)
elif config.provider == "openrouter":
from nanobot.providers.transcription import OpenRouterTranscriptionProvider
provider = OpenRouterTranscriptionProvider(
api_key=config.api_key,
api_base=config.api_base or None,
language=config.language,
model=config.model,
)
elif config.provider == "xiaomi_mimo":
from nanobot.providers.transcription import XiaomiMiMoTranscriptionProvider
provider = XiaomiMiMoTranscriptionProvider(
api_key=config.api_key,
api_base=config.api_base or None,
language=config.language,
model=config.model,
)
else:
from nanobot.providers.transcription import GroqTranscriptionProvider
provider = GroqTranscriptionProvider(
api_key=config.api_key,
api_base=config.api_base or None,
language=config.language,
model=config.model,
)
spec = get_transcription_provider(config.provider)
if spec is None:
logger.warning("Unknown transcription provider: {}", config.provider)
return ""
provider = spec.load_adapter()(
api_key=config.api_key,
api_base=config.api_base or None,
language=config.language,
model=config.model,
)
return await provider.transcribe(file_path)
+90
View File
@@ -0,0 +1,90 @@
"""Registry for speech-to-text providers.
Provider-specific HTTP adapters live in ``nanobot.providers.transcription``.
This module is the app-level source of truth for provider names, aliases,
default models, and adapter class paths.
"""
from __future__ import annotations
from dataclasses import dataclass
from importlib import import_module
from pathlib import Path
from typing import Any, Protocol
class TranscriptionProviderAdapter(Protocol):
"""Runtime protocol implemented by provider-specific transcription adapters."""
def __init__(
self,
api_key: str | None = None,
api_base: str | None = None,
language: str | None = None,
model: str | None = None,
) -> None: ...
async def transcribe(self, file_path: str | Path) -> str: ...
@dataclass(frozen=True)
class TranscriptionProviderSpec:
name: str
default_model: str
adapter: str
aliases: tuple[str, ...] = ()
def load_adapter(self) -> type[TranscriptionProviderAdapter]:
module_name, _, class_name = self.adapter.partition(":")
if not module_name or not class_name:
raise RuntimeError(f"Invalid transcription adapter path: {self.adapter}")
adapter = getattr(import_module(module_name), class_name)
return adapter
TRANSCRIPTION_PROVIDERS: tuple[TranscriptionProviderSpec, ...] = (
TranscriptionProviderSpec(
name="groq",
default_model="whisper-large-v3",
adapter="nanobot.providers.transcription:GroqTranscriptionProvider",
),
TranscriptionProviderSpec(
name="openai",
default_model="whisper-1",
adapter="nanobot.providers.transcription:OpenAITranscriptionProvider",
),
TranscriptionProviderSpec(
name="openrouter",
default_model="openai/whisper-1",
adapter="nanobot.providers.transcription:OpenRouterTranscriptionProvider",
),
TranscriptionProviderSpec(
name="xiaomi_mimo",
default_model="mimo-v2.5-asr",
adapter="nanobot.providers.transcription:XiaomiMiMoTranscriptionProvider",
aliases=("mimo", "xiaomi"),
),
TranscriptionProviderSpec(
name="assemblyai",
default_model="universal-3-pro,universal-2",
adapter="nanobot.providers.transcription:AssemblyAITranscriptionProvider",
),
)
_BY_NAME = {spec.name: spec for spec in TRANSCRIPTION_PROVIDERS}
_BY_ALIAS = {alias: spec for spec in TRANSCRIPTION_PROVIDERS for alias in spec.aliases}
def transcription_provider_names() -> tuple[str, ...]:
return tuple(spec.name for spec in TRANSCRIPTION_PROVIDERS)
def get_transcription_provider(name: str) -> TranscriptionProviderSpec | None:
return _BY_NAME.get(name)
def resolve_transcription_provider(value: Any) -> TranscriptionProviderSpec | None:
if not isinstance(value, str):
return None
name = value.strip().lower()
return _BY_NAME.get(name) or _BY_ALIAS.get(name)