refactor: extract platform media crates

This commit is contained in:
kdletters
2026-05-26 13:18:13 +08:00
parent 50f44489cd
commit 44c65df5c9
92 changed files with 7381 additions and 5848 deletions

View File

@@ -0,0 +1,13 @@
[package]
name = "platform-audio"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
platform-oss = { workspace = true }
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["time"] }
tracing = { workspace = true }
urlencoding = { workspace = true }

View File

@@ -0,0 +1,255 @@
use std::error::Error;
use reqwest::header;
use serde_json::Value;
use crate::response::{
extract_audio_urls, extract_string_by_path, find_first_string_by_key, normalize_task_status,
};
use crate::{
AudioError, AudioTaskKind, AudioTaskResponse, BackgroundMusicTaskRequest,
SoundEffectTaskRequest, VectorEngineAudioSettings, build_background_music_task_body,
build_sound_effect_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_string_by_path(&response, &["data"])
.or_else(|| find_first_string_by_key(&response, "task_id"))
.or_else(|| find_first_string_by_key(&response, "taskId"))
.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_sound_effect_task(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
request: SoundEffectTaskRequest,
) -> Result<AudioTaskResponse, AudioError> {
let body = build_sound_effect_task_body(request)?;
let response = post_vector_engine_json(
http_client,
settings,
AudioTaskKind::SoundEffect.submit_path(),
body,
"提交 Vidu 音效任务失败",
)
.await?;
let task_id = find_first_string_by_key(&response, "task_id")
.or_else(|| find_first_string_by_key(&response, "taskId"))
.ok_or_else(|| AudioError::missing_audio("提交 Vidu 音效任务失败:上游未返回任务 ID"))?;
let status = find_first_string_by_key(&response, "state").unwrap_or_else(|| "created".into());
Ok(AudioTaskResponse {
kind: AudioTaskKind::SoundEffect,
task_id,
provider: AudioTaskKind::SoundEffect.provider().to_string(),
status,
})
}
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::SoundEffect => "查询 Vidu 音效任务失败",
},
)
.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 kind == AudioTaskKind::BackgroundMusic && audio_urls.is_empty() {
if let Some(clip_id) = extract_string_by_path(&task_payload, &["data"]).and_then(|value| {
if value.trim().is_empty() {
None
} else {
Some(value)
}
}) {
let wav_payload = get_vector_engine_json(
http_client,
settings,
&format!("/suno/act/wav/{}", urlencoding::encode(clip_id.as_str())),
"获取 Suno wav 音频失败",
)
.await?;
audio_urls = extract_audio_urls(&wav_payload);
}
}
Ok((status, audio_urls))
}
async fn get_vector_engine_json(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
path: &str,
failure_context: &str,
) -> Result<Value, AudioError> {
let response = http_client
.get(format!(
"{}{}",
settings.base_url.trim_end_matches('/'),
path
))
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::ACCEPT, "application/json")
.send()
.await
.map_err(|error| map_reqwest_error(failure_context, path, 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 response = http_client
.post(format!(
"{}{}",
settings.base_url.trim_end_matches('/'),
path
))
.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, path, error))?;
parse_vector_engine_response(response, failure_context).await
}
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(
failure_context.to_string(),
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").and_then(Value::as_str)
&& !matches!(
code.trim().to_ascii_lowercase().as_str(),
"success" | "succeeded" | "ok"
)
{
return Err(AudioError::upstream(
payload
.get("message")
.and_then(Value::as_str)
.unwrap_or(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()
}

View File

@@ -0,0 +1,118 @@
use std::error::Error;
use reqwest::header;
use crate::{AudioError, DownloadedAudio, MAX_GENERATED_AUDIO_BYTES};
pub fn normalize_audio_mime_type(content_type: &str, audio_url: &str) -> String {
let mime_type = content_type
.split(';')
.next()
.map(str::trim)
.filter(|value| value.starts_with("audio/"))
.unwrap_or("");
match mime_type {
"audio/mpeg" | "audio/mp3" => "audio/mpeg".to_string(),
"audio/wav" | "audio/wave" | "audio/x-wav" => "audio/wav".to_string(),
"audio/ogg" => "audio/ogg".to_string(),
"audio/webm" => "audio/webm".to_string(),
"audio/aac" => "audio/aac".to_string(),
"audio/flac" => "audio/flac".to_string(),
"audio/mp4" | "audio/x-m4a" => "audio/mp4".to_string(),
_ => mime_type_from_audio_url(audio_url),
}
}
pub fn audio_mime_to_extension(mime_type: &str) -> &'static str {
match mime_type {
"audio/wav" => "wav",
"audio/ogg" => "ogg",
"audio/webm" => "webm",
"audio/aac" => "aac",
"audio/flac" => "flac",
"audio/mp4" => "m4a",
_ => "mp3",
}
}
pub async fn download_generated_audio(
http_client: &reqwest::Client,
audio_url: &str,
_provider: &str,
) -> Result<DownloadedAudio, AudioError> {
let response = http_client.get(audio_url).send().await.map_err(|error| {
AudioError::request(
format!("下载生成音频失败:{error}"),
Some(audio_url.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),
)
})?;
let status = response.status();
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("audio/mpeg")
.to_string();
let body = response.bytes().await.map_err(|error| {
AudioError::request(
format!("读取生成音频内容失败:{error}"),
Some(audio_url.to_string()),
false,
false,
false,
true,
None,
None,
)
})?;
if !status.is_success() {
return Err(AudioError::upstream(
format!("下载生成音频失败HTTP {}", status.as_u16()),
status.as_u16(),
truncate_raw(""),
));
}
if body.is_empty() || body.len() > MAX_GENERATED_AUDIO_BYTES {
return Err(AudioError::missing_audio("生成音频内容为空或超过大小上限"));
}
let mime_type = normalize_audio_mime_type(&content_type, audio_url);
Ok(DownloadedAudio {
extension: audio_mime_to_extension(&mime_type).to_string(),
mime_type,
bytes: body.to_vec(),
})
}
fn mime_type_from_audio_url(audio_url: &str) -> String {
let path = audio_url
.split('?')
.next()
.unwrap_or_default()
.to_ascii_lowercase();
if path.ends_with(".wav") {
"audio/wav".to_string()
} else if path.ends_with(".ogg") {
"audio/ogg".to_string()
} else if path.ends_with(".webm") {
"audio/webm".to_string()
} else if path.ends_with(".aac") {
"audio/aac".to_string()
} else if path.ends_with(".flac") {
"audio/flac".to_string()
} else if path.ends_with(".m4a") {
"audio/mp4".to_string()
} else {
"audio/mpeg".to_string()
}
}
fn truncate_raw(raw_text: &str) -> String {
raw_text.chars().take(800).collect()
}

View File

@@ -0,0 +1,167 @@
use std::{error::Error, fmt};
use crate::VECTOR_ENGINE_PROVIDER;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AudioStatusHint {
BadRequest,
ServiceUnavailable,
BadGateway,
GatewayTimeout,
}
#[derive(Clone, Debug)]
pub enum AudioError {
InvalidConfig {
provider: &'static str,
message: String,
},
InvalidRequest {
provider: &'static str,
message: String,
},
Request {
provider: &'static str,
message: String,
endpoint: Option<String>,
timeout: bool,
connect: bool,
request: bool,
body: bool,
status_code: Option<u16>,
source: Option<String>,
},
Upstream {
provider: &'static str,
message: String,
upstream_status: u16,
raw_excerpt: String,
},
ResponseParse {
provider: &'static str,
message: String,
raw_excerpt: String,
},
MissingAudio {
provider: &'static str,
message: String,
},
}
impl AudioError {
pub fn provider(&self) -> &'static str {
match self {
Self::InvalidConfig { provider, .. }
| Self::InvalidRequest { provider, .. }
| Self::Request { provider, .. }
| Self::Upstream { provider, .. }
| Self::ResponseParse { provider, .. }
| Self::MissingAudio { provider, .. } => provider,
}
}
pub fn message(&self) -> &str {
match self {
Self::InvalidConfig { message, .. }
| Self::InvalidRequest { message, .. }
| Self::Request { message, .. }
| Self::Upstream { message, .. }
| Self::ResponseParse { message, .. }
| Self::MissingAudio { message, .. } => message,
}
}
pub fn status_hint(&self) -> AudioStatusHint {
match self {
Self::InvalidConfig { .. } => AudioStatusHint::ServiceUnavailable,
Self::InvalidRequest { .. } => AudioStatusHint::BadRequest,
Self::Request {
timeout,
status_code,
..
} if *timeout => AudioStatusHint::GatewayTimeout,
Self::Request { status_code, .. }
if status_code.is_some_and(|status| status >= 500) =>
{
AudioStatusHint::BadGateway
}
Self::Upstream { .. } | Self::ResponseParse { .. } | Self::MissingAudio { .. } => {
AudioStatusHint::BadGateway
}
Self::Request { .. } => AudioStatusHint::BadGateway,
}
}
pub fn invalid_config(message: impl Into<String>) -> Self {
Self::InvalidConfig {
provider: VECTOR_ENGINE_PROVIDER,
message: message.into(),
}
}
pub fn invalid_request(message: impl Into<String>) -> Self {
Self::InvalidRequest {
provider: VECTOR_ENGINE_PROVIDER,
message: message.into(),
}
}
pub fn request(
message: impl Into<String>,
endpoint: Option<String>,
timeout: bool,
connect: bool,
request: bool,
body: bool,
status_code: Option<u16>,
source: Option<String>,
) -> Self {
Self::Request {
provider: VECTOR_ENGINE_PROVIDER,
message: message.into(),
endpoint,
timeout,
connect,
request,
body,
status_code,
source,
}
}
pub fn upstream(
message: impl Into<String>,
upstream_status: u16,
raw_excerpt: impl Into<String>,
) -> Self {
Self::Upstream {
provider: VECTOR_ENGINE_PROVIDER,
message: message.into(),
upstream_status,
raw_excerpt: raw_excerpt.into(),
}
}
pub fn response_parse(message: impl Into<String>, raw_excerpt: impl Into<String>) -> Self {
Self::ResponseParse {
provider: VECTOR_ENGINE_PROVIDER,
message: message.into(),
raw_excerpt: raw_excerpt.into(),
}
}
pub fn missing_audio(message: impl Into<String>) -> Self {
Self::MissingAudio {
provider: VECTOR_ENGINE_PROVIDER,
message: message.into(),
}
}
}
impl fmt::Display for AudioError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.message())
}
}
impl Error for AudioError {}

