diff --git a/lib/midi_import.py b/lib/midi_import.py
index 5c991890..954fdb3b 100644
--- a/lib/midi_import.py
+++ b/lib/midi_import.py
@@ -1,6 +1,6 @@
"""MIDI file import — list tracks and convert tracks to sloppak payloads.
-Two parallel flows live here:
+Three parallel flows live here:
- **Keys path** (`list_midi_tracks` + `convert_midi_track_to_keys_wire`):
filters channel-9 out and emits a standard guitar-style arrangement that
@@ -11,12 +11,19 @@
`docs/sloppak-spec.md` §5.3, ready to drop alongside the sloppak
manifest's `drum_tab:` key.
-The editor's track picker uses both for the +Drums and +Keys modals.
+- **Lyrics path** (`extract_midi_lyrics`): reads SMF Lyric (0x05) meta events
+ (with a Text-event fallback on vocal-ish tracks, covering karaoke `.kar`
+ files) and emits the `lyrics.json` / `vocal_pitch.json` sidecar payloads
+ documented in feedpak-spec §7.1 / §7.2, ready to drop alongside the
+ manifest's `lyrics:` / `lyrics_source:` / `vocal_pitch:` keys.
+
+The editor's track picker uses the first two for the +Drums and +Keys modals.
"""
from __future__ import annotations
import math
+import re
from bisect import bisect_right
from collections import deque
from typing import Callable
@@ -777,3 +784,430 @@ def convert_drum_track_from_midi(
],
"hits": out_hits,
}
+
+
+# ── Lyrics + vocal-melody extraction ─────────────────────────────────────────
+
+# Vocal-track detection, mirroring the idiom in `lib/gp2rs_gpx.py`'s
+# `_is_vocal_track` (GM voice/choir/lead-voice programs + name keywords).
+# Kept as a local copy because that helper consumes gp2rs_gpx's own GP track
+# dicts, not raw MIDI tracks. "melody" is added to the name hints: karaoke
+# MIDIs commonly label the sung line "Melody" rather than "Vocals".
+_VOCAL_MIDI_PROGRAMS = {52, 53, 54, 85, 86, 87} # Choir Aahs, Voice Oohs, Synth Voice, Lead 5-7 (voice)
+_VOCAL_NAME_HINTS = ("vocal", "voice", "vox", "sing", "lyric", "choir", "melody")
+
+# A dedicated karaoke *text* track (SMF 0x01 Text events, `.kar` convention)
+# is usually noteless and named "Words" or "Soft Karaoke" — names the vocal
+# hints above don't catch. Only the Text-event fallback consults this wider
+# set; note-track detection sticks to the gp2rs_gpx idiom.
+_LYRIC_TEXT_TRACK_HINTS = _VOCAL_NAME_HINTS + ("words", "karaoke")
+
+# A lyric event pairs with a vocal note-on when their onsets sit within this
+# window. Karaoke files place the lyric event at (or a hair before) the
+# note-on tick, so real matches are ~0; the window only absorbs sloppy
+# authoring, and staying well under a typical syllable gap keeps a melisma's
+# extra notes from being stolen by the next syllable.
+_LYRIC_PAIR_TOLERANCE_S = 0.30
+
+# Duration bounds for lyric entries with no pairable note (spoken lines,
+# lyrics-only files). "Until the next lyric event" is the natural display
+# duration, capped so a verse-final syllable before a long instrumental
+# break doesn't linger on screen, and floored so simultaneous/out-of-order
+# events can't produce a zero or negative duration.
+_UNPAIRED_LYRIC_MAX_D = 2.0
+_UNPAIRED_LYRIC_MIN_D = 0.1
+
+# Leading/word/trailing whitespace splitter for lyric tokens. DOTALL so
+# embedded newlines land in a group rather than killing the match.
+_LYRIC_TOKEN_RE = re.compile(r"^(\s*)(.*?)(\s*)$", re.S)
+
+
+def _scan_tracks_for_lyrics(midi: mido.MidiFile) -> list[dict]:
+ """One pass per track collecting the raw material `extract_midi_lyrics`
+ needs: name, per-channel programs, melodic (non-drum) notes with their
+ on/off ticks, and Lyric/Text meta events.
+
+ Each item: {name, channel_programs: {ch: program}, notes:
+ [(start_tick, end_tick, pitch, channel)], lyric_events: [(tick, text)],
+ text_events: [(tick, text)]}. Note pairing uses the same FIFO
+ note_on/note_off matching as the keys converter so retriggers don't
+ cross-wire durations.
+ """
+ out: list[dict] = []
+ for track in midi.tracks:
+ name = ""
+ channel_programs: dict[int, int] = {}
+ lyric_events: list[tuple[int, str]] = []
+ text_events: list[tuple[int, str]] = []
+ notes: list[tuple[int, int, int, int]] = []
+ active: dict[tuple[int, int], deque[int]] = {}
+ abs_tick = 0
+ for msg in track:
+ abs_tick += msg.time
+ if msg.type == "track_name" and not name:
+ name = msg.name or ""
+ elif msg.type == "lyrics":
+ lyric_events.append((abs_tick, msg.text or ""))
+ elif msg.type == "text":
+ text_events.append((abs_tick, msg.text or ""))
+ elif msg.type == "program_change":
+ ch = int(getattr(msg, "channel", -1))
+ if ch != 9 and ch not in channel_programs:
+ channel_programs[ch] = int(msg.program)
+ elif msg.type == "note_on" and int(getattr(msg, "velocity", 0)) > 0:
+ ch = int(getattr(msg, "channel", -1))
+ if ch == 9:
+ continue
+ active.setdefault((ch, int(msg.note)), deque()).append(abs_tick)
+ elif msg.type == "note_off" or (
+ msg.type == "note_on" and int(getattr(msg, "velocity", 0)) == 0
+ ):
+ ch = int(getattr(msg, "channel", -1))
+ pitch = int(msg.note)
+ stack = active.get((ch, pitch))
+ if not stack:
+ continue
+ start_tick = stack.popleft()
+ if not stack:
+ active.pop((ch, pitch), None)
+ notes.append((start_tick, abs_tick, pitch, ch))
+ # Close anything left hanging at end-of-track, mirroring the keys
+ # converter's end-of-track sweep.
+ for (ch, pitch), starts in active.items():
+ for start_tick in starts:
+ notes.append((start_tick, abs_tick, pitch, ch))
+ notes.sort(key=lambda n: n[0])
+ out.append({
+ "name": name,
+ "channel_programs": channel_programs,
+ "notes": notes,
+ "lyric_events": lyric_events,
+ "text_events": text_events,
+ })
+ return out
+
+
+def _name_matches(name: str, hints: tuple[str, ...]) -> bool:
+ name_l = (name or "").lower()
+ return any(h in name_l for h in hints)
+
+
+def _normalize_lyric_tokens(events: list[tuple[int, str]]) -> list[dict]:
+ """Turn raw lyric/text meta events into clean syllable tokens.
+
+ Handles both encodings seen in the wild:
+
+ - **`.kar` / karaoke convention**: `/` prefix = new line, `\\` prefix =
+ new paragraph (both mean "the previous syllable ended a line"),
+ `-` suffix = syllable joins the next one, `@`-prefixed tokens are
+ file metadata (`@KMIDI`, `@T
`, ...) and are dropped.
+ - **Plain Lyric-event convention**: word boundaries carried by leading
+ or trailing spaces; line breaks carried by embedded CR/LF.
+
+ Each token: {tick, word, lead_ws, trail_ws, line_end}. Spacing-only and
+ newline-only events don't emit a token — they fold their meaning
+ (word-break / line-end) onto the previous one.
+ """
+ toks: list[dict] = []
+ for tick, raw in events:
+ text = "" if raw is None else str(raw)
+ if not text:
+ continue
+ if text.lstrip().startswith(("@", "%")):
+ # .kar metadata / sequencer directives, not sung text.
+ continue
+ kar_break = text[0] in ("/", "\\")
+ if kar_break:
+ text = text[1:]
+ m = _LYRIC_TOKEN_RE.match(text)
+ head, body, tail = m.group(1), m.group(2), m.group(3)
+ nl_before = ("\n" in head) or ("\r" in head)
+ nl_after = ("\n" in tail) or ("\r" in tail)
+ if "\n" in body or "\r" in body:
+ # Rare multi-line event: keep it one token, treat the break as
+ # trailing so the line ends after this token.
+ body = re.sub(r"[\r\n]+", " ", body).strip()
+ nl_after = True
+ if (kar_break or nl_before) and toks:
+ toks[-1]["line_end"] = True
+ if not body:
+ # Pure spacing/newline token: fold onto the previous syllable.
+ if nl_after and toks:
+ toks[-1]["line_end"] = True
+ if toks:
+ toks[-1]["trail_ws"] = True
+ continue
+ toks.append({
+ "tick": tick,
+ "word": body,
+ "lead_ws": bool(head),
+ "trail_ws": bool(tail),
+ "line_end": nl_after,
+ })
+ return toks
+
+
+def _apply_word_conventions(toks: list[dict]) -> list[str]:
+ """Map tokens to spec §7.1 `w` strings: trailing ``-`` joins to the next
+ syllable, trailing ``+`` ends a line.
+
+ Which join convention the source used is detected per stream:
+
+ - Any token already carrying a ``-`` suffix → the stream is
+ hyphen-delimited (`.kar` style); those suffixes are the spec's own
+ join marker and pass through untouched.
+ - Otherwise, if the stream carries any spacing at all → space-delimited:
+ a token with no trailing space followed by a token with no leading
+ space is a mid-word syllable and gains a ``-``.
+ - No hyphens and no spacing anywhere → the tokens are whole words
+ (common for Text-event lyrics); no joins are synthesized.
+ """
+ has_hyphens = any(t["word"].endswith("-") for t in toks)
+ has_spacing = any(t["lead_ws"] or t["trail_ws"] for t in toks)
+ words: list[str] = []
+ for i, tk in enumerate(toks):
+ w = tk["word"]
+ nxt = toks[i + 1] if i + 1 < len(toks) else None
+ if tk["line_end"]:
+ # A join can't cross a line break — the line marker wins.
+ if w.endswith("-"):
+ w = w[:-1]
+ if w and not w.endswith("+"):
+ w += "+"
+ elif nxt is not None and not has_hyphens and has_spacing:
+ if not tk["trail_ws"] and not nxt["lead_ws"] and not w.endswith("-"):
+ w += "-"
+ words.append(w)
+ return words
+
+
+def _select_vocal_notes(
+ scans: list[dict],
+ lyric_track_index: int,
+ midi_type: int,
+) -> tuple[int, list[tuple[int, int, int, int]]] | None:
+ """Pick the note pool the lyric syllables should be pitch-paired with.
+
+ Returns ``(track_index, notes)`` or ``None`` when no vocal melody is
+ identifiable (→ lyrics-only import). Selection order:
+
+ 1. The lyric-carrying track itself, when it has notes:
+ - channels with a vocal GM program → only those channels' notes
+ (isolates the sung line inside a format-0 everything-in-one-track
+ file);
+ - vocal-ish track name → all its non-drum notes;
+ - SMF type 1/2 with neither → still trusted: a track that interleaves
+ per-syllable Lyric events with its own notes *is* the karaoke
+ melody by construction. Format-0 files don't get this benefit of
+ the doubt — there the single track holds every instrument, so
+ without a vocal program/name there is no way to isolate the melody
+ and we fall back to lyrics-only.
+ 2. Otherwise (dedicated noteless "Words" track), the vocal-ish track —
+ by name hint or vocal GM program, mirroring gp2rs_gpx — with the
+ most notes; within it, vocal-program channels only when present.
+ """
+ def _vocal_channels(scan: dict) -> set[int]:
+ return {
+ ch for ch, prog in scan["channel_programs"].items()
+ if prog in _VOCAL_MIDI_PROGRAMS
+ }
+
+ def _pool(scan: dict) -> list[tuple[int, int, int, int]]:
+ chans = _vocal_channels(scan)
+ if chans:
+ return [n for n in scan["notes"] if n[3] in chans]
+ return scan["notes"]
+
+ src = scans[lyric_track_index]
+ if src["notes"]:
+ if _vocal_channels(src) or _name_matches(src["name"], _VOCAL_NAME_HINTS):
+ return lyric_track_index, _pool(src)
+ if midi_type != 0:
+ return lyric_track_index, src["notes"]
+ return None
+
+ best: tuple[int, list] | None = None
+ for i, scan in enumerate(scans):
+ if not scan["notes"]:
+ continue
+ if not (_vocal_channels(scan)
+ or _name_matches(scan["name"], _VOCAL_NAME_HINTS)):
+ continue
+ pool = _pool(scan)
+ if pool and (best is None or len(pool) > len(best[1])):
+ best = (i, pool)
+ return best
+
+
+def extract_midi_lyrics(midi_path: str, audio_offset: float = 0.0) -> dict | None:
+ """Extract lyrics (and, when pairable, the vocal melody) from a `.mid`.
+
+ Returns ``None`` when the file carries no usable lyric events — callers
+ then change nothing, leaving any existing manifest keys and sidecar
+ files untouched. Otherwise returns::
+
+ {
+ "lyrics": [{"t": float, "d": float, "w": str}, ...],
+ "lyrics_source": "authored",
+ "vocal_pitch": {"version": 1,
+ "notes": [{"t", "d", "midi"}, ...]} | None,
+ }
+
+ ``lyrics`` is the feedpak `lyrics.json` payload (spec §7.1: flat list,
+ no version field; ``w`` uses trailing ``-`` for syllable joins and
+ trailing ``+`` for line ends). ``vocal_pitch`` is the `vocal_pitch.json`
+ payload (spec §7.2, same shape as gp2rs_gpx's
+ ``convert_vocal_track_to_pitch_sidecar`` and the lyrics-karaoke
+ plugin's ``_persist_pitch``) — ``None`` when no vocal note track could
+ be identified, in which case the caller writes `lyrics.json` only.
+ ``lyrics_source`` is always ``"authored"`` (spec §7.1 vocabulary):
+ lyric meta events are chart-author data, not machine transcription.
+
+ Callers assembling a pack write ``lyrics.json`` /
+ ``vocal_pitch.json`` and set the manifest ``lyrics`` /
+ ``lyrics_source`` / ``vocal_pitch`` keys — and should do so only for
+ keys not already present, so an import never clobbers lyrics that
+ arrived from another source.
+
+ Sourcing rules:
+
+ - Lyric text comes from SMF Lyric (0x05) meta events — the track with
+ the most of them wins when several carry some. When the file has
+ none at all, Text (0x01) events are accepted as a fallback, but only
+ from a vocal-ish track (gp2rs_gpx-style name/program detection,
+ widened with "words"/"karaoke" for `.kar` text tracks) — Text events
+ elsewhere are copyright notices / markers, not lyrics.
+ - `.kar` conventions are normalized (see ``_normalize_lyric_tokens`` /
+ ``_apply_word_conventions``): ``/`` and ``\\`` line-break prefixes
+ become the spec's ``+`` suffix on the previous syllable, ``@``
+ metadata tokens are dropped, ``-`` hyphen joins pass through.
+ - Each syllable is paired with the vocal note (see
+ ``_select_vocal_notes``) whose onset falls within
+ ``_LYRIC_PAIR_TOLERANCE_S`` of the lyric event, greedily in time
+ order, one note per syllable. Paired syllables snap ``t``/``d`` to
+ the note (the authored melody is timing-authoritative, and keeps
+ `lyrics.json` and `vocal_pitch.json` mirrored per §7.2); a melisma's
+ extra notes are skipped. Unpaired syllables (talkies) keep the lyric
+ event's own time and run until the next syllable, clamped to
+ [``_UNPAIRED_LYRIC_MIN_D``, ``_UNPAIRED_LYRIC_MAX_D``] — they appear
+ in ``lyrics`` only, which spec §7.2 explicitly allows
+ (`vocal_pitch.notes` MAY be shorter than `lyrics.json`).
+
+ ``audio_offset`` (seconds) shifts every emitted time, same handle as
+ the keys/drums converters. Tempo-map scope per SMF type also matches
+ them (type 2 reads only the involved track's tempo events).
+ """
+ offset = float(audio_offset)
+ if not math.isfinite(offset):
+ raise ValueError(f"audio_offset must be a finite number, got {audio_offset!r}")
+
+ midi = mido.MidiFile(midi_path)
+ midi_type = getattr(midi, "type", 1)
+ scans = _scan_tracks_for_lyrics(midi)
+
+ # ── choose the lyric event stream ────────────────────────────────────
+ lyric_idx = -1
+ best_count = 0
+ for i, scan in enumerate(scans):
+ if len(scan["lyric_events"]) > best_count:
+ lyric_idx = i
+ best_count = len(scan["lyric_events"])
+ if lyric_idx >= 0:
+ toks = _normalize_lyric_tokens(scans[lyric_idx]["lyric_events"])
+ else:
+ # Text-event fallback: vocal-ish tracks only (plus .kar "Words" /
+ # "Soft Karaoke" text tracks). Normalize before counting so a track
+ # of @-metadata can't outscore a real lyric track.
+ toks = []
+ for i, scan in enumerate(scans):
+ if not scan["text_events"]:
+ continue
+ vocal_prog = any(
+ p in _VOCAL_MIDI_PROGRAMS
+ for p in scan["channel_programs"].values()
+ )
+ if not (vocal_prog
+ or _name_matches(scan["name"], _LYRIC_TEXT_TRACK_HINTS)):
+ continue
+ cand = _normalize_lyric_tokens(scan["text_events"])
+ if len(cand) > len(toks):
+ lyric_idx = i
+ toks = cand
+ if lyric_idx < 0 or not toks:
+ return None
+
+ words = _apply_word_conventions(toks)
+ lyric_tick_to_seconds = _build_tick_to_seconds(midi, lyric_idx)
+
+ # ── pick + time the vocal note pool ──────────────────────────────────
+ picked = _select_vocal_notes(scans, lyric_idx, midi_type)
+ vocal_notes: list[dict] = []
+ if picked is not None:
+ note_idx, pool = picked
+ # Type-2 tracks own independent timelines — time the notes through
+ # their own track's tempo scope (same map as the lyric track for
+ # type 0/1, where tempo is merged across tracks anyway).
+ note_tick_to_seconds = (
+ lyric_tick_to_seconds if note_idx == lyric_idx
+ else _build_tick_to_seconds(midi, note_idx)
+ )
+ for start_tick, end_tick, pitch, _ch in pool:
+ t = note_tick_to_seconds(start_tick)
+ vocal_notes.append({
+ "t": t,
+ "d": max(0.0, note_tick_to_seconds(end_tick) - t),
+ "midi": int(pitch),
+ })
+ vocal_notes.sort(key=lambda n: n["t"])
+
+ # ── pair syllables with notes (greedy, time-ordered) ─────────────────
+ entries: list[dict] = [] # {t, d (None until resolved), w, paired}
+ j = 0
+ for tk, w in zip(toks, words):
+ t_lyric = lyric_tick_to_seconds(tk["tick"])
+ while (j < len(vocal_notes)
+ and vocal_notes[j]["t"] < t_lyric - _LYRIC_PAIR_TOLERANCE_S):
+ j += 1
+ if (j < len(vocal_notes)
+ and vocal_notes[j]["t"] <= t_lyric + _LYRIC_PAIR_TOLERANCE_S):
+ note = vocal_notes[j]
+ j += 1
+ entries.append({
+ "t": note["t"], "d": note["d"], "w": w,
+ "midi": note["midi"], "paired": True,
+ })
+ else:
+ entries.append({"t": t_lyric, "d": None, "w": w, "paired": False})
+
+ # Snapping can nudge a paired syllable past an unpaired neighbour;
+ # sort so both sidecars stay chronological for downstream consumers.
+ entries.sort(key=lambda e: e["t"])
+
+ # Unpaired durations: until the next syllable, clamped. Resolved after
+ # the sort so "next" is the true chronological neighbour.
+ for i, e in enumerate(entries):
+ if e["d"] is None:
+ if i + 1 < len(entries):
+ gap = entries[i + 1]["t"] - e["t"]
+ d = min(gap, _UNPAIRED_LYRIC_MAX_D)
+ else:
+ d = _UNPAIRED_LYRIC_MAX_D
+ e["d"] = max(d, _UNPAIRED_LYRIC_MIN_D)
+
+ lyrics_out = [
+ {"t": round(e["t"] + offset, 3), "d": round(e["d"], 3), "w": e["w"]}
+ for e in entries
+ ]
+ pitch_notes = [
+ {"t": round(e["t"] + offset, 3), "d": round(e["d"], 3),
+ "midi": int(e["midi"])}
+ for e in entries if e["paired"]
+ ]
+
+ return {
+ "lyrics": lyrics_out,
+ "lyrics_source": "authored",
+ "vocal_pitch": (
+ {"version": 1, "notes": pitch_notes} if pitch_notes else None
+ ),
+ }
diff --git a/tests/test_midi_lyrics.py b/tests/test_midi_lyrics.py
new file mode 100644
index 00000000..fa7cd496
--- /dev/null
+++ b/tests/test_midi_lyrics.py
@@ -0,0 +1,282 @@
+"""Tests for lib/midi_import.py — extract_midi_lyrics (lyrics + vocal melody).
+
+Synthetic mido.MidiFile objects are built in-memory and saved to tmp_path,
+same style as test_midi_import.py.
+
+Covers:
+ - Lyric (0x05) events on a vocal track → lyrics.json + vocal_pitch.json payloads
+ - lyric/note pairing snaps t/d to the note; midi pitch carried through
+ - lyrics with no identifiable vocal track → lyrics payload only
+ - no lyric events at all → None (import behavior unchanged)
+ - .kar '/' line-break prefixes → spec trailing '+' on the previous syllable
+ - .kar '-' hyphen joins pass through untouched
+ - space-delimited syllable streams gain '-' joins
+ - '@'-metadata tokens dropped; Text-event (0x01) fallback on vocal-ish tracks
+ - Text events on non-vocal tracks are NOT treated as lyrics
+ - unpaired lyric durations run to the next syllable, capped at 2.0 s
+ - format-0 mixed-channel file pairs only the vocal-program channel
+ - vocal GM program (52-54 / 85-87) detection without a track name
+ - audio_offset applied to both payloads
+"""
+
+import pytest
+import mido
+
+from midi_import import extract_midi_lyrics
+
+
+TPB = 480 # ticks per beat; default 120 BPM → 480 ticks = 0.5 s
+
+
+def _save(mid: mido.MidiFile, tmp_path, name: str = "test.mid") -> str:
+ p = tmp_path / name
+ mid.save(str(p))
+ return str(p)
+
+
+def _vocal_file(tmp_path, *, track_name="Vocals", program=None, meta="lyrics",
+ syllables=("Hel-", "lo", "world")):
+ """Type-1 file: conductor + one melody track carrying notes with a lyric
+ event at each note-on. Notes: 60, 62, 64, each one beat (0.5 s) long,
+ back to back."""
+ mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
+ conductor = mido.MidiTrack()
+ mid.tracks.append(conductor)
+ conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
+
+ tr = mido.MidiTrack()
+ mid.tracks.append(tr)
+ if track_name:
+ tr.append(mido.MetaMessage("track_name", name=track_name, time=0))
+ if program is not None:
+ tr.append(mido.Message("program_change", channel=0, program=program, time=0))
+ for i, syl in enumerate(syllables):
+ tr.append(mido.MetaMessage(meta, text=syl, time=0))
+ tr.append(mido.Message("note_on", channel=0, note=60 + 2 * i,
+ velocity=90, time=0))
+ tr.append(mido.Message("note_off", channel=0, note=60 + 2 * i,
+ velocity=0, time=TPB))
+ return _save(mid, tmp_path)
+
+
+# ── both sidecars from a lyric+vocal-track file ──────────────────────────────
+
+def test_vocal_track_emits_both_payloads(tmp_path):
+ result = extract_midi_lyrics(_vocal_file(tmp_path))
+ assert result is not None
+ assert result["lyrics_source"] == "authored"
+
+ lyr = result["lyrics"]
+ assert [e["w"] for e in lyr] == ["Hel-", "lo", "world"]
+ assert [e["t"] for e in lyr] == pytest.approx([0.0, 0.5, 1.0])
+ # Paired syllables snap d to the note duration (1 beat = 0.5 s).
+ assert [e["d"] for e in lyr] == pytest.approx([0.5, 0.5, 0.5])
+
+ vp = result["vocal_pitch"]
+ assert vp is not None
+ assert vp["version"] == 1
+ assert [n["midi"] for n in vp["notes"]] == [60, 62, 64]
+ # vocal_pitch t/d mirror the matching lyrics entries (spec §7.2).
+ assert [(n["t"], n["d"]) for n in vp["notes"]] == \
+ [(e["t"], e["d"]) for e in lyr]
+
+
+def test_vocal_program_detection_without_name(tmp_path):
+ """GM program 53 (Voice Oohs) marks the track vocal even with no name."""
+ path = _vocal_file(tmp_path, track_name="", program=53)
+ result = extract_midi_lyrics(path)
+ assert result is not None
+ assert result["vocal_pitch"] is not None
+ assert [n["midi"] for n in result["vocal_pitch"]["notes"]] == [60, 62, 64]
+
+
+# ── lyrics-only fallbacks ────────────────────────────────────────────────────
+
+def test_no_vocal_track_emits_lyrics_only(tmp_path):
+ """Lyric events on a noteless track + only a piano note track → the
+ lyrics payload is emitted but vocal_pitch is None (talkies path)."""
+ mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
+ conductor = mido.MidiTrack()
+ mid.tracks.append(conductor)
+ conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
+
+ words = mido.MidiTrack()
+ mid.tracks.append(words)
+ words.append(mido.MetaMessage("lyrics", text="Hello ", time=0))
+ words.append(mido.MetaMessage("lyrics", text="there ", time=TPB))
+
+ piano = mido.MidiTrack()
+ mid.tracks.append(piano)
+ piano.append(mido.MetaMessage("track_name", name="Piano", time=0))
+ piano.append(mido.Message("program_change", channel=0, program=0, time=0))
+ piano.append(mido.Message("note_on", channel=0, note=48, velocity=90, time=0))
+ piano.append(mido.Message("note_off", channel=0, note=48, velocity=0, time=TPB))
+
+ result = extract_midi_lyrics(_save(mid, tmp_path))
+ assert result is not None
+ assert [e["w"] for e in result["lyrics"]] == ["Hello", "there"]
+ assert result["vocal_pitch"] is None
+
+
+def test_no_lyrics_returns_none(tmp_path):
+ """A file without lyric events changes nothing — extraction reports None."""
+ mid = mido.MidiFile(type=0, ticks_per_beat=TPB)
+ tr = mido.MidiTrack()
+ mid.tracks.append(tr)
+ tr.append(mido.Message("note_on", channel=0, note=60, velocity=90, time=0))
+ tr.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=TPB))
+
+ assert extract_midi_lyrics(_save(mid, tmp_path)) is None
+
+
+def test_unpaired_duration_next_event_capped_at_2s(tmp_path):
+ """Unpaired lyric entries last until the next syllable, capped at 2.0 s."""
+ mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
+ conductor = mido.MidiTrack()
+ mid.tracks.append(conductor)
+ conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
+
+ words = mido.MidiTrack()
+ mid.tracks.append(words)
+ words.append(mido.MetaMessage("lyrics", text="one ", time=0))
+ words.append(mido.MetaMessage("lyrics", text="two ", time=TPB)) # +0.5 s
+ words.append(mido.MetaMessage("lyrics", text="three ", time=TPB * 8)) # +4.0 s
+
+ result = extract_midi_lyrics(_save(mid, tmp_path))
+ lyr = result["lyrics"]
+ assert lyr[0]["d"] == pytest.approx(0.5) # gap to next syllable
+ assert lyr[1]["d"] == pytest.approx(2.0) # 4.0 s gap capped
+ assert lyr[2]["d"] == pytest.approx(2.0) # last entry: cap value
+
+
+# ── .kar conventions ─────────────────────────────────────────────────────────
+
+def test_kar_slash_line_break_maps_to_plus(tmp_path):
+ """A '/' prefix on a syllable marks the END of the previous line — the
+ previous syllable gains the spec's trailing '+'."""
+ path = _vocal_file(
+ tmp_path, syllables=("Hel-", "lo", "/world"))
+ result = extract_midi_lyrics(path)
+ words = [e["w"] for e in result["lyrics"]]
+ assert words == ["Hel-", "lo+", "world"]
+
+
+def test_kar_backslash_paragraph_break_maps_to_plus(tmp_path):
+ path = _vocal_file(tmp_path, syllables=("one", "\\two", "three"))
+ words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
+ assert words == ["one+", "two", "three"]
+
+
+def test_kar_hyphen_joins_pass_through(tmp_path):
+ """.kar hyphen suffixes already ARE the spec join marker — untouched,
+ and no extra '-' is synthesized onto hyphenless word-final syllables."""
+ path = _vocal_file(tmp_path, syllables=("beau-", "ti-", "ful", "day"))
+ words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
+ assert words == ["beau-", "ti-", "ful", "day"]
+
+
+def test_space_delimited_stream_gains_hyphen_joins(tmp_path):
+ """Space-delimited Lyric streams ('Hel' 'lo ' 'world') carry word
+ boundaries in whitespace — mid-word syllables gain the '-' join."""
+ path = _vocal_file(tmp_path, syllables=("Hel", "lo ", "world "))
+ words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
+ assert words == ["Hel-", "lo", "world"]
+
+
+def test_newline_in_lyric_event_ends_line(tmp_path):
+ path = _vocal_file(tmp_path, syllables=("one \n", "two ", "three "))
+ words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
+ assert words == ["one+", "two", "three"]
+
+
+# ── Text-event (0x01) fallback ───────────────────────────────────────────────
+
+def test_text_event_fallback_on_vocal_track(tmp_path):
+ """With no 0x05 events anywhere, Text events on a vocal-ish track are
+ accepted as lyrics; '@'-prefixed .kar metadata tokens are dropped."""
+ mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
+ conductor = mido.MidiTrack()
+ mid.tracks.append(conductor)
+ conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
+
+ tr = mido.MidiTrack()
+ mid.tracks.append(tr)
+ tr.append(mido.MetaMessage("track_name", name="Melody", time=0))
+ tr.append(mido.MetaMessage("text", text="@KMIDI KARAOKE FILE", time=0))
+ tr.append(mido.MetaMessage("text", text="@T A Song", time=0))
+ for i, syl in enumerate(("Some ", "words ")):
+ tr.append(mido.MetaMessage("text", text=syl, time=0))
+ tr.append(mido.Message("note_on", channel=0, note=64 + i, velocity=90,
+ time=0))
+ tr.append(mido.Message("note_off", channel=0, note=64 + i, velocity=0,
+ time=TPB))
+
+ result = extract_midi_lyrics(_save(mid, tmp_path))
+ assert result is not None
+ assert [e["w"] for e in result["lyrics"]] == ["Some", "words"]
+ assert [n["midi"] for n in result["vocal_pitch"]["notes"]] == [64, 65]
+
+
+def test_text_events_on_non_vocal_track_ignored(tmp_path):
+ """Text events on a plain instrument track (copyright notices, markers)
+ are not lyrics — extraction returns None."""
+ mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
+ conductor = mido.MidiTrack()
+ mid.tracks.append(conductor)
+ conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
+
+ tr = mido.MidiTrack()
+ mid.tracks.append(tr)
+ tr.append(mido.MetaMessage("track_name", name="Guitar", time=0))
+ tr.append(mido.MetaMessage("text", text="Copyright 2026", time=0))
+ tr.append(mido.Message("note_on", channel=0, note=52, velocity=90, time=0))
+ tr.append(mido.Message("note_off", channel=0, note=52, velocity=0, time=TPB))
+
+ assert extract_midi_lyrics(_save(mid, tmp_path)) is None
+
+
+# ── format-0 channel isolation ───────────────────────────────────────────────
+
+def test_format0_pairs_only_vocal_program_channel(tmp_path):
+ """Format-0 file mixing a vocal-program channel with an accompaniment
+ channel: only the vocal channel's notes feed vocal_pitch."""
+ mid = mido.MidiFile(type=0, ticks_per_beat=TPB)
+ tr = mido.MidiTrack()
+ mid.tracks.append(tr)
+ tr.append(mido.Message("program_change", channel=0, program=53, time=0)) # Voice Oohs
+ tr.append(mido.Message("program_change", channel=1, program=0, time=0)) # Piano
+ # Simultaneous piano note that must NOT be paired.
+ tr.append(mido.Message("note_on", channel=1, note=40, velocity=90, time=0))
+ tr.append(mido.MetaMessage("lyrics", text="la ", time=0))
+ tr.append(mido.Message("note_on", channel=0, note=67, velocity=90, time=0))
+ tr.append(mido.Message("note_off", channel=0, note=67, velocity=0, time=TPB))
+ tr.append(mido.Message("note_off", channel=1, note=40, velocity=0, time=0))
+
+ result = extract_midi_lyrics(_save(mid, tmp_path))
+ assert result is not None
+ assert [n["midi"] for n in result["vocal_pitch"]["notes"]] == [67]
+
+
+def test_format0_without_vocal_channel_is_lyrics_only(tmp_path):
+ """Format-0 with lyrics but no vocal program/name: the merged note soup
+ cannot be trusted as a melody — lyrics.json only."""
+ mid = mido.MidiFile(type=0, ticks_per_beat=TPB)
+ tr = mido.MidiTrack()
+ mid.tracks.append(tr)
+ tr.append(mido.Message("program_change", channel=0, program=0, time=0))
+ tr.append(mido.MetaMessage("lyrics", text="la ", time=0))
+ tr.append(mido.Message("note_on", channel=0, note=60, velocity=90, time=0))
+ tr.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=TPB))
+
+ result = extract_midi_lyrics(_save(mid, tmp_path))
+ assert result is not None
+ assert len(result["lyrics"]) == 1
+ assert result["vocal_pitch"] is None
+
+
+# ── audio_offset ─────────────────────────────────────────────────────────────
+
+def test_audio_offset_applied_to_both_payloads(tmp_path):
+ result = extract_midi_lyrics(_vocal_file(tmp_path), audio_offset=1.5)
+ assert result["lyrics"][0]["t"] == pytest.approx(1.5)
+ assert result["vocal_pitch"]["notes"][0]["t"] == pytest.approx(1.5)