灵符lingfu
L

ling-daw

↓ 0 downloads

Ling DAW engine: synth, physical-modeling drums, effects, mixer, sequencer, project I/O

lingfu install ling-daw
LicenseApache-2.0 OR MIT
AuthorsLing Lin <taellinglin@gmail.com>, Sanny Lin <SannyLing53@gmail.com>
READMECode / FilesVersionsDependency GraphDependentsSecurityBuildsSettings

Code / Files (91)

src/lyricfit.rs
//! Fitting lyrics to notes: paste a song's words, get them spread over a clip's notes the way a singer would phrase them.
//!
//! The text is cleaned (section labels, chords and stage directions go), split into syllables (English by rule, or by the hyphens a
//! model / the user put in; Hangul, kana and Hanzi by character) and turned into IPA. The notes are grouped by onset, so a chord is ONE
//! thing that can carry one sustained syllable; short notes after a syllable become sustains; the lyric lines are matched to the phrases
//! the rests in the music make. Three ways to spread the syllables:
//!
//! * `Fit`: one syllable per note group when they match; fewer syllables than groups means melismas (the syllable lands on a strong beat
//!   or a long note near its even share and the following notes sustain it); more syllables than groups means the longest notes carry two.
//! * `Even`: the same, but purely by count, ignoring the beat.
//! * `Strict`: one syllable per group in order, nothing more.
//!
//! `looped` repeats the lyrics (lines across phrases, syllables across notes) when the notes outlast them.
use crate::lyrics::{self, Lang};

#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Mode {
    Fit,
    Even,
    Strict,
}

#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Chord {
    /// every note of a chord sings the syllable
    All,
    /// the top note sings it, the others hold the default vowel
    Top,
}

#[derive(Clone, Debug, PartialEq)]
pub struct Opts {
    pub mode: Mode,
    pub looped: bool,
    /// notes shorter than this many beats, right after another note, sustain it instead of taking a syllable (0 = off)
    pub short: f64,
    pub chord: Chord,
    /// a rest this long (beats) between notes starts a new phrase
    pub gap: f64,
    pub lang: Lang,
}

impl Default for Opts {
    fn default() -> Opts {
        Opts { mode: Mode::Fit, looped: false, short: 0.3, chord: Chord::All, gap: 1.0, lang: Lang::Auto }
    }
}

impl Opts {
    /// `fit:<mode>:<loop 0|1>:<short>:<chord>:<gap>:<lang>` as stored in a clip's `lyric_lang`; None when it is not a fit setting
    pub fn parse(s: &str) -> Option<Opts> {
        let p: Vec<&str> = s.split(':').collect();
        if p.first() != Some(&"fit") {
            return None;
        }
        let mut o = Opts::default();
        if let Some(m) = p.get(1) {
            o.mode = match *m {
                "even" => Mode::Even,
                "strict" => Mode::Strict,
                _ => Mode::Fit,
            };
        }
        if let Some(l) = p.get(2) {
            o.looped = *l == "1";
        }
        if let Some(x) = p.get(3).and_then(|x| x.parse::<f64>().ok()) {
            o.short = x.clamp(0.0, 4.0);
        }
        if let Some(c) = p.get(4) {
            o.chord = if *c == "top" { Chord::Top } else { Chord::All };
        }
        if let Some(g) = p.get(5).and_then(|x| x.parse::<f64>().ok()) {
            o.gap = g.clamp(0.25, 16.0);
        }
        if let Some(l) = p.get(6) {
            o.lang = Lang::parse(l);
        }
        Some(o)
    }
}

pub struct NoteIn {
    pub start: f64,
    pub len: f64,
    pub key: u8,
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct Info {
    pub syllables: usize,
    pub lines: usize,
    pub groups: usize,
    pub phrases: usize,
    pub chords: usize,
    pub bearing: usize,
}

// ── cleaning ──────────────────────────────────────────────────────────────────

fn is_chord_token(t: &str) -> bool {
    let mut c = t.trim_matches(|c: char| c == '(' || c == ')');
    if let Some((a, _)) = c.split_once('/') {
        c = a;
    }
    let mut ch = c.chars();
    match ch.next() {
        Some('A'..='G') => {}
        _ => return false,
    }
    let mut rest: &str = ch.as_str();
    if rest.starts_with('#') || rest.starts_with('b') {
        rest = &rest[1..];
    }
    loop {
        if rest.is_empty() {
            return true;
        }
        let mut found = false;
        for suf in ["maj", "min", "dim", "aug", "sus", "add", "m", "M"] {
            if let Some(r) = rest.strip_prefix(suf) {
                rest = r;
                found = true;
                break;
            }
        }
        if !found {
            if let Some(r) = rest.strip_prefix(|c: char| c.is_ascii_digit() || c == '#' || c == '+' || c == '-') {
                rest = r;
                found = true;
            }
        }
        if !found {
            return false;
        }
    }
}

fn is_direction(inner: &str) -> bool {
    let l = inner.trim().to_lowercase();
    let digits = l.trim_start_matches('x').trim_end_matches('x');
    (!digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()))
        || ["chorus", "verse", "intro", "outro", "bridge", "repeat", "refrain", "hook", "solo", "instrumental", "interlude", "pre-chorus", "x2", "x3", "x4"].iter().any(|w| l.contains(w))
}