View File

@@ -0,0 +1,32 @@
mod client;
mod download;
mod error;
mod persist;
mod request;
mod response;
mod types;
pub use client::{
build_vector_engine_audio_http_client, resolve_audio_task_download_urls,
submit_background_music_task, submit_sound_effect_task,
};
pub use download::{audio_mime_to_extension, download_generated_audio, normalize_audio_mime_type};
pub use error::{AudioError, AudioStatusHint};
pub use persist::{
GeneratedAudioPersistInput, GeneratedAudioPersistTarget, prepare_generated_audio_put_request,
};
pub use request::{
build_background_music_task_body, build_sound_effect_task_body, normalize_limited_text,
normalize_limited_text_allow_empty, normalize_optional_text,
};
pub use response::{
extract_audio_urls, is_failed_task_status, is_pending_task_status, normalize_task_status,
};
pub use types::{
AudioTaskKind, AudioTaskResponse, BackgroundMusicTaskRequest,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS, DownloadedAudio, MAX_GENERATED_AUDIO_BYTES,
SUNO_DEFAULT_MODEL, SUNO_PROMPT_MAX_CHARS, SUNO_TAGS_MAX_CHARS, SUNO_TITLE_MAX_CHARS,
SoundEffectTaskRequest, VECTOR_ENGINE_PROVIDER, VECTOR_ENGINE_SUNO_PROVIDER,
VECTOR_ENGINE_VIDU_PROVIDER, VIDU_AUDIO_MODEL, VIDU_PROMPT_MAX_CHARS,
VectorEngineAudioSettings,
};

