Files
Genarrative/server-rs/crates/platform-audio/src/elevenlabs.rs
lhk229 cad861a13d 完成 SFX V2 T6 测试与发布门禁
抽出单一 SFX Worker 编排并覆盖计费、翻译、ElevenLabs、OSS 与写回
补齐稳定失败分类、退款和零副作用组合测试
修复画布 Agent 的自动时长、小数范围、Loop 与 Prompt 参数契约
同步测试证据、发布门禁和项目共享记忆
2026-08-07 09:30:03 +00:00

411 lines
14 KiB
Rust

use std::{error::Error, time::Instant};
use bytes::BytesMut;
use reqwest::header;
use serde_json::{Value, json};
use crate::{
AudioError, DownloadedAudio, MAX_GENERATED_AUDIO_BYTES, validate_sound_effect_duration_seconds,
validate_sound_effect_prompt,
};
pub const ELEVENLABS_PROVIDER: &str = "elevenlabs";
pub const ELEVENLABS_SOUND_EFFECT_MODEL: &str = "eleven_text_to_sound_v2";
pub const ELEVENLABS_SOUND_EFFECT_OUTPUT_FORMAT: &str = "mp3_44100_128";
pub const ELEVENLABS_SOUND_EFFECT_PROMPT_INFLUENCE: f64 = 0.3;
const ELEVENLABS_SOUND_GENERATION_PATH: &str = "/v1/sound-generation";
#[derive(Clone)]
pub struct ElevenLabsAudioSettings {
pub base_url: String,
pub api_key: String,
pub request_timeout_ms: u64,
/// Worker 为本次任务留出的 provider 调用截止时刻。超出后不得再发起 provider 请求,
/// 否则终态写回预留时间会被 provider 调用吃掉。为 `None` 时只受固定超时约束。
pub request_deadline: Option<Instant>,
}
impl std::fmt::Debug for ElevenLabsAudioSettings {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ElevenLabsAudioSettings")
.field("base_url", &self.base_url)
.field("api_key", &"[redacted]")
.field("request_timeout_ms", &self.request_timeout_ms)
.field("request_deadline", &self.request_deadline)
.finish()
}
}
/// 单次 provider 请求的实际超时:固定超时与剩余预算取更严格的一个。
/// 返回 `None` 表示预算已经耗尽,调用方必须在发出请求之前失败。
fn effective_request_timeout_ms(
configured_timeout_ms: u64,
request_deadline: Option<Instant>,
) -> Option<u64> {
effective_request_timeout_ms_at(configured_timeout_ms, request_deadline, Instant::now())
}
fn effective_request_timeout_ms_at(
configured_timeout_ms: u64,
request_deadline: Option<Instant>,
now: Instant,
) -> Option<u64> {
let configured_timeout_ms = configured_timeout_ms.max(1);
let Some(request_deadline) = request_deadline else {
return Some(configured_timeout_ms);
};
let remaining_ms = u64::try_from(request_deadline.checked_duration_since(now)?.as_millis())
.unwrap_or(u64::MAX);
if remaining_ms == 0 {
return None;
}
Some(configured_timeout_ms.min(remaining_ms))
}
fn elevenlabs_budget_exhausted_error(endpoint: &str) -> AudioError {
AudioError::request_for(
ELEVENLABS_PROVIDER,
"ElevenLabs 音效生成超出本次任务的 provider 调用预算".to_string(),
Some(endpoint.to_string()),
true,
false,
true,
false,
None,
None,
)
}
#[derive(Clone, Debug, PartialEq)]
pub struct ElevenLabsSoundEffectRequest {
pub text: String,
pub duration_seconds: Option<f64>,
pub loop_enabled: bool,
}
#[derive(Clone, Debug)]
pub struct ElevenLabsGeneratedSoundEffect {
pub audio: DownloadedAudio,
pub duration_seconds: f64,
}
pub fn build_elevenlabs_audio_http_client(
settings: &ElevenLabsAudioSettings,
) -> Result<reqwest::Client, AudioError> {
reqwest::Client::builder()
.retry(reqwest::retry::never())
.timeout(std::time::Duration::from_millis(
settings.request_timeout_ms.max(1),
))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| {
AudioError::invalid_config_for(
ELEVENLABS_PROVIDER,
format!("构造 ElevenLabs 音效生成 HTTP 客户端失败:{error}"),
)
})
}
pub fn elevenlabs_sound_generation_endpoint(base_url: &str) -> String {
let normalized = base_url.trim().trim_end_matches('/');
let root = normalized
.strip_suffix("/v1")
.unwrap_or(normalized)
.trim_end_matches('/');
format!("{root}{ELEVENLABS_SOUND_GENERATION_PATH}")
}
pub fn build_elevenlabs_sound_effect_body(
request: &ElevenLabsSoundEffectRequest,
) -> Result<Value, AudioError> {
let prompt = validate_sound_effect_prompt(&request.text).map_err(|error| {
AudioError::invalid_request_for(ELEVENLABS_PROVIDER, error.message().to_string())
})?;
let duration_seconds = validate_sound_effect_duration_seconds(request.duration_seconds)
.map_err(|error| {
AudioError::invalid_request_for(ELEVENLABS_PROVIDER, error.message().to_string())
})?;
Ok(json!({
"text": prompt.prompt,
"model_id": ELEVENLABS_SOUND_EFFECT_MODEL,
"duration_seconds": duration_seconds,
"loop": request.loop_enabled,
"prompt_influence": ELEVENLABS_SOUND_EFFECT_PROMPT_INFLUENCE,
}))
}
pub async fn generate_elevenlabs_sound_effect(
http_client: &reqwest::Client,
settings: &ElevenLabsAudioSettings,
request: ElevenLabsSoundEffectRequest,
) -> Result<ElevenLabsGeneratedSoundEffect, AudioError> {
let endpoint = elevenlabs_sound_generation_endpoint(&settings.base_url);
let body = build_elevenlabs_sound_effect_body(&request)?;
// 预算耗尽时必须在发出请求之前失败:翻译轮次拖长后再发 provider 请求,会把 worker
// 留给终态写回的时间吃掉,且这次调用一定来不及被本 attempt 使用。
let Some(attempt_timeout_ms) =
effective_request_timeout_ms(settings.request_timeout_ms, settings.request_deadline)
else {
return Err(elevenlabs_budget_exhausted_error(endpoint.as_str()));
};
let response = http_client
.post(endpoint.as_str())
.timeout(std::time::Duration::from_millis(attempt_timeout_ms))
.query(&[("output_format", ELEVENLABS_SOUND_EFFECT_OUTPUT_FORMAT)])
.header("xi-api-key", settings.api_key.as_str())
.header(
header::ACCEPT,
"audio/mpeg,audio/mp3,application/octet-stream",
)
.header(header::ACCEPT_ENCODING, "identity")
.header(header::CONTENT_TYPE, "application/json")
.json(&body)
.send()
.await
.map_err(|error| map_elevenlabs_reqwest_error(endpoint.as_str(), false, error))?;
read_elevenlabs_sound_effect_response(response, endpoint.as_str()).await
}
async fn read_elevenlabs_sound_effect_response(
mut response: reqwest::Response,
endpoint: &str,
) -> Result<ElevenLabsGeneratedSoundEffect, AudioError> {
let status = response.status();
if !status.is_success() {
return Err(AudioError::upstream_for(
ELEVENLABS_PROVIDER,
format!("ElevenLabs 音效生成失败:HTTP {}", status.as_u16()),
status.as_u16(),
"",
));
}
if response
.content_length()
.is_some_and(|length| length > MAX_GENERATED_AUDIO_BYTES as u64)
{
return Err(invalid_elevenlabs_audio("生成音频超过 40 MiB 大小上限"));
}
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.map(|value| {
value
.to_str()
.map(normalize_content_type)
.map_err(|_| invalid_elevenlabs_audio("生成音频 Content-Type 非法"))
})
.transpose()?;
match content_type.as_deref() {
Some("audio/mpeg" | "audio/mp3" | "application/octet-stream") | None => {}
Some(_) => return Err(invalid_elevenlabs_audio("生成结果不是允许的 MP3 MIME 类型")),
}
let mut bytes = BytesMut::with_capacity(
response
.content_length()
.and_then(|length| usize::try_from(length).ok())
.unwrap_or(0)
.min(MAX_GENERATED_AUDIO_BYTES),
);
let read_limit = MAX_GENERATED_AUDIO_BYTES + 1;
while let Some(chunk) = response
.chunk()
.await
.map_err(|error| map_elevenlabs_reqwest_error(endpoint, true, error))?
{
let remaining = read_limit.saturating_sub(bytes.len());
bytes.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
if bytes.len() > MAX_GENERATED_AUDIO_BYTES {
return Err(invalid_elevenlabs_audio("生成音频超过 40 MiB 大小上限"));
}
}
if bytes.is_empty() {
return Err(invalid_elevenlabs_audio("生成音频内容为空"));
}
let bytes = bytes.freeze();
let duration_seconds = crate::mp3::probe_mp3_duration_seconds(bytes.clone())
.map_err(elevenlabs_duration_probe_failed)?;
Ok(ElevenLabsGeneratedSoundEffect {
audio: DownloadedAudio {
bytes: bytes.to_vec(),
mime_type: "audio/mpeg".to_string(),
extension: "mp3".to_string(),
},
duration_seconds,
})
}
fn normalize_content_type(content_type: &str) -> String {
content_type
.split(';')
.next()
.map(str::trim)
.unwrap_or_default()
.to_ascii_lowercase()
}
fn invalid_elevenlabs_audio(message: impl Into<String>) -> AudioError {
AudioError::missing_audio_with_reason_for(ELEVENLABS_PROVIDER, "invalid_audio", message)
}
fn elevenlabs_duration_probe_failed(message: impl Into<String>) -> AudioError {
AudioError::missing_audio_with_reason_for(ELEVENLABS_PROVIDER, "duration_probe_failed", message)
}
fn map_elevenlabs_reqwest_error(endpoint: &str, body: bool, error: reqwest::Error) -> AudioError {
AudioError::request_for(
ELEVENLABS_PROVIDER,
if body {
format!("读取 ElevenLabs 音效响应失败:{error}")
} else {
format!("请求 ElevenLabs 音效生成失败:{error}")
},
Some(endpoint.to_string()),
error.is_timeout(),
error.is_connect(),
error.is_request(),
body || error.is_body(),
error.status().map(|status| status.as_u16()),
Error::source(&error).map(ToString::to_string),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn endpoint_normalization_appends_the_sound_generation_path_once() {
for base_url in [
"https://api.elevenlabs.io",
"https://api.elevenlabs.io/",
"https://api.elevenlabs.io/v1",
" https://api.elevenlabs.io/v1/ ",
] {
assert_eq!(
elevenlabs_sound_generation_endpoint(base_url),
"https://api.elevenlabs.io/v1/sound-generation"
);
}
}
#[test]
fn request_body_fixes_provider_owned_fields_and_preserves_duration_precision() {
for (duration_seconds, loop_enabled) in [
(None, false),
(None, true),
(Some(1.234_567_89), false),
(Some(30.0), true),
] {
let body = build_elevenlabs_sound_effect_body(&ElevenLabsSoundEffectRequest {
text: " crisp metallic coin pickup ".to_string(),
duration_seconds,
loop_enabled,
})
.expect("valid ElevenLabs request body should build");
assert_eq!(body["text"], "crisp metallic coin pickup");
assert_eq!(body["model_id"], ELEVENLABS_SOUND_EFFECT_MODEL);
assert_eq!(body["duration_seconds"], json!(duration_seconds));
assert_eq!(body["loop"], loop_enabled);
assert_eq!(body["prompt_influence"], 0.3);
assert_eq!(body.as_object().expect("body should be an object").len(), 5);
}
}
#[test]
fn settings_debug_redacts_the_api_key() {
let settings = ElevenLabsAudioSettings {
base_url: "https://api.elevenlabs.test".to_string(),
api_key: "elevenlabs-secret-test-key".to_string(),
request_timeout_ms: 1_000,
request_deadline: None,
};
let debug = format!("{settings:?}");
assert!(debug.contains("[redacted]"));
assert!(!debug.contains(settings.api_key.as_str()));
}
#[test]
fn configured_timeout_is_preserved_without_a_request_deadline() {
let now = Instant::now();
assert_eq!(
effective_request_timeout_ms_at(180_000, None, now),
Some(180_000)
);
// 0 会被 reqwest 当成非法超时,最低收敛到 1ms。
assert_eq!(effective_request_timeout_ms_at(0, None, now), Some(1));
}
#[test]
fn request_timeout_is_clipped_to_the_remaining_provider_budget() {
let now = Instant::now();
let deadline = now + std::time::Duration::from_millis(2_000);
assert_eq!(
effective_request_timeout_ms_at(180_000, Some(deadline), now),
Some(2_000)
);
// 预算比固定超时宽松时不放大固定超时。
assert_eq!(
effective_request_timeout_ms_at(
500,
Some(now + std::time::Duration::from_secs(600)),
now
),
Some(500)
);
}
#[test]
fn exhausted_budget_stops_the_provider_request_before_it_is_sent() {
let now = Instant::now();
assert_eq!(
effective_request_timeout_ms_at(
180_000,
Some(now - std::time::Duration::from_millis(1)),
now
),
None
);
assert_eq!(
effective_request_timeout_ms_at(180_000, Some(now), now),
None
);
let error =
elevenlabs_budget_exhausted_error("https://api.elevenlabs.test/v1/sound-generation");
assert_eq!(error.provider(), ELEVENLABS_PROVIDER);
assert!(matches!(error, AudioError::Request { timeout: true, .. }));
}
#[test]
fn request_validation_errors_are_attributed_to_elevenlabs() {
let error = build_elevenlabs_sound_effect_body(&ElevenLabsSoundEffectRequest {
text: " ".to_string(),
duration_seconds: None,
loop_enabled: false,
})
.expect_err("empty text should fail");
assert_eq!(error.provider(), ELEVENLABS_PROVIDER);
}
#[test]
fn canonical_prompt_helper_uses_ecmascript_trim_boundaries() {
assert_eq!(
crate::canonicalize_sound_effect_prompt("\u{feff}sound\u{200b}\u{feff}"),
"sound\u{200b}"
);
}
}