/// the lyric lines of pasted text: section labels `[Chorus]`, chord lines, timestamps and `(x2)` go; lines are split at newlines and at a
/// spaced `/` or `|`; punctuation goes, apostrophes and hyphens stay
pub fn clean(raw: &str) -> Vec<String> {
    let text = raw.replace('\r', "").replace(" / ", "\n").replace(" | ", "\n").replace('\u{2028}', "\n");
    let mut out = vec![];
    for line in text.lines() {
        // drop [..] and directive-like (..)
        let mut s = String::new();
        let mut depth_sq = 0;
        let mut paren = String::new();
        let mut in_paren = false;
        for c in line.chars() {
            if c == '[' {
                depth_sq += 1;
                continue;
            }
            if c == ']' {
                depth_sq = (depth_sq - 1).max(0);
                continue;
            }
            if depth_sq > 0 {
                continue;
            }
            if c == '(' {
                in_paren = true;
                paren.clear();
                continue;
            }
            if c == ')' && in_paren {
                in_paren = false;
                if !is_direction(&paren) {
                    s.push(' ');
                    s.push_str(&paren);
                    s.push(' ');
                }
                continue;
            }
            if in_paren {
                paren.push(c);
            } else {
                s.push(c);
            }
        }
        if in_paren {
            s.push_str(&paren);
        }
        let toks: Vec<&str> = s.split_whitespace().collect();
        if toks.len() >= 2 && toks.iter().all(|t| is_chord_token(t)) {
            continue;
        }
        let kept: String = s
            .chars()
            .map(|c| if c.is_alphabetic() || c == '\'' || c == '’' || c == '-' || c == '~' || c == '_' || c.is_whitespace() { c } else { ' ' })
            .collect();
        let words: Vec<&str> = kept.split_whitespace().collect();
        if !words.is_empty() {
            out.push(words.join(" "));
        }
    }
    out
}

// ── syllables ─────────────────────────────────────────────────────────────────

fn is_vowel_at(w: &[char], i: usize) -> bool {
    match w[i] {
        'a' | 'e' | 'i' | 'o' | 'u' => true,
        'y' => i > 0 && !matches!(w[i - 1], 'a' | 'e' | 'i' | 'o' | 'u') || (i > 0 && matches!(w[i - 1], 'a' | 'e' | 'o' | 'u') && (i + 1 == w.len() || !is_vowel_letter(w[i + 1]))),
        'w' => i > 0 && matches!(w[i - 1], 'a' | 'e' | 'o') && (i + 1 == w.len() || !is_vowel_letter(w[i + 1])),
        _ => false,
    }
}

fn is_vowel_letter(c: char) -> bool {
    matches!(c, 'a' | 'e' | 'i' | 'o' | 'u' | 'y')
}

const GOOD_ONSETS: [&str; 22] = ["bl", "br", "cl", "cr", "dr", "fl", "fr", "gl", "gr", "pl", "pr", "tr", "tw", "th", "sh", "ch", "ph", "wh", "kn", "wr", "thr", "str"];

/// An English word split into its syllables by rule: vowel groups are the nuclei, a final silent e does not count, a consonant between
/// vowels goes with the next syllable, a cluster is split after its first consonant unless its end is a natural onset (pl, tr, th ...).
pub fn syllabify_en(word: &str) -> Vec<String> {
    let w: Vec<char> = word.to_lowercase().chars().filter(|c| c.is_alphabetic()).collect();
    // the pieces keep the letters' own case ("I", "Wonder-ful")
    let orig: Vec<char> = word.chars().filter(|c| c.is_alphabetic()).collect();
    let src: &[char] = if orig.len() == w.len() { &orig } else { &w };
    if w.len() <= 2 {
        return if w.is_empty() { vec![] } else { vec![src.iter().collect()] };
    }
    let n = w.len();
    let mut vowel: Vec<bool> = (0..n).map(|i| is_vowel_at(&w, i)).collect();
    // vowels in a word with no other vowel: the "y" of "my", the "e" of "me"
    let groups_of = |v: &[bool]| -> usize { (0..n).filter(|i| v[*i] && (*i == 0 || !v[*i - 1])).count() };
    let g = groups_of(&vowel);
    if g >= 2 {
        // silent final e (not "-le" after a consonant: ta-ble)
        if w[n - 1] == 'e' && !is_vowel_letter(w[n - 2]) && !(w[n - 2] == 'l' && n >= 3 && !is_vowel_letter(w[n - 3])) {
            vowel[n - 1] = false;
        } else if n >= 3 && w[n - 2] == 'e' && w[n - 1] == 'd' && !matches!(w[n - 3], 't' | 'd') && !is_vowel_letter(w[n - 3]) && !(w[n - 3] == 'r' && n >= 4 && !is_vowel_letter(w[n - 4])) {
            vowel[n - 2] = false;
        } else if n >= 3 && w[n - 2] == 'e' && w[n - 1] == 's' && !matches!(w[n - 3], 's' | 'x' | 'z' | 'c' | 'g' | 'h' | 'j') && !is_vowel_letter(w[n - 3]) {
            vowel[n - 2] = false;
        }
    }
    // nuclei: maximal runs of vowel letters
    let mut nuclei: Vec<(usize, usize)> = vec![];
    let mut i = 0;
    while i < n {
        if vowel[i] {
            let s = i;
            while i < n && vowel[i] {
                i += 1;
            }
            nuclei.push((s, i));
        } else {
            i += 1;
        }
    }
    if nuclei.len() <= 1 {
        return vec![src.iter().collect()];
    }
    let mut cuts: Vec<usize> = vec![];
    for k in 0..nuclei.len() - 1 {
        let (a, b) = (nuclei[k].1, nuclei[k + 1].0);
        let cl = b - a;
        let cut = if cl == 0 {
            a
        } else if cl == 1 {
            a
        } else {
            let cluster: String = w[a..b].iter().collect();
            // the longest natural onset at the end of the cluster (the whole cluster may be one: ta-ble)
            let mut best = 0;
            for len in (2..=3.min(cl)).rev() {
                let suf = &cluster[cluster.len() - len..];
                if GOOD_ONSETS.contains(&suf) {
                    best = len;
                    break;
                }
            }
            if best > 0 {
                b - best
            } else if cl == 2 {
                a + 1
            } else {
                b - 1
            }
        };
        cuts.push(cut);
    }
    let mut out = vec![];
    let mut s = 0;
    for c in cuts {
        out.push(src[s..c].iter().collect::<String>());
        s = c;
    }
    out.push(src[s..].iter().collect::<String>());
    out.retain(|x: &String| !x.is_empty());
    out
}