View File

@@ -0,0 +1,106 @@
use std::collections::BTreeMap;
use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest};
use crate::{AudioTaskKind, DownloadedAudio};
#[derive(Clone, Debug)]
pub struct GeneratedAudioPersistTarget {
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub profile_id: Option<String>,
pub storage_prefix: LegacyAssetPrefix,
pub storage_scope: String,
}
#[derive(Clone, Debug)]
pub struct GeneratedAudioPersistInput {
pub owner_user_id: String,
pub task_id: String,
pub task_kind: AudioTaskKind,
pub target: GeneratedAudioPersistTarget,
pub audio: DownloadedAudio,
}
pub fn prepare_generated_audio_put_request(
input: GeneratedAudioPersistInput,
) -> OssPutObjectRequest {
let file_name = format!(
"{}-{}.{}",
input.task_kind.file_stem(),
input.task_id,
input.audio.extension
);
OssPutObjectRequest {
prefix: input.target.storage_prefix,
path_segments: vec![
input.target.storage_scope.clone(),
input
.target
.profile_id
.clone()
.unwrap_or_else(|| "draft".to_string()),
input.target.entity_id.clone(),
input.target.slot.clone(),
]
.into_iter()
.map(|segment| sanitize_audio_path_segment(segment.as_str(), "audio"))
.collect(),
file_name,
content_type: Some(input.audio.mime_type),
access: OssObjectAccess::Private,
metadata: build_audio_asset_metadata(
input.owner_user_id.as_str(),
input.target.profile_id.as_deref(),
&input.target,
input.task_kind,
),
body: input.audio.bytes,
}
}
fn build_audio_asset_metadata(
owner_user_id: &str,
profile_id: Option<&str>,
target: &GeneratedAudioPersistTarget,
task_kind: AudioTaskKind,
) -> BTreeMap<String, String> {
let mut metadata = BTreeMap::from([
("asset-kind".to_string(), target.asset_kind.clone()),
("owner-user-id".to_string(), owner_user_id.to_string()),
("entity-kind".to_string(), target.entity_kind.clone()),
("entity-id".to_string(), target.entity_id.clone()),
("slot".to_string(), target.slot.clone()),
("provider".to_string(), task_kind.provider().to_string()),
]);
if let Some(profile_id) = profile_id {
metadata.insert("profile-id".to_string(), profile_id.to_string());
}
metadata
}
fn sanitize_audio_path_segment(raw: &str, fallback: &str) -> String {
let normalized = raw
.trim()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
let collapsed = normalized
.split('-')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-");
if collapsed.is_empty() {
fallback.to_string()
} else {
collapsed.chars().take(80).collect()
}
}

