This commit is contained in:
fengchuanhn@gmail.com
2026-05-16 11:24:18 +08:00
parent 491231bdd5
commit c88b2d0f3d
17 changed files with 2401 additions and 16 deletions

165
src-tauri/src/asr.rs Normal file
View File

@@ -0,0 +1,165 @@
//! 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,
})
}