/// English-ish spelling of one syllable -> a spelling `latin_to_ipa` reads right ("make" -> "meik", "light" -> "lait")
pub fn english_phonetic(syl: &str) -> String {
    const EXCEPT: [(&str, &str); 14] = [("love", "lav"), ("give", "giv"), ("have", "hav"), ("live", "liv"), ("come", "kam"), ("some", "sam"), ("done", "dan"), ("one", "wan"), ("gone", "gon"), ("none", "nan"), ("move", "muv"), ("dove", "dav"), ("bove", "bav"), ("the", "da")];
    let s = syl.to_lowercase();
    if let Some((_, r)) = EXCEPT.iter().find(|(k, _)| *k == s) {
        return r.to_string();
    }
    let c: Vec<char> = s.chars().collect();
    // magic e: one vowel, one consonant, a final e: the vowel says its name
    if c.len() >= 3 && c[c.len() - 1] == 'e' && !is_vowel_letter(c[c.len() - 2]) {
        let core = &c[..c.len() - 1];
        if let Some(p) = core.iter().rposition(|x| is_vowel_letter(*x)) {
            let single = p == 0 || !is_vowel_letter(core[p - 1]);
            if single && core[p + 1..].len() == 1 {
                let rep = match core[p] {
                    'a' => "ei",
                    'i' => "ai",
                    'o' => "ou",
                    'u' => "ju",
                    'e' => "i",
                    _ => "",
                };
                if !rep.is_empty() {
                    let mut o: String = core[..p].iter().collect();
                    o.push_str(rep);
                    o.extend(core[p + 1..].iter());
                    return phonetic_edges(&o);
                }
            }
        }
    }
    let mut s = s;
    for (a, b) in [("ee", "i"), ("ea", "i"), ("oo", "u"), ("ou", "au"), ("ai", "ei"), ("ay", "ei"), ("oa", "ou"), ("oy", "oi"), ("ie", "i")] {
        s = s.replace(a, b);
    }
    for (a, b) in [("tion", "shon"), ("sion", "shon"), ("eigh", "ei"), ("igh", "ai")] {
        s = s.replace(a, b);
    }
    phonetic_edges(&s)
}

fn phonetic_edges(s: &str) -> String {
    let mut s = s.replace("ph", "f").replace("ck", "k").replace("gh", "");
    if s.starts_with("kn") || s.starts_with("wr") {
        s = s[1..].to_string();
    }
    if s.starts_with("wh") {
        s = s.replacen("wh", "w", 1);
    }
    s
}

/// a lyric word is a sustain marker
fn is_sustain_token(t: &str) -> bool {
    matches!(t, "~" | "_" | "-" | "--" | "—" | "–")
}

fn script_class(c: char) -> u8 {
    match c as u32 {
        0xAC00..=0xD7A3 | 0x1100..=0x11FF | 0x3130..=0x318F => 1, // Hangul
        0x3040..=0x30FF | 0x31F0..=0x31FF => 2,                    // kana
        0x4E00..=0x9FFF | 0x3400..=0x4DBF => 3,                    // Han
        0x0E00..=0x0E7F => 4,                                      // Thai
        0x0250..=0x02AF | 0x1D00..=0x1DBF => 5,                    // IPA
        _ => 0,
    }
}

/// One syllable as it is written and as it is sung (IPA); an empty `ipa` is a sustain.
#[derive(Clone, Debug, PartialEq)]
pub struct Syl {
    pub orth: String,
    pub ipa: String,
}

/// A line of lyrics as syllables (Latin words by rule or by the hyphens already in them).
pub fn syllabify_line(line: &str, lang: Lang) -> Vec<Syl> {
    let mut out = vec![];
    for tok in line.split_whitespace() {
        if is_sustain_token(tok) {
            out.push(Syl { orth: "~".into(), ipa: String::new() });
            continue;
        }
        let first = tok.chars().find(|c| c.is_alphabetic());
        let class = first.map(script_class).unwrap_or(0);
        let forced_ipa = lang == Lang::Ipa || (lang == Lang::Auto && tok.chars().any(|c| script_class(c) == 5));
        if forced_ipa {
            for p in tok.split('-').filter(|p| !p.is_empty()) {
                out.push(Syl { orth: p.to_string(), ipa: p.to_string() });
            }
            continue;
        }
        match class {
            1 => {
                for c in tok.chars().filter(|c| script_class(*c) == 1) {
                    let s = c.to_string();
                    let ipa = lyrics::hangul_to_ipa(&s);
                    if !ipa.is_empty() {
                        out.push(Syl { orth: s, ipa });
                    }
                }
            }
            2 => {
                let chars: Vec<char> = tok.chars().collect();
                let mut i = 0;
                while i < chars.len() {
                    let c = chars[i];
                    if c == 'ー' {
                        out.push(Syl { orth: "ー".into(), ipa: String::new() });
                        i += 1;
                        continue;
                    }
                    if c == 'っ' || c == 'ッ' {
                        i += 1;
                        continue;
                    }
                    let mut g = c.to_string();
                    while i + 1 < chars.len() && "ゃゅょャュョぁぃぅぇぉァィゥェォ".contains(chars[i + 1]) {
                        g.push(chars[i + 1]);
                        i += 1;
                    }
                    let ipa = lyrics::kana_to_ipa(&g);
                    if !ipa.is_empty() {
                        out.push(Syl { orth: g, ipa });
                    }
                    i += 1;
                }
            }
            3 => {
                for c in tok.chars().filter(|c| script_class(*c) == 3) {
                    let s = c.to_string();
                    let ipa = lyrics::to_ipa(&s, Lang::Pinyin);
                    if !ipa.is_empty() {
                        out.push(Syl { orth: s, ipa });
                    }
                }
            }
            4 => {
                let ipa = lyrics::thai_to_ipa(tok);
                if !ipa.is_empty() {
                    out.push(Syl { orth: tok.to_string(), ipa });
                }
            }
            _ => {
                let word: String = tok.chars().filter(|c| c.is_alphabetic() || *c == '-').collect();
                let parts: Vec<String> = if word.contains('-') { word.split('-').filter(|p| !p.is_empty()).map(|p| p.to_string()).collect() } else { syllabify_en(&word) };
                for p in parts {
                    let ph = if lang == Lang::Latin && p.chars().count() <= 3 { p.to_lowercase() } else { english_phonetic(&p) };
                    let ipa = lyrics::latin_to_ipa(&ph);
                    if !ipa.is_empty() {
                        out.push(Syl { orth: p, ipa });
                    }
                }
            }
        }
    }
    out
}