View File

@@ -0,0 +1,94 @@
use serde_json::{Map, Value, json};
use crate::{
AudioError, BackgroundMusicTaskRequest, SUNO_DEFAULT_MODEL, SUNO_PROMPT_MAX_CHARS,
SUNO_TAGS_MAX_CHARS, SUNO_TITLE_MAX_CHARS, SoundEffectTaskRequest, VIDU_AUDIO_MODEL,
VIDU_PROMPT_MAX_CHARS,
};
pub fn build_background_music_task_body(
request: BackgroundMusicTaskRequest,
) -> Result<Value, AudioError> {
let prompt =
normalize_limited_text_allow_empty(&request.prompt, "prompt", SUNO_PROMPT_MAX_CHARS)?;
let title = normalize_limited_text(&request.title, "title", SUNO_TITLE_MAX_CHARS)?;
let tags = request
.tags
.as_deref()
.map(|value| normalize_limited_text(value, "tags", SUNO_TAGS_MAX_CHARS))
.transpose()?;
let model = normalize_optional_text(request.model.as_deref())
.unwrap_or_else(|| SUNO_DEFAULT_MODEL.to_string());
let mut body = Map::from_iter([
("prompt".to_string(), Value::String(prompt)),
("mv".to_string(), Value::String(model)),
("title".to_string(), Value::String(title)),
("task".to_string(), Value::String("generate".to_string())),
(
"make_instrumental".to_string(),
Value::Bool(request.instrumental),
),
]);
if let Some(tags) = tags {
body.insert("tags".to_string(), Value::String(tags));
}
Ok(Value::Object(body))
}
pub fn build_sound_effect_task_body(request: SoundEffectTaskRequest) -> Result<Value, AudioError> {
let prompt = normalize_limited_text(&request.prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?;
let duration = request.duration.clamp(2, 10);
let mut body = Map::from_iter([
(
"model".to_string(),
Value::String(VIDU_AUDIO_MODEL.to_string()),
),
("prompt".to_string(), Value::String(prompt)),
("duration".to_string(), json!(duration)),
]);
if let Some(seed) = request.seed {
body.insert("seed".to_string(), json!(seed));
}
Ok(Value::Object(body))
}
pub fn normalize_limited_text(
value: &str,
field: &'static str,
max_chars: usize,
) -> Result<String, AudioError> {
let normalized = value.trim().to_string();
if normalized.is_empty() {
return Err(AudioError::invalid_request(format!("{field} 不能为空")));
}
if normalized.chars().count() > max_chars {
return Err(AudioError::invalid_request(format!(
"{field} 超过 {} 字符",
max_chars
)));
}
Ok(normalized)
}
pub fn normalize_limited_text_allow_empty(
value: &str,
field: &'static str,
max_chars: usize,
) -> Result<String, AudioError> {
let normalized = value.trim().to_string();
if normalized.chars().count() > max_chars {
return Err(AudioError::invalid_request(format!(
"{field} 超过 {} 字符",
max_chars
)));
}
Ok(normalized)
}
pub fn normalize_optional_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}

