修复Suno背景音乐下载失败
识别audiopipe中转地址并通过item_id获取稳定WAV文件 避免封面地址混入音频候选并跳过失败任务的额外探测 补充Suno两跳解析回归测试并更新设计与排障文档
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user