Compare commits

...
Author SHA1 Message Date
Brandon Thomas 4eef1e84c1 seed audio client / (fal) 2026-07-07 02:27:14 -04:00
7 changed files with 654 additions and 0 deletions
@@ -0,0 +1 @@
pub mod omni;
@@ -0,0 +1 @@
pub mod seed_audio_1p0;
@@ -0,0 +1,459 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::audio::omni::seed_audio_1p0::raw_request::{
SeedAudio1p0Input, SeedAudio1p0Output,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
/// ByteDance Seed Audio 1.0: omni audio generation (speech, drama, ambience)
/// with voice presets, voice cloning, and audio/image references.
#[derive(Clone, Debug)]
pub struct SeedAudio1p0Request {
/// Text to synthesize. Reference audio clips supplied via `audio_urls` are
/// cited in the prompt as `@Audio1`, `@Audio2`, `@Audio3`.
pub prompt: String,
/// Voice to synthesize with: a preset or a cloned voice id.
/// The model picks a voice when `None`.
pub voice: Option<SeedAudio1p0Voice>,
/// Up to 3 reference audio URLs, cited in the prompt as `@Audio1`–`@Audio3`.
/// Each clip: ≤30 seconds, ≤10MB, wav/mp3/pcm/ogg_opus.
pub audio_urls: Option<Vec<String>>,
/// A single reference image URL (jpeg/png/webp, ≤10MB) to condition the
/// generation on (e.g. a scene to voice over).
/// NB: fal rejects requests that combine an image reference with audio
/// references — set `image_url` or `audio_urls`, not both.
pub image_url: Option<String>,
/// Output container. fal's default is mp3 when `None`.
pub output_format: Option<SeedAudio1p0OutputFormat>,
/// Output sample rate. fal's default is 24 kHz when `None`.
pub sample_rate: Option<SeedAudio1p0SampleRate>,
/// Speech speed multiplier. Valid range 0.5–2.0; fal's default is 1.0
/// when `None`.
pub speed: Option<f64>,
/// Volume multiplier. Valid range 0.5–2.0; fal's default is 1.0 when `None`.
pub volume: Option<f64>,
/// Pitch shift in semitones. Valid range −12 to +12; fal's default is 0
/// when `None`.
pub pitch: Option<i8>,
}
/// A voice for Seed Audio: either one of fal's preset voices or a cloned
/// voice id.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SeedAudio1p0Voice {
Preset(SeedAudio1p0VoicePreset),
/// The id of a cloned voice.
Custom(String),
}
impl SeedAudio1p0Voice {
fn to_wire_string(&self) -> String {
match self {
Self::Preset(preset) => preset.to_str().to_string(),
Self::Custom(voice_id) => voice_id.clone(),
}
}
}
/// fal's preset voices. The suffixes name the languages each voice supports
/// (en = English, zh = Chinese, ja = Japanese, es = Spanish, id = Indonesian,
/// pt = Portuguese).
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum SeedAudio1p0VoicePreset {
ViviMixedEnZhJaEsId,
MindyEnEsIdPtZh,
KianEnZh,
CedricEnZh,
SophieEnZh,
JeanEnZh,
MagnusEnZh,
MabelEnZh,
NadiaEnZh,
OpalEnZh,
PearlEnZh,
QuentinEnZh,
CorinneMixedEnZh,
EstherMixedEnZh,
LylaMixedEnZh,
TracyEsZh,
SandyEsMixedEnZh,
FelixZh,
CelesteZh,
MonkeyKingZh,
}
impl SeedAudio1p0VoicePreset {
pub fn to_str(&self) -> &'static str {
match self {
Self::ViviMixedEnZhJaEsId => "vivi_mixed_en_zh_ja_es_id",
Self::MindyEnEsIdPtZh => "mindy_en_es_id_pt_zh",
Self::KianEnZh => "kian_en_zh",
Self::CedricEnZh => "cedric_en_zh",
Self::SophieEnZh => "sophie_en_zh",
Self::JeanEnZh => "jean_en_zh",
Self::MagnusEnZh => "magnus_en_zh",
Self::MabelEnZh => "mabel_en_zh",
Self::NadiaEnZh => "nadia_en_zh",
Self::OpalEnZh => "opal_en_zh",
Self::PearlEnZh => "pearl_en_zh",
Self::QuentinEnZh => "quentin_en_zh",
Self::CorinneMixedEnZh => "corinne_mixed_en_zh",
Self::EstherMixedEnZh => "esther_mixed_en_zh",
Self::LylaMixedEnZh => "lyla_mixed_en_zh",
Self::TracyEsZh => "tracy_es_zh",
Self::SandyEsMixedEnZh => "sandy_es_mixed_en_zh",
Self::FelixZh => "felix_zh",
Self::CelesteZh => "celeste_zh",
Self::MonkeyKingZh => "monkey_king_zh",
}
}
pub fn all() -> [Self; 20] {
[
Self::ViviMixedEnZhJaEsId,
Self::MindyEnEsIdPtZh,
Self::KianEnZh,
Self::CedricEnZh,
Self::SophieEnZh,
Self::JeanEnZh,
Self::MagnusEnZh,
Self::MabelEnZh,
Self::NadiaEnZh,
Self::OpalEnZh,
Self::PearlEnZh,
Self::QuentinEnZh,
Self::CorinneMixedEnZh,
Self::EstherMixedEnZh,
Self::LylaMixedEnZh,
Self::TracyEsZh,
Self::SandyEsMixedEnZh,
Self::FelixZh,
Self::CelesteZh,
Self::MonkeyKingZh,
]
}
}
/// Output audio container.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum SeedAudio1p0OutputFormat {
Wav,
/// fal's default.
Mp3,
Pcm,
OggOpus,
}
impl SeedAudio1p0OutputFormat {
fn to_str(&self) -> &'static str {
match self {
Self::Wav => "wav",
Self::Mp3 => "mp3",
Self::Pcm => "pcm",
Self::OggOpus => "ogg_opus",
}
}
}
/// Output sample rate.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum SeedAudio1p0SampleRate {
Hz8000,
Hz16000,
/// fal's default.
Hz24000,
Hz32000,
Hz44100,
Hz48000,
}
impl SeedAudio1p0SampleRate {
fn as_hz(&self) -> u32 {
match self {
Self::Hz8000 => 8_000,
Self::Hz16000 => 16_000,
Self::Hz24000 => 24_000,
Self::Hz32000 => 32_000,
Self::Hz44100 => 44_100,
Self::Hz48000 => 48_000,
}
}
}
impl FalEndpoint for SeedAudio1p0Request {
const ENDPOINT: &str = "bytedance/seed-audio-1.0";
type RawRequest = SeedAudio1p0Input;
type RawResponse = SeedAudio1p0Output;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
voice: self.voice.as_ref().map(|voice| voice.to_wire_string()),
audio_urls: self.audio_urls.clone(),
image_url: self.image_url.clone(),
output_format: self.output_format.map(|format| format.to_str().to_string()),
sample_rate: self.sample_rate.map(|rate| rate.as_hz()),
speed: self.speed,
volume: self.volume,
pitch: self.pitch,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_seed_audio_queue_minimal() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = SeedAudio1p0Request {
prompt: "Generate a short suspense radio drama in a late-night convenience store.".to_string(),
voice: None,
audio_urls: None,
image_url: None,
output_format: None,
sample_rate: None,
speed: None,
volume: None,
pitch: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_seed_audio_queue_preset_voice_tuned() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = SeedAudio1p0Request {
prompt: "Welcome aboard the midnight express. Please keep your arms inside the train at all times.".to_string(),
voice: Some(SeedAudio1p0Voice::Preset(SeedAudio1p0VoicePreset::MagnusEnZh)),
audio_urls: None,
image_url: None,
output_format: Some(SeedAudio1p0OutputFormat::Wav),
sample_rate: Some(SeedAudio1p0SampleRate::Hz44100),
speed: Some(0.9),
volume: Some(1.2),
pitch: Some(-2),
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_seed_audio_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = SeedAudio1p0Request {
prompt: "A calm narrator describes ocean waves rolling onto a moonlit beach.".to_string(),
voice: Some(SeedAudio1p0Voice::Preset(SeedAudio1p0VoicePreset::SophieEnZh)),
audio_urls: None,
image_url: None,
output_format: Some(SeedAudio1p0OutputFormat::Mp3),
sample_rate: None,
speed: None,
volume: None,
pitch: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = SeedAudio1p0Request {
prompt: "Read @Audio1 in this voice.".to_string(),
voice: Some(SeedAudio1p0Voice::Preset(SeedAudio1p0VoicePreset::ViviMixedEnZhJaEsId)),
audio_urls: Some(vec![
"https://example.com/a1.mp3".to_string(),
"https://example.com/a2.wav".to_string(),
"https://example.com/a3.ogg".to_string(),
]),
image_url: None,
output_format: Some(SeedAudio1p0OutputFormat::OggOpus),
sample_rate: Some(SeedAudio1p0SampleRate::Hz48000),
speed: Some(1.5),
volume: Some(0.5),
pitch: Some(12),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "Read @Audio1 in this voice.");
assert_eq!(raw.voice.as_deref(), Some("vivi_mixed_en_zh_ja_es_id"));
assert_eq!(raw.audio_urls.as_ref().map(|urls| urls.len()), Some(3));
assert_eq!(raw.image_url, None);
assert_eq!(raw.output_format.as_deref(), Some("ogg_opus"));
assert_eq!(raw.sample_rate, Some(48_000));
assert_eq!(raw.speed, Some(1.5));
assert_eq!(raw.volume, Some(0.5));
assert_eq!(raw.pitch, Some(12));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = SeedAudio1p0Request {
prompt: "minimal".to_string(),
voice: None,
audio_urls: None,
image_url: None,
output_format: None,
sample_rate: None,
speed: None,
volume: None,
pitch: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({ "prompt": "minimal" }));
}
#[test]
fn custom_voice_id_passes_through_verbatim() {
let request = SeedAudio1p0Request {
prompt: "p".to_string(),
voice: Some(SeedAudio1p0Voice::Custom("my_cloned_voice_id_123".to_string())),
audio_urls: None,
image_url: None,
output_format: None,
sample_rate: None,
speed: None,
volume: None,
pitch: None,
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.voice.as_deref(), Some("my_cloned_voice_id_123"));
}
#[test]
fn image_url_serializes_when_set() {
let request = SeedAudio1p0Request {
prompt: "Narrate this scene.".to_string(),
voice: None,
audio_urls: None,
image_url: Some("https://example.com/scene.png".to_string()),
output_format: None,
sample_rate: None,
speed: None,
volume: None,
pitch: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({
"prompt": "Narrate this scene.",
"image_url": "https://example.com/scene.png",
}),
);
}
#[test]
fn negative_pitch_serializes() {
let request = SeedAudio1p0Request {
prompt: "p".to_string(),
voice: None,
audio_urls: None,
image_url: None,
output_format: None,
sample_rate: None,
speed: None,
volume: None,
pitch: Some(-12),
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json.get("pitch").and_then(|p| p.as_i64()), Some(-12));
}
#[test]
fn every_voice_preset_maps_to_wire_string() {
let expected = [
"vivi_mixed_en_zh_ja_es_id",
"mindy_en_es_id_pt_zh",
"kian_en_zh",
"cedric_en_zh",
"sophie_en_zh",
"jean_en_zh",
"magnus_en_zh",
"mabel_en_zh",
"nadia_en_zh",
"opal_en_zh",
"pearl_en_zh",
"quentin_en_zh",
"corinne_mixed_en_zh",
"esther_mixed_en_zh",
"lyla_mixed_en_zh",
"tracy_es_zh",
"sandy_es_mixed_en_zh",
"felix_zh",
"celeste_zh",
"monkey_king_zh",
];
for (preset, expected) in SeedAudio1p0VoicePreset::all().iter().zip(expected) {
assert_eq!(preset.to_str(), expected, "wire value for {preset:?}");
}
}
#[test]
fn every_output_format_maps_to_wire_string() {
for (variant, expected) in [
(SeedAudio1p0OutputFormat::Wav, "wav"),
(SeedAudio1p0OutputFormat::Mp3, "mp3"),
(SeedAudio1p0OutputFormat::Pcm, "pcm"),
(SeedAudio1p0OutputFormat::OggOpus, "ogg_opus"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn every_sample_rate_maps_to_hz() {
for (variant, expected) in [
(SeedAudio1p0SampleRate::Hz8000, 8_000),
(SeedAudio1p0SampleRate::Hz16000, 16_000),
(SeedAudio1p0SampleRate::Hz24000, 24_000),
(SeedAudio1p0SampleRate::Hz32000, 32_000),
(SeedAudio1p0SampleRate::Hz44100, 44_100),
(SeedAudio1p0SampleRate::Hz48000, 48_000),
] {
assert_eq!(variant.as_hz(), expected);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(SeedAudio1p0Request::ENDPOINT, "bytedance/seed-audio-1.0");
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,128 @@
use crate::requests::api::audio::omni::seed_audio_1p0::api::SeedAudio1p0Request;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Seed Audio 1.0 pricing (see https://fal.ai/models/bytedance/seed-audio-1.0):
// "Your request will cost $0.1875 per minute."
//
// fal bills per minute of GENERATED audio, and no request parameter fixes the
// output duration — it depends on the prompt. The rate is stored in
// hundredths-of-a-cent per minute (exact), and per-duration costs are rounded
// UP to whole cents so the user is never undercharged.
/// $0.1875 per minute, in hundredths of a cent.
pub const SEED_AUDIO_1P0_RATE_HUNDREDTH_CENTS_PER_MINUTE: u64 = 1_875;
/// The duration assumed by the parameterless [`FalRequestCostCalculator`]
/// estimate (the output length isn't knowable at request time).
pub const SEED_AUDIO_1P0_ESTIMATED_DURATION_SECONDS: u64 = 60;
/// Cost in whole cents for a known output duration:
/// ceil(1875 hundredth-cents/min × seconds / 60 / 100).
pub fn seed_audio_1p0_cost_cents_for_duration_seconds(duration_seconds: u64) -> UsdCents {
(SEED_AUDIO_1P0_RATE_HUNDREDTH_CENTS_PER_MINUTE * duration_seconds).div_ceil(6_000)
}
impl FalRequestCostCalculator for SeedAudio1p0Request {
/// The output duration isn't known until the audio is generated, so this
/// estimates one minute of output. Use
/// [`seed_audio_1p0_cost_cents_for_duration_seconds`] to price the actual
/// generated duration once it is known.
fn calculate_cost_in_cents(&self) -> UsdCents {
seed_audio_1p0_cost_cents_for_duration_seconds(SEED_AUDIO_1P0_ESTIMATED_DURATION_SECONDS)
}
}
#[cfg(test)]
mod tests {
use super::*;
mod duration_cost_table {
use super::*;
// (duration_seconds, expected_cents)
// Math: ceil($0.1875/min × seconds), i.e. ceil(18.75¢ × secs / 60).
const COST_TABLE: &[(u64, u64)] = &[
(0, 0),
(1, 1), // 0.3125 → 1
(10, 4), // 3.125 → 4
(30, 10), // 9.375 → 10
(32, 10), // exactly 10.0
(60, 19), // 18.75 → 19
(61, 20), // 19.0625 → 20
(90, 29), // 28.125 → 29
(96, 30), // exactly 30.0
(120, 38), // 37.5 → 38
(300, 94), // 93.75 → 94
(600, 188), // 187.5 → 188
];
#[test]
fn matches_cost_table() {
for &(duration_seconds, expected) in COST_TABLE {
let got = seed_audio_1p0_cost_cents_for_duration_seconds(duration_seconds);
assert_eq!(got, expected, "duration_seconds={duration_seconds}");
}
}
#[test]
fn cost_scales_with_duration() {
let c30 = seed_audio_1p0_cost_cents_for_duration_seconds(30);
let c60 = seed_audio_1p0_cost_cents_for_duration_seconds(60);
let c120 = seed_audio_1p0_cost_cents_for_duration_seconds(120);
assert!(c30 < c60);
assert!(c60 < c120);
}
#[test]
fn exact_minute_multiples_never_round() {
// 16 minutes = exactly 300¢ ($3.00): 1875 × 960 / 6000 = 300.
assert_eq!(seed_audio_1p0_cost_cents_for_duration_seconds(960), 300);
}
}
mod request_estimate {
use super::*;
fn make_request() -> SeedAudio1p0Request {
SeedAudio1p0Request {
prompt: "test".to_string(),
voice: None,
audio_urls: None,
image_url: None,
output_format: None,
sample_rate: None,
speed: None,
volume: None,
pitch: None,
}
}
#[test]
fn estimates_one_minute() {
// 60s at $0.1875/min = 18.75¢ → rounds up to 19¢.
assert_eq!(make_request().calculate_cost_in_cents(), 19);
assert_eq!(
make_request().calculate_cost_in_cents(),
seed_audio_1p0_cost_cents_for_duration_seconds(SEED_AUDIO_1P0_ESTIMATED_DURATION_SECONDS),
);
}
#[test]
fn request_options_do_not_affect_the_estimate() {
use crate::requests::api::audio::omni::seed_audio_1p0::api::{
SeedAudio1p0OutputFormat, SeedAudio1p0SampleRate, SeedAudio1p0Voice,
SeedAudio1p0VoicePreset,
};
let mut tuned = make_request();
tuned.voice = Some(SeedAudio1p0Voice::Preset(SeedAudio1p0VoicePreset::FelixZh));
tuned.output_format = Some(SeedAudio1p0OutputFormat::Wav);
tuned.sample_rate = Some(SeedAudio1p0SampleRate::Hz48000);
tuned.speed = Some(2.0);
tuned.volume = Some(0.5);
tuned.pitch = Some(-12);
assert_eq!(tuned.calculate_cost_in_cents(), make_request().calculate_cost_in_cents());
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,61 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `bytedance/seed-audio-1.0`.
/// fal's schema: <https://fal.ai/models/bytedance/seed-audio-1.0/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct SeedAudio1p0Input {
/// Text to synthesize. Reference audio clips are cited in the prompt as
/// `@Audio1`, `@Audio2`, `@Audio3`.
pub prompt: String,
/// Voice to use: a preset voice name or a cloned voice id.
#[serde(skip_serializing_if = "Option::is_none")]
pub voice: Option<String>,
/// Up to 3 reference audio URLs (each ≤30s and ≤10MB; wav/mp3/pcm/ogg_opus).
#[serde(skip_serializing_if = "Option::is_none")]
pub audio_urls: Option<Vec<String>>,
/// A single reference image URL (jpeg/png/webp, ≤10MB).
/// Cannot be combined with audio references.
#[serde(skip_serializing_if = "Option::is_none")]
pub image_url: Option<String>,
/// Output container.
/// Possible values: "wav", "mp3", "pcm", "ogg_opus". fal default: "mp3".
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format: Option<String>,
/// Output sample rate in Hz.
/// Possible values: 8000, 16000, 24000, 32000, 44100, 48000. fal default: 24000.
#[serde(skip_serializing_if = "Option::is_none")]
pub sample_rate: Option<u32>,
/// Speech speed multiplier. Range 0.5–2.0. fal default: 1.0.
#[serde(skip_serializing_if = "Option::is_none")]
pub speed: Option<f64>,
/// Volume multiplier. Range 0.5–2.0. fal default: 1.0.
#[serde(skip_serializing_if = "Option::is_none")]
pub volume: Option<f64>,
/// Pitch shift in semitones. Range −12 to +12. fal default: 0.
#[serde(skip_serializing_if = "Option::is_none")]
pub pitch: Option<i8>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SeedAudio1p0AudioFile {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_size: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SeedAudio1p0Output {
pub audio: SeedAudio1p0AudioFile,
}
@@ -1,3 +1,4 @@
pub mod audio;
pub mod image;
pub mod mesh;
pub mod video;