Compare commits

...
Author SHA1 Message Date
Brandon Thomas 65a1054579 suno client 2026-07-07 02:02:37 -04:00
7 changed files with 1248 additions and 6 deletions
@@ -0,0 +1,263 @@
use serde_derive::Serialize;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse,
};
const BUSINESS_TYPE: &str = "suno-music-generation";
const MODEL: &str = "suno-music";
const MODEL_VERSION: &str = "chirp-v5-5";
/// Flat credit price per generation.
const CREDITS_PER_GENERATION: u64 = 16;
// ── Args ──
pub struct GenerateSunoMusicArgs<'a> {
pub request: GenerateSunoMusicRequest,
pub session: &'a Seedance2ProSession,
pub host_override: Option<KinoviHost>,
}
// ── Request ──
/// Suno Music: text-to-music generation.
///
/// NB: the Kinovi UI offers a voice choice (any / male / female), but it is
/// never transmitted on the wire — steer the vocalist through the prompt or
/// style tags instead.
#[derive(Clone, Debug)]
pub struct GenerateSunoMusicRequest {
/// What the song should be about.
pub prompt: String,
/// Optional style/genre direction (the UI's "style prompt"), e.g.
/// "EDM style meets dance".
pub style_tags: Option<String>,
/// Generate an instrumental-only track (no vocals).
pub instrumental: bool,
}
// ── Pricing ──
//
// Suno Music: flat 16 Kinovi credits per generation, regardless of options.
impl GenerateSunoMusicRequest {
/// Calculate the cost of this generation request, in Kinovi credits and
/// USD cents.
pub fn calculate_costs(&self) -> KinoviGenerationCost {
KinoviGenerationCost::from_kinovi_credits(CREDITS_PER_GENERATION)
}
}
// ── Response ──
pub struct GenerateSunoMusicResponse {
pub task_id: String,
pub order_id: String,
}
// ── Entry point ──
pub async fn generate_suno_music(
args: GenerateSunoMusicArgs<'_>,
) -> Result<GenerateSunoMusicResponse, Seedance2ProError> {
let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: build_api_params(&args.request),
session: args.session,
host_override: args.host_override,
}).await?;
Ok(GenerateSunoMusicResponse {
task_id: raw_response.task_id,
order_id: raw_response.order_id,
})
}
// ── Wire payload ──
/// The `apiParams` shape for `suno-music-generation`. Field order matches the
/// captured browser traffic.
#[derive(Serialize, Debug)]
struct SunoMusicApiParams {
model: &'static str,
prompt: String,
custom: bool,
instrumental: bool,
mv: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
tags: Option<String>,
#[serde(rename = "generateVideo")]
generate_video: bool,
#[serde(rename = "isStorage")]
is_storage: bool,
}
fn build_api_params(request: &GenerateSunoMusicRequest) -> SunoMusicApiParams {
SunoMusicApiParams {
model: MODEL,
prompt: request.prompt.clone(),
custom: false,
instrumental: request.instrumental,
mv: MODEL_VERSION,
tags: request.style_tags.clone(),
generate_video: false,
is_storage: true,
}
}
// ── Tests ──
#[cfg(test)]
mod tests {
use super::*;
mod pricing_tests {
use super::*;
fn base_request() -> GenerateSunoMusicRequest {
GenerateSunoMusicRequest {
prompt: "A song".to_string(),
style_tags: None,
instrumental: false,
}
}
#[test]
fn flat_sixteen_credits() {
let costs = base_request().calculate_costs();
assert_eq!(costs.kinovi_credits, 16);
}
#[test]
fn usd_cents() {
// 16 credits; 1600/243 = 6.5843… → up 7¢, down 6¢.
let costs = base_request().calculate_costs();
assert_eq!(costs.usd_cents_rounded_up, 7);
assert_eq!(costs.usd_cents_rounded_down, 6);
assert!((costs.usd_cents_fractional - (1600.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn options_do_not_affect_cost() {
let base = base_request().calculate_costs();
let styled = GenerateSunoMusicRequest {
prompt: "Another song".to_string(),
style_tags: Some("EDM".to_string()),
instrumental: true,
}.calculate_costs();
assert_eq!(base, styled);
}
}
mod request_shape_tests {
use super::*;
/// Mirrors capture 1_suno_music.txt (no style tags, with vocals).
#[test]
fn basic_prompt() {
let params = build_api_params(&GenerateSunoMusicRequest {
prompt: "A song about corgi super heroes fighting shiba super villains.".to_string(),
style_tags: None,
instrumental: false,
});
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"model":"suno-music","prompt":"A song about corgi super heroes fighting shiba super villains.","custom":false,"instrumental":false,"mv":"chirp-v5-5","generateVideo":false,"isStorage":true}"#,
);
}
/// Mirrors capture 2_suno_music_style_female.txt (style tags present).
#[test]
fn with_style_tags() {
let params = build_api_params(&GenerateSunoMusicRequest {
prompt: "A song about Mario and Luigi fighting Bowser".to_string(),
style_tags: Some("EDM style meets dance".to_string()),
instrumental: false,
});
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"model":"suno-music","prompt":"A song about Mario and Luigi fighting Bowser","custom":false,"instrumental":false,"mv":"chirp-v5-5","tags":"EDM style meets dance","generateVideo":false,"isStorage":true}"#,
);
}
/// Mirrors capture 3_suno_music_instrumental.txt.
#[test]
fn instrumental() {
let params = build_api_params(&GenerateSunoMusicRequest {
prompt: "An epic journey for a JRPG".to_string(),
style_tags: Some("Japanese JRPG adventure music".to_string()),
instrumental: true,
});
let json = serde_json::to_string(&params).unwrap();
assert!(json.contains(r#""instrumental":true"#), "{json}");
assert!(json.contains(r#""custom":false"#), "{json}");
assert!(json.contains(r#""mv":"chirp-v5-5""#), "{json}");
}
#[test]
fn business_type() {
assert_eq!(BUSINESS_TYPE, "suno-music-generation");
}
}
mod live_tests {
use super::*;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits.
async fn test_music_with_vocals() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_suno_music(GenerateSunoMusicArgs {
session: &session,
host_override: None,
request: GenerateSunoMusicRequest {
prompt: "A song about a corgi who learns to sail the open sea".to_string(),
style_tags: Some("Sea shanty, folk".to_string()),
instrumental: false,
},
}).await?;
println!("suno music — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits.
async fn test_music_instrumental() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_suno_music(GenerateSunoMusicArgs {
session: &session,
host_override: None,
request: GenerateSunoMusicRequest {
prompt: "An epic journey across a frozen mountain range".to_string(),
style_tags: Some("Cinematic orchestral score".to_string()),
instrumental: true,
},
}).await?;
println!("suno music instrumental — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
}
}
@@ -0,0 +1,283 @@
use serde_derive::Serialize;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse,
};
const BUSINESS_TYPE: &str = "suno-remix-generation";
const MODEL: &str = "suno-remix";
const MODEL_VERSION: &str = "chirp-v5-5";
/// Flat credit price per generation.
const CREDITS_PER_GENERATION: u64 = 16;
// ── Args ──
pub struct GenerateSunoRemixArgs<'a> {
pub request: GenerateSunoRemixRequest,
pub session: &'a Seedance2ProSession,
pub host_override: Option<KinoviHost>,
}
// ── Request ──
/// Suno Remix: transform an existing piece of audio.
#[derive(Clone, Debug)]
pub struct GenerateSunoRemixRequest {
/// How to transform the audio, e.g. "Make this electronic".
pub prompt: String,
/// The audio to remix.
pub source: KinoviSunoRemixSource,
/// Optional style/genre direction (the UI's "style prompt").
pub style_tags: Option<String>,
/// Keep the original lyrics in the remix.
pub keep_lyrics: bool,
}
// ── Enums ──
/// The audio input for a remix: either an uploaded file (by CDN URL) or a
/// previously generated Suno track (by its music id).
#[derive(Clone, Debug)]
pub enum KinoviSunoRemixSource {
/// A Kinovi CDN URL to an uploaded audio file.
AudioUrl(String),
/// The id of a previously generated Suno track
/// (e.g. "fea7e70c-5373-452d-9c5e-f079ff837fcd").
MusicId(String),
}
// ── Pricing ──
//
// Suno Remix: flat 16 Kinovi credits per generation, regardless of options.
impl GenerateSunoRemixRequest {
/// Calculate the cost of this generation request, in Kinovi credits and
/// USD cents.
pub fn calculate_costs(&self) -> KinoviGenerationCost {
KinoviGenerationCost::from_kinovi_credits(CREDITS_PER_GENERATION)
}
}
// ── Response ──
pub struct GenerateSunoRemixResponse {
pub task_id: String,
pub order_id: String,
}
// ── Entry point ──
pub async fn generate_suno_remix(
args: GenerateSunoRemixArgs<'_>,
) -> Result<GenerateSunoRemixResponse, Seedance2ProError> {
let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: build_api_params(&args.request),
session: args.session,
host_override: args.host_override,
}).await?;
Ok(GenerateSunoRemixResponse {
task_id: raw_response.task_id,
order_id: raw_response.order_id,
})
}
// ── Wire payload ──
/// The `apiParams` shape for `suno-remix-generation`. Field order matches the
/// captured browser traffic. Exactly one of `audio_url` / `music_id` is set.
#[derive(Serialize, Debug)]
struct SunoRemixApiParams {
model: &'static str,
prompt: String,
#[serde(rename = "audioUrl", skip_serializing_if = "Option::is_none")]
audio_url: Option<String>,
#[serde(rename = "musicId", skip_serializing_if = "Option::is_none")]
music_id: Option<String>,
custom: bool,
#[serde(rename = "keepLyrics")]
keep_lyrics: bool,
mv: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
tags: Option<String>,
#[serde(rename = "isStorage")]
is_storage: bool,
}
fn build_api_params(request: &GenerateSunoRemixRequest) -> SunoRemixApiParams {
let (audio_url, music_id) = match &request.source {
KinoviSunoRemixSource::AudioUrl(url) => (Some(url.clone()), None),
KinoviSunoRemixSource::MusicId(id) => (None, Some(id.clone())),
};
SunoRemixApiParams {
model: MODEL,
prompt: request.prompt.clone(),
audio_url,
music_id,
custom: false,
keep_lyrics: request.keep_lyrics,
mv: MODEL_VERSION,
tags: request.style_tags.clone(),
is_storage: true,
}
}
// ── Tests ──
#[cfg(test)]
mod tests {
use super::*;
mod pricing_tests {
use super::*;
fn base_request() -> GenerateSunoRemixRequest {
GenerateSunoRemixRequest {
prompt: "Make this electronic".to_string(),
source: KinoviSunoRemixSource::AudioUrl("https://example.com/a.mp3".to_string()),
style_tags: None,
keep_lyrics: false,
}
}
#[test]
fn flat_sixteen_credits() {
let costs = base_request().calculate_costs();
assert_eq!(costs.kinovi_credits, 16);
}
#[test]
fn usd_cents() {
// 16 credits; 1600/243 = 6.5843… → up 7¢, down 6¢.
let costs = base_request().calculate_costs();
assert_eq!(costs.usd_cents_rounded_up, 7);
assert_eq!(costs.usd_cents_rounded_down, 6);
assert!((costs.usd_cents_fractional - (1600.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn options_do_not_affect_cost() {
let base = base_request().calculate_costs();
let other = GenerateSunoRemixRequest {
prompt: "Make this a symphony".to_string(),
source: KinoviSunoRemixSource::MusicId("abc".to_string()),
style_tags: Some("Classical music".to_string()),
keep_lyrics: true,
}.calculate_costs();
assert_eq!(base, other);
}
}
mod request_shape_tests {
use super::*;
/// Mirrors capture 5_suno_remix.txt (uploaded audio URL, no lyric keep).
#[test]
fn audio_url_source() {
let params = build_api_params(&GenerateSunoRemixRequest {
prompt: "Make this electronic".to_string(),
source: KinoviSunoRemixSource::AudioUrl(
"https://static.seedance2-pro.com/materials/20260707/1783399892833-7d9668fb.mp3".to_string(),
),
style_tags: Some("EDM style".to_string()),
keep_lyrics: false,
});
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"model":"suno-remix","prompt":"Make this electronic","audioUrl":"https://static.seedance2-pro.com/materials/20260707/1783399892833-7d9668fb.mp3","custom":false,"keepLyrics":false,"mv":"chirp-v5-5","tags":"EDM style","isStorage":true}"#,
);
}
/// Mirrors capture 6_suno_remix_lyrics.txt (existing track by music id,
/// keeping the lyrics).
#[test]
fn music_id_source_with_kept_lyrics() {
let params = build_api_params(&GenerateSunoRemixRequest {
prompt: "Make this a symphony".to_string(),
source: KinoviSunoRemixSource::MusicId("fea7e70c-5373-452d-9c5e-f079ff837fcd".to_string()),
style_tags: Some("Classical music".to_string()),
keep_lyrics: true,
});
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"model":"suno-remix","prompt":"Make this a symphony","musicId":"fea7e70c-5373-452d-9c5e-f079ff837fcd","custom":false,"keepLyrics":true,"mv":"chirp-v5-5","tags":"Classical music","isStorage":true}"#,
);
}
#[test]
fn business_type() {
assert_eq!(BUSINESS_TYPE, "suno-remix-generation");
}
}
mod live_tests {
use super::*;
use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits. Requires a local audio file.
async fn test_remix_uploaded_audio() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let audio_url = upload_test_audio(&session).await?;
println!("Uploaded audio: {}", audio_url);
let result = generate_suno_remix(GenerateSunoRemixArgs {
session: &session,
host_override: None,
request: GenerateSunoRemixRequest {
prompt: "Make this electronic".to_string(),
source: KinoviSunoRemixSource::AudioUrl(audio_url),
style_tags: Some("EDM style".to_string()),
keep_lyrics: false,
},
}).await?;
println!("suno remix — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
async fn upload_test_audio(session: &Seedance2ProSession) -> AnyhowResult<String> {
let audio_path = test_utils::test_file_path::test_file_path(
"test_data/audio/mp3/super_mario_rpg_beware_the_forests_mushrooms.mp3",
)?;
let audio_bytes = std::fs::read(&audio_path)?;
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
session,
extension: "mp3".to_string(),
host_override: None,
}).await?;
let upload_result = upload_file(UploadFileArgs {
upload_url: prepare_result.upload_url,
file_bytes: audio_bytes,
host_override: None,
}).await?;
Ok(upload_result.public_url)
}
}
}
@@ -0,0 +1,289 @@
use serde_derive::Serialize;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse,
};
const BUSINESS_TYPE: &str = "suno-sample-generation";
const MODEL: &str = "suno-sample";
const MODEL_VERSION: &str = "chirp-v5-5";
/// Flat credit price per generation.
const CREDITS_PER_GENERATION: u64 = 16;
// ── Args ──
pub struct GenerateSunoSampleArgs<'a> {
pub request: GenerateSunoSampleRequest,
pub session: &'a Seedance2ProSession,
pub host_override: Option<KinoviHost>,
}
// ── Request ──
/// Suno Sample: build a track from a chopped sample of an uploaded audio file.
///
/// NB: the Kinovi UI offers a voice choice (male / female / no voice), but it
/// is never transmitted on the wire — `instrumental` controls whether vocals
/// are added at all; steer the vocalist through the prompt or style tags.
#[derive(Clone, Debug)]
pub struct GenerateSunoSampleRequest {
/// What to build from the sample, e.g. "Mystical RPG adventure, make it
/// have a grand climax".
pub prompt: String,
/// A Kinovi CDN URL to the uploaded audio file to sample.
pub audio_url: String,
/// Start of the sample window within the audio, in seconds.
pub chop_sample_start_seconds: u32,
/// End of the sample window within the audio, in seconds.
pub chop_sample_end_seconds: u32,
/// Optional style/genre direction (the UI's "style prompt").
pub style_tags: Option<String>,
/// Generate an instrumental-only track (no vocals).
pub instrumental: bool,
}
// ── Pricing ──
//
// Suno Sample: flat 16 Kinovi credits per generation, regardless of options.
impl GenerateSunoSampleRequest {
/// Calculate the cost of this generation request, in Kinovi credits and
/// USD cents.
pub fn calculate_costs(&self) -> KinoviGenerationCost {
KinoviGenerationCost::from_kinovi_credits(CREDITS_PER_GENERATION)
}
}
// ── Response ──
pub struct GenerateSunoSampleResponse {
pub task_id: String,
pub order_id: String,
}
// ── Entry point ──
pub async fn generate_suno_sample(
args: GenerateSunoSampleArgs<'_>,
) -> Result<GenerateSunoSampleResponse, Seedance2ProError> {
let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: build_api_params(&args.request),
session: args.session,
host_override: args.host_override,
}).await?;
Ok(GenerateSunoSampleResponse {
task_id: raw_response.task_id,
order_id: raw_response.order_id,
})
}
// ── Wire payload ──
/// The `apiParams` shape for `suno-sample-generation`. Field order matches the
/// captured browser traffic.
#[derive(Serialize, Debug)]
struct SunoSampleApiParams {
model: &'static str,
prompt: String,
#[serde(rename = "audioUrl")]
audio_url: String,
#[serde(rename = "chopSampleStartS")]
chop_sample_start_s: u32,
#[serde(rename = "chopSampleEndS")]
chop_sample_end_s: u32,
custom: bool,
instrumental: bool,
#[serde(rename = "autoLyrics")]
auto_lyrics: bool,
mv: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
tags: Option<String>,
#[serde(rename = "isStorage")]
is_storage: bool,
}
fn build_api_params(request: &GenerateSunoSampleRequest) -> SunoSampleApiParams {
SunoSampleApiParams {
model: MODEL,
prompt: request.prompt.clone(),
audio_url: request.audio_url.clone(),
chop_sample_start_s: request.chop_sample_start_seconds,
chop_sample_end_s: request.chop_sample_end_seconds,
custom: false,
instrumental: request.instrumental,
auto_lyrics: true,
mv: MODEL_VERSION,
tags: request.style_tags.clone(),
is_storage: true,
}
}
// ── Tests ──
#[cfg(test)]
mod tests {
use super::*;
mod pricing_tests {
use super::*;
fn base_request() -> GenerateSunoSampleRequest {
GenerateSunoSampleRequest {
prompt: "Mystical RPG adventure".to_string(),
audio_url: "https://example.com/a.aac".to_string(),
chop_sample_start_seconds: 0,
chop_sample_end_seconds: 41,
style_tags: None,
instrumental: true,
}
}
#[test]
fn flat_sixteen_credits() {
let costs = base_request().calculate_costs();
assert_eq!(costs.kinovi_credits, 16);
}
#[test]
fn usd_cents() {
// 16 credits; 1600/243 = 6.5843… → up 7¢, down 6¢.
let costs = base_request().calculate_costs();
assert_eq!(costs.usd_cents_rounded_up, 7);
assert_eq!(costs.usd_cents_rounded_down, 6);
assert!((costs.usd_cents_fractional - (1600.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn options_do_not_affect_cost() {
let base = base_request().calculate_costs();
let other = GenerateSunoSampleRequest {
prompt: "A vocal fantasy track".to_string(),
audio_url: "https://example.com/b.aac".to_string(),
chop_sample_start_seconds: 10,
chop_sample_end_seconds: 30,
style_tags: Some("Fantasy video game".to_string()),
instrumental: false,
}.calculate_costs();
assert_eq!(base, other);
}
}
mod request_shape_tests {
use super::*;
/// Mirrors capture 11_suno_sample_1.txt (instrumental).
#[test]
fn instrumental_sample() {
let params = build_api_params(&GenerateSunoSampleRequest {
prompt: "Mystical RPG adventure, make it have a grand climax".to_string(),
audio_url: "https://static.seedance2-pro.com/materials/20260707/1783401649804-e654f994.aac".to_string(),
chop_sample_start_seconds: 0,
chop_sample_end_seconds: 41,
style_tags: Some("Fantasy video game, Motoi Sakuraba, Golden Sun".to_string()),
instrumental: true,
});
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"model":"suno-sample","prompt":"Mystical RPG adventure, make it have a grand climax","audioUrl":"https://static.seedance2-pro.com/materials/20260707/1783401649804-e654f994.aac","chopSampleStartS":0,"chopSampleEndS":41,"custom":false,"instrumental":true,"autoLyrics":true,"mv":"chirp-v5-5","tags":"Fantasy video game, Motoi Sakuraba, Golden Sun","isStorage":true}"#,
);
}
/// Mirrors capture 12_suno_sample_2.txt (with vocals).
#[test]
fn vocal_sample() {
let params = build_api_params(&GenerateSunoSampleRequest {
prompt: "Give this a lyrics track, a woman singing in a fantasy language".to_string(),
audio_url: "https://static.seedance2-pro.com/materials/20260707/1783401649804-e654f994.aac".to_string(),
chop_sample_start_seconds: 0,
chop_sample_end_seconds: 41,
style_tags: Some("Fantasy video game, Motoi Sakuraba, Golden Sun".to_string()),
instrumental: false,
});
let json = serde_json::to_string(&params).unwrap();
assert!(json.contains(r#""instrumental":false"#), "{json}");
assert!(json.contains(r#""autoLyrics":true"#), "{json}");
assert!(json.contains(r#""chopSampleStartS":0"#), "{json}");
assert!(json.contains(r#""chopSampleEndS":41"#), "{json}");
}
#[test]
fn business_type() {
assert_eq!(BUSINESS_TYPE, "suno-sample-generation");
}
}
mod live_tests {
use super::*;
use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits. Requires a local audio file.
async fn test_sample_instrumental() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let audio_url = upload_test_audio(&session).await?;
println!("Uploaded audio: {}", audio_url);
let result = generate_suno_sample(GenerateSunoSampleArgs {
session: &session,
host_override: None,
request: GenerateSunoSampleRequest {
prompt: "Mystical RPG adventure, make it have a grand climax".to_string(),
audio_url,
chop_sample_start_seconds: 0,
chop_sample_end_seconds: 30,
style_tags: Some("Fantasy video game score".to_string()),
instrumental: true,
},
}).await?;
println!("suno sample — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
async fn upload_test_audio(session: &Seedance2ProSession) -> AnyhowResult<String> {
let audio_path = test_utils::test_file_path::test_file_path(
"test_data/audio/aac/golden_sun_elemental_stars_cyanne.aac",
)?;
let audio_bytes = std::fs::read(&audio_path)?;
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
session,
extension: "aac".to_string(),
host_override: None,
}).await?;
let upload_result = upload_file(UploadFileArgs {
upload_url: prepare_result.upload_url,
file_bytes: audio_bytes,
host_override: None,
}).await?;
Ok(upload_result.public_url)
}
}
}
@@ -0,0 +1,360 @@
use serde_derive::Serialize;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse,
};
const BUSINESS_TYPE: &str = "suno-sounds-generation";
const MODEL: &str = "suno-sounds";
const MODEL_VERSION: &str = "chirp-v5-5";
/// Flat credit price per generation.
const CREDITS_PER_GENERATION: u64 = 4;
// ── Args ──
pub struct GenerateSunoSoundArgs<'a> {
pub request: GenerateSunoSoundRequest,
pub session: &'a Seedance2ProSession,
pub host_override: Option<KinoviHost>,
}
// ── Request ──
/// Suno Sounds: short sound-effect / sample generation.
#[derive(Clone, Debug)]
pub struct GenerateSunoSoundRequest {
/// What the sound should be, e.g. "Rain sound effects".
pub prompt: String,
/// Single hit vs seamless loop.
pub sound_type: KinoviSunoSoundType,
/// Optional tempo in BPM.
pub bpm: Option<u16>,
/// Musical key. `Auto` lets Kinovi pick.
pub key: KinoviSunoSoundKey,
}
// ── Enums ──
/// Single hit ("one-shot") vs seamless loop ("loop").
#[derive(Debug, Clone, Copy)]
pub enum KinoviSunoSoundType {
SingleHit,
Loopable,
}
/// Musical key for the generated sound.
///
/// NB: the Kinovi UI's "Auto" selection still transmits "C" on the wire
/// (observed in captured traffic), so `Auto` and `CMajor` are wire-identical.
#[derive(Debug, Clone, Copy)]
pub enum KinoviSunoSoundKey {
Auto,
CMajor,
CMinor,
DMajor,
DMinor,
FMajor,
FMinor,
GMajor,
GMinor,
AMajor,
AMinor,
BMajor,
BMinor,
}
// ── Pricing ──
//
// Suno Sounds: flat 4 Kinovi credits per generation, regardless of options.
impl GenerateSunoSoundRequest {
/// Calculate the cost of this generation request, in Kinovi credits and
/// USD cents.
pub fn calculate_costs(&self) -> KinoviGenerationCost {
KinoviGenerationCost::from_kinovi_credits(CREDITS_PER_GENERATION)
}
}
// ── Response ──
pub struct GenerateSunoSoundResponse {
pub task_id: String,
pub order_id: String,
}
// ── Entry point ──
pub async fn generate_suno_sound(
args: GenerateSunoSoundArgs<'_>,
) -> Result<GenerateSunoSoundResponse, Seedance2ProError> {
let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: build_api_params(&args.request),
session: args.session,
host_override: args.host_override,
}).await?;
Ok(GenerateSunoSoundResponse {
task_id: raw_response.task_id,
order_id: raw_response.order_id,
})
}
// ── Wire payload ──
/// The `apiParams` shape for `suno-sounds-generation`. Field order matches the
/// captured browser traffic.
#[derive(Serialize, Debug)]
struct SunoSoundApiParams {
model: &'static str,
prompt: String,
mv: &'static str,
#[serde(rename = "type")]
sound_type: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
bpm: Option<u16>,
key: &'static str,
#[serde(rename = "isStorage")]
is_storage: bool,
}
fn build_api_params(request: &GenerateSunoSoundRequest) -> SunoSoundApiParams {
SunoSoundApiParams {
model: MODEL,
prompt: request.prompt.clone(),
mv: MODEL_VERSION,
sound_type: sound_type_as_api_str(request.sound_type),
bpm: request.bpm,
key: key_as_api_str(request.key),
is_storage: true,
}
}
fn sound_type_as_api_str(sound_type: KinoviSunoSoundType) -> &'static str {
match sound_type {
KinoviSunoSoundType::SingleHit => "one-shot",
KinoviSunoSoundType::Loopable => "loop",
}
}
fn key_as_api_str(key: KinoviSunoSoundKey) -> &'static str {
match key {
// The UI's Auto choice sends "C" on the wire.
KinoviSunoSoundKey::Auto => "C",
KinoviSunoSoundKey::CMajor => "C",
KinoviSunoSoundKey::CMinor => "Cm",
KinoviSunoSoundKey::DMajor => "D",
KinoviSunoSoundKey::DMinor => "Dm",
KinoviSunoSoundKey::FMajor => "F",
KinoviSunoSoundKey::FMinor => "Fm",
KinoviSunoSoundKey::GMajor => "G",
KinoviSunoSoundKey::GMinor => "Gm",
KinoviSunoSoundKey::AMajor => "A",
KinoviSunoSoundKey::AMinor => "Am",
KinoviSunoSoundKey::BMajor => "B",
KinoviSunoSoundKey::BMinor => "Bm",
}
}
// ── Tests ──
#[cfg(test)]
mod tests {
use super::*;
mod pricing_tests {
use super::*;
fn base_request() -> GenerateSunoSoundRequest {
GenerateSunoSoundRequest {
prompt: "Rain sound effects".to_string(),
sound_type: KinoviSunoSoundType::SingleHit,
bpm: None,
key: KinoviSunoSoundKey::Auto,
}
}
#[test]
fn flat_four_credits() {
let costs = base_request().calculate_costs();
assert_eq!(costs.kinovi_credits, 4);
}
#[test]
fn usd_cents() {
// 4 credits; 400/243 = 1.6461… → up 2¢, down 1¢.
let costs = base_request().calculate_costs();
assert_eq!(costs.usd_cents_rounded_up, 2);
assert_eq!(costs.usd_cents_rounded_down, 1);
assert!((costs.usd_cents_fractional - (400.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn options_do_not_affect_cost() {
let base = base_request().calculate_costs();
let other = GenerateSunoSoundRequest {
prompt: "Thunder".to_string(),
sound_type: KinoviSunoSoundType::Loopable,
bpm: Some(180),
key: KinoviSunoSoundKey::DMinor,
}.calculate_costs();
assert_eq!(base, other);
}
}
mod request_shape_tests {
use super::*;
/// Mirrors capture 7_suno_sounds_1.txt (single hit, no BPM, auto key —
/// which transmits "C").
#[test]
fn single_hit_no_bpm_auto_key() {
let params = build_api_params(&GenerateSunoSoundRequest {
prompt: "Rain sound effects".to_string(),
sound_type: KinoviSunoSoundType::SingleHit,
bpm: None,
key: KinoviSunoSoundKey::Auto,
});
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"model":"suno-sounds","prompt":"Rain sound effects","mv":"chirp-v5-5","type":"one-shot","key":"C","isStorage":true}"#,
);
}
/// Mirrors capture 8_suno_sounds_2.txt (loop, 60 BPM, D minor).
#[test]
fn loop_with_bpm_and_minor_key() {
let params = build_api_params(&GenerateSunoSoundRequest {
prompt: "Thunder".to_string(),
sound_type: KinoviSunoSoundType::Loopable,
bpm: Some(60),
key: KinoviSunoSoundKey::DMinor,
});
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"model":"suno-sounds","prompt":"Thunder","mv":"chirp-v5-5","type":"loop","bpm":60,"key":"Dm","isStorage":true}"#,
);
}
/// Mirrors capture 9_suno_sounds_3.txt (loop, 180 BPM, F major).
#[test]
fn loop_with_major_key() {
let params = build_api_params(&GenerateSunoSoundRequest {
prompt: "Plates crashing in the kitchen".to_string(),
sound_type: KinoviSunoSoundType::Loopable,
bpm: Some(180),
key: KinoviSunoSoundKey::FMajor,
});
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"model":"suno-sounds","prompt":"Plates crashing in the kitchen","mv":"chirp-v5-5","type":"loop","bpm":180,"key":"F","isStorage":true}"#,
);
}
/// Mirrors capture 10_suno_sounds_4.txt (single hit, 100 BPM, C minor).
#[test]
fn single_hit_with_bpm_and_minor_key() {
let params = build_api_params(&GenerateSunoSoundRequest {
prompt: "Car crash".to_string(),
sound_type: KinoviSunoSoundType::SingleHit,
bpm: Some(100),
key: KinoviSunoSoundKey::CMinor,
});
assert_eq!(
serde_json::to_string(&params).unwrap(),
r#"{"model":"suno-sounds","prompt":"Car crash","mv":"chirp-v5-5","type":"one-shot","bpm":100,"key":"Cm","isStorage":true}"#,
);
}
#[test]
fn every_key_wire_value() {
let cases = [
(KinoviSunoSoundKey::Auto, "C"),
(KinoviSunoSoundKey::CMajor, "C"),
(KinoviSunoSoundKey::CMinor, "Cm"),
(KinoviSunoSoundKey::DMajor, "D"),
(KinoviSunoSoundKey::DMinor, "Dm"),
(KinoviSunoSoundKey::FMajor, "F"),
(KinoviSunoSoundKey::FMinor, "Fm"),
(KinoviSunoSoundKey::GMajor, "G"),
(KinoviSunoSoundKey::GMinor, "Gm"),
(KinoviSunoSoundKey::AMajor, "A"),
(KinoviSunoSoundKey::AMinor, "Am"),
(KinoviSunoSoundKey::BMajor, "B"),
(KinoviSunoSoundKey::BMinor, "Bm"),
];
for (key, expected) in cases {
assert_eq!(key_as_api_str(key), expected, "wire value for {key:?}");
}
}
#[test]
fn business_type() {
assert_eq!(BUSINESS_TYPE, "suno-sounds-generation");
}
}
mod live_tests {
use super::*;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits.
async fn test_single_hit() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_suno_sound(GenerateSunoSoundArgs {
session: &session,
host_override: None,
request: GenerateSunoSoundRequest {
prompt: "A heavy castle door creaking open".to_string(),
sound_type: KinoviSunoSoundType::SingleHit,
bpm: None,
key: KinoviSunoSoundKey::Auto,
},
}).await?;
println!("suno sound single hit — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits.
async fn test_loop_with_bpm_and_key() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_suno_sound(GenerateSunoSoundArgs {
session: &session,
host_override: None,
request: GenerateSunoSoundRequest {
prompt: "Lo-fi vinyl crackle groove".to_string(),
sound_type: KinoviSunoSoundType::Loopable,
bpm: Some(85),
key: KinoviSunoSoundKey::AMinor,
},
}).await?;
println!("suno sound loop — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
}
}
@@ -0,0 +1,4 @@
pub mod generate_suno_music;
pub mod generate_suno_remix;
pub mod generate_suno_sample;
pub mod generate_suno_sound;
@@ -1,2 +1,3 @@
pub mod audio;
pub mod image;
pub mod video;
@@ -274,10 +274,6 @@ pub struct WorkflowRunTaskResponse {
// --- Implementation ---
pub async fn workflow_run_task(args: WorkflowRunTaskArgs<'_>) -> Result<WorkflowRunTaskResponse, Seedance2ProError> {
let host = resolve_host(args.host_override.as_ref());
let base_url = host.api_base_url();
let run_task_url = format!("{}/api/trpc/workflow.runTask?batch=1", base_url);
let req = args.request;
info!("Requesting video from Seedance2Pro (v2): {:?}", req);
@@ -286,7 +282,53 @@ pub async fn workflow_run_task(args: WorkflowRunTaskArgs<'_>) -> Result<Workflow
info!("Seedance2pro request (v2): {:?}", request_body);
let cookie = args.session.cookies.as_str();
send_run_task_request(args.session, args.host_override, &request_body).await
}
/// Run a `workflow.runTask` call whose `apiParams` shape differs from the
/// standard video request — e.g. the Suno audio models, which carry their own
/// parameter sets. The caller provides the tRPC `businessType` and a
/// serializable `apiParams` payload; the HTTP plumbing and response handling
/// are shared with [`workflow_run_task`].
pub async fn workflow_run_task_custom<T: serde::Serialize + std::fmt::Debug>(
args: WorkflowRunTaskCustomArgs<'_, T>,
) -> Result<WorkflowRunTaskResponse, Seedance2ProError> {
info!(
"Requesting {} from Seedance2Pro (custom): {:?}",
args.business_type, args.api_params,
);
let request_body = serde_json::json!({
"0": {
"json": {
"businessType": args.business_type,
"apiParams": args.api_params,
}
}
});
send_run_task_request(args.session, args.host_override, &request_body).await
}
/// Bundle for [`workflow_run_task_custom`].
pub struct WorkflowRunTaskCustomArgs<'a, T: serde::Serialize + std::fmt::Debug> {
/// The tRPC `businessType` discriminator (e.g. "suno-music-generation").
pub business_type: &'static str,
pub api_params: T,
pub session: &'a Seedance2ProSession,
pub host_override: Option<KinoviHost>,
}
async fn send_run_task_request<B: serde::Serialize>(
session: &Seedance2ProSession,
host_override: Option<KinoviHost>,
request_body: &B,
) -> Result<WorkflowRunTaskResponse, Seedance2ProError> {
let host = resolve_host(host_override.as_ref());
let base_url = host.api_base_url();
let run_task_url = format!("{}/api/trpc/workflow.runTask?batch=1", base_url);
let cookie = session.cookies.as_str();
let client = Client::builder()
.emulation(Emulation::Firefox143)
@@ -311,7 +353,7 @@ pub async fn workflow_run_task(args: WorkflowRunTaskArgs<'_>) -> Result<Workflow
.header("Sec-Fetch-Site", "same-origin")
.header("Priority", "u=4")
.header("TE", "trailers")
.json(&request_body)
.json(request_body)
.send()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;