View File

@@ -0,0 +1,125 @@
use serde_json::Value;
pub fn normalize_task_status(status: &str) -> String {
let normalized = status.trim().to_ascii_lowercase().replace(' ', "_");
match normalized.as_str() {
"finish" | "finished" | "complete" | "completed" | "success" | "succeeded" => {
"completed".to_string()
}
"" => "processing".to_string(),
value => value.to_string(),
}
}
pub fn is_pending_task_status(status: &str) -> bool {
matches!(
status,
"created" | "pending" | "queued" | "processing" | "running" | "submitted" | "started"
)
}
pub fn is_failed_task_status(status: &str) -> bool {
matches!(
status,
"failed" | "error" | "canceled" | "cancelled" | "rejected" | "expired"
)
}
pub fn extract_audio_urls(payload: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_audio_url_strings(payload, &mut urls);
let mut deduped = Vec::new();
for url in urls {
if !deduped.contains(&url) {
deduped.push(url);
}
}
deduped
}
pub(crate) fn find_first_string_by_key(value: &Value, target_key: &str) -> Option<String> {
match value {
Value::Object(object) => {
for (key, value) in object {
if key.eq_ignore_ascii_case(target_key)
&& let Some(text) = value.as_str()
{
return Some(text.trim().to_string());
}
if let Some(found) = find_first_string_by_key(value, target_key) {
return Some(found);
}
}
None
}
Value::Array(items) => items
.iter()
.find_map(|item| find_first_string_by_key(item, target_key)),
_ => None,
}
}
pub(crate) fn extract_string_by_path(value: &Value, path: &[&str]) -> Option<String> {
let mut current = value;
for key in path {
current = current.get(*key)?;
}
current.as_str().map(str::trim).map(ToOwned::to_owned)
}
fn collect_audio_url_strings(value: &Value, output: &mut Vec<String>) {
match value {
Value::Object(object) => {
for (key, value) in object {
if let Some(raw) = value.as_str()
&& looks_like_audio_url_key(key)
&& looks_like_http_url(raw)
{
output.push(raw.trim().to_string());
}
collect_audio_url_strings(value, output);
}
}
Value::Array(items) => {
for item in items {
collect_audio_url_strings(item, output);
}
}
Value::String(raw) if looks_like_http_url(raw) && looks_like_audio_url(raw) => {
output.push(raw.trim().to_string());
}
_ => {}
}
}
fn looks_like_audio_url_key(key: &str) -> bool {
let normalized = key.trim().to_ascii_lowercase();
normalized.contains("audio")
|| normalized.contains("wav")
|| normalized.contains("mp3")
|| normalized.contains("fileurl")
|| normalized == "url"
|| normalized.ends_with("_url")
|| normalized.ends_with("url")
}
fn looks_like_http_url(value: &str) -> bool {
let value = value.trim().to_ascii_lowercase();
value.starts_with("http://") || value.starts_with("https://")
}
fn looks_like_audio_url(value: &str) -> bool {
let value = value
.trim()
.split('?')
.next()
.unwrap_or_default()
.to_ascii_lowercase();
value.ends_with(".mp3")
|| value.ends_with(".wav")
|| value.ends_with(".m4a")
|| value.ends_with(".aac")
|| value.ends_with(".ogg")
|| value.ends_with(".webm")
|| value.ends_with(".flac")
}

View File

