修复Suno背景音乐下载失败

识别audiopipe中转地址并通过item_id获取稳定WAV文件

避免封面地址混入音频候选并跳过失败任务的额外探测

补充Suno两跳解析回归测试并更新设计与排障文档
This commit is contained in:
2026-07-13 22:06:25 +08:00
parent 4e7bcb8f24
commit 838d819c3d
3 changed files with 149 additions and 34 deletions
@@ -152,11 +152,11 @@
- 验证:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml --test vector_engine_audio` 中音效请求体测试必须同时断言 `prompt``sound`;必要时用线上生成音效 smoke 确认不再出现 `missing field sound`
- 关联:`server-rs/crates/platform-audio/src/request.rs``server-rs/crates/platform-audio/tests/vector_engine_audio.rs``docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`
## Suno 任务完成不代表已经拿到 wav 下载地址
## Suno 任务完成或返回 audiopipe 不代表已经拿到稳定下载地址
- 现象:画板生成背景音乐时,前端报 `音频生成尚未返回可下载地址(requestId:...)`;画板生成音效时,前端可能报 `获取 Suno 音效 wav 失败(requestId:...)`。上游任务可能已经完成,但 wav 下载地址还没就绪
- 原因:VectorEngine Suno `/suno/fetch/{task_id}` 可能先在 `data` 中返回歌曲 / 音效 clip id,而不是直接返回 `.wav` / `.mp3` URL;需要再调用 `/suno/act/wav/{clipId}` 获取 `wav_file_url`。如果只兼容 `data` 是字符串,会漏掉 `data` 对象 / 数组里的 `id``clip_id``audioId``songId`
- 处理:`platform-audio` 查询 Suno 结果时先提取直接音频 URL没有 URL 时,从 `data` 字符串、对象或数组提取 clip id,逐个调用 `/suno/act/wav/{clipId}`。已拿到 clip id 但 wav 地址仍未就绪,或 wav 子请求暂时返回上游错误时,都保持 `processing` 让上层继续轮询,不能直接判定为缺少可下载地址或 wav 获取失败。
- 现象:画板生成背景音乐时,前端可能`音频生成尚未返回可下载地址(requestId:...)``获取 Suno 音效 wav 失败(requestId:...)``读取生成音频内容失败:error decoding response body`。上游任务可能已经完成并返回 `https://audiopipe.suno.ai/?item_id=...`,但该地址仍可能以 `200 + chunked` 开始响应后不返回完整正文
- 原因:VectorEngine Suno `/suno/fetch/{task_id}` 可能先在 `data` 中返回歌曲 / 音效 clip id,或返回只携带 `item_id` 的 audiopipe 流式中转地址,而不是稳定 `.wav` / `.mp3` 文件 URL;需要再调用 `/suno/act/wav/{clipId}` 获取实际文件地址。如果只在“完全没有 URL”时回退 wav,会误把 audiopipe 当最终文件并让 worker 在正文读取阶段卡满请求超时
- 处理:`platform-audio` 查询 Suno 结果时保留普通直接音频 URL遇到 audiopipe 时不直接下载,而是从查询结果的 `id` / `clip_id` / `audioId` / `songId` 或 audiopipe `item_id` 提取 clip id,逐个调用 `/suno/act/wav/{clipId}`。已拿到 clip id 但 wav 地址仍未就绪,或 wav 子请求暂时返回上游错误时,都保持 `processing` 让上层继续轮询,不能直接判定为缺少可下载地址或 wav 获取失败。
- 验证:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml``cargo test -p api-server vector_engine_audio_generation --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/platform-audio/src/client.rs``server-rs/crates/platform-audio/src/response.rs``docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`
@@ -106,7 +106,7 @@ POST /api/editor/audios/background-music/generations
- Suno 音乐接口路径固定为 `/suno/submit/music``VECTOR_ENGINE_BASE_URL` 即使配置为带 `/v1` 的图片接口根,也要在 `platform-audio` 中归一为根路径后再拼接,避免误请求 `/v1/suno/submit/music`
- 音效 body 使用 Vidu 文生音频契约:提交 `/ent/v2/text2audio`,请求体包含 `model: "audio1.0"``prompt``sound: prompt``duration` 和可选 `seed``model``prompt` 为文档必填,`sound` 用于兼容线上网关实际校验,`prompt` 最长 1500 字符,`duration` 按 Vidu 文档限制在 `2-10` 秒。
- 编辑器音效轮询使用 Vidu 路径 `/ent/v2/tasks/{taskId}/creations`,不再使用 Suno `/suno/fetch/{taskId}`Suno 文生音效 `task: "sound"` 暂不从编辑器入口暴露。
- Suno 提交成功后的任务 ID 兼容与 wav clip 轮询逻辑只保留给背景音乐链路,避免误伤 `/api/editor/audios/background-music/generations`
- Suno 提交成功后的任务 ID 兼容与 wav clip 轮询逻辑只保留给背景音乐链路`/suno/fetch/{taskId}` 返回 `audiopipe.suno.ai/?item_id=...` 时,该地址只作为 clip id 来源,不作为最终下载文件,后端继续调用 `/suno/act/wav/{clipId}` 获取稳定 wav URL,避免 worker 在不完整 chunked body 上卡满超时
- VectorEngine 音频响应的 `code` 需要兼容 `"success"``"ok"``"0"``"200"` 以及数字 `0` / `200`;HTTP 非 2xx 时后端错误信息应透出安全的上游状态和短响应摘要,避免前端只显示笼统提交失败。
-`api-server` 增加编辑器音频 BFF
- `/api/editor/audios/sound-effects/generations`
+144 -29
View File
@@ -171,40 +171,49 @@ pub async fn resolve_audio_task_download_urls(
if matches!(
kind,
AudioTaskKind::BackgroundMusic | AudioTaskKind::SunoSoundEffect
) && audio_urls.is_empty()
{
let clip_ids = extract_suno_clip_ids(&task_payload);
) {
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;
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
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;
}
Err(error) => {
latest_wav_error = Some(error);
continue;
}
};
audio_urls = extract_audio_urls(&wav_payload);
if !audio_urls.is_empty() {
break;
}
}
// 中文注释:Suno 任务完成后可能先返回 clip id/suno/act/wav/{clip_id}
// 可能返回 404/业务错误或 wav_file_url 仍为 null。此时不能把任务判成失败,
// 让上层轮询继续等待;真正失败仍交给 fetch 任务状态判断
// 中文注释: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,
@@ -217,6 +226,42 @@ pub async fn resolve_audio_task_download_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)
}
@@ -419,6 +464,13 @@ fn extract_upstream_payload_message(payload: &Value) -> Option<String> {
#[cfg(test)]
mod tests {
use std::{
io::{Read, Write},
net::TcpListener,
thread,
time::Duration,
};
use super::*;
fn settings(base_url: &str) -> VectorEngineAudioSettings {
@@ -527,4 +579,67 @@ mod tests {
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");
}
}