Files
yaoayanui/src-tauri/src/asr.rs
fengchuanhn@gmail.com 27e83c564c 11
2026-05-18 08:52:38 +08:00

179 lines
5.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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,
})
}