@@ -0,0 +1,87 @@
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AudioTaskKind {
BackgroundMusic,
SoundEffect,
}
impl AudioTaskKind {
pub fn provider(self) -> &'static str {
match self {
Self::BackgroundMusic => VECTOR_ENGINE_SUNO_PROVIDER,
Self::SoundEffect => VECTOR_ENGINE_VIDU_PROVIDER,
}
}
pub fn submit_path(self) -> &'static str {
match self {
Self::BackgroundMusic => "/suno/submit/music",
Self::SoundEffect => "/ent/v2/text2audio",
}
}
pub fn fetch_path(self, task_id: &str) -> String {
match self {
Self::BackgroundMusic => format!("/suno/fetch/{}", urlencoding::encode(task_id)),
Self::SoundEffect => {
format!("/ent/v2/tasks/{}/creations", urlencoding::encode(task_id))
}
}
}
pub fn file_stem(self) -> &'static str {
match self {
Self::BackgroundMusic => "background-music",
Self::SoundEffect => "sound-effect",
}
}
}
#[derive(Clone, Debug)]
pub struct BackgroundMusicTaskRequest {
pub prompt: String,
pub title: String,
pub tags: Option<String>,
pub model: Option<String>,
pub instrumental: bool,
}
#[derive(Clone, Debug)]
pub struct SoundEffectTaskRequest {
pub prompt: String,
pub duration: u8,
pub seed: Option<u64>,
}
#[derive(Clone, Debug)]
pub struct AudioTaskResponse {
pub kind: AudioTaskKind,
pub task_id: String,
pub provider: String,
pub status: String,
}
#[derive(Clone, Debug)]
pub struct VectorEngineAudioSettings {
pub base_url: String,
pub api_key: String,
pub request_timeout_ms: u64,
}
#[derive(Clone, Debug)]
pub struct DownloadedAudio {
pub bytes: Vec<u8>,
pub mime_type: String,
pub extension: String,
}
pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
pub const VECTOR_ENGINE_SUNO_PROVIDER: &str = "vector-engine-suno";
pub const VECTOR_ENGINE_VIDU_PROVIDER: &str = "vector-engine-vidu";
pub const SUNO_DEFAULT_MODEL: &str = "chirp-v4";
pub const VIDU_AUDIO_MODEL: &str = "audio1.0";
pub const SUNO_PROMPT_MAX_CHARS: usize = 5_000;
pub const SUNO_TITLE_MAX_CHARS: usize = 80;
pub const SUNO_TAGS_MAX_CHARS: usize = 160;
pub const VIDU_PROMPT_MAX_CHARS: usize = 1_500;
pub const DEFAULT_SOUND_EFFECT_DURATION_SECONDS: u8 = 5;
pub const MAX_GENERATED_AUDIO_BYTES: usize = 40 * 1024 * 1024;

View File

@@ -0,0 +1,84 @@
use platform_audio::{
AudioTaskKind, BackgroundMusicTaskRequest, SUNO_DEFAULT_MODEL, VIDU_PROMPT_MAX_CHARS,
audio_mime_to_extension, build_background_music_task_body, build_sound_effect_task_body,
extract_audio_urls, is_failed_task_status, is_pending_task_status, normalize_audio_mime_type,
normalize_task_status,
};
use serde_json::json;
#[test]
fn normalizes_audio_mime_type_from_content_type_and_url() {
assert_eq!(
normalize_audio_mime_type("audio/x-wav; charset=utf-8", "https://x/a.bin"),
"audio/wav"
);
assert_eq!(
normalize_audio_mime_type("application/octet-stream", "https://x/a.m4a?token=1"),
"audio/mp4"
);
assert_eq!(audio_mime_to_extension("audio/mp4"), "m4a");
}
#[test]
fn extracts_nested_audio_urls() {
let payload = json!({
"Response": {
"Status": "FINISH",
"Task": {
"Output": {
"FileInfos": [
{ "FileUrl": "https://cdn.example.test/audio.wav" }
]
}
}
}
});
assert_eq!(
extract_audio_urls(&payload),
vec!["https://cdn.example.test/audio.wav".to_string()]
);
}
#[test]
fn vector_engine_task_status_is_stable() {
assert_eq!(normalize_task_status("FINISH"), "completed");
assert!(is_pending_task_status("processing"));
assert!(is_failed_task_status("failed"));
}
#[test]
fn background_music_request_body_uses_default_model_and_optional_instrumental_flag() {
let body = build_background_music_task_body(BackgroundMusicTaskRequest {
prompt: " 风里的木琴 ".to_string(),
title: " 林间 ".to_string(),
tags: Some(" warm, wood ".to_string()),
model: None,
instrumental: true,
})
.expect("request body should be valid");
assert_eq!(body["prompt"], "风里的木琴");
assert_eq!(body["title"], "林间");
assert_eq!(body["tags"], "warm, wood");
assert_eq!(body["mv"], SUNO_DEFAULT_MODEL);
assert_eq!(body["task"], "generate");
assert_eq!(body["make_instrumental"], true);
assert_eq!(
AudioTaskKind::BackgroundMusic.provider(),
"vector-engine-suno"
);
}
#[test]
fn sound_effect_request_rejects_overlong_prompt() {
let prompt = "".repeat(VIDU_PROMPT_MAX_CHARS + 1);
let error = build_sound_effect_task_body(platform_audio::SoundEffectTaskRequest {
prompt,
duration: 5,
seed: None,
})
.expect_err("long prompt should fail");
assert!(error.message().contains("prompt 超过"));
}