/// the cleaned lyrics with every Latin word hyphenated into syllables (what the fit will sing, in letters), lines joined with ` / `
pub fn prepare(raw: &str) -> String {
    clean(raw)
        .iter()
        .map(|l| {
            l.split_whitespace()
                .map(|t| {
                    let c = t.chars().find(|c| c.is_alphabetic());
                    if t.contains('-') || is_sustain_token(t) || c.map(script_class).unwrap_or(0) != 0 || c.is_none() {
                        t.to_string()
                    } else {
                        let s = syllabify_en(t);
                        if s.is_empty() {
                            t.to_string()
                        } else {
                            s.join("-")
                        }
                    }
                })
                .collect::<Vec<_>>()
                .join(" ")
        })
        .collect::<Vec<_>>()
        .join(" / ")
}

fn letters(s: &str) -> String {
    s.chars().filter(|c| c.is_alphabetic()).collect::<String>().to_lowercase()
}

/// Take a model's hyphenated version of the lyrics: every word whose letters still match the original is kept as the model split it
/// (1 to 8 syllables), every other word is split by rule. Lines are matched by position when the counts agree, else by word order.
pub fn accept_split(raw: &str, reply: &str) -> String {
    let orig = clean(raw);
    let rep = clean(reply.trim().trim_start_matches("```").trim_end_matches("```"));
    let flat = |ls: &[String]| -> Vec<String> { ls.iter().flat_map(|l| l.split_whitespace().map(|w| w.to_string())).collect() };
    let rule = |w: &str| -> String {
        let c = w.chars().find(|c| c.is_alphabetic());
        if w.contains('-') || is_sustain_token(w) || c.map(script_class).unwrap_or(0) != 0 || c.is_none() {
            w.to_string()
        } else {
            syllabify_en(w).join("-")
        }
    };
    let pick = |w: &str, r: Option<&String>| -> String {
        if let Some(r) = r {
            let parts = r.split('-').filter(|p| !p.is_empty()).count();
            if letters(r) == letters(w) && (1..=8).contains(&parts) && !r.starts_with('-') && !r.ends_with('-') && script_class(w.chars().find(|c| c.is_alphabetic()).unwrap_or('a')) == 0 {
                return r.trim_matches('-').to_string();
            }
        }
        rule(w)
    };
    let mut lines = vec![];
    let same_lines = orig.len() == rep.len();
    let rep_flat = flat(&rep);
    let mut at = 0usize;
    for (li, l) in orig.iter().enumerate() {
        let ws: Vec<&str> = l.split_whitespace().collect();
        let mut o = vec![];
        for (wi, w) in ws.iter().enumerate() {
            let r = if same_lines { rep[li].split_whitespace().nth(wi).map(|x| x.to_string()) } else { rep_flat.get(at + wi).cloned() };
            o.push(pick(w, r.as_ref()));
        }
        at += ws.len();
        lines.push(o.join(" "));
    }
    lines.join(" / ")
}

pub fn qwen_system() -> String {
    "You prepare song lyrics for a singing synthesizer. Remove section labels like [Verse] and [Chorus], chord symbols and anything that is not sung. Keep one line of lyrics per line. Split every word into the syllables it is sung with, joined by hyphens, as in won-der-ful, ba-by, hel-lo, fire, love, to-geth-er. One-syllable words stay as they are. Do not change, add or drop any word. Answer with the lyrics only.".to_string()
}

pub fn qwen_example() -> String {
    "<|user|>\n[Verse 1]\nAm       F\nWonderful tonight, my baby\nI'll hold you in the morning light (x2)\n<|assistant|>\nWon-der-ful to-night my ba-by\nI'll hold you in the morn-ing light\n".to_string()
}

pub fn qwen_prompt(raw: &str) -> String {
    format!("{}<|user|>\n{}\n", qwen_example(), clean_keep_labels(raw))
}

/// what the model is shown: the pasted text with line breaks, trimmed (it is told to remove the labels itself)
fn clean_keep_labels(raw: &str) -> String {
    let text = raw.replace('\r', "").replace(" / ", "\n");
    let s: Vec<&str> = text.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).take(80).collect();
    s.join("\n")
}

// ── fitting ──────────────────────────────────────────────────────────────────

