1
This commit is contained in:
165
src-tauri/src/asr.rs
Normal file
165
src-tauri/src/asr.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user