166 lines
5.4 KiB
Rust
166 lines
5.4 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
|
|
let submit_body = json!({
|
|
"model": model,
|
|
"input": { "file_urls": [audio_url] },
|
|
"parameters": {}
|
|
});
|
|
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
|
|
let trans_url = 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,
|
|
})
|
|
}
|