From 838d819c3da8a29fe8ac0160c2460f37d1e6a8f8 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 13 Jul 2026 22:06:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DSuno=E8=83=8C=E6=99=AF?= =?UTF-8?q?=E9=9F=B3=E4=B9=90=E4=B8=8B=E8=BD=BD=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 识别audiopipe中转地址并通过item_id获取稳定WAV文件 避免封面地址混入音频候选并跳过失败任务的额外探测 补充Suno两跳解析回归测试并更新设计与排障文档 --- docs/project-memory/shared-memory/pitfalls.md | 8 +- ...编辑器】画板音乐生成入口设计-2026-06-18.md | 2 +- server-rs/crates/platform-audio/src/client.rs | 173 +++++++++++++++--- 3 files changed, 149 insertions(+), 34 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 69e905c5d..7b76e0806 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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`。 diff --git a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md index df513b2cc..d6b3ae675 100644 --- a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md +++ b/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` diff --git a/server-rs/crates/platform-audio/src/client.rs b/server-rs/crates/platform-audio/src/client.rs index d993d1005..df8205746 100644 --- a/server-rs/crates/platform-audio/src/client.rs +++ b/server-rs/crates/platform-audio/src/client.rs @@ -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, +) -> (Vec, Vec, 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 { #[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"); + } }