LyrixLib

main

A simple implementation of lyrics file parsing
RustyKnight/LyrixLib

LyrixLib

A Swift 6 package for parsing LRC and LRCX lyrics files and querying lyrics by playback position.

AI

GitHub Copilot

This library is generated via AI using GitHub Copilot

Features

  • LRC — standard line-level timestamps, metadata tags, ruby/furigana annotations, repeated-chorus multi-timestamps
  • LRCX — word-level (karaoke) timestamps, [<ts] unfill/reverse-karaoke effect
  • NetEase LRCX extensions — [tt] progressive character-highlighting, [tr:lang] translations
  • Binary-search time queries: activeLine(at:), activeLines(at:), activeWord(at:), highlightedCharacterCount(atOffset:)
  • Silence detection: isLyricsSilent(at:) — detects gaps before lyrics start, instrumental breaks, and line end-times
  • Full [offset:] support; 2-digit centisecond and 3-digit millisecond timestamp precision
  • Swift 6, Sendable throughout, zero dependencies

Installation

Add LyrixLib as a Swift Package Manager dependency:

// Package.swift
dependencies: [
    .package(url: "https://github.com/RustyKnight/LyrixLib.git", from: "1.0.0"),
],
targets: [
    .target(name: "MyTarget", dependencies: ["LyrixLib"]),
]

Quick Start

import LyrixLib

let parser = LyrixParser()

// Parse from a file URL — format (.lrc / .lrcx) is inferred from the extension
let doc = try parser.parse(contentsOf: url)

// Or parse a string directly
let doc = try parser.parse(content, format: .lrcx)

// Query the active line at a playback position (milliseconds)
let line = doc.activeLine(at: 32_566)
print(line?.displayText)  // plain text, no annotations

// Also get the next upcoming line for preview
let (current, next) = doc.activeLines(at: 32_566)

File Formats

Standard LRC

[ti:Song Title]
[ar:Artist Name]
[al:Album Name]
[offset:0]

[00:32.56]This is the end
[00:37.18]Hold your breath and count to ten

LRCX — word-level (karaoke) timing

Each line begins with a line timestamp, followed by per-word timestamps:

[01:23.20][01:23.20]Let [01:23.70]the [01:24.07]sky [01:24.57]fall
[01:29.26][01:29.26]We [01:29.51]will [01:29.93]stand [01:30.63]tall

The [<ts] syntax marks a word for the unfill (reverse-karaoke) effect:

[00:05.00][00:05.00]Normal [<00:05.50]reverse [00:06.00]normal

LRCX — NetEase extensions

Real-world LRCX files often include translation and character-timing sub-tags at the same timestamp as the main lyric:

[00:32.56]This is the end
[00:32.56][tr:zh-Hans]这便是终结
[00:32.56][tt]<0,0><190,5><498,8><877,12><1565,15><1565>
  • [tr:lang] — translated lyric stored in LyrixLine.translation
  • [tt]<offset_ms,char_count>… — progressive character-highlight steps stored in LyrixLine.characterTiming

API Reference

Parsing

let parser = LyrixParser()

// From a file URL (extension determines format)
let doc = try parser.parse(contentsOf: URL(filePath: "song.lrcx"))

// From a String with an explicit format
let doc = try parser.parse(content, format: .lrc)
let doc = try parser.parse(content, format: .lrcx)

Metadata

doc.metadata.title      // String?
doc.metadata.artist     // String?
doc.metadata.album      // String?
doc.metadata.author     // String?  — songwriter ([au:])
doc.metadata.lyricist   // String?  — lyricist ([lr:])
doc.metadata.createdBy  // String?  — LRC file creator ([by:])
doc.metadata.offset     // Int      — global ms shift, already applied to all timestamps
doc.metadata.length     // String?  — e.g. "3:45"

Querying lines by time

// Active line at a given playback position
let line: LyrixLine? = doc.activeLine(at: milliseconds)

// Active line + the immediately following line (useful for preview)
let (current, next): (LyrixLine?, LyrixLine?) = doc.activeLines(at: milliseconds)

// All lines, sorted ascending by timestamp
let lines: [LyrixLine] = doc.lines

// Silence detection — true before the first line, on instrumental breaks,
// and after a line's end time (when determinable from [tt] or LRCX word timing)
let silent: Bool = doc.isLyricsSilent(at: milliseconds)

LyrixLine

line.timestamp           // Int     — start time in ms (offset already applied)
line.displayText         // String  — plain text, no ruby/annotation markup
line.text                // String  — text preserving {ruby} notation
line.isInstrumentalBreak // Bool   — true when the line has no text (silent gap)
line.translation         // String? — from [tr:lang] (LRCX)
line.characterTiming     // [CharacterTiming] — from [tt] (LRCX)
line.lineDurationMilliseconds // Int? — total line duration from [tt] trailing marker (LRCX)
line.words               // [WordSegment] — word-level segments (LRCX karaoke)
line.rubySegments        // [RubySegment]  — line-level ruby segments (LRC)
line.wordCharBoundaries  // [Int]  — character end-positions in displayText for each word (LRCX)

