Files
Genarrative/server-rs/crates/platform-audio/src/client.rs
T
kdletters 29e84a77d1
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m31s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m31s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m47s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m48s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m43s
Project CI / AI game creator shell Rust crates (push) Successful in 2m3s
Project CI / Repository checks (push) Successful in 3m43s
Project CI / Frontend tests (push) Successful in 4m53s
Project CI / Native shell tests (push) Successful in 6m35s
Project CI / Backend tests (push) Successful in 7m25s
Project CI / AI game creator shell web tests (push) Successful in 2m35s
切换 Tiantoken 并删除旧 Vidu 音效
新增 TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY 配置并切换文本与图片链路

保留 Suno VectorEngine 路径,删除旧 Vidu submit/poll/builder 实现

同步脚本、文档、测试与运行时 provider 路由
2026-09-15 19:43:35 +08:00

591 lines
20 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.
use std::error::Error;
use reqwest::header;
use serde_json::Value;
use crate::response::{
extract_audio_urls, extract_string_by_path, extract_suno_clip_ids, find_first_string_by_key,
is_failed_task_status, normalize_task_status,
};
use crate::{
AudioError, AudioTaskKind, AudioTaskResponse, BackgroundMusicTaskRequest,
EditorBackgroundMusicTaskRequest, VectorEngineAudioSettings, build_background_music_task_body,
build_editor_background_music_task_body,
};
pub fn build_vector_engine_audio_http_client(
settings: &VectorEngineAudioSettings,
) -> Result<reqwest::Client, AudioError> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(
settings.request_timeout_ms.max(1),
))
.build()
.map_err(|error| {
AudioError::invalid_config(format!(
"构造 VectorEngine 音频生成 HTTP 客户端失败:{error}"
))
})
}
pub async fn submit_background_music_task(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
request: BackgroundMusicTaskRequest,
) -> Result<AudioTaskResponse, AudioError> {
let body = build_background_music_task_body(request)?;
let response = post_vector_engine_json(
http_client,
settings,
AudioTaskKind::BackgroundMusic.submit_path(),
body,
"提交 Suno 背景音乐任务失败",
)
.await?;
let task_id = extract_submit_task_id(&response).ok_or_else(|| {
AudioError::missing_audio("提交 Suno 背景音乐任务失败:上游未返回任务 ID")
})?;
Ok(AudioTaskResponse {
kind: AudioTaskKind::BackgroundMusic,
task_id,
provider: AudioTaskKind::BackgroundMusic.provider().to_string(),
status: "submitted".to_string(),
})
}
pub async fn submit_editor_background_music_task(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
request: EditorBackgroundMusicTaskRequest,
) -> Result<AudioTaskResponse, AudioError> {
let body = build_editor_background_music_task_body(request)?;
let response = post_vector_engine_json(
http_client,
settings,
AudioTaskKind::BackgroundMusic.submit_path(),
body,
"提交编辑器背景音乐任务失败",
)
.await?;
let task_id = extract_submit_task_id(&response).ok_or_else(|| {
AudioError::missing_audio("提交编辑器背景音乐任务失败:上游未返回任务 ID")
})?;
Ok(AudioTaskResponse {
kind: AudioTaskKind::BackgroundMusic,
task_id,
provider: AudioTaskKind::BackgroundMusic.provider().to_string(),
status: "submitted".to_string(),
})
}
async fn fetch_audio_task_payload(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
kind: AudioTaskKind,
task_id: &str,
) -> Result<Value, AudioError> {
get_vector_engine_json(
http_client,
settings,
&kind.fetch_path(task_id),
match kind {
AudioTaskKind::BackgroundMusic => "查询 Suno 背景音乐任务失败",
AudioTaskKind::SunoSoundEffect => "查询 Suno 音效任务失败",
},
)
.await
}
pub async fn resolve_audio_task_download_urls(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
kind: AudioTaskKind,
task_id: &str,
) -> Result<(String, Vec<String>), AudioError> {
let task_payload = fetch_audio_task_payload(http_client, settings, kind, task_id).await?;
let status = normalize_task_status(
find_first_string_by_key(&task_payload, "status")
.or_else(|| find_first_string_by_key(&task_payload, "state"))
.or_else(|| find_first_string_by_key(&task_payload, "Status"))
.as_deref()
.unwrap_or(""),
);
let mut audio_urls = extract_audio_urls(&task_payload);
if matches!(
kind,
AudioTaskKind::BackgroundMusic | AudioTaskKind::SunoSoundEffect
) {
let (downloadable_audio_urls, clip_ids, has_audiopipe_url) =
resolve_suno_audio_candidates(&task_payload, audio_urls);
// Suno 查询结果还会混入 image_url 等非音频 URL。一旦存在 audiopipe,
// 不保留同一 payload 的其它直连候选,强制用 item_id 获取稳定 wav。
audio_urls = if has_audiopipe_url {
Vec::new()
} else {
downloadable_audio_urls
};
let has_clip_ids = !clip_ids.is_empty();
let mut latest_wav_error = None;
if should_wait_for_suno_wav_audio(&status, has_clip_ids, &audio_urls) {
for clip_id in clip_ids {
let wav_payload = match get_vector_engine_json(
http_client,
settings,
&format!("/suno/act/wav/{}", urlencoding::encode(clip_id.as_str())),
match kind {
AudioTaskKind::SunoSoundEffect => "获取 Suno 音效 wav 失败",
_ => "获取 Suno wav 音频失败",
},
)
.await
{
Ok(payload) => {
latest_wav_error = None;
payload
}
Err(error) => {
latest_wav_error = Some(error);
continue;
}
};
audio_urls = extract_audio_urls(&wav_payload);
if !audio_urls.is_empty() {
break;
}
}
}
// 中文注释:Suno 的 audiopipe 地址只是携带 item_id 的流式中转地址,线上可能
// 返回 200 后不结束 chunked body。统一把 item_id 当 clip id 转走稳定 wav 地址;
// wav 地址尚未就绪或子请求暂时失败时保持 processing,让上层继续轮询。
if let Some(status) = resolve_suno_status_after_wav_probe(
&status,
has_clip_ids,
&audio_urls,
latest_wav_error,
) {
return Ok((status, audio_urls));
}
}
Ok((status, audio_urls))
}
fn resolve_suno_audio_candidates(
task_payload: &Value,
audio_urls: Vec<String>,
) -> (Vec<String>, Vec<String>, bool) {
let mut clip_ids = extract_suno_clip_ids(task_payload);
let mut downloadable_audio_urls = Vec::new();
let mut has_audiopipe_url = false;
for audio_url in audio_urls {
let Ok(parsed_url) = reqwest::Url::parse(audio_url.as_str()) else {
downloadable_audio_urls.push(audio_url);
continue;
};
if !parsed_url
.host_str()
.is_some_and(|host| host.eq_ignore_ascii_case("audiopipe.suno.ai"))
{
downloadable_audio_urls.push(audio_url);
continue;
}
has_audiopipe_url = true;
if let Some(clip_id) = parsed_url
.query_pairs()
.find(|(key, _)| key.eq_ignore_ascii_case("item_id"))
.map(|(_, value)| value.trim().to_string())
.filter(|value| {
!value.is_empty() && value.len() <= 180 && !value.chars().any(char::is_whitespace)
})
&& !clip_ids.contains(&clip_id)
{
clip_ids.push(clip_id);
}
}
(downloadable_audio_urls, clip_ids, has_audiopipe_url)
}
fn should_wait_for_suno_wav_audio(status: &str, has_clip_ids: bool, audio_urls: &[String]) -> bool {
audio_urls.is_empty() && has_clip_ids && !is_failed_task_status(status)
}
fn resolve_suno_status_after_wav_probe(
status: &str,
has_clip_ids: bool,
audio_urls: &[String],
_latest_wav_error: Option<AudioError>,
) -> Option<String> {
if should_wait_for_suno_wav_audio(status, has_clip_ids, audio_urls) {
return Some("processing".to_string());
}
None
}
async fn get_vector_engine_json(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
path: &str,
failure_context: &str,
) -> Result<Value, AudioError> {
let endpoint = vector_engine_audio_endpoint_url(settings, path);
let response = http_client
.get(endpoint.as_str())
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::ACCEPT, "application/json")
.send()
.await
.map_err(|error| map_reqwest_error(failure_context, endpoint.as_str(), error))?;
parse_vector_engine_response(response, failure_context).await
}
async fn post_vector_engine_json(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
path: &str,
body: Value,
failure_context: &str,
) -> Result<Value, AudioError> {
let endpoint = vector_engine_audio_endpoint_url(settings, path);
let response = http_client
.post(endpoint.as_str())
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::ACCEPT, "application/json")
.header(header::CONTENT_TYPE, "application/json")
.json(&body)
.send()
.await
.map_err(|error| map_reqwest_error(failure_context, endpoint.as_str(), error))?;
parse_vector_engine_response(response, failure_context).await
}
pub(crate) fn vector_engine_audio_endpoint_url(
settings: &VectorEngineAudioSettings,
path: &str,
) -> String {
let base_url = normalize_vector_engine_audio_base_url(settings.base_url.as_str());
let path = path.trim();
if path.starts_with('/') {
format!("{base_url}{path}")
} else {
format!("{base_url}/{path}")
}
}
fn normalize_vector_engine_audio_base_url(base_url: &str) -> String {
let normalized = base_url.trim().trim_end_matches('/');
// 中文注释:Suno / Vidu 音频接口在 VectorEngine 根路径下,不能拼成 /v1/suno/*。
normalized
.strip_suffix("/v1")
.unwrap_or(normalized)
.trim_end_matches('/')
.to_string()
}
async fn parse_vector_engine_response(
response: reqwest::Response,
failure_context: &str,
) -> Result<Value, AudioError> {
let status = response.status();
let raw_text = response.text().await.map_err(|error| {
AudioError::request(
format!("{failure_context}:读取响应失败:{error}"),
None,
false,
false,
false,
true,
Some(status.as_u16()),
None,
)
})?;
if !status.is_success() {
return Err(AudioError::upstream(
build_upstream_error_message(failure_context, status.as_u16(), raw_text.as_str()),
status.as_u16(),
truncate_raw(raw_text.as_str()),
));
}
let payload = serde_json::from_str::<Value>(&raw_text).map_err(|error| {
AudioError::response_parse(
format!("{failure_context}:解析响应失败:{error}"),
truncate_raw(raw_text.as_str()),
)
})?;
if let Some(code) = payload.get("code")
&& !is_success_response_code(code)
{
return Err(AudioError::upstream(
extract_upstream_payload_message(&payload)
.unwrap_or_else(|| failure_context.to_string()),
status.as_u16(),
truncate_raw(raw_text.as_str()),
));
}
Ok(payload)
}
fn map_reqwest_error(failure_context: &str, endpoint: &str, error: reqwest::Error) -> AudioError {
AudioError::request(
format!("{failure_context}:{error}"),
Some(endpoint.to_string()),
error.is_timeout(),
error.is_connect(),
error.is_request(),
error.is_body(),
error.status().map(|status| status.as_u16()),
Error::source(&error).map(ToString::to_string),
)
}
fn truncate_raw(raw_text: &str) -> String {
raw_text.chars().take(800).collect()
}
fn extract_submit_task_id(response: &Value) -> Option<String> {
// 中文注释:Suno 音频提交接口文档未声明响应结构,实际可能直接把任务 ID 放在 data 字符串或 data 对象中。
extract_string_by_path(response, &["data"])
.or_else(|| extract_string_by_path(response, &["data", "task_id"]))
.or_else(|| extract_string_by_path(response, &["data", "taskId"]))
.or_else(|| extract_string_by_path(response, &["data", "id"]))
.or_else(|| find_first_string_by_key(response, "task_id"))
.or_else(|| find_first_string_by_key(response, "taskId"))
.or_else(|| find_first_string_by_key(response, "id"))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn build_upstream_error_message(failure_context: &str, status: u16, raw_text: &str) -> String {
let excerpt = truncate_raw(raw_text);
if excerpt.trim().is_empty() {
return format!("{failure_context}:上游 HTTP {status}");
}
format!("{failure_context}:上游 HTTP {status}:{excerpt}")
}
fn is_success_response_code(code: &Value) -> bool {
match code {
Value::String(code) => matches!(
code.trim().to_ascii_lowercase().as_str(),
"success" | "succeeded" | "ok" | "0" | "200"
),
Value::Number(code) => code.as_i64().is_some_and(|code| code == 0 || code == 200),
_ => false,
}
}
fn extract_upstream_payload_message(payload: &Value) -> Option<String> {
payload
.get("message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|message| !message.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
payload
.get("error")
.and_then(|value| match value {
Value::String(message) => Some(message.as_str()),
Value::Object(object) => object
.get("message")
.and_then(Value::as_str)
.or_else(|| object.get("detail").and_then(Value::as_str)),
_ => None,
})
.map(str::trim)
.filter(|message| !message.is_empty())
.map(ToOwned::to_owned)
})
}
#[cfg(test)]
mod tests {
use std::{
io::{Read, Write},
net::TcpListener,
thread,
time::Duration,
};
use super::*;
fn settings(base_url: &str) -> VectorEngineAudioSettings {
VectorEngineAudioSettings {
base_url: base_url.to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 1_000,
}
}
#[test]
fn audio_endpoint_urls_strip_v1_for_root_level_suno_paths() {
assert_eq!(
vector_engine_audio_endpoint_url(
&settings("https://api.vectorengine.cn"),
"/suno/submit/music"
),
"https://api.vectorengine.cn/suno/submit/music"
);
assert_eq!(
vector_engine_audio_endpoint_url(
&settings("https://api.vectorengine.cn/v1"),
"/suno/submit/music"
),
"https://api.vectorengine.cn/suno/submit/music"
);
assert_eq!(
vector_engine_audio_endpoint_url(
&settings(" https://api.vectorengine.cn/v1/ "),
"suno/submit/music"
),
"https://api.vectorengine.cn/suno/submit/music"
);
}
#[test]
fn vector_engine_audio_success_codes_accept_numeric_and_string_values() {
for code in [
Value::String("success".to_string()),
Value::String("200".to_string()),
Value::String("0".to_string()),
Value::from(200),
Value::from(0),
] {
assert!(is_success_response_code(&code), "{code}");
}
assert!(!is_success_response_code(&Value::String(
"invalid_request".to_string()
)));
}
#[test]
fn submit_task_id_extracts_suno_data_string_and_nested_ids() {
assert_eq!(
extract_submit_task_id(&serde_json::json!({
"code": 200,
"data": "suno-task-1"
})),
Some("suno-task-1".to_string())
);
assert_eq!(
extract_submit_task_id(&serde_json::json!({
"code": 200,
"data": {
"id": "suno-task-2"
}
})),
Some("suno-task-2".to_string())
);
assert_eq!(
extract_submit_task_id(&serde_json::json!({
"taskId": "legacy-task-1"
})),
Some("legacy-task-1".to_string())
);
}
#[test]
fn suno_clip_ids_without_wav_url_keep_task_pending() {
assert!(should_wait_for_suno_wav_audio(
"completed",
true,
&Vec::new()
));
assert!(!should_wait_for_suno_wav_audio("failed", true, &Vec::new()));
assert!(!should_wait_for_suno_wav_audio(
"completed",
true,
&["https://cdn.example.test/audio.wav".to_string()]
));
}
#[test]
fn suno_wav_probe_error_keeps_completed_task_pending() {
let status = resolve_suno_status_after_wav_probe(
"completed",
true,
&Vec::new(),
Some(AudioError::upstream(
"获取 Suno 音效 wav 失败",
502,
r#"{"message":"wav not ready"}"#,
)),
);
assert_eq!(status.as_deref(), Some("processing"));
}
#[test]
fn suno_audiopipe_url_uses_item_id_to_resolve_stable_wav_url() {
let listener = TcpListener::bind("127.0.0.1:0").expect("mock server should bind");
let address = listener
.local_addr()
.expect("mock server address should be readable");
let server = thread::spawn(move || {
let responses = [
(
"/suno/fetch/task-1",
r#"{"code":200,"data":{"status":"complete","audio_url":"https://audiopipe.suno.ai/?item_id=clip-1","image_url":"https://cdn.example.test/cover.jpg"}}"#,
),
(
"/suno/act/wav/clip-1",
r#"{"code":200,"data":{"url":"https://cdn.example.test/audio.wav"}}"#,
),
];
for (expected_path, body) in responses {
let (mut stream, _) = listener.accept().expect("request should connect");
let mut request = [0_u8; 2048];
let size = stream
.read(&mut request)
.expect("request should be readable");
let request = String::from_utf8_lossy(&request[..size]);
assert!(
request.starts_with(format!("GET {expected_path} HTTP/1.1").as_str()),
"unexpected request: {request}"
);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream
.write_all(response.as_bytes())
.expect("response should be writable");
}
});
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime should build");
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("http client should build");
let (status, audio_urls) = runtime
.block_on(resolve_audio_task_download_urls(
&http_client,
&settings(format!("http://{address}").as_str()),
AudioTaskKind::BackgroundMusic,
"task-1",
))
.expect("audiopipe item id should resolve through the wav endpoint");
assert_eq!(status, "completed");
assert_eq!(
audio_urls,
vec!["https://cdn.example.test/audio.wav".to_string()]
);
server.join().expect("mock server should finish");
}
}