mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-17 13:17:20 +08:00
## What this PR does Adds support for Alibaba Cloud's hosted Fun-ASR-Flash snapshots to the existing Tongyi-Qianwen speech-to-text provider. - registers `fun-asr-flash-2026-06-15` as a speech-to-text model; - routes only `fun-asr-flash*` models to the documented workspace-native multimodal-generation endpoint; - supports local audio through size-checked data URIs as well as URL/data-URI inputs; - uses the documented SSE response mode for incremental streaming transcription; - closes the streamed HTTP response on completion, failure, or early consumer cancellation; - preserves the existing `dashscope.MultiModalConversation` path for all other Qwen audio models; - keeps RAGFlow's existing synchronous and streaming adapter interfaces. ## Why Fun-ASR-Flash does not use the legacy Qwen audio request shape currently used by `QWenSeq2txt`. Its synchronous API expects `input_audio` at: `/api/v1/services/aigc/multimodal-generation/generation` Without a narrowly scoped adapter path, the hosted model cannot be selected successfully through RAGFlow's Tongyi-Qianwen speech-to-text provider. Closes #16843. ## Compatibility The new behavior is gated by the `fun-asr-flash` model-name prefix. Existing Qwen audio models continue through the original code path unchanged. ## Validation - `pytest test/unit_test/rag/llm/test_sequence2txt_model.py`: 10 passed - Ruff check: passed - Ruff format check: passed - `llm_factories.json` validation: passed - Real hosted-API validation with WAV audio - Real RAGFlow upload/indexing validation with MP3 audio The unit tests cover the native Fun-ASR-Flash request, regression behavior for the legacy Qwen path, SSE streaming, and early response cleanup. ## Documentation - https://help.aliyun.com/document_detail/2979031.html - https://help.aliyun.com/document_detail/2869541.html ### Why a dedicated adapter path is necessary (official evidence) Alibaba Cloud's [Fun-ASR RESTful API reference](https://help.aliyun.com/en/model-studio/fun-asr-recorded-speech-recognition-http-api) makes the incompatibilities with RAGFlow's existing Qwen audio path explicit: | Adapter change | Official API requirement | Why the existing path is insufficient | | --- | --- | --- | | Call the workspace-native HTTP endpoint | The Fun-ASR-Flash synchronous section states that SDK calls are not supported and specifies `POST /api/v1/services/aigc/multimodal-generation/generation`. | The existing adapter calls `dashscope.MultiModalConversation`, so a direct HTTP path is required. | | Use the `input_audio` message shape | `input.messages`, `content`, `type: input_audio`, `input_audio`, and `input_audio.data` are documented as required for an audio request. | The existing Qwen path sends the legacy `audio` content shape, which does not match this API contract. | | Send `parameters.format` | The request schema marks `parameters` and `format` as **Required**, and says the value must match the actual audio format. | The legacy request has no Fun-ASR-Flash `parameters.format` field, so the adapter must derive and send it. | | Encode local files as Data URIs | `input_audio.data` accepts either a public URL or a Base64 Data URI; the reference gives the exact `data:{MIME_TYPE};base64,...` form. | RAGFlow supplies local file paths, which the remote API cannot read directly. | | Parse `output.text` | The documented non-streaming response returns the accumulated transcription in `output.text`. | The legacy Qwen response parser reads `output.choices[].message.content`, so a separate response parser is required. | | Enforce the Base64 input limit | The reference requires the Base64-encoded audio to remain within the 10 MB input limit. | The adapter checks encoded size before reading/sending local audio and directs oversized inputs to the existing public-URL path. | | Use SSE for streaming | The reference specifies `X-DashScope-SSE: enable` and documents intermediate and final SSE events. | The adapter parses those events instead of wrapping one blocking response as a synthetic stream. | | Release streamed responses | Streaming responses must be closed when iteration completes or stops early. | A `finally` cleanup releases the HTTP response on completion, errors, and consumer cancellation. | `sample_rate` is documented as **Optional**. The implementation omits it instead of declaring a fixed value that may not match remote or compressed audio. The [official speech-to-text model list](https://help.aliyun.com/en/model-studio/asr-model/) separately confirms that `fun-asr-flash-2026-06-15` is an offline HTTP model with a five-minute audio limit. --------- Signed-off-by: LauraGPT <LauraGPT@users.noreply.github.com> Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: LauraGPT <LauraGPT@users.noreply.github.com>
564 lines
21 KiB
Python
564 lines
21 KiB
Python
#
|
|
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
#
|
|
import base64
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
from abc import ABC
|
|
import tempfile
|
|
import logging
|
|
from urllib.parse import urlparse
|
|
|
|
import requests
|
|
from openai import OpenAI
|
|
from openai.lib.azure import AzureOpenAI
|
|
|
|
from common.token_utils import num_tokens_from_string
|
|
from rag.utils.url_utils import ensure_v1
|
|
|
|
|
|
class Base(ABC):
|
|
def __init__(self, key, model_name, **kwargs):
|
|
"""
|
|
Abstract base class constructor.
|
|
Parameters are not stored; initialization is left to subclasses.
|
|
"""
|
|
pass
|
|
|
|
def transcription(self, audio_path, **kwargs):
|
|
with open(audio_path, "rb") as audio_file:
|
|
transcription = self.client.audio.transcriptions.create(model=self.model_name, file=audio_file)
|
|
return transcription.text.strip(), num_tokens_from_string(transcription.text.strip())
|
|
|
|
def audio2base64(self, audio):
|
|
if isinstance(audio, bytes):
|
|
return base64.b64encode(audio).decode("utf-8")
|
|
if isinstance(audio, io.BytesIO):
|
|
return base64.b64encode(audio.getvalue()).decode("utf-8")
|
|
raise TypeError("The input audio file should be in binary format.")
|
|
|
|
|
|
class GPTSeq2txt(Base):
|
|
_FACTORY_NAME = "OpenAI"
|
|
|
|
def __init__(self, key, model_name="whisper-1", base_url="https://api.openai.com/v1", **kwargs):
|
|
if not base_url:
|
|
base_url = "https://api.openai.com/v1"
|
|
self.base_url = ensure_v1(base_url)
|
|
self.client = OpenAI(api_key=key, base_url=self.base_url)
|
|
self.model_name = model_name
|
|
|
|
|
|
class StepFunSeq2txt(GPTSeq2txt):
|
|
_FACTORY_NAME = "StepFun"
|
|
|
|
def __init__(self, key, model_name="step-asr", lang="Chinese", base_url="https://api.stepfun.com/v1", **kwargs):
|
|
if not base_url:
|
|
base_url = "https://api.stepfun.com/v1"
|
|
super().__init__(key, model_name=model_name, base_url=base_url, **kwargs)
|
|
|
|
|
|
class FuturMixSeq2txt(GPTSeq2txt):
|
|
_FACTORY_NAME = "FuturMix"
|
|
|
|
def __init__(self, key, model_name="whisper-1", base_url="https://futurmix.ai/v1", **kwargs):
|
|
if not base_url:
|
|
base_url = "https://futurmix.ai/v1"
|
|
super().__init__(key, model_name=model_name, base_url=base_url, **kwargs)
|
|
logging.info("[FuturMix] Speech2Text initialized with model %s", model_name)
|
|
|
|
|
|
class QWenSeq2txt(Base):
|
|
_FACTORY_NAME = "Tongyi-Qianwen"
|
|
_FUN_ASR_FLASH_PREFIX = "fun-asr-flash"
|
|
_FUN_ASR_BASE64_MAX_SIZE = 10 * 1024 * 1024
|
|
_DASHSCOPE_API_BASE = "https://dashscope.aliyuncs.com/api/v1"
|
|
_AUDIO_MIME_FORMATS = {
|
|
"audio/mpeg": "mp3",
|
|
"audio/mp3": "mp3",
|
|
"audio/wav": "wav",
|
|
"audio/wave": "wav",
|
|
"audio/x-wav": "wav",
|
|
}
|
|
|
|
def __init__(self, key, model_name="qwen-audio-asr", base_url=None, **kwargs):
|
|
import dashscope
|
|
|
|
dashscope.api_key = key
|
|
self.api_key = key
|
|
self.model_name = model_name
|
|
self.base_url = (base_url or self._DASHSCOPE_API_BASE).rstrip("/")
|
|
|
|
def transcription(self, audio_path):
|
|
# Fun-ASR-Flash uses DashScope's workspace-scoped native multimodal
|
|
# endpoint and payload instead of MultiModalConversation.
|
|
if self.model_name.startswith(self._FUN_ASR_FLASH_PREFIX):
|
|
return self._transcribe_fun_asr_flash(audio_path)
|
|
|
|
return self._transcribe_qwen_audio(audio_path)
|
|
|
|
def _transcribe_qwen_audio(self, audio_path):
|
|
import dashscope
|
|
|
|
if audio_path.startswith("http"):
|
|
audio_input = audio_path
|
|
else:
|
|
audio_input = f"file://{audio_path}"
|
|
|
|
messages = [{"role": "system", "content": [{"text": ""}]}, {"role": "user", "content": [{"audio": audio_input}]}]
|
|
|
|
resp = dashscope.MultiModalConversation.call(model=self.model_name, messages=messages, result_format="message", asr_options={"enable_lid": True, "enable_itn": False})
|
|
|
|
try:
|
|
text = resp["output"]["choices"][0]["message"].content[0]["text"]
|
|
except Exception as e:
|
|
text = "**ERROR**: " + str(e)
|
|
return text, num_tokens_from_string(text)
|
|
|
|
@classmethod
|
|
def _fun_asr_audio_format(cls, audio_path):
|
|
"""Derive the Fun-ASR audio format from a data URI, URL, or path."""
|
|
|
|
if audio_path.startswith("data:"):
|
|
mime_type = audio_path[5:].split(";", 1)[0].lower()
|
|
if not mime_type.startswith("audio/"):
|
|
raise ValueError(f"Unsupported audio data URI MIME type: {mime_type or 'missing'}")
|
|
audio_format = cls._AUDIO_MIME_FORMATS.get(mime_type, mime_type.split("/", 1)[1].removeprefix("x-"))
|
|
else:
|
|
path = urlparse(audio_path).path if audio_path.startswith(("http://", "https://")) else audio_path
|
|
audio_format = os.path.splitext(path)[1].lower().lstrip(".")
|
|
|
|
if audio_format == "wave":
|
|
audio_format = "wav"
|
|
if not audio_format:
|
|
raise ValueError("Cannot determine audio format; use a URL/path extension or an audio data URI MIME type")
|
|
return audio_format
|
|
|
|
@classmethod
|
|
def _validate_fun_asr_base64_size(cls, encoded_size):
|
|
if encoded_size > cls._FUN_ASR_BASE64_MAX_SIZE:
|
|
raise ValueError("Fun-ASR-Flash Base64 audio exceeds the 10 MB encoded-input limit; provide a publicly accessible URL (for example, OSS) instead")
|
|
|
|
def _fun_asr_flash_request(self, audio_path, *, stream=False):
|
|
audio_format = self._fun_asr_audio_format(audio_path)
|
|
|
|
if audio_path.startswith(("http://", "https://")):
|
|
audio_input = audio_path
|
|
elif audio_path.startswith("data:"):
|
|
_, separator, encoded_audio = audio_path.partition(",")
|
|
if not separator:
|
|
raise ValueError("Invalid audio data URI: missing Base64 payload")
|
|
self._validate_fun_asr_base64_size(len(encoded_audio.encode("utf-8")))
|
|
audio_input = audio_path
|
|
else:
|
|
file_size = os.path.getsize(audio_path)
|
|
encoded_size = 4 * ((file_size + 2) // 3)
|
|
self._validate_fun_asr_base64_size(encoded_size)
|
|
mime_type = "audio/mpeg" if audio_format == "mp3" else f"audio/{audio_format}"
|
|
with open(audio_path, "rb") as audio_file:
|
|
audio_input = f"data:{mime_type};base64,{base64.b64encode(audio_file.read()).decode('utf-8')}"
|
|
|
|
api_base = self.base_url
|
|
if api_base.endswith("/compatible-mode/v1"):
|
|
api_base = api_base[: -len("/compatible-mode/v1")] + "/api/v1"
|
|
url = f"{api_base}/services/aigc/multimodal-generation/generation"
|
|
payload = {
|
|
"model": self.model_name,
|
|
"input": {
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "input_audio", "input_audio": {"data": audio_input}}],
|
|
}
|
|
]
|
|
},
|
|
# sample_rate is optional in the Fun-ASR-Flash API. Omitting it
|
|
# avoids declaring incorrect metadata for remote or compressed audio.
|
|
"parameters": {"format": audio_format},
|
|
}
|
|
headers = {
|
|
"Authorization": f"Bearer {self.api_key}",
|
|
"Content-Type": "application/json",
|
|
"X-DashScope-SSE": "enable" if stream else "disable",
|
|
}
|
|
return url, headers, payload
|
|
|
|
def _transcribe_fun_asr_flash(self, audio_path):
|
|
try:
|
|
url, headers, payload = self._fun_asr_flash_request(audio_path)
|
|
|
|
response = requests.post(url, headers=headers, json=payload, timeout=60)
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
text = result.get("text") or result.get("output", {}).get("text")
|
|
if not text:
|
|
raise ValueError("Missing transcription text in Fun-ASR-Flash response")
|
|
text = text.strip()
|
|
return text, num_tokens_from_string(text)
|
|
except Exception as e:
|
|
logging.exception("Fun-ASR-Flash transcription failed for model %s", self.model_name)
|
|
return "**ERROR**: " + str(e), 0
|
|
|
|
def _stream_fun_asr_flash(self, audio_path):
|
|
response = None
|
|
try:
|
|
url, headers, payload = self._fun_asr_flash_request(audio_path, stream=True)
|
|
response = requests.post(url, headers=headers, json=payload, timeout=60, stream=True)
|
|
response.raise_for_status()
|
|
|
|
full = ""
|
|
for line in response.iter_lines(decode_unicode=True):
|
|
if not line or not line.startswith("data:"):
|
|
continue
|
|
event_data = line[5:].strip()
|
|
if not event_data or event_data == "[DONE]":
|
|
continue
|
|
result = json.loads(event_data)
|
|
text = result.get("text") or result.get("output", {}).get("text")
|
|
if not text:
|
|
continue
|
|
full = text.strip()
|
|
yield {"event": "delta", "text": full}
|
|
|
|
if not full:
|
|
raise ValueError("Missing transcription text in Fun-ASR-Flash stream")
|
|
yield {"event": "final", "text": full}
|
|
except Exception as e:
|
|
logging.exception("Fun-ASR-Flash streaming transcription failed for model %s", self.model_name)
|
|
yield {"event": "error", "text": "**ERROR**: " + str(e)}
|
|
finally:
|
|
if response is not None:
|
|
response.close()
|
|
|
|
def stream_transcription(self, audio_path):
|
|
if self.model_name.startswith(self._FUN_ASR_FLASH_PREFIX):
|
|
yield from self._stream_fun_asr_flash(audio_path)
|
|
return
|
|
|
|
import dashscope
|
|
|
|
if audio_path.startswith("http"):
|
|
audio_input = audio_path
|
|
else:
|
|
audio_input = f"file://{audio_path}"
|
|
|
|
messages = [{"role": "system", "content": [{"text": ""}]}, {"role": "user", "content": [{"audio": audio_input}]}]
|
|
|
|
stream = dashscope.MultiModalConversation.call(model=self.model_name, messages=messages, result_format="message", stream=True, asr_options={"enable_lid": True, "enable_itn": False})
|
|
|
|
full = ""
|
|
for chunk in stream:
|
|
try:
|
|
piece = chunk["output"]["choices"][0]["message"].content[0]["text"]
|
|
full = piece
|
|
yield {"event": "delta", "text": piece}
|
|
except Exception as e:
|
|
yield {"event": "error", "text": str(e)}
|
|
|
|
yield {"event": "final", "text": full}
|
|
|
|
|
|
class AzureSeq2txt(Base):
|
|
_FACTORY_NAME = "Azure-OpenAI"
|
|
|
|
def __init__(self, key, model_name, lang="Chinese", **kwargs):
|
|
self.base_url = ensure_v1(kwargs["base_url"])
|
|
self.client = AzureOpenAI(api_key=key, azure_endpoint=self.base_url, api_version="2024-02-01")
|
|
self.model_name = model_name
|
|
self.lang = lang
|
|
|
|
|
|
class XinferenceSeq2txt(Base):
|
|
_FACTORY_NAME = "Xinference"
|
|
|
|
def __init__(self, key, model_name="whisper-small", **kwargs):
|
|
self.base_url = ensure_v1(kwargs["base_url"]) if kwargs.get("base_url") else None
|
|
self.model_name = model_name
|
|
self.key = key
|
|
|
|
def transcription(self, audio, language="zh", prompt=None, response_format="json", temperature=0.7):
|
|
if isinstance(audio, str):
|
|
with open(audio, "rb") as audio_file:
|
|
audio_data = audio_file.read()
|
|
audio_file_name = audio.split("/")[-1]
|
|
else:
|
|
audio_data = audio
|
|
audio_file_name = "audio.wav"
|
|
|
|
payload = {"model": self.model_name, "language": language, "prompt": prompt, "response_format": response_format, "temperature": temperature}
|
|
|
|
files = {"file": (audio_file_name, audio_data, "audio/wav")}
|
|
|
|
try:
|
|
response = requests.post(f"{self.base_url}/v1/audio/transcriptions", files=files, data=payload, timeout=60)
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
|
|
if "text" in result:
|
|
transcription_text = result["text"].strip()
|
|
return transcription_text, num_tokens_from_string(transcription_text)
|
|
else:
|
|
return "**ERROR**: Failed to retrieve transcription.", 0
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
return f"**ERROR**: {str(e)}", 0
|
|
|
|
|
|
class TencentCloudSeq2txt(Base):
|
|
_FACTORY_NAME = "Tencent Cloud"
|
|
|
|
def __init__(self, key, model_name="16k_zh", base_url="https://asr.tencentcloudapi.com"):
|
|
from tencentcloud.asr.v20190614 import asr_client
|
|
from tencentcloud.common import credential
|
|
|
|
key = json.loads(key)
|
|
sid = key.get("tencent_cloud_sid", "")
|
|
sk = key.get("tencent_cloud_sk", "")
|
|
cred = credential.Credential(sid, sk)
|
|
self.client = asr_client.AsrClient(cred, "")
|
|
self.model_name = model_name
|
|
|
|
def transcription(self, audio, max_retries=60, retry_interval=5):
|
|
import time
|
|
|
|
from tencentcloud.asr.v20190614 import models
|
|
from tencentcloud.common.exception.tencent_cloud_sdk_exception import (
|
|
TencentCloudSDKException,
|
|
)
|
|
|
|
b64 = self.audio2base64(audio)
|
|
try:
|
|
# dispatch disk
|
|
req = models.CreateRecTaskRequest()
|
|
params = {
|
|
"EngineModelType": self.model_name,
|
|
"ChannelNum": 1,
|
|
"ResTextFormat": 0,
|
|
"SourceType": 1,
|
|
"Data": b64,
|
|
}
|
|
req.from_json_string(json.dumps(params))
|
|
resp = self.client.CreateRecTask(req)
|
|
|
|
# loop query
|
|
req = models.DescribeTaskStatusRequest()
|
|
params = {"TaskId": resp.Data.TaskId}
|
|
req.from_json_string(json.dumps(params))
|
|
retries = 0
|
|
while retries < max_retries:
|
|
resp = self.client.DescribeTaskStatus(req)
|
|
if resp.Data.StatusStr == "success":
|
|
text = re.sub(r"\[\d+:\d+\.\d+,\d+:\d+\.\d+\]\s*", "", resp.Data.Result).strip()
|
|
return text, num_tokens_from_string(text)
|
|
elif resp.Data.StatusStr == "failed":
|
|
return (
|
|
"**ERROR**: Failed to retrieve speech recognition results.",
|
|
0,
|
|
)
|
|
else:
|
|
time.sleep(retry_interval)
|
|
retries += 1
|
|
return "**ERROR**: Max retries exceeded. Task may still be processing.", 0
|
|
|
|
except TencentCloudSDKException as e:
|
|
return "**ERROR**: " + str(e), 0
|
|
except Exception as e:
|
|
return "**ERROR**: " + str(e), 0
|
|
|
|
|
|
class GPUStackSeq2txt(Base):
|
|
_FACTORY_NAME = "GPUStack"
|
|
|
|
def __init__(self, key, model_name, base_url):
|
|
if not base_url:
|
|
raise ValueError("url cannot be None")
|
|
if base_url.split("/")[-1] != "v1":
|
|
base_url = os.path.join(base_url, "v1")
|
|
self.base_url = base_url
|
|
self.model_name = model_name
|
|
self.key = key
|
|
|
|
|
|
class GiteeSeq2txt(Base):
|
|
_FACTORY_NAME = "GiteeAI"
|
|
|
|
def __init__(self, key, model_name="whisper-1", base_url="https://ai.gitee.com/v1/", **kwargs):
|
|
if not base_url:
|
|
base_url = "https://ai.gitee.com/v1/"
|
|
self.base_url = ensure_v1(base_url)
|
|
self.client = OpenAI(api_key=key, base_url=self.base_url)
|
|
self.model_name = model_name
|
|
|
|
|
|
class DeepInfraSeq2txt(Base):
|
|
_FACTORY_NAME = "DeepInfra"
|
|
|
|
def __init__(self, key, model_name, base_url="https://api.deepinfra.com/v1/openai", **kwargs):
|
|
if not base_url:
|
|
base_url = "https://api.deepinfra.com/v1/openai"
|
|
self.base_url = ensure_v1(base_url)
|
|
self.client = OpenAI(api_key=key, base_url=self.base_url)
|
|
self.model_name = model_name
|
|
|
|
|
|
class CometAPISeq2txt(Base):
|
|
_FACTORY_NAME = "CometAPI"
|
|
|
|
def __init__(self, key, model_name="whisper-1", base_url="https://api.cometapi.com/v1", **kwargs):
|
|
if not base_url:
|
|
base_url = "https://api.cometapi.com/v1"
|
|
self.base_url = ensure_v1(base_url)
|
|
self.client = OpenAI(api_key=key, base_url=self.base_url)
|
|
self.model_name = model_name
|
|
|
|
|
|
class DeerAPISeq2txt(Base):
|
|
_FACTORY_NAME = "DeerAPI"
|
|
|
|
def __init__(self, key, model_name="whisper-1", base_url="https://api.deerapi.com/v1", **kwargs):
|
|
if not base_url:
|
|
base_url = "https://api.deerapi.com/v1"
|
|
self.base_url = ensure_v1(base_url)
|
|
self.client = OpenAI(api_key=key, base_url=self.base_url)
|
|
self.model_name = model_name
|
|
|
|
|
|
class ZhipuSeq2txt(Base):
|
|
_FACTORY_NAME = "ZHIPU-AI"
|
|
|
|
def __init__(self, key, model_name="glm-asr", base_url="https://open.bigmodel.cn/api/paas/v4", **kwargs):
|
|
if not base_url:
|
|
base_url = "https://open.bigmodel.cn/api/paas/v4"
|
|
self.base_url = base_url
|
|
self.api_key = key
|
|
self.model_name = model_name
|
|
self.gen_conf = kwargs.get("gen_conf", {})
|
|
self.stream = kwargs.get("stream", False)
|
|
|
|
def _convert_to_wav(self, input_path):
|
|
ext = os.path.splitext(input_path)[1].lower()
|
|
if ext in [".wav", ".mp3"]:
|
|
return input_path
|
|
fd, out_path = tempfile.mkstemp(suffix=".wav")
|
|
os.close(fd)
|
|
try:
|
|
import ffmpeg
|
|
import imageio_ffmpeg as ffmpeg_exe
|
|
|
|
ffmpeg_path = ffmpeg_exe.get_ffmpeg_exe()
|
|
(ffmpeg.input(input_path).output(out_path, ar=16000, ac=1).overwrite_output().run(cmd=ffmpeg_path, quiet=True))
|
|
return out_path
|
|
except Exception as e:
|
|
raise RuntimeError(f"audio convert failed: {e}")
|
|
|
|
def transcription(self, audio_path):
|
|
payload = {
|
|
"model": self.model_name,
|
|
"temperature": str(self.gen_conf.get("temperature", 0.75)) or "0.75",
|
|
"stream": self.stream,
|
|
}
|
|
|
|
headers = {"Authorization": f"Bearer {self.api_key}"}
|
|
converted = self._convert_to_wav(audio_path)
|
|
|
|
with open(converted, "rb") as audio_file:
|
|
files = {"file": audio_file}
|
|
|
|
try:
|
|
response = requests.post(
|
|
url=f"{self.base_url}/audio/transcriptions",
|
|
data=payload,
|
|
files=files,
|
|
headers=headers,
|
|
timeout=60,
|
|
)
|
|
body = response.json()
|
|
if response.status_code == 200:
|
|
full_content = body["text"]
|
|
return full_content, num_tokens_from_string(full_content)
|
|
else:
|
|
error = body["error"]
|
|
return f"**ERROR**: code: {error['code']}, message: {error['message']}", 0
|
|
except Exception as e:
|
|
return "**ERROR**: " + str(e), 0
|
|
|
|
|
|
class RAGconSeq2txt(Base):
|
|
"""
|
|
RAGcon Sequence2Text Provider - routes through LiteLLM proxy
|
|
|
|
Speech-to-text models routed through LiteLLM.
|
|
Default Base URL: https://connect.ragcon.com/v1
|
|
"""
|
|
|
|
_FACTORY_NAME = "RAGcon"
|
|
|
|
def __init__(self, key, model_name, base_url=None, lang="English", **kwargs):
|
|
# Use provided base_url or fallback to default
|
|
if not base_url:
|
|
base_url = "https://connect.ragcon.com/v1"
|
|
|
|
self.base_url = ensure_v1(base_url)
|
|
self.model_name = model_name
|
|
self.key = key
|
|
self.lang = lang
|
|
|
|
self.client = OpenAI(api_key=key, base_url=self.base_url)
|
|
|
|
def transcription(self, audio_path, **kwargs):
|
|
"""
|
|
Transcribe audio file using RAGcon's OpenAI-compatible API.
|
|
Uses Whisper's automatic language detection for German and English audio.
|
|
|
|
Args:
|
|
audio_path: Path to the audio file
|
|
**kwargs: Additional parameters (currently unused but maintained for compatibility)
|
|
|
|
Returns:
|
|
tuple: (transcribed_text, token_count)
|
|
"""
|
|
with open(audio_path, "rb") as audio_file:
|
|
# Call RAGcon API - Whisper will auto-detect language
|
|
transcription = self.client.audio.transcriptions.create(model=self.model_name, file=audio_file)
|
|
|
|
# Return text and token count
|
|
text = transcription.text.strip()
|
|
return text, num_tokens_from_string(text)
|
|
|
|
|
|
class NewAPISeq2txt(GPTSeq2txt):
|
|
_FACTORY_NAME = "New API"
|
|
|
|
def __init__(self, key, model_name="whisper-1", base_url="", **kwargs):
|
|
if not base_url:
|
|
raise ValueError("url cannot be None")
|
|
model_name = model_name.split("___")[0]
|
|
super().__init__(key, model_name=model_name, base_url=base_url, **kwargs)
|
|
|
|
|
|
class FunASRSeq2txt(GPTSeq2txt):
|
|
"""FunASR speech-to-text provider for its OpenAI-compatible API."""
|
|
|
|
_FACTORY_NAME = "FunASR"
|
|
|
|
def __init__(self, key, model_name="sensevoice", base_url="http://localhost:8000/v1", **kwargs):
|
|
"""Initialize a client for a FunASR OpenAI-compatible endpoint."""
|
|
if not base_url:
|
|
base_url = "http://localhost:8000/v1"
|
|
super().__init__(key=key or "funasr", model_name=model_name, base_url=base_url, **kwargs)
|
|
logging.info("[FunASR] Speech2Text initialized with model %s at %s", model_name, self.base_url)
|