selene-core 0.8.2

selene-core is the backend for Selene, a local-first music player
Documentation
use std::fmt::Write;

use lunar_lib::iterator_ext::IteratorExtensions;
use rgb::Rgb;
use serde::{Deserialize, Serialize};

use crate::lyrics::{
    LyricFormat,
    synced_lyrics::{LyricParseError, LyricSpan},
};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LyricLine {
    pub start_us: u32,
    pub vocals: Vec<LyricSpan>,
    pub vocalist: Option<usize>,
}

pub struct ClassifiedSpans<'a> {
    pub said: &'a [LyricSpan],
    pub current: Option<(&'a LyricSpan, f32)>,
    pub unsaid: &'a [LyricSpan],
}

// Core
impl LyricLine {
    #[must_use]
    pub fn line_end_us(&self, lines: &[LyricLine], duration_us: u32) -> u32 {
        let pos = lines
            .iter()
            .position(|l| std::ptr::eq(self, l))
            .expect("self (LyricLine) is not contained in the slice");

        lines.get(pos + 1).map_or(duration_us, |l| l.start_us)
    }

    #[must_use]
    pub fn time_spans<'a>(
        &'a self,
        current_us: u32,
        lines: &[LyricLine],
        duration_us: u32,
    ) -> ClassifiedSpans<'a> {
        let line_end = self.line_end_us(lines, duration_us);

        let sung_end = self
            .vocals
            .iter()
            .take_while(|span| current_us >= span.span_end_us(&self.vocals, line_end))
            .count();

        let current = self.vocals.get(sung_end).and_then(|span| {
            if current_us < span.start_us {
                return None;
            }
            let span_end = span.span_end_us(&self.vocals, line_end);
            let progress = (current_us - span.start_us) as f32 / (span_end - span.start_us) as f32;
            Some((span, progress))
        });

        let unsung_start = sung_end + usize::from(current.is_some());

        ClassifiedSpans {
            said: &self.vocals[..sung_end],
            current,
            unsaid: &self.vocals[unsung_start..],
        }
    }
}

// Exports
impl LyricLine {
    fn timestamp_string(&self) -> String {
        let mm = self.start_us / 60_000_000;
        let ss = (self.start_us / 1_000_000) % 60;
        let ms = (self.start_us / 10_000) % 100;

        format!("[{mm:02}:{ss:02}.{ms:02}]")
    }

    #[must_use]
    pub fn to_lrc_string(&self, a2: bool) -> String {
        let mut buf = self.timestamp_string();

        for span in &self.vocals {
            if a2 && span.vocals.len() > 1 {
                buf.push_str(&span.timestamp_string());
            }
            buf.push_str(&span.vocals);
        }

        buf
    }

    pub(crate) fn to_lyrics(&self, format: LyricFormat) -> String {
        let mut buf = String::new();

        if self.vocals.is_empty() {
            return String::new();
        }

        match format {
            LyricFormat::Plain => {
                for span in &self.vocals {
                    buf.push_str(&span.vocals);
                }
            }
            LyricFormat::Lrc { a2 } => {
                write!(buf, "{} ", self.timestamp_string()).unwrap();

                let span_count = self.vocals.len();
                for span in &self.vocals {
                    if a2 && span_count > 1 {
                        buf.push_str(&span.timestamp_string());
                    }
                    buf.push_str(&span.vocals);
                }
            }
            LyricFormat::SeleneLrc => {
                let vocalist = self.vocalist.map_or(String::new(), |v| format!("[{v}]"));
                write!(buf, "{}{vocalist} ", self.timestamp_string()).unwrap();

                let span_count = self.vocals.len();
                for span in &self.vocals {
                    if span_count > 1 {
                        buf.push_str(&span.timestamp_string());
                    }
                    buf.push_str(&span.vocals);
                }
            }
        }

        buf
    }
}

pub enum RawLyricLine {
    Line(LyricLine),
    Break,
    Offset(u32),
    Vocalist(String, Option<Rgb<u8>>),
    By(String),
    Tool(String),
    Ignored,
}

impl RawLyricLine {
    pub fn from_lyric_line(line: &str) -> Result<Self, LyricParseError> {
        let line = line.trim();
        if line.is_empty() {
            return Ok(RawLyricLine::Break);
        }

        if !line.starts_with('[') {
            return Err(LyricParseError::CorruptLineTimestamp(line.to_owned()));
        }

        let close = line
            .find(']')
            .ok_or(LyricParseError::CorruptLineTimestamp(line.to_owned()))?;

        let Some(start_us) = line[1..close].split_once(':').and_then(|(mins, secs)| {
            let mins = mins.parse::<usize>().ok()?;
            let secs = secs.parse::<f32>().ok()?;
            Some(((mins as f32 * 60.0 + secs) * 1_000_000.0) as u32)
        }) else {
            if let Some(tag) = line.strip_prefix("[").and_then(|s| s.strip_suffix(']')) {
                let parts = tag.split(':').to_vec();
                return match parts[0].to_ascii_lowercase().as_str() {
                    "tool" | "re" => return Ok(RawLyricLine::Tool(parts[1..].join(":"))),
                    "by" => return Ok(RawLyricLine::By(parts[1..].join(":"))),
                    "vocalist" => {
                        let vocalist = parts
                            .get(1)
                            .ok_or(LyricParseError::CorruptLineTag(line.to_owned()))?;

                        let Some(color_str) = parts.get(2) else {
                            return Ok(RawLyricLine::Vocalist(vocalist.to_string(), None));
                        };

                        let color_str = color_str.strip_prefix('#').unwrap_or(*color_str);

                        if color_str.len() != 6 {
                            return Err(LyricParseError::CorruptLineTag(line.to_owned()));
                        }
                        let r = u8::from_str_radix(&color_str[0..2], 16)
                            .map_err(|_| LyricParseError::CorruptLineTag(line.to_owned()))?;
                        let g = u8::from_str_radix(&color_str[2..4], 16)
                            .map_err(|_| LyricParseError::CorruptLineTag(line.to_owned()))?;
                        let b = u8::from_str_radix(&color_str[4..6], 16)
                            .map_err(|_| LyricParseError::CorruptLineTag(line.to_owned()))?;

                        let color = Rgb { r, g, b };
                        return Ok(RawLyricLine::Vocalist(vocalist.to_string(), Some(color)));
                    }
                    _ => Ok(RawLyricLine::Ignored),
                };
            }
            return Err(LyricParseError::CorruptLineTimestamp(line.to_owned()));
        };

        let vocal_str = line[close + 1..].trim();

        let (vocals, vocalist) = if vocal_str.starts_with('[')
            && let Some(end_pos) = vocal_str.find(']')
        {
            let vocalist = vocal_str[1..end_pos]
                .parse::<usize>()
                .map_err(|_| LyricParseError::InvalidLineVocalists(line.to_owned()))?;

            let vocals = LyricSpan::from_lyric_str(vocal_str[end_pos + 1..].trim(), start_us)?;

            (vocals, Some(vocalist))
        } else {
            (
                LyricSpan::from_lyric_str(line[close + 1..].trim(), start_us)?,
                None,
            )
        };

        Ok(RawLyricLine::Line(LyricLine {
            start_us,
            vocals,
            vocalist,
        }))
    }
}