#[derive(Clone, Debug)]
struct Group {
    start: f64,
    len: f64,
    end: f64,
    idx: Vec<usize>,
    top: usize,
}

fn groups(notes: &[NoteIn]) -> Vec<Group> {
    let mut order: Vec<usize> = (0..notes.len()).collect();
    order.sort_by(|a, b| notes[*a].start.total_cmp(&notes[*b].start).then(notes[*a].key.cmp(&notes[*b].key)));
    let mut gs: Vec<Group> = vec![];
    for i in order {
        let n = &notes[i];
        match gs.last_mut() {
            Some(g) if n.start - g.start < 0.06 => {
                g.idx.push(i);
                g.len = g.len.max(n.len);
                g.end = g.start + g.len;
                if n.key >= notes[g.top].key {
                    g.top = i;
                }
            }
            _ => gs.push(Group { start: n.start, len: n.len, end: n.start + n.len, idx: vec![i], top: i }),
        }
    }
    gs
}

fn break_score(g: &Group, prev: Option<&Group>) -> f64 {
    let on = |x: f64, m: f64| (x / m - (x / m).round()).abs() < 0.02;
    let mut sc = 0.0;
    if on(g.start, 4.0) {
        sc += 2.5;
    } else if on(g.start, 1.0) {
        sc += 1.5;
    } else if on(g.start, 0.5) {
        sc += 0.6;
    }
    if let Some(p) = prev {
        let gap = g.start - p.end;
        if gap >= 0.5 {
            sc += 3.0;
        } else if gap >= 0.2 {
            sc += 1.0;
        }
        if p.len >= 1.0 {
            sc += 0.8;
        }
    }
    if g.len >= 0.75 {
        sc += 0.8;
    }
    if g.len < 0.2 {
        sc -= 2.0;
    }
    sc
}

type Slot = Option<Vec<String>>;

/// Spread `units` (IPA syllables, "" = sustain) over the groups `gs[idxs[..]]`.
fn assign(gs: &[Group], idxs: &[usize], units: &[String], mode: Mode, looped: bool) -> Vec<Slot> {
    let n = idxs.len();
    let s = units.len();
    if n == 0 {
        return vec![];
    }
    if s == 0 {
        return vec![None; n];
    }
    let strict = |units: &[String]| -> Vec<Slot> { (0..n).map(|i| units.get(i).map(|u| vec![u.clone()])).collect() };
    if looped && s < n {
        return (0..n).map(|i| Some(vec![units[i % s].clone()])).collect();
    }
    match mode {
        Mode::Strict => strict(units),
        Mode::Even => {
            if s <= n {
                let mut out: Vec<Slot> = vec![Some(vec![]); n];
                for k in 0..s {
                    out[k * n / s] = Some(vec![units[k].clone()]);
                }
                out
            } else {
                (0..n).map(|i| Some(units[i * s / n..(i + 1) * s / n].to_vec())).collect()
            }
        }
        Mode::Fit => {
            if s == n {
                strict(units)
            } else if s < n {
                let score = |i: usize| -> f64 {
                    let g = &gs[idxs[i]];
                    break_score(g, if i > 0 { Some(&gs[idxs[i - 1]]) } else { None })
                };
                let mut starts = vec![0usize];
                for k in 1..s {
                    let ideal = k as f64 * n as f64 / s as f64;
                    let w = (n as f64 / s as f64 * 0.5).max(1.0);
                    let lo = (starts[k - 1] + 1).max((ideal - w).ceil() as usize);
                    let limit = n - (s - k);
                    let lo = lo.min(limit);
                    let hi = limit.min((ideal + w).floor() as usize).max(lo);
                    let mut best = lo;
                    let mut bs = f64::MIN;
                    for i in lo..=hi {
                        let v = score(i) - 0.3 * (i as f64 - ideal).abs();
                        if v > bs + 1e-9 {
                            bs = v;
                            best = i;
                        }
                    }
                    starts.push(best);
                }
                let mut out: Vec<Slot> = vec![Some(vec![]); n];
                for (k, st) in starts.iter().enumerate() {
                    out[*st] = Some(vec![units[k].clone()]);
                }
                out
            } else {
                // more syllables than notes: the longest notes take the extras
                let mut count = vec![1usize; n];
                for _ in 0..s - n {
                    let mut best = 0;
                    let mut bv = f64::MIN;
                    for i in 0..n {
                        let v = gs[idxs[i]].len / count[i] as f64;
                        if v > bv + 1e-9 {
                            bv = v;
                            best = i;
                        }
                    }
                    count[best] += 1;
                }
                let mut at = 0;
                count
                    .iter()
                    .map(|c| {
                        let v = units[at..at + c].to_vec();
                        at += c;
                        Some(v)
                    })
                    .collect()
            }
        }
    }
}

/// what a note stores for a set of syllables: `~` alone sustains, otherwise the IPA syllables joined by `-`
fn compose(units: &[String]) -> String {
    let parts: Vec<&str> = units.iter().map(|u| u.as_str()).filter(|u| !u.is_empty()).collect();
    if parts.is_empty() {
        return "~".to_string();
    }
    let lead = units.first().map(|u| u.is_empty()).unwrap_or(false);
    format!("{}{}", if lead { "~" } else { "" }, parts.join("-"))
}

/// Fit the lyrics to the notes: one stored lyric per note (same order as `notes`) and what happened.
pub fn fit(notes: &[NoteIn], raw: &str, o: &Opts) -> (Vec<String>, Info) {
    let lines: Vec<Vec<String>> = clean(raw).iter().map(|l| syllabify_line(l, o.lang).into_iter().map(|s| s.ipa).collect::<Vec<_>>()).filter(|l: &Vec<String>| !l.is_empty()).collect();
    fit_lines(notes, lines, o)
}

