//! 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, #[serde(default)] pub end_time: Option, } #[derive(Debug, Serialize)] pub struct AsrResult { pub success: bool, #[serde(skip_serializing_if = "Option::is_none")] pub text: Option, #[serde(skip_serializing_if = "Option::is_none")] pub sentences: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } pub async fn transcribe( audio_url: &str, api_key: &str, model: &str, ) -> Result { 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::::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, }) }