Word-level queries (LRCX karaoke)

// Active word segment at a given playback position
let word: WordSegment? = line.activeWord(at: milliseconds)

word.timestamp          // Int    — word start time in ms
word.displayText        // String — plain word text
word.isUnfill           // Bool   — true for [<ts] reverse-fill words
word.rubySegments       // [RubySegment]

Character-highlighting queries (LRCX [tt])

// Number of characters to highlight, given ms elapsed since the line started
let offset = currentMs - line.timestamp
let count  = line.highlightedCharacterCount(atOffset: offset)

// The character-timing steps themselves
for step in line.characterTiming {
    print("\(step.offsetMilliseconds) ms → highlight \(step.characterCount) chars")
}

Ruby / furigana annotations

// RubySegment appears in both LyrixLine.rubySegments and WordSegment.rubySegments
segment.base            // String  — displayed base text (e.g. "心")
segment.ruby            // String? — annotation text  (e.g. "こころ"), nil if absent
segment.displayText     // String  — alias for .base

In LRC/LRCX files, ruby is encoded inline as base{ruby} — for example 心{こころ}.

Errors

LyrixParser throws LyrixError on failure:

do {
    let doc = try LyrixParser().parse(contentsOf: url)
} catch LyrixError.fileReadFailed(let path) {
    print("Could not read file: \(path)")
} catch LyrixError.emptyContent {
    print("The lyrics content was empty")
}
Case Thrown when
emptyContent The content string (or file) is empty
fileReadFailed(String) The file at the given path could not be read

Usage Examples

Basic playback sync (LRC)

import LyrixLib

func syncToPlayback(url: URL, positionMs: Int) throws {
    let doc = try LyrixParser().parse(contentsOf: url)

    let (current, next) = doc.activeLines(at: positionMs)
    print("Now:  \(current?.displayText ?? "(intro)")")
    print("Next: \(next?.displayText   ?? "")")
}

Karaoke word highlighting (LRCX)

import LyrixLib

func karaokeHighlight(url: URL, positionMs: Int) throws {
    let doc = try LyrixParser().parse(contentsOf: url)
    guard let line = doc.activeLine(at: positionMs) else { return }

    // Highlight the active word
    let word = line.activeWord(at: positionMs)
    print("Line: \(line.displayText)")
    print("Word: \(word?.displayText ?? "")")

    // wordCharBoundaries gives character end-positions in displayText for each word,
    // accounting for any synthetic inter-word spaces inserted by displayText.
    let boundaries = line.wordCharBoundaries
    print("Word boundaries: \(boundaries)")
}

Character-level highlight bar (NetEase LRCX)

import LyrixLib

func characterBar(url: URL, positionMs: Int) throws {
    let doc = try LyrixParser().parse(contentsOf: url)
    guard let line = doc.activeLine(at: positionMs) else { return }

    let offset  = positionMs - line.timestamp
    let count   = line.highlightedCharacterCount(atOffset: offset)
    let total   = line.displayText.count

    let filled  = String(repeating: "█", count: count)
    let empty   = String(repeating: "░", count: total - count)
    print("\(filled)\(empty)  \(line.displayText)")

    // Show translation if available
    if let translation = line.translation {
        print("  ↳ \(translation)")
    }
}

Showing furigana (ruby annotations)

import LyrixLib

func printWithFurigana(url: URL, positionMs: Int) throws {
    let doc = try LyrixParser().parse(contentsOf: url)
    guard let line = doc.activeLine(at: positionMs) else { return }

    for segment in line.rubySegments {
        if let ruby = segment.ruby {
            print("\(segment.base)[\(ruby)]", terminator: "")
        } else {
            print(segment.base, terminator: "")
        }
    }
    print()
}

Silence detection

isLyricsSilent(at:) returns true when no lyric content is audible — useful for fading UI elements or pausing animations during intros, outros, and instrumental breaks.

import LyrixLib

func updateLyricsUI(doc: LyrixDocument, positionMs: Int) {
    if doc.isLyricsSilent(at: positionMs) {
        // Hide or fade lyrics display
        return
    }

    let (current, next) = doc.activeLines(at: positionMs)
    print("Now:  \(current?.displayText ?? "")")
    print("Next: \(next?.displayText   ?? "")")
}

Silence is detected in three ways:

  • The position precedes the first lyric line (intro)
  • The active line is an instrumental break (empty line in the file)
  • The active line has ended, as determined by the [tt] duration marker or a trailing empty word segment in LRCX files

Note: Plain LRC files have no line end-time information, so isLyricsSilent only returns true before the first line or on instrumental breaks in that format.

License

MIT

Description

  • Swift Tools 6.3.0
View More Packages from this Author

Dependencies

  • None
Last updated: Mon Oct 26 2026 00:02:29 GMT-0900 (Hawaii-Aleutian Daylight Time)