diff --git a/ai_agents/agents/ten_packages/extension/funasr_asr_python/README.md b/ai_agents/agents/ten_packages/extension/funasr_asr_python/README.md new file mode 100644 index 0000000000..f3c40b35c4 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/funasr_asr_python/README.md @@ -0,0 +1,37 @@ +# FunASR ASR Python Extension + +A local (self-hosted) speech-to-text extension for TEN Framework, powered by +[FunASR](https://github.com/modelscope/FunASR) — an open-source speech toolkit +from Tongyi Lab with strong multilingual ASR (Chinese, Cantonese, English, +Japanese, Korean and more). The model runs locally on CPU or CUDA; **no API key +is required** and audio never leaves your machine. + +It mirrors the existing `whisper_stt_python` extension, swapping faster-whisper +for a local FunASR model — a natural fit for Chinese / Asian-language agents. + +## Features + +- Local, self-hosted ASR — no cloud, no API key, no per-minute billing. +- Strong Chinese / multilingual recognition; default model **SenseVoice-Small** + auto-detects the spoken language and emits inverse-text-normalized text. +- Swappable models via config (e.g. the flagship `FunAudioLLM/Fun-ASR-Nano-2512` + on GPU, or `paraformer-zh` for Chinese with timestamps). +- CPU or CUDA via the `device` parameter. + +## Configuration (`property.json` → `params`) + +| Param | Default | Description | +|---|---|---| +| `model` | `iic/SenseVoiceSmall` | FunASR model id. Use `FunAudioLLM/Fun-ASR-Nano-2512` (flagship LLM-ASR) on GPU, or `paraformer-zh` for Chinese. | +| `device` | `cpu` | `cpu` or `cuda`. | +| `language` | `auto` | Language hint, or `auto` to detect. | +| `use_itn` | `true` | Apply inverse text normalization. | +| `sample_rate` | `16000` | Input PCM sample rate (16-bit mono). | + +## Requirements + +``` +pip install funasr +``` + +The model is downloaded automatically on first run. diff --git a/ai_agents/agents/ten_packages/extension/funasr_asr_python/__init__.py b/ai_agents/agents/ten_packages/extension/funasr_asr_python/__init__.py new file mode 100644 index 0000000000..68ee8ef51b --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/funasr_asr_python/__init__.py @@ -0,0 +1,8 @@ +# +# This file is part of TEN Framework, an open source project. +# Licensed under the Apache License, Version 2.0. +# + +from . import addon + +__all__ = ["addon"] diff --git a/ai_agents/agents/ten_packages/extension/funasr_asr_python/addon.py b/ai_agents/agents/ten_packages/extension/funasr_asr_python/addon.py new file mode 100644 index 0000000000..2ab3f62cfd --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/funasr_asr_python/addon.py @@ -0,0 +1,15 @@ +# +# This file is part of TEN Framework, an open source project. +# Licensed under the Apache License, Version 2.0. +# + +from ten_runtime import Addon, register_addon_as_extension, TenEnv + + +@register_addon_as_extension("funasr_asr_python") +class FunASRExtensionAddon(Addon): + def on_create_instance(self, ten_env: TenEnv, name: str, context) -> None: + from .extension import FunASRExtension + + ten_env.log_info("FunASRExtensionAddon on_create_instance") + ten_env.on_create_instance_done(FunASRExtension(name), context) diff --git a/ai_agents/agents/ten_packages/extension/funasr_asr_python/config.py b/ai_agents/agents/ten_packages/extension/funasr_asr_python/config.py new file mode 100644 index 0000000000..4b14bb0de0 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/funasr_asr_python/config.py @@ -0,0 +1,63 @@ +# +# This file is part of TEN Framework, an open source project. +# Licensed under the Apache License, Version 2.0. +# + +from typing import Dict, Any +from pydantic import BaseModel, Field +from ten_ai_base.utils import encrypt + + +class FunASRConfig(BaseModel): + """FunASR ASR Configuration""" + + # Debugging and dumping + dump: bool = False + dump_path: str = "/tmp" + + # Finalize mode: "disconnect" or "silence" + finalize_mode: str = "disconnect" + silence_duration_ms: int = 1000 + + # Vendor parameters (pass-through design) + params: Dict[str, Any] = Field(default_factory=dict) + + def update(self, params: Dict[str, Any]) -> None: + """Update configuration with additional parameters.""" + for key, value in params.items(): + if hasattr(self, key): + setattr(self, key, value) + + def to_json(self, sensitive_handling: bool = False) -> str: + """Convert config to JSON string with optional sensitive data handling.""" + config_dict = self.model_dump() + if sensitive_handling and config_dict.get("params"): + # Mask sensitive keys + for key in ["api_key", "key", "token", "secret"]: + if key in config_dict["params"] and config_dict["params"][key]: + config_dict["params"][key] = encrypt( + config_dict["params"][key] + ) + return str(config_dict) + + @property + def normalized_language(self) -> str: + """Convert language code to normalized format""" + language_map = { + "zh": "zh-CN", + "en": "en-US", + "ja": "ja-JP", + "ko": "ko-KR", + "yue": "yue-CN", + "de": "de-DE", + "fr": "fr-FR", + "ru": "ru-RU", + "es": "es-ES", + "pt": "pt-PT", + "it": "it-IT", + "hi": "hi-IN", + "ar": "ar-AE", + } + params_dict = self.params or {} + language_code = params_dict.get("language", "") or "" + return language_map.get(language_code, language_code) diff --git a/ai_agents/agents/ten_packages/extension/funasr_asr_python/const.py b/ai_agents/agents/ten_packages/extension/funasr_asr_python/const.py new file mode 100644 index 0000000000..7a81e97b78 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/funasr_asr_python/const.py @@ -0,0 +1,9 @@ +# +# This file is part of TEN Framework, an open source project. +# Licensed under the Apache License, Version 2.0. +# + +MODULE_NAME_ASR = "asr" +DUMP_FILE_NAME = "funasr_asr_in.pcm" +LOG_CATEGORY_VENDOR = "vendor" +LOG_CATEGORY_KEY_POINT = "key_point" diff --git a/ai_agents/agents/ten_packages/extension/funasr_asr_python/extension.py b/ai_agents/agents/ten_packages/extension/funasr_asr_python/extension.py new file mode 100644 index 0000000000..8d3d4a4594 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/funasr_asr_python/extension.py @@ -0,0 +1,334 @@ +# +# This file is part of TEN Framework, an open source project. +# Licensed under the Apache License, Version 2.0. +# + +import os +from datetime import datetime +from typing import Optional +from typing_extensions import override + +from ten_runtime import AsyncTenEnv, AudioFrame +from ten_ai_base.asr import ( + ASRBufferConfig, + ASRBufferConfigModeKeep, + ASRResult, + AsyncASRBaseExtension, +) +from ten_ai_base.message import ( + ModuleError, + ModuleErrorVendorInfo, + ModuleErrorCode, +) +from ten_ai_base.const import ( + LOG_CATEGORY_VENDOR, + LOG_CATEGORY_KEY_POINT, +) +from ten_ai_base.dumper import Dumper + +from .config import FunASRConfig +from .const import MODULE_NAME_ASR, DUMP_FILE_NAME +from .reconnect_manager import ReconnectManager +from .funasr_client import FunASRClient + + +class FunASRExtension(AsyncASRBaseExtension): + """FunASR ASR Extension using a local FunASR model (SenseVoice / Fun-ASR-Nano / Paraformer).""" + + def __init__(self, name: str): + super().__init__(name) + self.config: Optional[FunASRConfig] = None + self.client: Optional[FunASRClient] = None + self.audio_dumper: Optional[Dumper] = None + self.reconnect_manager: Optional[ReconnectManager] = None + self.sent_user_audio_duration_ms_before_last_reset: int = 0 + self.last_finalize_timestamp: int = 0 + + @override + def vendor(self) -> str: + """Get ASR vendor name""" + return "funasr" + + @override + async def on_init(self, ten_env: AsyncTenEnv) -> None: + await super().on_init(ten_env) + + # Initialize reconnection manager + self.reconnect_manager = ReconnectManager(logger=ten_env) + + config_json, _ = await ten_env.get_property_to_json("") + + try: + self.config = FunASRConfig.model_validate_json(config_json) + self.config.update(self.config.params) + ten_env.log_info( + f"config: {self.config.to_json(sensitive_handling=True)}", + category=LOG_CATEGORY_KEY_POINT, + ) + + # Initialize audio dumper if enabled + if self.config.dump: + dump_file_path = os.path.join( + self.config.dump_path, DUMP_FILE_NAME + ) + self.audio_dumper = Dumper(dump_file_path) + await self.audio_dumper.start() + + except Exception as e: + ten_env.log_error(f"Invalid FunASR config: {e}") + self.config = FunASRConfig.model_validate_json("{}") + await self.send_asr_error( + ModuleError( + module=MODULE_NAME_ASR, + code=ModuleErrorCode.FATAL_ERROR.value, + message=str(e), + ), + ) + + @override + async def on_deinit(self, ten_env: AsyncTenEnv) -> None: + await super().on_deinit(ten_env) + if self.audio_dumper: + await self.audio_dumper.stop() + self.audio_dumper = None + + @override + async def start_connection(self) -> None: + """Start ASR connection (load the local FunASR model)""" + assert self.config is not None + self.ten_env.log_info("Starting FunASR connection") + + try: + # Stop existing connection + if self.is_connected(): + await self.stop_connection() + + # Get configuration parameters + model = self.config.params.get("model", "iic/SenseVoiceSmall") + device = self.config.params.get("device", "cpu") + language = self.config.params.get("language", "auto") + use_itn = self.config.params.get("use_itn", True) + sample_rate = self.config.params.get("sample_rate", 16000) + + # Create client + self.client = FunASRClient( + model=model, + device=device, + language=language, + use_itn=use_itn, + sample_rate=sample_rate, + on_result_callback=self._on_result, + on_error_callback=self._on_error, + logger=self.ten_env, + ) + + await self.client.connect() + + # Mark connection successful + if self.reconnect_manager: + self.reconnect_manager.mark_connection_successful() + + # Reset timeline + self.sent_user_audio_duration_ms_before_last_reset += ( + self.audio_timeline.get_total_user_audio_duration() + ) + self.audio_timeline.reset() + + self.ten_env.log_info( + "FunASR connection established", + category=LOG_CATEGORY_VENDOR, + ) + + except Exception as e: + self.ten_env.log_error(f"Failed to start FunASR connection: {e}") + await self.send_asr_error( + ModuleError( + module=MODULE_NAME_ASR, + code=ModuleErrorCode.FATAL_ERROR.value, + message=str(e), + ), + ) + + @override + async def stop_connection(self) -> None: + """Stop ASR connection""" + self.ten_env.log_info("Stopping FunASR connection") + try: + if self.client: + await self.client.disconnect() + self.client = None + self.ten_env.log_info("FunASR connection stopped") + except Exception as e: + self.ten_env.log_error(f"Error stopping FunASR connection: {e}") + + @override + def is_connected(self) -> bool: + """Check connection status""" + return self.client is not None and self.client.is_connected() + + @override + def input_audio_sample_rate(self) -> int: + """Input audio sample rate""" + assert self.config is not None + return self.config.params.get("sample_rate", 16000) + + @override + def buffer_strategy(self) -> ASRBufferConfig: + """Buffer strategy configuration""" + return ASRBufferConfigModeKeep(byte_limit=1024 * 1024 * 10) + + @override + async def send_audio( + self, frame: AudioFrame, _session_id: str | None + ) -> bool: + """Send audio data""" + if not self.is_connected() or not self.client: + return False + + try: + buf = frame.lock_buf() + audio_data = bytes(buf) + + # Dump audio data + if self.audio_dumper: + await self.audio_dumper.push_bytes(audio_data) + + # Send to client + await self.client.send_audio(audio_data) + + frame.unlock_buf(buf) + return True + + except Exception as e: + self.ten_env.log_error(f"Error sending audio to FunASR: {e}") + frame.unlock_buf(buf) + return False + + @override + async def finalize(self, _session_id: str | None) -> None: + """Finalize recognition""" + assert self.config is not None + + self.last_finalize_timestamp = int(datetime.now().timestamp() * 1000) + self.ten_env.log_debug( + f"FunASR finalize start at {self.last_finalize_timestamp}" + ) + + finalize_mode = self.config.finalize_mode + if finalize_mode == "disconnect": + await self._handle_finalize_disconnect() + elif finalize_mode == "silence": + await self._handle_finalize_silence() + else: + raise ValueError(f"invalid finalize mode: {finalize_mode}") + + async def _handle_finalize_disconnect(self) -> None: + """Handle disconnect mode finalization""" + if self.client: + await self.client.finalize() + self.ten_env.log_debug( + "FunASR finalize completed (disconnect mode)" + ) + + async def _handle_finalize_silence(self) -> None: + """Handle silence mode finalization""" + if self.client and self.config: + # Process any remaining audio + await self.client.finalize() + self.ten_env.log_debug("FunASR finalize completed (silence mode)") + + async def _handle_reconnect(self) -> None: + """Handle reconnection""" + if not self.reconnect_manager: + self.ten_env.log_error("ReconnectManager not initialized") + return + + success = await self.reconnect_manager.handle_reconnect( + connection_func=self.start_connection, + error_handler=self.send_asr_error, + ) + + if success: + self.ten_env.log_debug( + "Reconnection attempt initiated successfully" + ) + else: + info = self.reconnect_manager.get_attempts_info() + self.ten_env.log_debug( + f"Reconnection attempt failed. Status: {info}" + ) + + async def _finalize_end(self) -> None: + """Handle finalization end logic""" + if self.last_finalize_timestamp != 0: + timestamp = int(datetime.now().timestamp() * 1000) + latency = timestamp - self.last_finalize_timestamp + self.ten_env.log_debug( + f"FunASR finalize end at {timestamp}, latency: {latency}ms" + ) + self.last_finalize_timestamp = 0 + await self.send_asr_finalize_end() + + async def _on_result( + self, + text: str, + start_ms: int, + duration_ms: int, + language: str, + final: bool, + ) -> None: + """Handle recognition result callback""" + try: + if not text: + return + + # Calculate actual start time using audio timeline + actual_start_ms = int( + self.audio_timeline.get_audio_duration_before_time(start_ms) + + self.sent_user_audio_duration_ms_before_last_reset + ) + + # Create ASR result + asr_result = ASRResult( + text=text, + final=final, + start_ms=actual_start_ms, + duration_ms=duration_ms, + language=( + self.config.normalized_language if self.config else language + ), + words=[], + ) + + await self.send_asr_result(asr_result) + + # Handle finalize end + if final: + await self._finalize_end() + + except Exception as e: + self.ten_env.log_error(f"Error processing FunASR result: {e}") + + async def _on_error(self, error_msg: str) -> None: + """Handle error callback""" + self.ten_env.log_error( + f"vendor_error: {error_msg}", + category=LOG_CATEGORY_VENDOR, + ) + + # Send error information + await self.send_asr_error( + ModuleError( + module=MODULE_NAME_ASR, + code=ModuleErrorCode.NON_FATAL_ERROR.value, + message=error_msg, + ), + ModuleErrorVendorInfo( + vendor=self.vendor(), + code="unknown", + message=error_msg, + ), + ) + + # Attempt reconnection + await self._handle_reconnect() diff --git a/ai_agents/agents/ten_packages/extension/funasr_asr_python/funasr_client.py b/ai_agents/agents/ten_packages/extension/funasr_asr_python/funasr_client.py new file mode 100644 index 0000000000..e9bd769a9e --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/funasr_asr_python/funasr_client.py @@ -0,0 +1,167 @@ +# +# This file is part of TEN Framework, an open source project. +# Licensed under the Apache License, Version 2.0. +# + +import asyncio +import numpy as np +from typing import Optional, Callable +from funasr import AutoModel +from funasr.utils.postprocess_utils import rich_transcription_postprocess + + +class FunASRClient: + """Client for local FunASR ASR processing (SenseVoice / Fun-ASR-Nano / Paraformer).""" + + def __init__( + self, + model: str = "iic/SenseVoiceSmall", + device: str = "cpu", + language: str = "auto", + use_itn: bool = True, + sample_rate: int = 16000, + on_result_callback: Optional[Callable] = None, + on_error_callback: Optional[Callable] = None, + logger: Optional[any] = None, + ): + self.model_id = model + self.device = device + self.language = language + self.use_itn = use_itn + self.sample_rate = sample_rate + self.on_result_callback = on_result_callback + self.on_error_callback = on_error_callback + self.logger = logger + + self.model: Optional[AutoModel] = None + self.audio_buffer = bytearray() + self.is_connected_flag = False + self.processing_lock = asyncio.Lock() + + # Buffer settings + self.min_audio_length_ms = 1000 # Minimum 1 second of audio + self.max_audio_length_ms = 30000 # Maximum 30 seconds per chunk + + async def connect(self) -> None: + """Load the local FunASR model (off the event loop).""" + try: + if self.logger: + self.logger.log_info( + f"Loading FunASR model: {self.model_id} on {self.device}" + ) + + loop = asyncio.get_event_loop() + self.model = await loop.run_in_executor( + None, + lambda: AutoModel( + model=self.model_id, + device=self.device, + disable_update=True, + ), + ) + + self.is_connected_flag = True + if self.logger: + self.logger.log_info("FunASR model loaded successfully") + + except Exception as e: + self.is_connected_flag = False + if self.logger: + self.logger.log_error(f"Failed to load FunASR model: {e}") + if self.on_error_callback: + await self.on_error_callback(str(e)) + raise + + async def disconnect(self) -> None: + """Clean up resources""" + self.is_connected_flag = False + self.model = None + self.audio_buffer.clear() + if self.logger: + self.logger.log_info("FunASR client disconnected") + + def is_connected(self) -> bool: + """Check if model is loaded""" + return self.is_connected_flag and self.model is not None + + async def send_audio(self, audio_data: bytes) -> None: + """Add audio data to buffer and process once enough has accumulated.""" + if not self.is_connected(): + return + + self.audio_buffer.extend(audio_data) + + # Calculate buffer duration in milliseconds (16-bit PCM => 2 bytes/sample) + buffer_duration_ms = ( + len(self.audio_buffer) / (self.sample_rate * 2) * 1000 + ) + + if buffer_duration_ms >= self.min_audio_length_ms: + await self._process_audio() + + async def _process_audio(self) -> None: + """Process accumulated audio buffer with the FunASR model.""" + async with self.processing_lock: + if len(self.audio_buffer) == 0: + return + + try: + # 16-bit PCM bytes -> float32 in [-1, 1] + audio_np = ( + np.frombuffer(self.audio_buffer, dtype=np.int16).astype( + np.float32 + ) + / 32768.0 + ) + + # Limit to max length + max_samples = int( + self.max_audio_length_ms / 1000 * self.sample_rate + ) + if len(audio_np) > max_samples: + audio_np = audio_np[:max_samples] + + duration_ms = int(len(audio_np) / self.sample_rate * 1000) + + # Run inference in a thread pool to avoid blocking the loop + loop = asyncio.get_event_loop() + res = await loop.run_in_executor( + None, + lambda: self.model.generate( + input=audio_np, + language=self.language, + use_itn=self.use_itn, + ), + ) + + # SenseVoice output carries tags like <|zh|><|NEUTRAL|>...; strip them. + text = ( + rich_transcription_postprocess(res[0]["text"]).strip() + if res + else "" + ) + + if text and self.on_result_callback: + await self.on_result_callback( + text=text, + start_ms=0, + duration_ms=duration_ms, + language=self.language, + final=True, + ) + + # Clear processed audio + self.audio_buffer.clear() + + except Exception as e: + # Clear buffer on error to prevent accumulation + self.audio_buffer.clear() + if self.logger: + self.logger.log_error(f"Error processing audio: {e}") + if self.on_error_callback: + await self.on_error_callback(str(e)) + + async def finalize(self) -> None: + """Process any remaining audio in buffer""" + if len(self.audio_buffer) > 0: + await self._process_audio() diff --git a/ai_agents/agents/ten_packages/extension/funasr_asr_python/manifest.json b/ai_agents/agents/ten_packages/extension/funasr_asr_python/manifest.json new file mode 100644 index 0000000000..a7a70b3238 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/funasr_asr_python/manifest.json @@ -0,0 +1,57 @@ +{ + "type": "extension", + "name": "funasr_asr_python", + "version": "0.1.0", + "dependencies": [ + { + "type": "system", + "name": "ten_runtime_python", + "version": "0.11" + }, + { + "type": "system", + "name": "ten_ai_base", + "version": "0.7" + } + ], + "api": { + "interface": [ + { + "import_uri": "../../system/ten_ai_base/api/asr-interface.json" + } + ], + "property": { + "properties": { + "params": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "device": { + "type": "string" + }, + "language": { + "type": "string" + }, + "use_itn": { + "type": "bool" + }, + "sample_rate": { + "type": "int64" + } + } + } + } + } + }, + "package": { + "include": [ + "manifest.json", + "property.json", + "**.py", + "requirements.txt", + "README.md" + ] + } +} diff --git a/ai_agents/agents/ten_packages/extension/funasr_asr_python/property.json b/ai_agents/agents/ten_packages/extension/funasr_asr_python/property.json new file mode 100644 index 0000000000..29c90b0ea1 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/funasr_asr_python/property.json @@ -0,0 +1,13 @@ +{ + "dump": false, + "dump_path": "/tmp", + "finalize_mode": "disconnect", + "silence_duration_ms": 1000, + "params": { + "model": "iic/SenseVoiceSmall", + "device": "cpu", + "language": "auto", + "use_itn": true, + "sample_rate": 16000 + } +} diff --git a/ai_agents/agents/ten_packages/extension/funasr_asr_python/reconnect_manager.py b/ai_agents/agents/ten_packages/extension/funasr_asr_python/reconnect_manager.py new file mode 100644 index 0000000000..f5760806c1 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/funasr_asr_python/reconnect_manager.py @@ -0,0 +1,90 @@ +# +# This file is part of TEN Framework, an open source project. +# Licensed under the Apache License, Version 2.0. +# + +import asyncio +from typing import Callable, Optional +from ten_ai_base.message import ModuleError, ModuleErrorCode + + +class ReconnectManager: + """Manages reconnection attempts with exponential backoff""" + + def __init__( + self, + max_attempts: int = 5, + base_delay: float = 0.5, + logger: Optional[any] = None, + ): + self.max_attempts = max_attempts + self.base_delay = base_delay + self.current_attempts = 0 + self.logger = logger + + def can_retry(self) -> bool: + """Check if more retry attempts are available""" + return self.current_attempts < self.max_attempts + + def mark_connection_successful(self) -> None: + """Reset retry counter after successful connection""" + self.current_attempts = 0 + + def get_attempts_info(self) -> str: + """Get current attempts information""" + return f"{self.current_attempts}/{self.max_attempts}" + + async def handle_reconnect( + self, + connection_func: Callable, + error_handler: Optional[Callable] = None, + ) -> bool: + """Handle reconnection with exponential backoff + + Args: + connection_func: Async function to establish connection + error_handler: Optional async function to handle errors + + Returns: + bool: True if reconnection initiated, False if max attempts reached + """ + if not self.can_retry(): + if self.logger: + self.logger.log_error( + f"Max reconnection attempts ({self.max_attempts}) reached" + ) + if error_handler: + await error_handler( + ModuleError( + module="asr", + code=ModuleErrorCode.FATAL_ERROR.value, + message=f"Max reconnection attempts ({self.max_attempts}) reached", + ) + ) + return False + + self.current_attempts += 1 + delay = self.base_delay * (2 ** (self.current_attempts - 1)) + + if self.logger: + self.logger.log_info( + f"Reconnecting in {delay}s (attempt {self.current_attempts}/{self.max_attempts})" + ) + + await asyncio.sleep(delay) + + try: + await connection_func() + return True + except Exception as e: + if self.logger: + self.logger.log_error(f"Reconnection attempt failed: {e}") + if error_handler: + await error_handler( + ModuleError( + module="asr", + code=ModuleErrorCode.NON_FATAL_ERROR.value, + message=f"Reconnection attempt {self.current_attempts} failed: {str(e)}", + ) + ) + return False diff --git a/ai_agents/agents/ten_packages/extension/funasr_asr_python/requirements.txt b/ai_agents/agents/ten_packages/extension/funasr_asr_python/requirements.txt new file mode 100644 index 0000000000..87b6137477 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/funasr_asr_python/requirements.txt @@ -0,0 +1,4 @@ +funasr>=1.1.0 +numpy>=1.24.0 +pydantic>=2.0.0 +pytest==8.3.4 diff --git a/ai_agents/agents/ten_packages/extension/funasr_asr_python/tests/__init__.py b/ai_agents/agents/ten_packages/extension/funasr_asr_python/tests/__init__.py new file mode 100644 index 0000000000..b8c07eef1c --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/funasr_asr_python/tests/__init__.py @@ -0,0 +1,4 @@ +# +# This file is part of TEN Framework, an open source project. +# Licensed under the Apache License, Version 2.0. +# diff --git a/ai_agents/agents/ten_packages/extension/funasr_asr_python/tests/test_config.py b/ai_agents/agents/ten_packages/extension/funasr_asr_python/tests/test_config.py new file mode 100644 index 0000000000..b634364c1a --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/funasr_asr_python/tests/test_config.py @@ -0,0 +1,45 @@ +# +# This file is part of TEN Framework, an open source project. +# Licensed under the Apache License, Version 2.0. +# + +import pytest +from funasr_asr_python.config import FunASRConfig + + +def test_config_default_values(): + """Test default configuration values""" + config = FunASRConfig() + assert config.dump is False + assert config.dump_path == "/tmp" + assert config.finalize_mode == "disconnect" + assert config.silence_duration_ms == 1000 + assert config.params == {} + + +def test_config_from_json(): + """Test configuration from JSON""" + json_str = """{ + "dump": true, + "dump_path": "/var/log", + "finalize_mode": "silence", + "params": { + "model": "iic/SenseVoiceSmall", + "device": "cpu", + "language": "auto" + } + }""" + config = FunASRConfig.model_validate_json(json_str) + assert config.dump is True + assert config.dump_path == "/var/log" + assert config.finalize_mode == "silence" + assert config.params["model"] == "iic/SenseVoiceSmall" + assert config.params["device"] == "cpu" + + +def test_normalized_language(): + """Language codes map to normalized BCP-47-ish tags.""" + config = FunASRConfig(params={"language": "zh"}) + assert config.normalized_language == "zh-CN" + config2 = FunASRConfig(params={"language": "auto"}) + assert config2.normalized_language == "auto"