/// The same fit as `fit`, but giving what is sung as written: per note the syllables joined by `-`, a leading space where a word starts
/// (`" ly-ric"`), `~` for a sustain, empty for a rest. The spread is identical to `fit`'s because the units are in the same places.
pub fn fit_text(notes: &[NoteIn], raw: &str, o: &Opts) -> Vec<String> {
    let lines: Vec<Vec<String>> = clean(raw)
        .iter()
        .map(|l| {
            let mut v: Vec<String> = vec![];
            for tok in l.split_whitespace() {
                for (k, s) in syllabify_line(tok, o.lang).into_iter().enumerate() {
                    // the first sung unit of a line carries a line break marker
                    let brk = if v.iter().all(|u| u.is_empty()) && !s.ipa.is_empty() { "\n" } else { "" };
                    v.push(if s.ipa.is_empty() { String::new() } else if k == 0 { format!("{brk} {}", s.orth) } else { format!("{brk}{}", s.orth) });
                }
            }
            v
        })
        .filter(|l: &Vec<String>| !l.is_empty())
        .collect();
    fit_lines(notes, lines, o).0
}

fn fit_lines(notes: &[NoteIn], lines: Vec<Vec<String>>, o: &Opts) -> (Vec<String>, Info) {
    let flat: Vec<String> = lines.iter().flatten().cloned().collect();
    let gs = groups(notes);
    let mut lyric = vec![String::new(); notes.len()];
    let mut info = Info { syllables: flat.iter().filter(|u| !u.is_empty()).count(), lines: lines.len(), groups: gs.len(), chords: gs.iter().filter(|g| g.idx.len() > 1).count(), ..Info::default() };
    if gs.is_empty() || flat.is_empty() {
        return (lyric, info);
    }
    // short notes right after a note sustain it
    let mut auto_tied = vec![false; gs.len()];
    if o.short > 0.0 {
        for i in 1..gs.len() {
            if gs[i].len < o.short && gs[i].start - gs[i - 1].end < 0.5 {
                auto_tied[i] = true;
            }
        }
    }
    let singable: Vec<usize> = (0..gs.len()).filter(|i| !auto_tied[*i]).collect();
    info.bearing = singable.len();
    // phrases: a rest of `gap` beats starts a new one
    let mut phrases: Vec<Vec<usize>> = vec![];
    for (pos, gi) in singable.iter().enumerate() {
        let new = pos == 0 || gs[*gi].start - gs[singable[pos - 1]].end >= o.gap;
        if new {
            phrases.push(vec![pos]);
        } else if let Some(p) = phrases.last_mut() {
            p.push(pos);
        }
    }
    info.phrases = phrases.len();
    let by_phrase = lines.len() >= 2 && phrases.len() >= 2 && (phrases.len() == lines.len() || (o.looped && lines.len() < phrases.len()));
    let mut slots: Vec<Slot> = vec![None; singable.len()];
    if by_phrase {
        for (p, positions) in phrases.iter().enumerate() {
            let line = if o.looped { &lines[p % lines.len()] } else { &lines[p] };
            let idxs: Vec<usize> = positions.iter().map(|pos| singable[*pos]).collect();
            let part = assign(&gs, &idxs, line, o.mode, o.looped);
            for (k, pos) in positions.iter().enumerate() {
                slots[*pos] = part[k].clone();
            }
        }
    } else {
        slots = assign(&gs, &singable, &flat, o.mode, o.looped);
    }
    let give = |lyric: &mut Vec<String>, g: &Group, text: &str| match o.chord {
        Chord::All => {
            for i in &g.idx {
                lyric[*i] = text.to_string();
            }
        }
        Chord::Top => {
            for i in &g.idx {
                lyric[*i] = if *i == g.top { text.to_string() } else { String::new() };
            }
        }
    };
    for (pos, gi) in singable.iter().enumerate() {
        match &slots[pos] {
            Some(units) => give(&mut lyric, &gs[*gi], &compose(units)),
            None => give(&mut lyric, &gs[*gi], ""),
        }
    }
    for gi in 0..gs.len() {
        if auto_tied[gi] {
            give(&mut lyric, &gs[gi], "~");
        }
    }
    (lyric, info)
}

