diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 7e10a39e..154531f3 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -3680,7 +3680,7 @@ TextAlign _lyricTextAlign(BuildContext context, LyricLine line) { if (voice == null) { return context.isMornye ? TextAlign.start : TextAlign.center; } - if (voice.isGroup) return TextAlign.center; + if (voice.isGroup) return TextAlign.left; return voice.index.isEven ? TextAlign.left : TextAlign.right; } diff --git a/rust_backend/crates/core/src/lyrics/payloads.rs b/rust_backend/crates/core/src/lyrics/payloads.rs index 1a823749..7ec1ac08 100644 --- a/rust_backend/crates/core/src/lyrics/payloads.rs +++ b/rust_backend/crates/core/src/lyrics/payloads.rs @@ -1,7 +1,9 @@ use super::{LyricsResponse, LyricsWord, json, lrc}; +use regex::Regex; use serde::Serialize; use serde_json::value::RawValue; use std::collections::{BTreeMap, BTreeSet}; +use std::sync::LazyLock; #[derive(Clone, Debug, Default, Serialize)] pub struct PaxDetail { @@ -23,12 +25,28 @@ pub struct PaxLine { pub background: bool, pub background_text: Option>, pub endtime: isize, + #[serde(skip_serializing_if = "String::is_empty")] + pub agent: String, } json::go_deserialize!(PaxLine { "text" => text, "timestamp" => timestamp, "oppositeturn" => opposite_turn, "background" => background, "backgroundtext" => background_text, "endtime" => endtime, + "agent" => agent, }); +#[derive(Default)] +struct AppleAgent { + id: String, + kind: String, +} +json::go_deserialize!(AppleAgent { "id" => id, "type" => kind, }); + +#[derive(Default)] +struct AppleMetadata { + agents: Option>, +} +json::go_deserialize!(AppleMetadata { "agents" => agents, }); + #[derive(Default)] struct ApplePayload { kind: String, @@ -37,12 +55,86 @@ struct ApplePayload { elrc_multi_person: String, plain: String, ttml_content: String, + metadata: Option, } json::go_deserialize!(ApplePayload { "type" => kind, "content" => content, "elrc" => elrc, "elrcmultiperson" => elrc_multi_person, "plain" => plain, "ttmlcontent" => ttml_content, + "metadata" => metadata, }); +static APPLE_VOCAL_LINE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)^\[([0-9]{1,3}):([0-9]{1,2})(?:[.:]([0-9]{1,3}))?\]\s*(v[1-9][0-9]*):") + .unwrap() +}); + +/// The proxy's oppositeTurn flag can turn a group agent into the second +/// singer. Recover the original roles while retaining every eLRC word time, +/// space and backing part. Group vocals use the primary side, as in TTML. +fn apple_vocal_sides(text: &str, payload: &ApplePayload) -> String { + let agents = payload + .metadata + .as_ref() + .and_then(|metadata| metadata.agents.as_deref()) + .unwrap_or_default(); + let mut voices = BTreeMap::new(); + let mut person = 0; + for agent in agents { + let voice = match agent.kind.as_str() { + "person" => { + person += 1; + person + } + "group" => 1, + _ => continue, + }; + voices.insert(agent.id.as_str(), format!("v{voice}")); + } + let lines = payload.content.as_deref().unwrap_or_default(); + if voices.is_empty() || lines.is_empty() { + return text.into(); + } + text.lines() + .map(|line| { + let Some(captures) = APPLE_VOCAL_LINE.captures(line) else { + return line.to_owned(); + }; + let fraction = captures.get(3).map_or(0, |value| { + value.as_str().parse::().unwrap() * 10_i64.pow(3 - value.as_str().len() as u32) + }); + let start = captures[1].parse::().unwrap() * 60_000 + + captures[2].parse::().unwrap() * 1000 + + fraction; + // Proxy eLRC may round to centiseconds; prefer an exact match. + let source = lines + .iter() + .filter(|source| !source.agent.is_empty()) + .min_by_key(|source| (source.timestamp as i64 - start).abs()) + .filter(|source| (source.timestamp as i64 - start).abs() <= 10); + let Some(source) = source else { + return line.to_owned(); + }; + let Some(voice) = voices.get(source.agent.as_str()) else { + return line.to_owned(); + }; + let distance = (source.timestamp as i64 - start).abs(); + if lines.iter().any(|other| { + (other.timestamp as i64 - start).abs() == distance + && voices.get(other.agent.as_str()) != Some(voice) + }) { + // Simultaneous independent lines cannot be identified by time + // alone. Retain their supplied labels rather than swapping them. + return line.to_owned(); + } + let prefix = captures.get(4).unwrap(); + let mut corrected = line.to_owned(); + corrected.replace_range(prefix.range(), voice); + corrected + }) + .collect::>() + .join("\n") +} + #[derive(Default)] struct ProxyPayload { kind: String, @@ -134,7 +226,7 @@ pub fn format_apple(raw: &str, multi_person: bool, word_timing: bool) -> Result< .any(|value| !value.trim().is_empty())) { if word_timing && multi_person && !value.elrc_multi_person.trim().is_empty() { - return Ok(value.elrc_multi_person.trim().into()); + return Ok(apple_vocal_sides(value.elrc_multi_person.trim(), &value)); } if word_timing && !value.elrc.trim().is_empty() { return Ok(value.elrc.trim().into()); @@ -146,12 +238,12 @@ pub fn format_apple(raw: &str, multi_person: bool, word_timing: bool) -> Result< if content.is_empty() { return Err("unsupported apple music lyrics payload".into()); } - return Ok(format_pax_content( - &value.kind, - content, - multi_person, - word_timing, - )); + let text = format_pax_content(&value.kind, content, multi_person, word_timing); + return Ok(if multi_person { + apple_vocal_sides(&text, &value) + } else { + text + }); } if let Ok(Some(lines)) = json::decode::>>(raw) && !lines.is_empty() @@ -462,6 +554,79 @@ pub fn format_kpoe(response: &KpoeResponse, multi_person: bool, word_timing: boo mod supplement_tests { use super::*; + #[test] + fn apple_agents_restore_vocal_sides_without_reformatting_word_times() { + let raw = serde_json::json!({ + "type": "Syllable", + "elrcMultiPerson": "[00:01.01]v1: <00:01.009>Lead<00:02.00>\n[bg:<00:01.50>Echo<00:02.50>]\n[00:03.00]v2: <00:03.00>Guest<00:04.00>\n[00:05.00]v2: <00:05.00>Together<00:06.00>\n[00:07.00]v2: Third", + "content": [ + {"timestamp": 1009, "agent": "lead"}, + {"timestamp": 3000, "agent": "guest"}, + {"timestamp": 5000, "agent": "all"}, + {"timestamp": 7000, "agent": "third"} + ], + "metadata": {"agents": [ + {"id": "lead", "type": "person"}, + {"id": "guest", "type": "person"}, + {"id": "all", "type": "group"}, + {"id": "third", "type": "person"} + ]} + }); + let text = format_apple(&raw.to_string(), true, true).unwrap(); + assert_eq!( + text, + "[00:01.01]v1: <00:01.009>Lead<00:02.00>\n[bg:<00:01.50>Echo<00:02.50>]\n[00:03.00]v2: <00:03.00>Guest<00:04.00>\n[00:05.00]v1: <00:05.00>Together<00:06.00>\n[00:07.00]v3: Third" + ); + let lyrics = LyricsResponse::from_text(&text, "Apple Music", "Apple Music"); + let stored = lrc::with_metadata(&lyrics, "Track", "Artist"); + assert!(stored.contains("[00:05.00]v1: <00:05.00>Together<00:06.00>")); + assert!(stored.contains("[bg:<00:01.50>Echo<00:02.50>]")); + } + + #[test] + fn apple_content_fallback_honors_agents_only_when_multi_person_is_enabled() { + let raw = serde_json::json!({ + "type": "Syllable", + "content": [{"timestamp": 1000, "oppositeTurn": true, "agent": "group", "text": [ + {"text": "Together", "timestamp": 1000, "endtime": 2000} + ]}], + "metadata": {"agents": [{"id": "group", "type": "group"}]} + }) + .to_string(); + for timing in [false, true] { + assert!( + format_apple(&raw, true, timing) + .unwrap() + .starts_with("[00:01.00]v1:") + ); + assert!(!format_apple(&raw, false, timing).unwrap().contains("v1:")); + assert!(!format_apple(&raw, false, timing).unwrap().contains("v2:")); + } + } + + #[test] + fn apple_voice_correction_preserves_unknown_or_ambiguous_lines() { + let text = "[00:01.00]v2: Unknown\n[00:02.00]v2: Too far\n[00:03.00]v2: Ambiguous\n[00:04.00]v2: Exact"; + let mut raw = serde_json::json!({"elrcMultiPerson": text}); + assert_eq!(format_apple(&raw.to_string(), true, true).unwrap(), text); + raw["metadata"] = serde_json::json!({"agents": [ + {"id": "v1", "type": "person"}, {"id": "v2", "type": "person"}, + {"id": "v3", "type": "group"} + ]}); + raw["content"] = serde_json::json!([ + {"timestamp": 1000, "agent": "unknown"}, + {"timestamp": 2011, "agent": "v3"}, + {"timestamp": 3000, "agent": "v3"}, + {"timestamp": 3000, "agent": "v2"}, + {"timestamp": 3999, "agent": "v2"}, + {"timestamp": 4000, "agent": "v3"} + ]); + assert_eq!( + format_apple(&raw.to_string(), true, true).unwrap(), + text.replace("[00:04.00]v2:", "[00:04.00]v1:") + ); + } + fn payload(lang: &str) -> serde_json::Value { serde_json::json!({ "type": "Syllable", diff --git a/rust_backend/crates/providers/src/lyrics/cache.rs b/rust_backend/crates/providers/src/lyrics/cache.rs index c0a7eb89..0e01f777 100644 --- a/rust_backend/crates/providers/src/lyrics/cache.rs +++ b/rust_backend/crates/providers/src/lyrics/cache.rs @@ -13,7 +13,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; pub const MAX_ENTRIES: usize = 500; pub const TTL: Duration = Duration::from_secs(24 * 60 * 60); const MAX_PERSISTED_BYTES: u64 = 64 << 20; -const SNAPSHOT_VERSION: u32 = 3; +const SNAPSHOT_VERSION: u32 = 4; #[derive(Clone)] struct Entry { @@ -152,7 +152,8 @@ impl LyricsCache { let Some(response) = entry.response else { continue; }; - // Older versions discarded Apple text or romanization timing. + // Older versions lost Apple supplements or collapsed group vocals + // into the second singer instead of preserving the vocal side. // Refetch once, preserving other providers' caches. if loaded.version < SNAPSHOT_VERSION && response.provider == "Apple Music" { continue; diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index b67fc291..9db8b0f6 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -2593,6 +2593,51 @@ void main() { } for (final mornye in [false, true]) { + testWidgets('TTML group vocals keep the primary side ($mornye)', ( + tester, + ) async { + metadataOverrides['lyrics'] = ''' + + +

Guest

Together

Guest returns

+'''; + final playback = StreamController.broadcast(); + addTearDown(playback.close); + await pumpNowPlaying( + tester, + theme: mornye ? MornyeTheme.build(Brightness.dark) : null, + size: const Size(390, 1100), + playbackEvents: playback.stream, + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + if (mornye) { + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); + } else { + await tester.drag(find.byType(PageView), const Offset(-350, 0)); + } + await tester.pumpAndSettle(); + for (final seconds in [0, 2, 4, 7, 4]) { + playback.add( + PlaybackState( + playing: false, + processingState: AudioProcessingState.ready, + updatePosition: Duration(seconds: seconds), + ), + ); + await tester.pumpAndSettle(); + expect( + tester.widget(find.text('Together')).textAlign, + TextAlign.left, + ); + expect( + tester.widget(find.text('Guest returns')).textAlign, + TextAlign.right, + ); + } + expect(tester.takeException(), isNull); + }); + for (final ttml in [false, true]) { testWidgets( 'backing vocals stay smaller under the lead with independent timing (Mornye: $mornye, TTML: $ttml)',