Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ futures-util = "0.3.31"
image = { version = "0.25.10", default-features = false, features = ["gif", "jpeg", "png", "webp"] }
opentelemetry = "0.32.0"
pgvector = "0.4.1"
pulldown-cmark = "0.13.4"
pulldown-cmark = "0.13.5"
quick-xml = { version = "0.41.0", features = ["serialize"] }
rand = "0.10.1"
redis = "1.2.1"
Expand Down
12 changes: 11 additions & 1 deletion crates/openplotva-app/src/agent_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,17 @@ impl MediaFailureExplainer {
.await
.ok()?;
input.disable_tools = true;
input.reference_context.push(format!("Media delivery has ended with a problem: {failure}. Explain this once in your usual voice. Do not invent a cause or claim an undelivered result. Do not restart generation."));
input.reference_context.push(format!(
"The bot supports image and music generation. It accepted this request, but delivery ended with a problem: {failure}. Tools are disabled only for this failure notice. This does not mean the bot lacks the capability."
));
input.reference_context.push(format!(
"Original request, for context only: {}",
input.message.text
));
let notice_task = "Briefly report the delivery problem stated above. Preserve the distinction between failed and partial delivery. Do not invent a cause or claim that the bot cannot generate music or images. Do not perform the original request, write lyrics, provide a text substitute, or restart generation. Write in the language of the original request quoted above, in your usual voice.";
input.message.text = notice_task.to_owned();
input.message.normalized = notice_task.to_owned();
input.message.original_text = notice_task.to_owned();
struct NoTools;
impl DialogToolbox for NoTools {}
let result = crate::dialog_turn::run_captured_session(
Expand Down
12 changes: 12 additions & 0 deletions crates/openplotva-app/src/music_jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ fn song_prompt_retryable_reason(error: &MusicGenerationError) -> Option<FailureR
// lyrics at 1145 chars). Fall through to the next routed model
// instead of dying terminally.
if openplotva_media::acestep::is_invalid_song_material_message(message)
|| message.contains("song reprompt: decode response:")
|| openplotva_llm::retry::is_truncated_model_output_message(message)
{
return Some(FailureReason::ProviderProtocolError);
Expand Down Expand Up @@ -2115,6 +2116,17 @@ mod tests {
assert_eq!(song_prompt_retryable_reason(&provider), None);
}

#[test]
fn song_director_schema_errors_try_the_next_model() {
let error = super::MusicGenerationError::Material(
"song reprompt failed: song reprompt: decode response: invalid type: string, expected a sequence at line 1 column 697".to_owned(),
);
assert_eq!(
super::song_prompt_retryable_reason(&error),
Some(openplotva_llm::retry::FailureReason::ProviderProtocolError)
);
}

use openplotva_taskman::{
DEFAULT_LLM_JOB_MAX_ATTEMPTS, HIGHEST_PRIORITY, JobStatus, LLM_JOB_RETRY_EXHAUSTED_STAGE,
LLM_JOB_RETRY_STAGE, MUSIC_VIP_QUEUE_NAME, MusicGenJobParams, TaskQueueJobEvent,
Expand Down
86 changes: 85 additions & 1 deletion crates/openplotva-llm/src/aifarm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2393,7 +2393,12 @@ where
on_status,
)
.await?;
openplotva_media::acestep::decode_song_prompt_payload(&content, &topic, &language)
let payload = serde_json::from_str(&content).map_err(|source| {
AifarmMediaOptimizerError::SongPrompt {
source: openplotva_media::acestep::AceStepError::Json(source),
}
})?;
openplotva_media::acestep::normalize_song_prompt_payload_for_request(payload, &request)
.map_err(|source| AifarmMediaOptimizerError::SongPrompt { source })
}

Expand Down Expand Up @@ -10298,6 +10303,85 @@ mod tests {
assert_eq!(job.invocation.timeout_ms, DEFAULT_TIMEOUT_MS);
}

#[tokio::test]
async fn song_director_accepts_short_lyrics_copied_from_the_request()
-> Result<(), Box<dyn Error>> {
let lyrics = "[Verse]\nMorning. Новый day.\nAt home ждёт семья.";
let response = json!({
"choices": [{
"message": {
"role": "assistant",
"content": serde_json::to_string(&json!({
"analysis": "Synthwave with clean female vocals.",
"title": "Night City",
"vocal_language": "ru",
"vocals": "female",
"genre": "synthwave",
"bpm": 102,
"key": "",
"sound": ["synth bass", "gated reverb drums", "analog pads", "arpeggiated plucks", "bright lead synth"],
"character": ["neon mood", "nostalgic 80s", "driving"],
"structure": ["synth intro", "verse over a pulsing bass", "big chorus", "outro fades on the hook"],
"vocal_style": "clean vocals with light reverb",
"references": [],
"duration_seconds": 180,
"lyrics": lyrics,
}))?
}
}]
});
let transport = FakeTransport::new(vec![Ok(AifarmHttpResponse {
status_code: 200,
status_text: "OK".to_owned(),
body: serde_json::to_vec(&response)?,
..AifarmHttpResponse::default()
})]);
let generator = AifarmStructuredJsonGenerator::with_transport(
AifarmStructuredJsonConfig {
client: AifarmClientConfig {
direct_url: "https://llm.example/v1/chat/completions".to_owned(),
..AifarmClientConfig::default()
},
..AifarmStructuredJsonConfig::default()
},
transport.clone(),
)
.with_prompt_store(prompt_store_with(&[(
"music/song_director.prompt",
"{{role \"system\"}}custom song system {{topic}}{{role \"user\"}}custom song user {{request}} {{languageHint}}",
)]));

let result = generator
.optimize_song_prompt(
openplotva_media::acestep::SongPromptRequest {
topic: "night city".to_owned(),
request_text:
"Спой ровно этот текст:\nMorning. Новый day.\nAt home ждёт семья."
.to_owned(),
language_hint: "en-US".to_owned(),
..openplotva_media::acestep::SongPromptRequest::default()
},
&mut |_status| {},
)
.await?;

assert_eq!(result.title, "Night City");
assert_eq!(result.vocal_language, "ru");
assert_eq!(result.vocals, "female");
assert_eq!(result.duration_seconds, 180);
assert!(
result
.style
.starts_with("synthwave, female clean vocals with light reverb, synth bass"),
"{}",
result.style
);
assert!(result.style.ends_with(", 102 BPM"), "{}", result.style);
assert_eq!(result.lyrics, lyrics);

Ok(())
}

#[tokio::test]
async fn http_client_takes_the_result_from_the_sync_submit() -> Result<(), CompletionError> {
let completion_payload =
Expand Down
2 changes: 1 addition & 1 deletion crates/openplotva-llm/src/gemini.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1361,7 +1361,7 @@ where
.await?;
let payload: openplotva_media::acestep::SongPromptPayload =
serde_json::from_value(payload).map_err(GeminiMediaPromptOptimizerError::Decode)?;
openplotva_media::acestep::normalize_song_prompt_payload(payload, &topic, &language)
openplotva_media::acestep::normalize_song_prompt_payload_for_request(payload, &request)
.map_err(|error| GeminiMediaPromptOptimizerError::Tool(error.to_string()))
}

Expand Down
103 changes: 97 additions & 6 deletions crates/openplotva-media/src/acestep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,24 @@ pub fn normalize_song_prompt_payload(
payload: SongPromptPayload,
requested_topic: &str,
requested_language: &str,
) -> Result<SongPromptResult, AceStepError> {
normalize_song_prompt_payload_with_source(payload, requested_topic, requested_language, "")
}

/// Validate generated material against the listener's original text when supplied.
pub fn normalize_song_prompt_payload_for_request(
payload: SongPromptPayload,
request: &SongPromptRequest,
) -> Result<SongPromptResult, AceStepError> {
let (topic, language) = normalize_song_prompt_input(request)?;
normalize_song_prompt_payload_with_source(payload, &topic, &language, &request.request_text)
}

fn normalize_song_prompt_payload_with_source(
payload: SongPromptPayload,
requested_topic: &str,
requested_language: &str,
request_text: &str,
) -> Result<SongPromptResult, AceStepError> {
let topic = requested_topic.trim();
if topic.is_empty() {
Expand All @@ -801,6 +819,7 @@ pub fn normalize_song_prompt_payload(
));
}
let lyrics = canonicalize_song_lyrics(&payload.lyrics);
let supplied_lyrics = lyrics_copied_from_request(&lyrics.text, request_text);
let vocals_field = payload.vocals.trim().to_ascii_lowercase();
let instrumental = vocals_field == "instrumental" || lyrics.line_count == 0;
let vocals = if instrumental {
Expand All @@ -811,17 +830,19 @@ pub fn normalize_song_prompt_payload(
String::new()
};
if !instrumental {
if lyrics.line_count < SONG_MIN_LYRIC_LINES
|| lyrics.line_count > SONG_MAX_LYRIC_LINES
|| lyrics.sections < 2
|| !lyrics.has_chorus
if lyrics.line_count > SONG_MAX_LYRIC_LINES
|| (!supplied_lyrics
&& (lyrics.line_count < SONG_MIN_LYRIC_LINES
|| lyrics.sections < 2
|| !lyrics.has_chorus))
{
return Err(AceStepError::InvalidResponse(
SONG_LYRICS_STRUCTURE_REJECTION.to_owned(),
));
}
if !lyrics_script_matches_language(&lyrics.text, &language)
|| foreign_script_line_share(&lyrics.text, &language) > SONG_MAX_FOREIGN_LINE_SHARE
if !supplied_lyrics
&& (!lyrics_script_matches_language(&lyrics.text, &language)
|| foreign_script_line_share(&lyrics.text, &language) > SONG_MAX_FOREIGN_LINE_SHARE)
{
return Err(AceStepError::InvalidResponse(
SONG_LYRICS_LANGUAGE_REJECTION.to_owned(),
Expand Down Expand Up @@ -876,6 +897,21 @@ pub fn normalize_song_prompt_payload(
})
}

fn lyrics_copied_from_request(lyrics: &str, request_text: &str) -> bool {
let words = |text: &str| {
text.lines()
.filter(|line| section_marker(line.trim()).is_none())
.flat_map(str::split_whitespace)
.map(str::to_owned)
.collect::<Vec<_>>()
};
let sung = words(lyrics);
!sung.is_empty()
&& words(request_text)
.windows(sung.len())
.any(|part| part == sung)
}

const fn non_zero_or(value: u32, fallback: u32) -> u32 {
if value == 0 { fallback } else { value }
}
Expand Down Expand Up @@ -2627,6 +2663,61 @@ mod tests {
Ok(())
}

#[test]
fn supplied_lyrics_keep_short_mixed_language_material() {
let mut payload = rap_payload();
payload.vocal_language = "ru".to_owned();
payload.lyrics = "[Verse]\nMorning. Новый day.\n[Chorus]\nAt home ждёт семья.".to_owned();
let request = SongPromptRequest {
topic: "garage grime".to_owned(),
request_text: "Спой ровно этот текст:\n\nMorning. Новый day. At home ждёт семья."
.to_owned(),
..SongPromptRequest::default()
};
let result = super::normalize_song_prompt_payload_for_request(payload.clone(), &request)
.expect("supplied words do not need a generated song structure");
assert_eq!(
result.lyrics,
"[Verse]\nMorning. Новый day.\n\n[Chorus]\nAt home ждёт семья."
);

let unrelated = SongPromptRequest {
request_text: "Write a song about city lights".to_owned(),
..request.clone()
};
assert!(
super::normalize_song_prompt_payload_for_request(payload.clone(), &unrelated).is_err()
);
let missing = SongPromptRequest {
request_text: String::new(),
topic: request.request_text.clone(),
..request.clone()
};
assert!(
super::normalize_song_prompt_payload_for_request(payload.clone(), &missing).is_err(),
"a model-written topic is not the original lyrics"
);
payload
.lyrics
.push_str("\nA new line the listener never supplied.");
assert!(super::normalize_song_prompt_payload_for_request(payload, &request).is_err());
}

#[test]
fn supplied_lyrics_do_not_bypass_the_size_limit() {
let mut payload = rap_payload();
payload.lyrics = (0..70)
.map(|n| format!("Original lyric line {n}"))
.collect::<Vec<_>>()
.join("\n");
let request = SongPromptRequest {
topic: "song".to_owned(),
request_text: payload.lyrics.clone(),
..SongPromptRequest::default()
};
assert!(super::normalize_song_prompt_payload_for_request(payload, &request).is_err());
}

#[test]
fn song_director_payload_rejects_bad_material() {
let rejection = |payload: SongPromptPayload, language: &str| {
Expand Down
1 change: 1 addition & 0 deletions prompts/music/song_director.prompt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Every tag is an English phrase of one to eight words that a producer of that gen

## 4. Write the lyrics (empty string for instrumentals)

- If the listener supplies exact lyrics, preserve every word, punctuation mark, language switch and the order. You may add section markers and line breaks. Do not add verses, ad-libs or repeated choruses unless requested. Supplied lyrics override the length, rhyme, language and chorus rules below. Choose a duration that fits the supplied text.
- Same language throughout, in that language's natural word order and stress; native idioms, no calques. Names and details from the request appear as they are.
- The chorus carries the title line and a hook that repeats; verses tell the story in concrete images (objects, places, actions), not abstractions. Every line should be singable in one breath.
- Rhyme the way the genre does: rap uses internal and multi-syllable rhymes and rhythmic repetition; pop and rock use clean end rhymes with a lift into the chorus; metal and punk favour hard consonants and shouted chants; folk and chanson tell a story with plain exact rhymes; ambient has no lyrics.
Expand Down
Loading