selene-core 0.6.0

selene-core is the backend for Selene, a local-first music player
Documentation
use serde::{Deserialize, Serialize};

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

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum LyricLineType {
    Break,
    Lyric(LyricLine),
}

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

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

// Core
impl LyricLine {
    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)
    }

    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 + current.is_some() as usize;

        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}] ")
    }

    pub fn to_lrc_string(&self) -> String {
        let mut buf = self.timestamp_string();

        for span in &self.vocals {
            buf.push_str(&span.vocals);
        }

        buf
    }

    pub fn to_plain_string(&self) -> String {
        let mut buf = String::new();

        for span in &self.vocals {
            buf.push_str(&span.vocals);
        }

        buf
    }
}

pub enum LyricLineParseResult {
    Line(LyricLine),
    Break,
    Offset(u32),
    Ignored,
}

impl LyricLineType {
    pub fn from_lyric_line(line: &str) -> Result<LyricLineParseResult, LyricParseError> {
        if line.is_empty() {
            return Ok(LyricLineParseResult::Break);
        }

        if line.strip_prefix('#').is_some() {
            return Ok(LyricLineParseResult::Ignored);
        }

        let line = line.trim();
        let close = line
            .find(']')
            .ok_or(LyricParseError::CorruptLineTag)?;

        let tag = &line[1..close];
        let vocals = line[close + 1..].trim();

        let (tag_left, tag_right) = tag
            .split_once(':')
            .ok_or(LyricParseError::CorruptLineTag)?;

        let Ok(mins) = tag_left.parse::<f32>() else {
            if !vocals.is_empty() {
                return Err(LyricParseError::CorruptLineTag);
            }

            let key = tag_left.trim();
            let value = tag_right.trim();

            if key == "offset"
                && let Ok(offset) = value.parse::<u32>()
            {
                return Ok(LyricLineParseResult::Offset(offset));
            } else {
                return Ok(LyricLineParseResult::Ignored);
            }
        };

        let secs: f32 = tag_right
            .parse()
            .map_err(|_| LyricParseError::InvalidLineTimestamp(format!("{mins}:{tag_right}")))?;
        let start_us = ((mins * 60.0 + secs) * 1_000_000.0) as u32;

        let vocals = LyricSpan::from_lyric_str(vocals, start_us)?;

        Ok(LyricLineParseResult::Line(LyricLine { start_us, vocals }))
    }
}