| name | beadline-lyrics |
| description | Domain knowledge, architecture, and API reference for the beadline-lyrics parsing crate. Covers LRC/BLRC parsing, the unified output model, serialization, error handling, and the BLRC file format specification. |
What I Do
- Provide the complete API reference for the
beadline-lyrics crate (crates/beadline-lyrics/).
- Document the unified
LyricsDocument output model and all its constituent types.
- Explain the parsing architecture (LRC with regex, BLRC with nom + TOML).
- Serve as the authoritative BLRC file format specification.
- Enforce the crate's design constraints when changes are proposed.
Crate Architecture
crates/beadline-lyrics/src/
├── lib.rs # Crate root: re-exports, 3 top-level free functions, integration tests
├── error.rs # LyricError enum (thiserror)
├── model.rs # LyricsDocument, LyricLine, LyricWord, BreakEvent, LyricsMetadata, ChannelConfig
├── traits.rs # LyricParser trait
├── parser.rs # Module declaration, re-exports lrc and blrc sub-modules
├── parser/
│ ├── lrc.rs # LRC parser: regex-based, private
│ └── blrc.rs # BLRC parser: nom combinators + toml header, private (many internal types)
├── serialize.rs # serialize(&LyricsDocument) -> String (BLRC output only)
└── util.rs # Shared timestamp utilities: parse_timestamp, parse_word_timing
Key Design Decisions
- Public API surface is minimal: Only
lib.rs, serialize.rs, error.rs, model.rs, and traits.rs contain pub items. All parser internals are private.
- Two-parser architecture:
LrcParser and BlrcParser both implement the LyricParser trait. The top-level free functions delegate to the correct parser.
- Unified output model: Both LRC and BLRC parsing produce the same
LyricsDocument struct. The model has fields that are only populated by BLRC (e.g., words, channels, breaks).
- No feature flags: All parsing functionality is always compiled. The only conditional compilation is
#[cfg(not(frb_expand))] on helper methods that flutter_rust_bridge cannot handle.
- Regex for LRC, nom for BLRC: LRC uses regex since the format is simple timestamp-tag lines. BLRC uses nom for combinator-based parsing of the more complex line grammar.
Public API Reference
Top-Level Functions (crate root)
pub fn detect_format(content: &str) -> Option<String>
pub fn parse_lyrics(content: &str) -> Result<LyricsDocument, LyricError>
pub fn parse_lyrics_with_format(content: &str, format: &str) -> Result<LyricsDocument, LyricError>
Serialization
pub fn serialize(document: &LyricsDocument) -> String
The serialize function produces:
###-delimited TOML header with [meta] and [channels.*] sections
[channel | start | +duration] text lines for each LyricLine
![start | end | type] lines for each BreakEvent
- Does NOT output per-word timing tags or channel switch tags (these are not round-tripped)
Error Type
#[derive(Error, Debug)]
pub enum LyricError {
Toml(#[from] toml::de::Error),
InvalidTimestamp { ts: String, msg: String },
MissingField(&'static str),
Syntax { line: usize, msg: String },
Other(String),
}
Trait
pub trait LyricParser: Send + Sync {
fn name(&self) -> &'static str;
fn can_parse(&self, content: &str) -> bool;
fn parse(&self, content: &str) -> Result<LyricsDocument, LyricError>;
}
Both LrcParser and BlrcParser implement this trait. Custom parsers can be added
by implementing LyricParser and registering them externally.
Model Types
Type Hierarchy
LyricsDocument
├── metadata: LyricsMetadata
│ ├── title, artist, album, lyricist, composer: Option<String>
│ ├── offset: chrono::Duration
│ ├── length: Option<chrono::Duration>
│ └── channels: Vec<ChannelConfig>
│ ├── id: String
│ ├── name: Option<String>
│ ├── color_pending: Option<String>
│ └── color_done: Option<String>
├── lines: Vec<LyricLine>
│ ├── channels: Vec<String> (empty = default)
│ ├── start: chrono::Duration
│ ├── duration: Option<chrono::Duration>
│ ├── text: String (plain text, tags stripped; for interleaved LRC, segments joined)
│ └── words: Vec<LyricWord>
│ ├── text: String
│ ├── rel_start: chrono::Duration (relative within line)
│ ├── duration: chrono::Duration
│ └── channels: Vec<String> (per-word override)
└── breaks: Vec<BreakEvent>
├── start: chrono::Duration
├── end: chrono::Duration
└── break_type: BreakType (Intro | Bridge | Outro)
Model Methods (conditional compilation)
impl LyricsMetadata {
pub fn channel_id_exists(&self, id: &str) -> bool;
}
#[cfg(not(frb_expand))]
impl LyricsMetadata {
pub fn get_channel(&self, id: &str) -> Option<&ChannelConfig>;
}
#[cfg(not(frb_expand))]
impl ChannelConfig {
pub fn resolve_color_pending(&self) -> &str;
pub fn resolve_color_done(&self) -> &str;
}
The #[cfg(not(frb_expand))] guard exists because flutter_rust_bridge code generation
sets the frb_expand cfg flag and cannot handle these methods. They are available in all
other contexts (regular Rust compilation, testing, etc.).
The lint config in Cargo.toml tells the compiler about this custom cfg:
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] }
Parsing Architecture
Format Detection (detect_format)
- Checks if content starts with
### → "blrc"
- Delegates to
LrcParser::can_parse() which checks for timestamp-prefixed lines via regex: ^\[(\d+):(\d{2}(?:\.\d+)?)\]
- Otherwise returns
None
LRC Parsing (parser/lrc.rs)
- Uses two regexes: one for timestamp extraction, one for metadata tag extraction
parse_lrc(): splits content by lines, identifies metadata lines by regex, timestamp lines by regex
build_metadata(): converts LRC metadata tags ([ti:...], [ar:...], [al:...], [by:...], [length:...], [offset:...]) into LyricsMetadata
parse_centiseconds() handles LRC's minutes:seconds.centiseconds format
- Two multi-timestamp modes (detected by whether text appears between consecutive timestamp brackets):
- Prefix-style (
[00:01.00][00:02.00]Shared text): timestamps all precede a shared text block. Produces one LyricLine per timestamp with the same text and empty words.
- Interleaved-style (
[00:13.02]我[00:13.44]会[00:13.89]告): text segments appear between timestamps (karaoke per-word timing). Produces ONE LyricLine with populated words containing rel_start and duration.
- Trailing empty timestamp:
[ts]word[ts]word[ts] — the final timestamp with no text provides an end boundary for the last word's duration. Empty segments are kept for timing computation but skipped when building word entries.
- Post-processing: after all lines are parsed and sorted, last-word durations that are still placeholder (zero) get filled from the next line's start. Prefix-style line durations also get filled from the next line's start.
BLRC Parsing (parser/blrc.rs)
- Two-phase: (1) extract and parse TOML header block, (2) parse line body with nom
extract_toml_header(): finds the ###-delimited block at start, parses with toml::from_str
parse_blrc(): line-by-line parsing using nom combinators
- Private helper types (
BlrcHeader, BlrcMeta, BlrcLocalized, BlrcChannelRaw, LyricsColorRaw) are used only for TOML deserialization
LyricLineResult is an intermediate builder struct for assembling LyricLine from parsed components
- Nom parsers handle: timestamps, channel lists, break lines (
![...]), lyric lines ([...] text with <tags>), per-word timing tags, channel switch tags, and combined tags
distribute_remaining_time(): fills in durations for text segments not covered by explicit per-word tags
- Duration conflict is a hard error: if per-word durations sum exceeds the prescribed line duration, parsing fails
Shared Utilities (util.rs)
parse_timestamp(s: &str) -> Result<Duration, LyricError>: handles all timestamp formats (fractional seconds, whole seconds, minutes:seconds, minutes:seconds.milliseconds)
parse_word_timing(s: &str) -> Result<Duration, ()>: parses inline word timing values (both whole and fractional)
parse_seconds_and_ms(s: &str) -> Option<Duration>: internal helper for parsing seconds.milliseconds format
Testing
34 tests across the crate, organized in #[cfg(test)] mod tests blocks:
| File | Test count | Focus |
|---|
lib.rs | 3 | Format auto-detection (lrc, blrc, none) |
util.rs | 7 | Timestamp parsing across all formats |
parser/lrc.rs | 6 | LRC parsing: basic, metadata, multi-timestamp (prefix & interleaved), trailing-empty |
parser/blrc.rs | 16 | BLRC parsing: header, timestamps, durations, words, breaks, channels, safety, conflicts |
serialize.rs | 1 | Basic round-trip serialization |
Key test patterns:
parse_safety_override is the only is_err() assertion — verifies that word total exceeding prescribed duration produces an error
- All other tests assert
is_ok() and verify specific fields of the parsed document
proptest is declared as a dev-dependency (workspace) but not yet used in any test
BLRC Format Specification
BLRC (Beadline Lyric Resource) is the crate's primary format. The full specification
is in crates/beadline-lyrics/README.md#blrc-specification-beadline-lyric-resource.
Key structural rules:
- Header: TOML block delimited by
###, must contain [meta] and optional [channels.*] sections
- Line syntax:
[channel? | start | duration/end?] text
- Break syntax:
![start | end | type] where type is intro, bridge, or outro
- Inline tags (forward-looking):
<duration> for timing, <[channel]> for channel switch, <[channel] duration> for combined
- Compound channels: Comma-separated IDs in channel name provide unified styling for multi-singer lines
- Duration conflict: Per-word durations must not exceed prescribed line duration (parse error, not silent override)
Workspace Integration
- Consumed by
crates/ffi_beadline/ via path dependency: beadline-lyrics = { path = "../beadline-lyrics" }
- The FFI crate re-exposes the parsing API to Flutter/Dart through
flutter_rust_bridge
- This crate has zero Flutter/Dart dependencies — pure Rust
- The
frb_expand cfg flag is used to exclude methods that flutter_rust_bridge cannot generate bindings for
Constraints & Rules
When modifying this crate, observe these rules:
- Never add
pub to parser internals. The public API is parse_lyrics, parse_lyrics_with_format, detect_format, serialize, the model types, the error type, and the trait. Everything in parser/ stays private.
- Don't add
unwrap() in production code paths. Use ? and LyricError variants.
- Timestamp parsing must be unified. Always use
util::parse_timestamp or util::parse_word_timing — never write ad-hoc timestamp parsers.
- Model types are plain structs. No derives beyond what's already present. The
BreakType enum is the only type with derives (Debug, Clone, Copy, PartialEq, Eq).
- New
LyricError variants must carry context. Follow the pattern: InvalidTimestamp carries the raw string and a message, Syntax carries line number and message.
- Tests go in
#[cfg(test)] mod tests in the same file. No separate test files.
- The
frb_expand cfg gate must be maintained on get_channel, resolve_color_pending, and resolve_color_done. If adding similar methods, apply the same gate.
serialize only outputs BLRC. If LRC output is ever needed, it should be a separate function or a format parameter.
- Skill/doc update: Whenever a public API change is made, update the "Public API Reference" section of this SKILL.md to reflect the new API. Whenever internal architecture changes, update the "Crate Architecture" and "Parsing Architecture" sections as needed to keep them accurate.