Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
42147f2
core: remove `^`..`*$` pattern from `Lrc.isValid()`
MSOB7YY Aug 13, 2024
2131d62
perf: use `StringBuffer` for formatting lrc
MSOB7YY Aug 15, 2024
57389fa
feat: parse multi-timestamp lyrics
MSOB7YY Aug 15, 2024
61f2b6d
core: add tests
MSOB7YY Aug 15, 2024
d95622b
feat: parse multi language lines separated by `;` ` ` `|`
MSOB7YY Aug 28, 2024
3a9fa81
chore: remove `;` as multi-lang splitter
MSOB7YY Aug 30, 2024
94f9538
core: improve regex matching
MSOB7YY Mar 5, 2025
596b633
chore: losen 'isValid' matching
MSOB7YY Mar 5, 2025
dc4a21f
feat: add `Lrc.cleanPlainLyrics()`
MSOB7YY Mar 5, 2025
e0e6779
fix: properly sort if lrc has another language in the end of the file
MSOB7YY Aug 29, 2025
e31ae5c
core: improve enhanced lyrics extraction & optimize code
MSOB7YY Dec 3, 2025
3b34a63
chore: improve `isValid` regex
MSOB7YY Dec 3, 2025
d7fec07
code: project restructure
MSOB7YY Dec 3, 2025
63b7b09
chore: improve enhanced lyrics extraction v2
MSOB7YY Dec 3, 2025
d794a07
feat: parse ttml lyrics (xml files)
MSOB7YY Dec 3, 2025
212a400
test: add tests
MSOB7YY Dec 3, 2025
a3c9358
fix: lines sorting for multi lang lyrics
MSOB7YY Dec 26, 2025
7fc4436
core: improve ttml parser to support normal format
MSOB7YY Jan 6, 2026
83f04af
chore: support word synced lyrics that has no trailing timestamp
MSOB7YY Jan 11, 2026
dadca50
fix: disable multi-lingual for double spaces when line is word synced
MSOB7YY Jan 11, 2026
f3a2926
fix: ensure persontext has `:`
MSOB7YY Jan 11, 2026
0fd4d8e
chore: support utf16 encoded lrc files
MSOB7YY Jan 24, 2026
f12f60f
feat: `Lrc.forUiDisplay()`
MSOB7YY Feb 15, 2026
9e82620
feat: optional `romanize` for `Lrc.forUiDisplay()`
MSOB7YY Feb 15, 2026
fbc2806
chore: use `List<int>` for `highlightTimestampsMap` in `Lrc.forUiDisp…
MSOB7YY Mar 4, 2026
3887107
fix: wrong timestamp for custom added empty line in `Lrc.forUiDisplay()`
MSOB7YY Mar 10, 2026
039461a
fix: ignore space after lrc duration
MSOB7YY Mar 11, 2026
b6df7e3
test: rename lrc.dart to lrc_test.dart
MSOB7YY Mar 22, 2026
49f824e
core: improve ttml parser
MSOB7YY Mar 22, 2026
971ebfc
core: improve ttml parser again
MSOB7YY Mar 28, 2026
6327f34
chore: ensure word parts has at least 250ms in between
MSOB7YY Apr 9, 2026
a5a9ea6
fix: ttml parser not parsing timestamp correctly
MSOB7YY Apr 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ build/
# Omit committing pubspec.lock for library packages; see
# https://dart.dev/guides/libraries/private-files#pubspeclock.
pubspec.lock


test/files/extra
18 changes: 17 additions & 1 deletion lib/lrc.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,20 @@
/// The main library for support for LRCs.
library lrc;

export 'src/lrc_main.dart';
import 'dart:convert';
import 'dart:io';

import 'package:charset/charset.dart';
import 'package:html_unescape/html_unescape.dart';
import 'package:kana_kit/kana_kit.dart';
import 'package:xml/xml.dart';

part 'src/core/enums.dart';
part 'src/core/extensions.dart';
part 'src/models/lrc.dart';
part 'src/models/lrc_line.dart';
part 'src/models/lrc_line_part.dart';
part 'src/models/lrc_stream.dart';
part 'src/parsers/lrc_parser.dart';
part 'src/parsers/multi_timestamp_parser.dart';
part 'src/parsers/ttml_parser.dart';
16 changes: 16 additions & 0 deletions lib/src/core/enums.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
part of lrc;

///The types of LRC
enum LrcTypes {
///A simple LRC, with no extra formatting, etc
simple,

///LRC with modifiers at the start in the form `A: foo`
extended,

///LRC with additional timestamps per line in the form `<00:00.00> foo`
enhanced,

///LRC that some lines are extended and some are enhanced
extended_enhanced
}
235 changes: 235 additions & 0 deletions lib/src/core/extensions.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
part of lrc;

/// Handy extensions on lists of LrcLine
extension LrcLineExtensions on List<LrcLine> {
/// Creates a stream for each lyric using their durations
Stream<LrcStream> toStream() async* {
for (var i = 0; i < length; i++) {
var lineCurrent = this[i];
var lineNext = (i + 1 < length) ? this[i + 1] : null;
var durationToNext = (lineNext != null)
? Duration(
milliseconds: lineNext.timestamp.inMilliseconds -
lineCurrent.timestamp.inMilliseconds)
: null;
yield LrcStream(
duration: durationToNext,
previous: (i != 0) ? this[i - 1] : null,
current: lineCurrent,
next: lineNext,
position: i,
length: length - 1);
if (durationToNext != null) {
await Future.delayed(durationToNext);
}
}
}
}

extension LrcExtensions on Lrc {
({
List<LrcLine> uiLyricsLines,
Map<Duration, List<int>> highlightTimestampsMap,
}) forUiDisplay(
double multiplier, {
Duration durationDifferenceToInsertEmptyLine = const Duration(seconds: 1),
bool romanize = false,
}) {
final originalLyrics = lyrics;
final offset = this.offset ?? 0;
final uiLyricsLines = <LrcLine>[];
final highlightTimestampsMap =
<Duration, List<int>>{}; // timestamp: [index]
var indexExtra = 0;
for (var index = 0; index < originalLyrics.length; index++) {
final ogItem = originalLyrics[index];

final lineTimeStamp = ogItem.timestamp - Duration(milliseconds: offset);
final calculatedForSpedUpVersions =
multiplier == 0 ? lineTimeStamp : (lineTimeStamp * multiplier);

final newLrcLine = ogItem.withTimeStamp(
newTimestamp: calculatedForSpedUpVersions,
parts: _mergeParts(ogItem.parts),
);
final indicesList =
highlightTimestampsMap[calculatedForSpedUpVersions] ??= [];
indicesList.add(index + indexExtra);
uiLyricsLines.add(newLrcLine);
final newParts = newLrcLine.parts;
if (newParts != null) {
try {
final nextLine = index == originalLyrics.length - 1
? null
: originalLyrics[index + 1];
if (nextLine != null) {
final partEndTimestamp = newParts.last.endTimestamp;
if ((nextLine.timestamp - partEndTimestamp) >
durationDifferenceToInsertEmptyLine) {
// -- insert empty line to allow dynamic lrc view to hide lrc during long transitions
indexExtra++;
final emptyLineIndex = index + indexExtra;

uiLyricsLines.add(
LrcLine(
timestamp: partEndTimestamp,
originalIndex: emptyLineIndex + 0.1,
lyrics: '',
readableText: '',
type: LrcTypes.simple,
parts: const [],
person: null,
),
);
final indicesList =
highlightTimestampsMap[partEndTimestamp] ??= [];
indicesList.add(emptyLineIndex);
}
}
} catch (_) {}
}
if (romanize) {
final romanized =
_romanized(newLrcLine, index, calculatedForSpedUpVersions);
if (romanized != null) {
uiLyricsLines.add(romanized);
}
}
}
return (
uiLyricsLines: uiLyricsLines,
highlightTimestampsMap: highlightTimestampsMap,
);
}
}

List<LrcLinePart>? _mergeParts(
List<LrcLinePart>? parts, {
int minDurationMs = 250,
}) {
if (parts == null || parts.isEmpty) return parts;

final merged = <LrcLinePart>[];
var current = parts[0];

for (var i = 1; i < parts.length; i++) {
final next = parts[i];
final duration = current.endTimestamp.inMilliseconds -
current.startTimestamp.inMilliseconds;

if (duration < minDurationMs) {
current = LrcLinePart(
startTimestamp: current.startTimestamp,
endTimestamp: next.endTimestamp,
lyrics: current.lyrics + next.lyrics,
);
} else {
merged.add(current);
current = next;
}
}
merged.add(current);
return merged;
}

const _kanaKit = KanaKit(
config: KanaKitConfig(
passRomaji: true,
passKanji: false,
upcaseKatakana: true,
),
);

String? _toRomajiOrNull(String txt) {
if (txt.isNotEmpty) {
return _kanaKit.toRomaji(txt);
}
return null;
}

String _toRomajiOrOriginal(String txt) {
return _toRomajiOrNull(txt) ?? txt;
}

LrcLine? _romanized(LrcLine line, int index, Duration lineTimestamp) {
LrcLine? romanizedLine;
final parts = line.parts;
if (parts != null && parts.isNotEmpty) {
var hasRomajiPart = false;
final romanizedParts = <LrcLinePart>[];
for (final p in parts) {
final romanizedPart = _toRomajiOrNull(p.lyrics);
if (romanizedPart != null && romanizedPart != p.lyrics) {
hasRomajiPart = true;
}
// -- always add to retain order
romanizedParts.add(
LrcLinePart(
startTimestamp: p.startTimestamp,
endTimestamp: p.endTimestamp,
lyrics: romanizedPart ?? p.lyrics,
),
);
}
if (hasRomajiPart) {
romanizedLine = LrcLine(
timestamp: lineTimestamp,
originalIndex: index - 0.1,
lyrics: _toRomajiOrOriginal(line.lyrics),
readableText: romanizedParts.map((e) => e.lyrics).join(),
type: line.type,
parts: romanizedParts,
person: line.person,
);
}
} else {
final txt = line.lyrics;
final romanized = _toRomajiOrNull(txt);
if (romanized != null && romanized != txt) {
romanizedLine = LrcLine(
timestamp: lineTimestamp,
originalIndex: index - 0.1,
lyrics: romanized,
readableText: romanized,
type: line.type,
parts: parts,
person: line.person,
);
}
}

return romanizedLine;
}

/// Handy extensions on strings
extension StringExtensions on String {
/// Handy extension method that parses the string to an [Lrc]
Lrc toLrc() => LrcParser.parse(this);

/// Handy extension getter if the given string is a valid LRC
bool get isValidLrc => LrcParser.isValid(this);
}

extension LrcFileExtensions on File {
String readLrcStringSync() {
try {
return readAsStringSync(encoding: utf8);
} on FileSystemException catch (_) {
try {
return readAsStringSync(encoding: utf16);
} catch (_) {}
}
return '';
}

Future<String> readLrcString() async {
try {
return await readAsString(encoding: utf8);
} on FileSystemException catch (_) {
try {
return await readAsString(encoding: utf16);
} catch (_) {}
}
return '';
}
}
Loading