// ── tests ───────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    fn notes(spec: &[(f64, f64, u8)]) -> Vec<NoteIn> {
        spec.iter().map(|(s, l, k)| NoteIn { start: *s, len: *l, key: *k }).collect()
    }

    fn syl(w: &str) -> Vec<String> {
        syllabify_en(w)
    }

    #[test]
    fn english_words_split_into_the_syllables_they_are_sung_with() {
        assert_eq!(syl("hello"), ["hel", "lo"]);
        assert_eq!(syl("wonderful"), ["won", "der", "ful"]);
        assert_eq!(syl("beautiful"), ["beau", "ti", "ful"]);
        assert_eq!(syl("baby"), ["ba", "by"]);
        assert_eq!(syl("happy"), ["hap", "py"]);
        assert_eq!(syl("water"), ["wa", "ter"]);
        assert_eq!(syl("table"), ["ta", "ble"]);
        assert_eq!(syl("monster"), ["mons", "ter"]);
        assert_eq!(syl("hundred"), ["hun", "dred"]);
        assert_eq!(syl("angry"), ["an", "gry"]);
        for one in ["love", "time", "make", "light", "dream", "fire", "you", "the", "walked", "my", "me", "night", "heart", "cry"] {
            assert_eq!(syl(one).len(), 1, "{one}: {:?}", syl(one));
        }
        assert_eq!(syl("wanted").len(), 2);
        assert_eq!(syl("yesterday").len(), 3);
        assert_eq!(syl("morning").len(), 2);
        assert!(syl("").is_empty());
        assert_eq!(syl("Wonderful"), ["Won", "der", "ful"], "case is kept");
        assert_eq!(syl("I"), ["I"]);
    }

    #[test]
    fn spelling_becomes_something_the_singer_can_say() {
        assert_eq!(english_phonetic("make"), "meik");
        assert_eq!(english_phonetic("time"), "taim");
        assert_eq!(english_phonetic("light"), "lait");
        assert_eq!(english_phonetic("love"), "lav");
        assert_eq!(english_phonetic("dream"), "drim");
        assert_eq!(english_phonetic("knight"), "nait");
    }

    #[test]
    fn pasted_text_loses_labels_chords_and_directions() {
        let raw = "[Verse 1]\nAm       F\nWonderful tonight, my baby!\n(x2)\n[Chorus]\nI'll hold you (ooh) in the light\nC G Am";
        let l = clean(raw);
        assert_eq!(l, ["Wonderful tonight my baby", "I'll hold you ooh in the light"], "{l:?}");
        assert_eq!(clean("one / two | three"), ["one", "two", "three"]);
        assert!(clean("").is_empty());
        assert!(clean("[Intro]\n[Chorus]").is_empty());
        // a line that is just "A" or "Am" is a word, not a chord line
        assert_eq!(clean("A"), ["A"]);
    }

    #[test]
    fn other_scripts_split_per_character() {
        let k = syllabify_line("사랑해", Lang::Auto);
        assert_eq!(k.len(), 3);
        let j = syllabify_line("さくら", Lang::Auto);
        assert_eq!(j.len(), 3);
        let jj = syllabify_line("きょう", Lang::Auto);
        assert_eq!(jj.len(), 2, "kyo is one mora: {jj:?}");
        let z = syllabify_line("你好", Lang::Auto);
        assert_eq!(z.len(), 2);
        let s = syllabify_line("hello ~ world", Lang::Auto);
        assert_eq!(s.iter().filter(|x| x.ipa.is_empty()).count(), 1, "the ~ is a sustain");
        assert_eq!(s.len(), 4);
        // hyphens are trusted as syllable breaks
        assert_eq!(syllabify_line("fan-tas-tic", Lang::Auto).len(), 3);
    }

    #[test]
    fn matching_counts_give_one_syllable_per_note() {
        let n = notes(&[(0.0, 1.0, 60), (1.0, 1.0, 62), (2.0, 1.0, 64), (3.0, 1.0, 65)]);
        let (l, info) = fit(&n, "do re mi fa", &Opts::default());
        assert!(l.iter().all(|x| !x.is_empty() && !x.starts_with('~')), "{l:?}");
        assert_eq!(info.syllables, 4);
        assert_eq!(info.groups, 4);
    }

    #[test]
    fn extra_notes_sustain_the_syllable_before_them_and_land_on_strong_beats() {
        // 2 syllables over 8 eighth-note-ish notes: the second syllable should start at the downbeat of bar 2, not at a random note
        let n = notes(&[(0.0, 0.5, 60), (0.5, 0.5, 62), (1.0, 0.5, 64), (1.5, 0.5, 65), (2.0, 0.5, 67), (2.5, 0.5, 65), (3.0, 0.5, 64), (3.5, 0.5, 62)]);
        let (l, _) = fit(&n, "hel lo", &Opts { short: 0.0, ..Opts::default() });
        assert!(!l[0].starts_with('~'));
        let second = (1..l.len()).find(|i| !l[*i].starts_with('~')).expect("the second syllable is placed");
        assert!([3, 4, 5].contains(&second), "the second syllable lands near the middle: at note {second}: {l:?}");
        assert!(l.iter().enumerate().all(|(i, x)| i == 0 || i == second || x == "~"));
    }

    #[test]
    fn more_syllables_than_notes_pack_the_longest_notes() {
        let n = notes(&[(0.0, 0.5, 60), (1.0, 3.0, 64), (4.0, 0.5, 62)]);
        let (l, _) = fit(&n, "a b c d e", &Opts { short: 0.0, ..Opts::default() });
        // 5 syllables on 3 notes: the 3-beat note takes the extra ones
        assert_eq!(l[1].matches('-').count(), 2, "{l:?}");
        assert!(!l[0].contains('-') && !l[2].contains('-'));
    }

    #[test]
    fn a_chord_carries_one_sustained_syllable_on_every_note() {
        // two chords of three notes and a long single note
        let n = notes(&[(0.0, 4.0, 60), (0.0, 4.0, 64), (0.0, 4.0, 67), (4.0, 4.0, 62), (4.0, 4.0, 65), (4.0, 4.0, 69), (8.0, 2.0, 60)]);
        let (l, info) = fit(&n, "a-ma-zing", &Opts::default());
        assert_eq!(info.groups, 3);
        assert_eq!(info.chords, 2);
        assert_eq!(l[0], l[1]);
        assert_eq!(l[1], l[2]);
        assert_eq!(l[3], l[4]);
        assert_ne!(l[0], l[3]);
        // top-note mode: only the top note sings, the rest take the default vowel
        let (t, _) = fit(&n, "a-ma-zing", &Opts { chord: Chord::Top, ..Opts::default() });
        assert!(!t[2].is_empty() && t[0].is_empty() && t[1].is_empty(), "{t:?}");
        // one syllable over a chord and the notes that follow: it is held
        let (h, _) = fit(&n, "ah", &Opts::default());
        assert!(!h[0].starts_with('~') && h[3] == "~" && h[4] == "~" && h[6] == "~", "{h:?}");
    }

    #[test]
    fn short_notes_after_a_note_become_sustains() {
        let n = notes(&[(0.0, 1.0, 60), (1.0, 0.15, 62), (1.15, 0.15, 64), (2.0, 1.0, 65)]);
        let (l, info) = fit(&n, "la la", &Opts::default());
        assert_eq!(l[1], "~");
        assert_eq!(l[2], "~");
        assert!(!l[3].starts_with('~'));
        assert_eq!(info.bearing, 2);
        let (l, _) = fit(&n, "a b c d", &Opts { short: 0.0, ..Opts::default() });
        assert!(l.iter().all(|x| !x.starts_with('~')));
    }

    #[test]
    fn lyrics_loop_when_the_notes_outlast_them() {
        let n: Vec<NoteIn> = (0..8).map(|i| NoteIn { start: i as f64, len: 1.0, key: 60 + i as u8 }).collect();
        let plain = fit(&n, "la li lu", &Opts { short: 0.0, ..Opts::default() }).0;
        let looped = fit(&n, "la li lu", &Opts { looped: true, short: 0.0, ..Opts::default() }).0;
        // without loop the lyrics are stretched with sustains, with loop they come round again
        assert!(plain.iter().filter(|x| x.as_str() == "~").count() >= 4);
        assert!(looped.iter().all(|x| !x.is_empty() && x != "~"));
        assert_eq!(looped[0], looped[3]);
        assert_eq!(looped[1], looped[4]);
        assert_ne!(looped[0], looped[1]);
    }

    #[test]
    fn lines_follow_the_phrases_the_rests_make_and_loop_across_them() {
        // three phrases of three notes, separated by 2-beat rests
        let mut spec = vec![];
        for p in 0..3 {
            for k in 0..3 {
                spec.push((p as f64 * 6.0 + k as f64, 0.9, 60 + k as u8));
            }
        }
        let n = notes(&spec);
        let o = Opts { short: 0.0, ..Opts::default() };
        // three lines, three phrases: line 2 starts at the second phrase
        let (l, info) = fit(&n, "one two three\nfour five six\nseven eight nine", &o);
        assert_eq!(info.phrases, 3);
        assert_eq!(l.len(), 9);
        assert!(l.iter().all(|x| !x.is_empty() && x != "~"));
        // two lines over three phrases: without loop the global fit runs; with loop line 1 comes back for phrase 3
        let (lp, _) = fit(&n, "one two three\nfour five six", &Opts { looped: true, ..o.clone() });
        assert_eq!(lp[0], lp[6]);
        assert_eq!(lp[1], lp[7]);
        assert_ne!(lp[0], lp[3]);
    }

    #[test]
    fn modes_differ_and_nothing_panics_on_odd_input() {
        let n: Vec<NoteIn> = (0..12).map(|i| NoteIn { start: i as f64 * 0.5, len: 0.5, key: 60 }).collect();
        let a = fit(&n, "one two three four", &Opts { mode: Mode::Even, short: 0.0, ..Opts::default() }).0;
        let b = fit(&n, "one two three four", &Opts { mode: Mode::Strict, short: 0.0, ..Opts::default() }).0;
        assert_eq!(a.iter().filter(|x| x.as_str() != "~").count(), 4);
        assert_eq!(b.iter().filter(|x| !x.is_empty()).count(), 4, "strict: one each for the first four notes only");
        for text in ["", "   ", "[Chorus]", "~ ~ ~", "-", "日本語 한국어 hello", "a", "(((", "ɑ ɛ ɔ", "😀 😀"] {
            for notes_n in [0usize, 1, 5] {
                let nn: Vec<NoteIn> = (0..notes_n).map(|i| NoteIn { start: i as f64, len: 0.0, key: 60 }).collect();
                for mode in [Mode::Fit, Mode::Even, Mode::Strict] {
                    for looped in [false, true] {
                        let (l, _) = fit(&nn, text, &Opts { mode, looped, ..Opts::default() });
                        assert_eq!(l.len(), notes_n);
                    }
                }
            }
        }
    }

    #[test]
    fn options_round_trip_through_the_clip_setting() {
        let o = Opts::parse("fit:even:1:0.25:top:2:korean").unwrap();
        assert_eq!((o.mode, o.looped, o.chord, o.lang), (Mode::Even, true, Chord::Top, Lang::Korean));
        assert_eq!(o.short, 0.25);
        assert_eq!(o.gap, 2.0);
        assert!(Opts::parse("auto").is_none());
        assert_eq!(Opts::parse("fit").unwrap(), Opts::default());
        assert_eq!(Opts::parse("fit:bogus:x:y:z:w:q").unwrap().mode, Mode::Fit);
    }

    #[test]
    fn a_models_hyphenation_is_kept_only_when_it_matches_the_words() {
        let raw = "Wonderful tonight, my baby\nI hold you";
        let good = "Won-der-ful to-night my ba-by\nI hold you";
        assert_eq!(accept_split(raw, good), "Won-der-ful to-night my ba-by / I hold you");
        // a word the model changed is split by rule instead
        let bad = "Won-der-ful to-day my ba-by\nI hold you";
        assert_eq!(accept_split(raw, bad), "Won-der-ful to-night my ba-by / I hold you");
        // prose, nothing, or the wrong number of lines: the rule-based split
        assert_eq!(accept_split(raw, "Sure! Here you go."), prepare(raw));
        assert_eq!(accept_split(raw, ""), prepare(raw));
        // too many hyphens
        assert_eq!(accept_split("hello", "h-e-l-l-o-w-o-r-l-d"), "hel-lo");
        assert_eq!(prepare("[x]\nhello world"), "hel-lo world");
    }
}

← Back to index