lrc-audio-player 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +25 -0
- package/README.md +124 -0
- package/dist/index.cjs +310 -0
- package/dist/index.d.cts +161 -0
- package/dist/index.d.ts +161 -0
- package/dist/index.js +281 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
=====================
|
|
3
|
+
|
|
4
|
+
Copyright © `2026` `HangerThem`
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person
|
|
7
|
+
obtaining a copy of this software and associated documentation
|
|
8
|
+
files (the “Software”), to deal in the Software without
|
|
9
|
+
restriction, including without limitation the rights to use,
|
|
10
|
+
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the
|
|
12
|
+
Software is furnished to do so, subject to the following
|
|
13
|
+
conditions:
|
|
14
|
+
|
|
15
|
+
The above copyright notice and this permission notice shall be
|
|
16
|
+
included in all copies or substantial portions of the Software.
|
|
17
|
+
|
|
18
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
|
|
19
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
20
|
+
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
21
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
22
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
23
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
24
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
25
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# lrc-audio-player
|
|
2
|
+
|
|
3
|
+
Sync LRC (and word-level "enhanced" LRC) lyrics to an `HTMLAudioElement`
|
|
4
|
+
from a single constructor. Gives you the current line, the next line,
|
|
5
|
+
word-level highlighting, seeking by line, and change events — without
|
|
6
|
+
re-scanning the whole lyric file on every `timeupdate`.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install lrc-audio-player
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { LyricPlayer } from 'lrc-audio-player';
|
|
18
|
+
|
|
19
|
+
const lrcText = await fetch('/song.lrc').then((r) => r.text());
|
|
20
|
+
|
|
21
|
+
const player = new LyricPlayer({
|
|
22
|
+
audio: '/song.mp3',
|
|
23
|
+
lyrics: lrcText,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
player.on('linechange', (line, index) => {
|
|
27
|
+
console.log(index, line?.text);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
player.play();
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
You can also hand it an existing `<audio>` element instead of a URL:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
const audioEl = document.querySelector('audio')!;
|
|
37
|
+
const player = new LyricPlayer({ audio: audioEl, lyrics: lrcText });
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Lyric formats
|
|
41
|
+
|
|
42
|
+
- **Standard LRC**: `[01:23.45]Some lyric line`
|
|
43
|
+
- **Repeated lines** (e.g. choruses): `[00:10.00][00:20.00]Same line`
|
|
44
|
+
- **Word-level / enhanced LRC**: `[00:01.00]<00:01.00>Hello <00:01.50>world`
|
|
45
|
+
- **Metadata tags**: `[ti:]`, `[ar:]`, `[al:]`, `[by:]`/`[au:]`, `[offset:]`
|
|
46
|
+
- **Plain JSON**: pass an array of `{ time, text }` objects directly, or
|
|
47
|
+
use `lyrics: { type: 'json', data: [...] }`
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
new LyricPlayer({
|
|
51
|
+
audio: '/song.mp3',
|
|
52
|
+
lyrics: [
|
|
53
|
+
{ time: 0, text: 'First line' },
|
|
54
|
+
{ time: 3.5, text: 'Second line' },
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## API
|
|
60
|
+
|
|
61
|
+
### `new LyricPlayer(options)`
|
|
62
|
+
|
|
63
|
+
| Option | Type | Description |
|
|
64
|
+
| ---------- | ------------------------------------------------- | --------------------------------------------- |
|
|
65
|
+
| `audio` | `string \| HTMLAudioElement` | Audio source URL, or an existing element |
|
|
66
|
+
| `lyrics` | `string \| LyricLine[] \| ParsedLyrics \| LyricSource` | LRC text, JSON lines, or pre-parsed lyrics |
|
|
67
|
+
| `offsetMs` | `number` (optional) | Extra global offset on top of `[offset:]` |
|
|
68
|
+
|
|
69
|
+
### Playback
|
|
70
|
+
|
|
71
|
+
- `play()` / `pause()` / `toggle()` — delegate to the underlying audio element
|
|
72
|
+
- `seek(seconds)` — jump to a specific time
|
|
73
|
+
- `seekToLine(index)` — jump to the start of a given lyric line
|
|
74
|
+
- `currentTime`, `duration`, `paused`, `volume` — pass-through getters/setters
|
|
75
|
+
|
|
76
|
+
### Lyrics
|
|
77
|
+
|
|
78
|
+
- `lines: LyricLine[]` — all parsed lines, sorted by time
|
|
79
|
+
- `metadata` — parsed `[ti]`/`[ar]`/`[al]`/`[by]`/`[offset]` tags
|
|
80
|
+
- `getCurrentLine()` / `getCurrentIndex()` — active line right now
|
|
81
|
+
- `getNextLine()` — line after the current one
|
|
82
|
+
- `getCurrentToken()` / `getCurrentTokenIndex()` — active word, for
|
|
83
|
+
karaoke-style word highlighting (enhanced LRC only)
|
|
84
|
+
- `findLineIndexAtTime(seconds)` — binary-search lookup at an arbitrary time,
|
|
85
|
+
without touching playback state
|
|
86
|
+
- `setLyrics(...)` — swap in a new lyric source at runtime
|
|
87
|
+
- `setOffset(ms)` — adjust global timing offset at runtime
|
|
88
|
+
|
|
89
|
+
### Events
|
|
90
|
+
|
|
91
|
+
`on(event, handler)` / `off(event, handler)`:
|
|
92
|
+
|
|
93
|
+
| Event | Payload |
|
|
94
|
+
| ------------ | ------------------------------------------ |
|
|
95
|
+
| `linechange` | `(line: LyricLine \| null, index: number)` |
|
|
96
|
+
| `timeupdate` | `(currentTime: number)` |
|
|
97
|
+
| `play` | — |
|
|
98
|
+
| `pause` | — |
|
|
99
|
+
| `ended` | — |
|
|
100
|
+
| `error` | `(event: Event)` |
|
|
101
|
+
|
|
102
|
+
## Example: word-by-word highlighting
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
player.on('timeupdate', () => {
|
|
106
|
+
const line = player.getCurrentLine();
|
|
107
|
+
const tokenIndex = player.getCurrentTokenIndex();
|
|
108
|
+
|
|
109
|
+
if (!line?.tokens) return;
|
|
110
|
+
renderLine(line.tokens.map((tok, i) => ({
|
|
111
|
+
text: tok.text,
|
|
112
|
+
active: i === tokenIndex,
|
|
113
|
+
})));
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
npm install
|
|
121
|
+
npm run build # bundle to dist/ (cjs + esm + types)
|
|
122
|
+
npm test # run vitest
|
|
123
|
+
npm run typecheck # tsc --noEmit
|
|
124
|
+
```
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
LyricPlayer: () => LyricPlayer,
|
|
24
|
+
parseJSONLyrics: () => parseJSONLyrics,
|
|
25
|
+
parseLRC: () => parseLRC
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(index_exports);
|
|
28
|
+
|
|
29
|
+
// src/parser.ts
|
|
30
|
+
var TIME_TAG = /\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]/g;
|
|
31
|
+
var META_TAG = /^\[([a-zA-Z]+):(.*)\]$/;
|
|
32
|
+
var WORD_TAG = /<(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?>/g;
|
|
33
|
+
var KNOWN_META_KEYS = {
|
|
34
|
+
ti: "title",
|
|
35
|
+
ar: "artist",
|
|
36
|
+
al: "album",
|
|
37
|
+
au: "author",
|
|
38
|
+
by: "author",
|
|
39
|
+
offset: "offset"
|
|
40
|
+
};
|
|
41
|
+
function timeToSeconds(min, sec, frac) {
|
|
42
|
+
const fraction = frac ? Number(`0.${frac}`) : 0;
|
|
43
|
+
return Number(min) * 60 + Number(sec) + fraction;
|
|
44
|
+
}
|
|
45
|
+
function parseTokens(raw, lineTime) {
|
|
46
|
+
if (!WORD_TAG.test(raw)) {
|
|
47
|
+
return { text: raw.trim() };
|
|
48
|
+
}
|
|
49
|
+
WORD_TAG.lastIndex = 0;
|
|
50
|
+
const tokens = [];
|
|
51
|
+
let lastIndex = 0;
|
|
52
|
+
let lastTime = lineTime;
|
|
53
|
+
let match;
|
|
54
|
+
let plain = "";
|
|
55
|
+
while (match = WORD_TAG.exec(raw)) {
|
|
56
|
+
const chunk = raw.slice(lastIndex, match.index);
|
|
57
|
+
if (chunk.length) {
|
|
58
|
+
tokens.push({ time: lastTime, text: chunk });
|
|
59
|
+
plain += chunk;
|
|
60
|
+
}
|
|
61
|
+
lastTime = timeToSeconds(match[1], match[2], match[3]);
|
|
62
|
+
lastIndex = WORD_TAG.lastIndex;
|
|
63
|
+
}
|
|
64
|
+
const tail = raw.slice(lastIndex);
|
|
65
|
+
if (tail.length) {
|
|
66
|
+
tokens.push({ time: lastTime, text: tail });
|
|
67
|
+
plain += tail;
|
|
68
|
+
}
|
|
69
|
+
return { text: plain.trim(), tokens };
|
|
70
|
+
}
|
|
71
|
+
function parseLRC(lrc) {
|
|
72
|
+
const metadata = {};
|
|
73
|
+
const lines = [];
|
|
74
|
+
const rawLines = lrc.split(/\r?\n/);
|
|
75
|
+
for (const rawLine of rawLines) {
|
|
76
|
+
const trimmed = rawLine.trim();
|
|
77
|
+
if (!trimmed) continue;
|
|
78
|
+
const metaMatch = trimmed.match(META_TAG);
|
|
79
|
+
if (metaMatch && !TIME_TAG.test(trimmed)) {
|
|
80
|
+
const key = metaMatch[1].toLowerCase();
|
|
81
|
+
const value = metaMatch[2].trim();
|
|
82
|
+
const knownKey = KNOWN_META_KEYS[key];
|
|
83
|
+
if (knownKey === "offset") {
|
|
84
|
+
metadata.offset = Number(value);
|
|
85
|
+
} else if (knownKey) {
|
|
86
|
+
metadata[knownKey] = value;
|
|
87
|
+
} else {
|
|
88
|
+
metadata[key] = value;
|
|
89
|
+
}
|
|
90
|
+
TIME_TAG.lastIndex = 0;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
TIME_TAG.lastIndex = 0;
|
|
94
|
+
const times = [];
|
|
95
|
+
let rest = trimmed;
|
|
96
|
+
let leadingMatch;
|
|
97
|
+
while (leadingMatch = rest.match(/^\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]/)) {
|
|
98
|
+
times.push(timeToSeconds(leadingMatch[1], leadingMatch[2], leadingMatch[3]));
|
|
99
|
+
rest = rest.slice(leadingMatch[0].length);
|
|
100
|
+
}
|
|
101
|
+
if (times.length === 0) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
for (const time of times) {
|
|
105
|
+
const { text, tokens } = parseTokens(rest, time);
|
|
106
|
+
lines.push({ time, text, tokens });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const offsetSeconds = (metadata.offset ?? 0) / 1e3;
|
|
110
|
+
if (offsetSeconds) {
|
|
111
|
+
for (const line of lines) {
|
|
112
|
+
line.time -= offsetSeconds;
|
|
113
|
+
if (line.tokens) {
|
|
114
|
+
for (const token of line.tokens) {
|
|
115
|
+
token.time -= offsetSeconds;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
lines.sort((a, b) => a.time - b.time);
|
|
121
|
+
return { metadata, lines };
|
|
122
|
+
}
|
|
123
|
+
function parseJSONLyrics(json) {
|
|
124
|
+
const lines = typeof json === "string" ? JSON.parse(json) : json;
|
|
125
|
+
const sorted = [...lines].sort((a, b) => a.time - b.time);
|
|
126
|
+
return { metadata: {}, lines: sorted };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// src/player.ts
|
|
130
|
+
var LyricPlayer = class {
|
|
131
|
+
constructor(options) {
|
|
132
|
+
this.currentIndex = -1;
|
|
133
|
+
this.listeners = {};
|
|
134
|
+
this.handleTimeUpdate = () => {
|
|
135
|
+
const time = this.audio.currentTime;
|
|
136
|
+
const newIndex = this.findLineIndexAtTime(time);
|
|
137
|
+
if (newIndex !== this.currentIndex) {
|
|
138
|
+
this.currentIndex = newIndex;
|
|
139
|
+
this.emit("linechange", this.getCurrentLine(), newIndex);
|
|
140
|
+
}
|
|
141
|
+
this.emit("timeupdate", time);
|
|
142
|
+
};
|
|
143
|
+
this.audio = typeof options.audio === "string" ? new Audio(options.audio) : options.audio;
|
|
144
|
+
const parsed = this.resolveLyrics(options.lyrics);
|
|
145
|
+
this.metadata = parsed.metadata;
|
|
146
|
+
this.lines = parsed.lines;
|
|
147
|
+
this.offsetSeconds = (options.offsetMs ?? 0) / 1e3;
|
|
148
|
+
this.audio.addEventListener("timeupdate", this.handleTimeUpdate);
|
|
149
|
+
this.audio.addEventListener("play", () => this.emit("play"));
|
|
150
|
+
this.audio.addEventListener("pause", () => this.emit("pause"));
|
|
151
|
+
this.audio.addEventListener("ended", () => this.emit("ended"));
|
|
152
|
+
this.audio.addEventListener("error", (e) => this.emit("error", e));
|
|
153
|
+
}
|
|
154
|
+
// ---------------------------------------------------------------------
|
|
155
|
+
// Lyric source resolution
|
|
156
|
+
// ---------------------------------------------------------------------
|
|
157
|
+
resolveLyrics(lyrics) {
|
|
158
|
+
if (!lyrics) return { metadata: {}, lines: [] };
|
|
159
|
+
if (typeof lyrics === "string") {
|
|
160
|
+
return parseLRC(lyrics);
|
|
161
|
+
}
|
|
162
|
+
if (Array.isArray(lyrics)) {
|
|
163
|
+
return parseJSONLyrics(lyrics);
|
|
164
|
+
}
|
|
165
|
+
if ("type" in lyrics) {
|
|
166
|
+
switch (lyrics.type) {
|
|
167
|
+
case "lrc":
|
|
168
|
+
return parseLRC(lyrics.data);
|
|
169
|
+
case "json":
|
|
170
|
+
return parseJSONLyrics(lyrics.data);
|
|
171
|
+
case "parsed":
|
|
172
|
+
return lyrics.data;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return lyrics;
|
|
176
|
+
}
|
|
177
|
+
/** Replace the loaded lyrics at any time (e.g. after fetching a file). */
|
|
178
|
+
setLyrics(lyrics) {
|
|
179
|
+
const parsed = this.resolveLyrics(lyrics);
|
|
180
|
+
this.metadata = parsed.metadata;
|
|
181
|
+
this.lines.length = 0;
|
|
182
|
+
this.lines.push(...parsed.lines);
|
|
183
|
+
this.currentIndex = -1;
|
|
184
|
+
this.handleTimeUpdate();
|
|
185
|
+
}
|
|
186
|
+
/** Adjust the global lyric offset (in milliseconds) at runtime. */
|
|
187
|
+
setOffset(offsetMs) {
|
|
188
|
+
this.offsetSeconds = offsetMs / 1e3;
|
|
189
|
+
this.currentIndex = -1;
|
|
190
|
+
this.handleTimeUpdate();
|
|
191
|
+
}
|
|
192
|
+
// ---------------------------------------------------------------------
|
|
193
|
+
// Playback controls (thin wrappers over the audio element)
|
|
194
|
+
// ---------------------------------------------------------------------
|
|
195
|
+
play() {
|
|
196
|
+
return this.audio.play();
|
|
197
|
+
}
|
|
198
|
+
pause() {
|
|
199
|
+
this.audio.pause();
|
|
200
|
+
}
|
|
201
|
+
toggle() {
|
|
202
|
+
return this.audio.paused ? this.audio.play() : this.audio.pause();
|
|
203
|
+
}
|
|
204
|
+
seek(timeSeconds) {
|
|
205
|
+
this.audio.currentTime = Math.max(0, timeSeconds);
|
|
206
|
+
this.handleTimeUpdate();
|
|
207
|
+
}
|
|
208
|
+
/** Seek directly to the start of a given lyric line. */
|
|
209
|
+
seekToLine(index) {
|
|
210
|
+
const line = this.lines[index];
|
|
211
|
+
if (line) this.seek(line.time + this.offsetSeconds);
|
|
212
|
+
}
|
|
213
|
+
get currentTime() {
|
|
214
|
+
return this.audio.currentTime;
|
|
215
|
+
}
|
|
216
|
+
get duration() {
|
|
217
|
+
return this.audio.duration;
|
|
218
|
+
}
|
|
219
|
+
get paused() {
|
|
220
|
+
return this.audio.paused;
|
|
221
|
+
}
|
|
222
|
+
set volume(value) {
|
|
223
|
+
this.audio.volume = value;
|
|
224
|
+
}
|
|
225
|
+
get volume() {
|
|
226
|
+
return this.audio.volume;
|
|
227
|
+
}
|
|
228
|
+
// ---------------------------------------------------------------------
|
|
229
|
+
// Lyric lookup
|
|
230
|
+
// ---------------------------------------------------------------------
|
|
231
|
+
/** The currently active lyric line, or null if before the first line. */
|
|
232
|
+
getCurrentLine() {
|
|
233
|
+
return this.currentIndex >= 0 ? this.lines[this.currentIndex] : null;
|
|
234
|
+
}
|
|
235
|
+
/** The index of the currently active lyric line (-1 if none yet). */
|
|
236
|
+
getCurrentIndex() {
|
|
237
|
+
return this.currentIndex;
|
|
238
|
+
}
|
|
239
|
+
/** The next lyric line after the current one, if any. */
|
|
240
|
+
getNextLine() {
|
|
241
|
+
const next = this.lines[this.currentIndex + 1];
|
|
242
|
+
return next ?? null;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* For lines with word-level timing, returns the index of the active
|
|
246
|
+
* token within the current line (-1 if no tokens or none active yet).
|
|
247
|
+
*/
|
|
248
|
+
getCurrentTokenIndex() {
|
|
249
|
+
const line = this.getCurrentLine();
|
|
250
|
+
if (!line?.tokens?.length) return -1;
|
|
251
|
+
const t = this.audio.currentTime - this.offsetSeconds;
|
|
252
|
+
let idx = -1;
|
|
253
|
+
for (let i = 0; i < line.tokens.length; i++) {
|
|
254
|
+
if (line.tokens[i].time <= t) idx = i;
|
|
255
|
+
else break;
|
|
256
|
+
}
|
|
257
|
+
return idx;
|
|
258
|
+
}
|
|
259
|
+
getCurrentToken() {
|
|
260
|
+
const line = this.getCurrentLine();
|
|
261
|
+
const idx = this.getCurrentTokenIndex();
|
|
262
|
+
return line?.tokens && idx >= 0 ? line.tokens[idx] : null;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Find the line index active at an arbitrary time, without affecting
|
|
266
|
+
* playback or the cached current index. Uses binary search.
|
|
267
|
+
*/
|
|
268
|
+
findLineIndexAtTime(timeSeconds) {
|
|
269
|
+
const t = timeSeconds - this.offsetSeconds;
|
|
270
|
+
const lines = this.lines;
|
|
271
|
+
if (lines.length === 0 || t < lines[0].time) return -1;
|
|
272
|
+
let lo = 0;
|
|
273
|
+
let hi = lines.length - 1;
|
|
274
|
+
while (lo < hi) {
|
|
275
|
+
const mid = lo + hi + 1 >> 1;
|
|
276
|
+
if (lines[mid].time <= t) lo = mid;
|
|
277
|
+
else hi = mid - 1;
|
|
278
|
+
}
|
|
279
|
+
return lo;
|
|
280
|
+
}
|
|
281
|
+
// ---------------------------------------------------------------------
|
|
282
|
+
// Event handling
|
|
283
|
+
// ---------------------------------------------------------------------
|
|
284
|
+
on(event, listener) {
|
|
285
|
+
let set = this.listeners[event];
|
|
286
|
+
if (!set) {
|
|
287
|
+
set = /* @__PURE__ */ new Set();
|
|
288
|
+
this.listeners[event] = set;
|
|
289
|
+
}
|
|
290
|
+
set.add(listener);
|
|
291
|
+
}
|
|
292
|
+
off(event, listener) {
|
|
293
|
+
this.listeners[event]?.delete(listener);
|
|
294
|
+
}
|
|
295
|
+
emit(event, ...args) {
|
|
296
|
+
this.listeners[event]?.forEach((listener) => listener(...args));
|
|
297
|
+
}
|
|
298
|
+
/** Remove all listeners and detach from the underlying audio element. */
|
|
299
|
+
destroy() {
|
|
300
|
+
this.audio.removeEventListener("timeupdate", this.handleTimeUpdate);
|
|
301
|
+
this.audio.pause();
|
|
302
|
+
this.listeners = {};
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
306
|
+
0 && (module.exports = {
|
|
307
|
+
LyricPlayer,
|
|
308
|
+
parseJSONLyrics,
|
|
309
|
+
parseLRC
|
|
310
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single token within a lyric line (for word/syllable-level timing,
|
|
3
|
+
* "enhanced LRC" style: <mm:ss.xx>word).
|
|
4
|
+
*/
|
|
5
|
+
interface LyricToken {
|
|
6
|
+
/** Time in seconds at which this token starts. */
|
|
7
|
+
time: number;
|
|
8
|
+
/** The text of the token (word, syllable, or chunk). */
|
|
9
|
+
text: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A single line of lyrics with its start time and optional
|
|
13
|
+
* word-level timing tokens.
|
|
14
|
+
*/
|
|
15
|
+
interface LyricLine {
|
|
16
|
+
/** Time in seconds at which this line starts. */
|
|
17
|
+
time: number;
|
|
18
|
+
/** Full text of the line (tokens stripped of timestamps, joined). */
|
|
19
|
+
text: string;
|
|
20
|
+
/** Optional word/syllable-level tokens, if the source had enhanced timing. */
|
|
21
|
+
tokens?: LyricToken[];
|
|
22
|
+
/** Optional metadata tag, e.g. for translation/extension lines (`[tr]` etc). */
|
|
23
|
+
tag?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Metadata commonly found in LRC files ([ti], [ar], [al], [by], [offset], etc).
|
|
27
|
+
*/
|
|
28
|
+
interface LyricMetadata {
|
|
29
|
+
title?: string;
|
|
30
|
+
artist?: string;
|
|
31
|
+
album?: string;
|
|
32
|
+
author?: string;
|
|
33
|
+
/** Offset in milliseconds. Positive values make lyrics appear later. */
|
|
34
|
+
offset?: number;
|
|
35
|
+
[key: string]: string | number | undefined;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Parsed lyric file: metadata plus a time-sorted array of lines.
|
|
39
|
+
*/
|
|
40
|
+
interface ParsedLyrics {
|
|
41
|
+
metadata: LyricMetadata;
|
|
42
|
+
lines: LyricLine[];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Events emitted by LyricPlayer.
|
|
46
|
+
*/
|
|
47
|
+
interface LyricPlayerEvents {
|
|
48
|
+
/** Fired whenever the active lyric line index changes (including to -1). */
|
|
49
|
+
linechange: (line: LyricLine | null, index: number) => void;
|
|
50
|
+
/** Fired on every underlying `timeupdate` from the audio element. */
|
|
51
|
+
timeupdate: (currentTime: number) => void;
|
|
52
|
+
/** Fired when playback starts. */
|
|
53
|
+
play: () => void;
|
|
54
|
+
/** Fired when playback pauses. */
|
|
55
|
+
pause: () => void;
|
|
56
|
+
/** Fired when the track finishes playing. */
|
|
57
|
+
ended: () => void;
|
|
58
|
+
/** Fired if the audio element raises an error. */
|
|
59
|
+
error: (error: Event) => void;
|
|
60
|
+
}
|
|
61
|
+
type LyricPlayerEventName = keyof LyricPlayerEvents;
|
|
62
|
+
|
|
63
|
+
type LyricSource = {
|
|
64
|
+
type: 'lrc';
|
|
65
|
+
data: string;
|
|
66
|
+
} | {
|
|
67
|
+
type: 'json';
|
|
68
|
+
data: string | LyricLine[];
|
|
69
|
+
} | {
|
|
70
|
+
type: 'parsed';
|
|
71
|
+
data: ParsedLyrics;
|
|
72
|
+
};
|
|
73
|
+
interface LyricPlayerOptions {
|
|
74
|
+
/** Audio source: URL/path, or an existing HTMLAudioElement to take over. */
|
|
75
|
+
audio: string | HTMLAudioElement;
|
|
76
|
+
/** Lyrics source. Defaults to LRC text if a plain string is passed. */
|
|
77
|
+
lyrics?: string | LyricLine[] | ParsedLyrics | LyricSource;
|
|
78
|
+
/**
|
|
79
|
+
* Additional offset in milliseconds applied on top of any [offset:] tag
|
|
80
|
+
* in the LRC file. Positive values shift lyrics later.
|
|
81
|
+
*/
|
|
82
|
+
offsetMs?: number;
|
|
83
|
+
}
|
|
84
|
+
type Listener<E extends LyricPlayerEventName> = LyricPlayerEvents[E];
|
|
85
|
+
/**
|
|
86
|
+
* Wraps an HTMLAudioElement together with parsed, time-synced lyrics.
|
|
87
|
+
*
|
|
88
|
+
* ```ts
|
|
89
|
+
* const player = new LyricPlayer({ audio: 'song.mp3', lyrics: lrcText });
|
|
90
|
+
* player.on('linechange', (line) => console.log(line?.text));
|
|
91
|
+
* player.play();
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
declare class LyricPlayer {
|
|
95
|
+
readonly audio: HTMLAudioElement;
|
|
96
|
+
readonly metadata: LyricMetadata;
|
|
97
|
+
readonly lines: LyricLine[];
|
|
98
|
+
private offsetSeconds;
|
|
99
|
+
private currentIndex;
|
|
100
|
+
private listeners;
|
|
101
|
+
constructor(options: LyricPlayerOptions);
|
|
102
|
+
private resolveLyrics;
|
|
103
|
+
/** Replace the loaded lyrics at any time (e.g. after fetching a file). */
|
|
104
|
+
setLyrics(lyrics: LyricPlayerOptions['lyrics']): void;
|
|
105
|
+
/** Adjust the global lyric offset (in milliseconds) at runtime. */
|
|
106
|
+
setOffset(offsetMs: number): void;
|
|
107
|
+
play(): Promise<void>;
|
|
108
|
+
pause(): void;
|
|
109
|
+
toggle(): Promise<void> | void;
|
|
110
|
+
seek(timeSeconds: number): void;
|
|
111
|
+
/** Seek directly to the start of a given lyric line. */
|
|
112
|
+
seekToLine(index: number): void;
|
|
113
|
+
get currentTime(): number;
|
|
114
|
+
get duration(): number;
|
|
115
|
+
get paused(): boolean;
|
|
116
|
+
set volume(value: number);
|
|
117
|
+
get volume(): number;
|
|
118
|
+
/** The currently active lyric line, or null if before the first line. */
|
|
119
|
+
getCurrentLine(): LyricLine | null;
|
|
120
|
+
/** The index of the currently active lyric line (-1 if none yet). */
|
|
121
|
+
getCurrentIndex(): number;
|
|
122
|
+
/** The next lyric line after the current one, if any. */
|
|
123
|
+
getNextLine(): LyricLine | null;
|
|
124
|
+
/**
|
|
125
|
+
* For lines with word-level timing, returns the index of the active
|
|
126
|
+
* token within the current line (-1 if no tokens or none active yet).
|
|
127
|
+
*/
|
|
128
|
+
getCurrentTokenIndex(): number;
|
|
129
|
+
getCurrentToken(): LyricToken | null;
|
|
130
|
+
/**
|
|
131
|
+
* Find the line index active at an arbitrary time, without affecting
|
|
132
|
+
* playback or the cached current index. Uses binary search.
|
|
133
|
+
*/
|
|
134
|
+
findLineIndexAtTime(timeSeconds: number): number;
|
|
135
|
+
on<E extends LyricPlayerEventName>(event: E, listener: Listener<E>): void;
|
|
136
|
+
off<E extends LyricPlayerEventName>(event: E, listener: Listener<E>): void;
|
|
137
|
+
private emit;
|
|
138
|
+
private handleTimeUpdate;
|
|
139
|
+
/** Remove all listeners and detach from the underlying audio element. */
|
|
140
|
+
destroy(): void;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Parse an LRC-format lyric file into structured, time-sorted lines plus
|
|
145
|
+
* any metadata tags found at the top of the file.
|
|
146
|
+
*
|
|
147
|
+
* Supports:
|
|
148
|
+
* - Standard line timestamps: `[01:23.45]Some lyric text`
|
|
149
|
+
* - Multiple timestamps per line (repeated lines): `[00:01.00][00:05.00]Chorus`
|
|
150
|
+
* - Word-level / "enhanced" timing: `[00:01.00]<00:01.00>Hello <00:01.50>world`
|
|
151
|
+
* - Metadata tags: `[ti:Title]`, `[ar:Artist]`, `[al:Album]`, `[by:Author]`, `[offset:1000]`
|
|
152
|
+
*/
|
|
153
|
+
declare function parseLRC(lrc: string): ParsedLyrics;
|
|
154
|
+
/**
|
|
155
|
+
* Parse a simple JSON lyric format:
|
|
156
|
+
* `[{ "time": 1.0, "text": "Hello" }, ...]`
|
|
157
|
+
* Useful as an alternative to LRC when you control the lyric source yourself.
|
|
158
|
+
*/
|
|
159
|
+
declare function parseJSONLyrics(json: string | LyricLine[]): ParsedLyrics;
|
|
160
|
+
|
|
161
|
+
export { type LyricLine, type LyricMetadata, LyricPlayer, type LyricPlayerEventName, type LyricPlayerEvents, type LyricPlayerOptions, type LyricSource, type LyricToken, type ParsedLyrics, parseJSONLyrics, parseLRC };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single token within a lyric line (for word/syllable-level timing,
|
|
3
|
+
* "enhanced LRC" style: <mm:ss.xx>word).
|
|
4
|
+
*/
|
|
5
|
+
interface LyricToken {
|
|
6
|
+
/** Time in seconds at which this token starts. */
|
|
7
|
+
time: number;
|
|
8
|
+
/** The text of the token (word, syllable, or chunk). */
|
|
9
|
+
text: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A single line of lyrics with its start time and optional
|
|
13
|
+
* word-level timing tokens.
|
|
14
|
+
*/
|
|
15
|
+
interface LyricLine {
|
|
16
|
+
/** Time in seconds at which this line starts. */
|
|
17
|
+
time: number;
|
|
18
|
+
/** Full text of the line (tokens stripped of timestamps, joined). */
|
|
19
|
+
text: string;
|
|
20
|
+
/** Optional word/syllable-level tokens, if the source had enhanced timing. */
|
|
21
|
+
tokens?: LyricToken[];
|
|
22
|
+
/** Optional metadata tag, e.g. for translation/extension lines (`[tr]` etc). */
|
|
23
|
+
tag?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Metadata commonly found in LRC files ([ti], [ar], [al], [by], [offset], etc).
|
|
27
|
+
*/
|
|
28
|
+
interface LyricMetadata {
|
|
29
|
+
title?: string;
|
|
30
|
+
artist?: string;
|
|
31
|
+
album?: string;
|
|
32
|
+
author?: string;
|
|
33
|
+
/** Offset in milliseconds. Positive values make lyrics appear later. */
|
|
34
|
+
offset?: number;
|
|
35
|
+
[key: string]: string | number | undefined;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Parsed lyric file: metadata plus a time-sorted array of lines.
|
|
39
|
+
*/
|
|
40
|
+
interface ParsedLyrics {
|
|
41
|
+
metadata: LyricMetadata;
|
|
42
|
+
lines: LyricLine[];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Events emitted by LyricPlayer.
|
|
46
|
+
*/
|
|
47
|
+
interface LyricPlayerEvents {
|
|
48
|
+
/** Fired whenever the active lyric line index changes (including to -1). */
|
|
49
|
+
linechange: (line: LyricLine | null, index: number) => void;
|
|
50
|
+
/** Fired on every underlying `timeupdate` from the audio element. */
|
|
51
|
+
timeupdate: (currentTime: number) => void;
|
|
52
|
+
/** Fired when playback starts. */
|
|
53
|
+
play: () => void;
|
|
54
|
+
/** Fired when playback pauses. */
|
|
55
|
+
pause: () => void;
|
|
56
|
+
/** Fired when the track finishes playing. */
|
|
57
|
+
ended: () => void;
|
|
58
|
+
/** Fired if the audio element raises an error. */
|
|
59
|
+
error: (error: Event) => void;
|
|
60
|
+
}
|
|
61
|
+
type LyricPlayerEventName = keyof LyricPlayerEvents;
|
|
62
|
+
|
|
63
|
+
type LyricSource = {
|
|
64
|
+
type: 'lrc';
|
|
65
|
+
data: string;
|
|
66
|
+
} | {
|
|
67
|
+
type: 'json';
|
|
68
|
+
data: string | LyricLine[];
|
|
69
|
+
} | {
|
|
70
|
+
type: 'parsed';
|
|
71
|
+
data: ParsedLyrics;
|
|
72
|
+
};
|
|
73
|
+
interface LyricPlayerOptions {
|
|
74
|
+
/** Audio source: URL/path, or an existing HTMLAudioElement to take over. */
|
|
75
|
+
audio: string | HTMLAudioElement;
|
|
76
|
+
/** Lyrics source. Defaults to LRC text if a plain string is passed. */
|
|
77
|
+
lyrics?: string | LyricLine[] | ParsedLyrics | LyricSource;
|
|
78
|
+
/**
|
|
79
|
+
* Additional offset in milliseconds applied on top of any [offset:] tag
|
|
80
|
+
* in the LRC file. Positive values shift lyrics later.
|
|
81
|
+
*/
|
|
82
|
+
offsetMs?: number;
|
|
83
|
+
}
|
|
84
|
+
type Listener<E extends LyricPlayerEventName> = LyricPlayerEvents[E];
|
|
85
|
+
/**
|
|
86
|
+
* Wraps an HTMLAudioElement together with parsed, time-synced lyrics.
|
|
87
|
+
*
|
|
88
|
+
* ```ts
|
|
89
|
+
* const player = new LyricPlayer({ audio: 'song.mp3', lyrics: lrcText });
|
|
90
|
+
* player.on('linechange', (line) => console.log(line?.text));
|
|
91
|
+
* player.play();
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
declare class LyricPlayer {
|
|
95
|
+
readonly audio: HTMLAudioElement;
|
|
96
|
+
readonly metadata: LyricMetadata;
|
|
97
|
+
readonly lines: LyricLine[];
|
|
98
|
+
private offsetSeconds;
|
|
99
|
+
private currentIndex;
|
|
100
|
+
private listeners;
|
|
101
|
+
constructor(options: LyricPlayerOptions);
|
|
102
|
+
private resolveLyrics;
|
|
103
|
+
/** Replace the loaded lyrics at any time (e.g. after fetching a file). */
|
|
104
|
+
setLyrics(lyrics: LyricPlayerOptions['lyrics']): void;
|
|
105
|
+
/** Adjust the global lyric offset (in milliseconds) at runtime. */
|
|
106
|
+
setOffset(offsetMs: number): void;
|
|
107
|
+
play(): Promise<void>;
|
|
108
|
+
pause(): void;
|
|
109
|
+
toggle(): Promise<void> | void;
|
|
110
|
+
seek(timeSeconds: number): void;
|
|
111
|
+
/** Seek directly to the start of a given lyric line. */
|
|
112
|
+
seekToLine(index: number): void;
|
|
113
|
+
get currentTime(): number;
|
|
114
|
+
get duration(): number;
|
|
115
|
+
get paused(): boolean;
|
|
116
|
+
set volume(value: number);
|
|
117
|
+
get volume(): number;
|
|
118
|
+
/** The currently active lyric line, or null if before the first line. */
|
|
119
|
+
getCurrentLine(): LyricLine | null;
|
|
120
|
+
/** The index of the currently active lyric line (-1 if none yet). */
|
|
121
|
+
getCurrentIndex(): number;
|
|
122
|
+
/** The next lyric line after the current one, if any. */
|
|
123
|
+
getNextLine(): LyricLine | null;
|
|
124
|
+
/**
|
|
125
|
+
* For lines with word-level timing, returns the index of the active
|
|
126
|
+
* token within the current line (-1 if no tokens or none active yet).
|
|
127
|
+
*/
|
|
128
|
+
getCurrentTokenIndex(): number;
|
|
129
|
+
getCurrentToken(): LyricToken | null;
|
|
130
|
+
/**
|
|
131
|
+
* Find the line index active at an arbitrary time, without affecting
|
|
132
|
+
* playback or the cached current index. Uses binary search.
|
|
133
|
+
*/
|
|
134
|
+
findLineIndexAtTime(timeSeconds: number): number;
|
|
135
|
+
on<E extends LyricPlayerEventName>(event: E, listener: Listener<E>): void;
|
|
136
|
+
off<E extends LyricPlayerEventName>(event: E, listener: Listener<E>): void;
|
|
137
|
+
private emit;
|
|
138
|
+
private handleTimeUpdate;
|
|
139
|
+
/** Remove all listeners and detach from the underlying audio element. */
|
|
140
|
+
destroy(): void;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Parse an LRC-format lyric file into structured, time-sorted lines plus
|
|
145
|
+
* any metadata tags found at the top of the file.
|
|
146
|
+
*
|
|
147
|
+
* Supports:
|
|
148
|
+
* - Standard line timestamps: `[01:23.45]Some lyric text`
|
|
149
|
+
* - Multiple timestamps per line (repeated lines): `[00:01.00][00:05.00]Chorus`
|
|
150
|
+
* - Word-level / "enhanced" timing: `[00:01.00]<00:01.00>Hello <00:01.50>world`
|
|
151
|
+
* - Metadata tags: `[ti:Title]`, `[ar:Artist]`, `[al:Album]`, `[by:Author]`, `[offset:1000]`
|
|
152
|
+
*/
|
|
153
|
+
declare function parseLRC(lrc: string): ParsedLyrics;
|
|
154
|
+
/**
|
|
155
|
+
* Parse a simple JSON lyric format:
|
|
156
|
+
* `[{ "time": 1.0, "text": "Hello" }, ...]`
|
|
157
|
+
* Useful as an alternative to LRC when you control the lyric source yourself.
|
|
158
|
+
*/
|
|
159
|
+
declare function parseJSONLyrics(json: string | LyricLine[]): ParsedLyrics;
|
|
160
|
+
|
|
161
|
+
export { type LyricLine, type LyricMetadata, LyricPlayer, type LyricPlayerEventName, type LyricPlayerEvents, type LyricPlayerOptions, type LyricSource, type LyricToken, type ParsedLyrics, parseJSONLyrics, parseLRC };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// src/parser.ts
|
|
2
|
+
var TIME_TAG = /\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]/g;
|
|
3
|
+
var META_TAG = /^\[([a-zA-Z]+):(.*)\]$/;
|
|
4
|
+
var WORD_TAG = /<(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?>/g;
|
|
5
|
+
var KNOWN_META_KEYS = {
|
|
6
|
+
ti: "title",
|
|
7
|
+
ar: "artist",
|
|
8
|
+
al: "album",
|
|
9
|
+
au: "author",
|
|
10
|
+
by: "author",
|
|
11
|
+
offset: "offset"
|
|
12
|
+
};
|
|
13
|
+
function timeToSeconds(min, sec, frac) {
|
|
14
|
+
const fraction = frac ? Number(`0.${frac}`) : 0;
|
|
15
|
+
return Number(min) * 60 + Number(sec) + fraction;
|
|
16
|
+
}
|
|
17
|
+
function parseTokens(raw, lineTime) {
|
|
18
|
+
if (!WORD_TAG.test(raw)) {
|
|
19
|
+
return { text: raw.trim() };
|
|
20
|
+
}
|
|
21
|
+
WORD_TAG.lastIndex = 0;
|
|
22
|
+
const tokens = [];
|
|
23
|
+
let lastIndex = 0;
|
|
24
|
+
let lastTime = lineTime;
|
|
25
|
+
let match;
|
|
26
|
+
let plain = "";
|
|
27
|
+
while (match = WORD_TAG.exec(raw)) {
|
|
28
|
+
const chunk = raw.slice(lastIndex, match.index);
|
|
29
|
+
if (chunk.length) {
|
|
30
|
+
tokens.push({ time: lastTime, text: chunk });
|
|
31
|
+
plain += chunk;
|
|
32
|
+
}
|
|
33
|
+
lastTime = timeToSeconds(match[1], match[2], match[3]);
|
|
34
|
+
lastIndex = WORD_TAG.lastIndex;
|
|
35
|
+
}
|
|
36
|
+
const tail = raw.slice(lastIndex);
|
|
37
|
+
if (tail.length) {
|
|
38
|
+
tokens.push({ time: lastTime, text: tail });
|
|
39
|
+
plain += tail;
|
|
40
|
+
}
|
|
41
|
+
return { text: plain.trim(), tokens };
|
|
42
|
+
}
|
|
43
|
+
function parseLRC(lrc) {
|
|
44
|
+
const metadata = {};
|
|
45
|
+
const lines = [];
|
|
46
|
+
const rawLines = lrc.split(/\r?\n/);
|
|
47
|
+
for (const rawLine of rawLines) {
|
|
48
|
+
const trimmed = rawLine.trim();
|
|
49
|
+
if (!trimmed) continue;
|
|
50
|
+
const metaMatch = trimmed.match(META_TAG);
|
|
51
|
+
if (metaMatch && !TIME_TAG.test(trimmed)) {
|
|
52
|
+
const key = metaMatch[1].toLowerCase();
|
|
53
|
+
const value = metaMatch[2].trim();
|
|
54
|
+
const knownKey = KNOWN_META_KEYS[key];
|
|
55
|
+
if (knownKey === "offset") {
|
|
56
|
+
metadata.offset = Number(value);
|
|
57
|
+
} else if (knownKey) {
|
|
58
|
+
metadata[knownKey] = value;
|
|
59
|
+
} else {
|
|
60
|
+
metadata[key] = value;
|
|
61
|
+
}
|
|
62
|
+
TIME_TAG.lastIndex = 0;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
TIME_TAG.lastIndex = 0;
|
|
66
|
+
const times = [];
|
|
67
|
+
let rest = trimmed;
|
|
68
|
+
let leadingMatch;
|
|
69
|
+
while (leadingMatch = rest.match(/^\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]/)) {
|
|
70
|
+
times.push(timeToSeconds(leadingMatch[1], leadingMatch[2], leadingMatch[3]));
|
|
71
|
+
rest = rest.slice(leadingMatch[0].length);
|
|
72
|
+
}
|
|
73
|
+
if (times.length === 0) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
for (const time of times) {
|
|
77
|
+
const { text, tokens } = parseTokens(rest, time);
|
|
78
|
+
lines.push({ time, text, tokens });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const offsetSeconds = (metadata.offset ?? 0) / 1e3;
|
|
82
|
+
if (offsetSeconds) {
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
line.time -= offsetSeconds;
|
|
85
|
+
if (line.tokens) {
|
|
86
|
+
for (const token of line.tokens) {
|
|
87
|
+
token.time -= offsetSeconds;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
lines.sort((a, b) => a.time - b.time);
|
|
93
|
+
return { metadata, lines };
|
|
94
|
+
}
|
|
95
|
+
function parseJSONLyrics(json) {
|
|
96
|
+
const lines = typeof json === "string" ? JSON.parse(json) : json;
|
|
97
|
+
const sorted = [...lines].sort((a, b) => a.time - b.time);
|
|
98
|
+
return { metadata: {}, lines: sorted };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/player.ts
|
|
102
|
+
var LyricPlayer = class {
|
|
103
|
+
constructor(options) {
|
|
104
|
+
this.currentIndex = -1;
|
|
105
|
+
this.listeners = {};
|
|
106
|
+
this.handleTimeUpdate = () => {
|
|
107
|
+
const time = this.audio.currentTime;
|
|
108
|
+
const newIndex = this.findLineIndexAtTime(time);
|
|
109
|
+
if (newIndex !== this.currentIndex) {
|
|
110
|
+
this.currentIndex = newIndex;
|
|
111
|
+
this.emit("linechange", this.getCurrentLine(), newIndex);
|
|
112
|
+
}
|
|
113
|
+
this.emit("timeupdate", time);
|
|
114
|
+
};
|
|
115
|
+
this.audio = typeof options.audio === "string" ? new Audio(options.audio) : options.audio;
|
|
116
|
+
const parsed = this.resolveLyrics(options.lyrics);
|
|
117
|
+
this.metadata = parsed.metadata;
|
|
118
|
+
this.lines = parsed.lines;
|
|
119
|
+
this.offsetSeconds = (options.offsetMs ?? 0) / 1e3;
|
|
120
|
+
this.audio.addEventListener("timeupdate", this.handleTimeUpdate);
|
|
121
|
+
this.audio.addEventListener("play", () => this.emit("play"));
|
|
122
|
+
this.audio.addEventListener("pause", () => this.emit("pause"));
|
|
123
|
+
this.audio.addEventListener("ended", () => this.emit("ended"));
|
|
124
|
+
this.audio.addEventListener("error", (e) => this.emit("error", e));
|
|
125
|
+
}
|
|
126
|
+
// ---------------------------------------------------------------------
|
|
127
|
+
// Lyric source resolution
|
|
128
|
+
// ---------------------------------------------------------------------
|
|
129
|
+
resolveLyrics(lyrics) {
|
|
130
|
+
if (!lyrics) return { metadata: {}, lines: [] };
|
|
131
|
+
if (typeof lyrics === "string") {
|
|
132
|
+
return parseLRC(lyrics);
|
|
133
|
+
}
|
|
134
|
+
if (Array.isArray(lyrics)) {
|
|
135
|
+
return parseJSONLyrics(lyrics);
|
|
136
|
+
}
|
|
137
|
+
if ("type" in lyrics) {
|
|
138
|
+
switch (lyrics.type) {
|
|
139
|
+
case "lrc":
|
|
140
|
+
return parseLRC(lyrics.data);
|
|
141
|
+
case "json":
|
|
142
|
+
return parseJSONLyrics(lyrics.data);
|
|
143
|
+
case "parsed":
|
|
144
|
+
return lyrics.data;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return lyrics;
|
|
148
|
+
}
|
|
149
|
+
/** Replace the loaded lyrics at any time (e.g. after fetching a file). */
|
|
150
|
+
setLyrics(lyrics) {
|
|
151
|
+
const parsed = this.resolveLyrics(lyrics);
|
|
152
|
+
this.metadata = parsed.metadata;
|
|
153
|
+
this.lines.length = 0;
|
|
154
|
+
this.lines.push(...parsed.lines);
|
|
155
|
+
this.currentIndex = -1;
|
|
156
|
+
this.handleTimeUpdate();
|
|
157
|
+
}
|
|
158
|
+
/** Adjust the global lyric offset (in milliseconds) at runtime. */
|
|
159
|
+
setOffset(offsetMs) {
|
|
160
|
+
this.offsetSeconds = offsetMs / 1e3;
|
|
161
|
+
this.currentIndex = -1;
|
|
162
|
+
this.handleTimeUpdate();
|
|
163
|
+
}
|
|
164
|
+
// ---------------------------------------------------------------------
|
|
165
|
+
// Playback controls (thin wrappers over the audio element)
|
|
166
|
+
// ---------------------------------------------------------------------
|
|
167
|
+
play() {
|
|
168
|
+
return this.audio.play();
|
|
169
|
+
}
|
|
170
|
+
pause() {
|
|
171
|
+
this.audio.pause();
|
|
172
|
+
}
|
|
173
|
+
toggle() {
|
|
174
|
+
return this.audio.paused ? this.audio.play() : this.audio.pause();
|
|
175
|
+
}
|
|
176
|
+
seek(timeSeconds) {
|
|
177
|
+
this.audio.currentTime = Math.max(0, timeSeconds);
|
|
178
|
+
this.handleTimeUpdate();
|
|
179
|
+
}
|
|
180
|
+
/** Seek directly to the start of a given lyric line. */
|
|
181
|
+
seekToLine(index) {
|
|
182
|
+
const line = this.lines[index];
|
|
183
|
+
if (line) this.seek(line.time + this.offsetSeconds);
|
|
184
|
+
}
|
|
185
|
+
get currentTime() {
|
|
186
|
+
return this.audio.currentTime;
|
|
187
|
+
}
|
|
188
|
+
get duration() {
|
|
189
|
+
return this.audio.duration;
|
|
190
|
+
}
|
|
191
|
+
get paused() {
|
|
192
|
+
return this.audio.paused;
|
|
193
|
+
}
|
|
194
|
+
set volume(value) {
|
|
195
|
+
this.audio.volume = value;
|
|
196
|
+
}
|
|
197
|
+
get volume() {
|
|
198
|
+
return this.audio.volume;
|
|
199
|
+
}
|
|
200
|
+
// ---------------------------------------------------------------------
|
|
201
|
+
// Lyric lookup
|
|
202
|
+
// ---------------------------------------------------------------------
|
|
203
|
+
/** The currently active lyric line, or null if before the first line. */
|
|
204
|
+
getCurrentLine() {
|
|
205
|
+
return this.currentIndex >= 0 ? this.lines[this.currentIndex] : null;
|
|
206
|
+
}
|
|
207
|
+
/** The index of the currently active lyric line (-1 if none yet). */
|
|
208
|
+
getCurrentIndex() {
|
|
209
|
+
return this.currentIndex;
|
|
210
|
+
}
|
|
211
|
+
/** The next lyric line after the current one, if any. */
|
|
212
|
+
getNextLine() {
|
|
213
|
+
const next = this.lines[this.currentIndex + 1];
|
|
214
|
+
return next ?? null;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* For lines with word-level timing, returns the index of the active
|
|
218
|
+
* token within the current line (-1 if no tokens or none active yet).
|
|
219
|
+
*/
|
|
220
|
+
getCurrentTokenIndex() {
|
|
221
|
+
const line = this.getCurrentLine();
|
|
222
|
+
if (!line?.tokens?.length) return -1;
|
|
223
|
+
const t = this.audio.currentTime - this.offsetSeconds;
|
|
224
|
+
let idx = -1;
|
|
225
|
+
for (let i = 0; i < line.tokens.length; i++) {
|
|
226
|
+
if (line.tokens[i].time <= t) idx = i;
|
|
227
|
+
else break;
|
|
228
|
+
}
|
|
229
|
+
return idx;
|
|
230
|
+
}
|
|
231
|
+
getCurrentToken() {
|
|
232
|
+
const line = this.getCurrentLine();
|
|
233
|
+
const idx = this.getCurrentTokenIndex();
|
|
234
|
+
return line?.tokens && idx >= 0 ? line.tokens[idx] : null;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Find the line index active at an arbitrary time, without affecting
|
|
238
|
+
* playback or the cached current index. Uses binary search.
|
|
239
|
+
*/
|
|
240
|
+
findLineIndexAtTime(timeSeconds) {
|
|
241
|
+
const t = timeSeconds - this.offsetSeconds;
|
|
242
|
+
const lines = this.lines;
|
|
243
|
+
if (lines.length === 0 || t < lines[0].time) return -1;
|
|
244
|
+
let lo = 0;
|
|
245
|
+
let hi = lines.length - 1;
|
|
246
|
+
while (lo < hi) {
|
|
247
|
+
const mid = lo + hi + 1 >> 1;
|
|
248
|
+
if (lines[mid].time <= t) lo = mid;
|
|
249
|
+
else hi = mid - 1;
|
|
250
|
+
}
|
|
251
|
+
return lo;
|
|
252
|
+
}
|
|
253
|
+
// ---------------------------------------------------------------------
|
|
254
|
+
// Event handling
|
|
255
|
+
// ---------------------------------------------------------------------
|
|
256
|
+
on(event, listener) {
|
|
257
|
+
let set = this.listeners[event];
|
|
258
|
+
if (!set) {
|
|
259
|
+
set = /* @__PURE__ */ new Set();
|
|
260
|
+
this.listeners[event] = set;
|
|
261
|
+
}
|
|
262
|
+
set.add(listener);
|
|
263
|
+
}
|
|
264
|
+
off(event, listener) {
|
|
265
|
+
this.listeners[event]?.delete(listener);
|
|
266
|
+
}
|
|
267
|
+
emit(event, ...args) {
|
|
268
|
+
this.listeners[event]?.forEach((listener) => listener(...args));
|
|
269
|
+
}
|
|
270
|
+
/** Remove all listeners and detach from the underlying audio element. */
|
|
271
|
+
destroy() {
|
|
272
|
+
this.audio.removeEventListener("timeupdate", this.handleTimeUpdate);
|
|
273
|
+
this.audio.pause();
|
|
274
|
+
this.listeners = {};
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
export {
|
|
278
|
+
LyricPlayer,
|
|
279
|
+
parseJSONLyrics,
|
|
280
|
+
parseLRC
|
|
281
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lrc-audio-player",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sync LRC/word-level lyrics to an HTML audio element with one constructor.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/hangerthem/lrc-audio-player.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/hangerthem/lrc-audio-player#readme",
|
|
10
|
+
"bugs": "https://github.com/hangerthem/lrc-audio-player/issues",
|
|
11
|
+
"author": "HangerThem",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"main": "dist/index.cjs",
|
|
17
|
+
"module": "dist/index.js",
|
|
18
|
+
"types": "dist/index.d.ts",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js",
|
|
24
|
+
"require": "./dist/index.cjs"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
32
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run build",
|
|
33
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"typecheck": "tsc --noEmit"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"lyrics",
|
|
39
|
+
"lrc",
|
|
40
|
+
"audio",
|
|
41
|
+
"player",
|
|
42
|
+
"karaoke",
|
|
43
|
+
"sync"
|
|
44
|
+
],
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"tsup": "^8.5.1",
|
|
48
|
+
"typescript": "^5.9.3",
|
|
49
|
+
"vitest": "^4.1.8"
|
|
50
|
+
}
|
|
51
|
+
}
|