179 lines
5.8 KiB
Rust
179 lines
5.8 KiB
Rust
//! Aliyun DashScope file-trans ASR client (qwen3-asr-flash-filetrans).
|
||
//!
|
||
//! The dashscope async transcription flow has two steps:
|
||
//! 1. POST https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription
|
||
//! header `X-DashScope-Async: enable`
|
||
//! body { model, input: { file_urls: [audio_url] }, parameters: {...} }
|
||
//! => returns task_id
|
||
//! 2. GET https://dashscope.aliyuncs.com/api/v1/tasks/{task_id}
|
||
//! poll until task_status in {SUCCEEDED, FAILED}
|
||
//!
|
||
//! On success the response carries one or more `transcription_url`s pointing
|
||
//! at JSON files containing the recognized sentences. We download the first
|
||
//! one and concatenate the sentence texts.
|
||
|
||
use std::time::Duration;
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{json, Value};
|
||
use thiserror::Error;
|
||
use tokio::time::sleep;
|
||
|
||
const SUBMIT_URL: &str =
|
||
"https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription";
|
||
const TASK_URL_PREFIX: &str = "https://dashscope.aliyuncs.com/api/v1/tasks/";
|
||
|
||
#[derive(Debug, Error)]
|
||
pub enum AsrError {
|
||
#[error("missing api key")]
|
||
MissingApiKey,
|
||
#[error("http: {0}")]
|
||
Http(#[from] reqwest::Error),
|
||
#[error("dashscope: {0}")]
|
||
Api(String),
|
||
#[error("timeout waiting for asr task")]
|
||
Timeout,
|
||
#[error("json: {0}")]
|
||
Json(#[from] serde_json::Error),
|
||
}
|
||
|
||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||
pub struct AsrSentence {
|
||
#[serde(default)]
|
||
pub text: String,
|
||
#[serde(default)]
|
||
pub begin_time: Option<i64>,
|
||
#[serde(default)]
|
||
pub end_time: Option<i64>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct AsrResult {
|
||
pub success: bool,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub text: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub sentences: Option<Vec<AsrSentence>>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub error: Option<String>,
|
||
}
|
||
|
||
pub async fn transcribe(
|
||
audio_url: &str,
|
||
api_key: &str,
|
||
model: &str,
|
||
) -> Result<AsrResult, AsrError> {
|
||
if api_key.trim().is_empty() {
|
||
return Err(AsrError::MissingApiKey);
|
||
}
|
||
let client = reqwest::Client::builder()
|
||
.timeout(Duration::from_secs(60))
|
||
.build()?;
|
||
|
||
// 1) submit — filetrans 用 file_url,其它模型用 file_urls
|
||
let input = if model == "qwen3-asr-flash-filetrans" {
|
||
json!({ "file_url": audio_url })
|
||
} else {
|
||
json!({ "file_urls": [audio_url] })
|
||
};
|
||
let submit_body = json!({
|
||
"model": model,
|
||
"input": input,
|
||
"parameters": {
|
||
"channel_id": [0],
|
||
"enable_itn": false
|
||
}
|
||
});
|
||
let submit: Value = client
|
||
.post(SUBMIT_URL)
|
||
.bearer_auth(api_key)
|
||
.header("X-DashScope-Async", "enable")
|
||
.json(&submit_body)
|
||
.send()
|
||
.await?
|
||
.json()
|
||
.await?;
|
||
let task_id = submit
|
||
.pointer("/output/task_id")
|
||
.and_then(Value::as_str)
|
||
.ok_or_else(|| AsrError::Api(format!("submit: no task_id, raw={submit}")))?
|
||
.to_string();
|
||
|
||
// 2) poll
|
||
let max_polls = 60; // ~5 min @ 5s
|
||
let mut poll_resp: Value = Value::Null;
|
||
let mut succeeded = false;
|
||
for _ in 0..max_polls {
|
||
sleep(Duration::from_secs(5)).await;
|
||
poll_resp = client
|
||
.get(format!("{TASK_URL_PREFIX}{task_id}"))
|
||
.bearer_auth(api_key)
|
||
.send()
|
||
.await?
|
||
.json()
|
||
.await?;
|
||
let status = poll_resp
|
||
.pointer("/output/task_status")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("");
|
||
match status {
|
||
"SUCCEEDED" => { succeeded = true; break; }
|
||
"FAILED" | "UNKNOWN" => {
|
||
let msg = poll_resp
|
||
.pointer("/output/message")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("FAILED");
|
||
return Err(AsrError::Api(msg.to_string()));
|
||
}
|
||
_ => continue,
|
||
}
|
||
}
|
||
if !succeeded {
|
||
return Err(AsrError::Timeout);
|
||
}
|
||
|
||
// 3) fetch transcription file (filetrans: output.result; batch: output.results[0])
|
||
let trans_url = poll_resp
|
||
.pointer("/output/result/transcription_url")
|
||
.and_then(Value::as_str)
|
||
.or_else(|| {
|
||
poll_resp
|
||
.pointer("/output/results/0/transcription_url")
|
||
.and_then(Value::as_str)
|
||
})
|
||
.ok_or_else(|| AsrError::Api(format!("no transcription_url, raw={poll_resp}")))?
|
||
.to_string();
|
||
let trans_json: Value = client.get(&trans_url).send().await?.json().await?;
|
||
|
||
// dashscope transcription file shape:
|
||
// { "transcripts": [{ "text": "...", "sentences": [{text, begin_time, end_time}, ...] }] }
|
||
let mut full_text = String::new();
|
||
let mut sentences = Vec::<AsrSentence>::new();
|
||
if let Some(transcripts) = trans_json.get("transcripts").and_then(Value::as_array) {
|
||
for t in transcripts {
|
||
if let Some(text) = t.get("text").and_then(Value::as_str) {
|
||
if !full_text.is_empty() { full_text.push('\n'); }
|
||
full_text.push_str(text);
|
||
}
|
||
if let Some(arr) = t.get("sentences").and_then(Value::as_array) {
|
||
for s in arr {
|
||
sentences.push(AsrSentence {
|
||
text: s.get("text").and_then(Value::as_str).unwrap_or("").to_string(),
|
||
begin_time: s.get("begin_time").and_then(Value::as_i64),
|
||
end_time: s.get("end_time").and_then(Value::as_i64),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
} else if let Some(text) = trans_json.get("text").and_then(Value::as_str) {
|
||
full_text.push_str(text);
|
||
}
|
||
|
||
Ok(AsrResult {
|
||
success: true,
|
||
text: Some(full_text),
|
||
sentences: if sentences.is_empty() { None } else { Some(sentences) },
|
||
error: None,
|
||
})
|
||
}
|