lrc-audio-player 0.1.2 → 0.1.3
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/README.md +69 -16
- package/dist/{chunk-O4Y7SZ2V.js → chunk-3A2M5KTA.js} +94 -3
- package/dist/index.cjs +104 -3
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/{player-C3ZCZJVs.d.cts → player-sLsiwLVO.d.cts} +18 -0
- package/dist/{player-C3ZCZJVs.d.ts → player-sLsiwLVO.d.ts} +18 -0
- package/dist/react.cjs +104 -3
- package/dist/react.d.cts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/package.json +13 -3
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# lrc-audio-player
|
|
2
2
|
|
|
3
3
|
Sync LRC (and word-level "enhanced" LRC) lyrics to an `HTMLAudioElement`
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
with accurate seeking. Automatically converts variable-bitrate (VBR)
|
|
5
|
+
audio to constant bitrate (CBR) for precise `currentTime` synchronization
|
|
6
|
+
— critical for lyrics that must stay locked to the audio.
|
|
7
7
|
|
|
8
8
|
## Install
|
|
9
9
|
|
|
@@ -11,6 +11,16 @@ re-scanning the whole lyric file on every `timeupdate`.
|
|
|
11
11
|
npm install lrc-audio-player
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
+
**Optional:** If you need CBR conversion (recommended), also install:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install @ffmpeg/ffmpeg @ffmpeg/util
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
> `ffmpeg.wasm` is loaded on-demand and only when needed. If you skip
|
|
21
|
+
> installing it, you must set `skipCBR: true` and provide CBR-encoded
|
|
22
|
+
> audio files yourself.
|
|
23
|
+
|
|
14
24
|
## Quick start
|
|
15
25
|
|
|
16
26
|
```ts
|
|
@@ -18,7 +28,8 @@ import { LyricPlayer } from 'lrc-audio-player';
|
|
|
18
28
|
|
|
19
29
|
const lrcText = await fetch('/song.lrc').then((r) => r.text());
|
|
20
30
|
|
|
21
|
-
|
|
31
|
+
// Async factory — handles CBR conversion in the background
|
|
32
|
+
const player = await LyricPlayer.create({
|
|
22
33
|
audio: '/song.mp3',
|
|
23
34
|
lyrics: lrcText,
|
|
24
35
|
});
|
|
@@ -34,9 +45,20 @@ You can also hand it an existing `<audio>` element instead of a URL:
|
|
|
34
45
|
|
|
35
46
|
```ts
|
|
36
47
|
const audioEl = document.querySelector('audio')!;
|
|
37
|
-
const player =
|
|
48
|
+
const player = await LyricPlayer.create({ audio: audioEl, lyrics: lrcText });
|
|
38
49
|
```
|
|
39
50
|
|
|
51
|
+
## Why CBR conversion?
|
|
52
|
+
|
|
53
|
+
Browsers estimate `audio.currentTime` from average bitrate when seek tables
|
|
54
|
+
are missing. With VBR files, this causes drift — lyrics appear early or
|
|
55
|
+
late after seeking. **CBR guarantees linear time-to-byte mapping**, so
|
|
56
|
+
seeking is sample-accurate.
|
|
57
|
+
|
|
58
|
+
By default, `lrc-audio-player` detects VBR MP3s and re-encodes them to
|
|
59
|
+
CBR using `ffmpeg.wasm` (all in-browser, no server needed). If your audio
|
|
60
|
+
is already CBR, set `skipCBR: true` to skip conversion.
|
|
61
|
+
|
|
40
62
|
## Lyric formats
|
|
41
63
|
|
|
42
64
|
- **Standard LRC**: `[01:23.45]Some lyric line`
|
|
@@ -47,7 +69,7 @@ const player = new LyricPlayer({ audio: audioEl, lyrics: lrcText });
|
|
|
47
69
|
use `lyrics: { type: 'json', data: [...] }`
|
|
48
70
|
|
|
49
71
|
```ts
|
|
50
|
-
|
|
72
|
+
await LyricPlayer.create({
|
|
51
73
|
audio: '/song.mp3',
|
|
52
74
|
lyrics: [
|
|
53
75
|
{ time: 0, text: 'First line' },
|
|
@@ -58,13 +80,30 @@ new LyricPlayer({
|
|
|
58
80
|
|
|
59
81
|
## API
|
|
60
82
|
|
|
61
|
-
### `
|
|
83
|
+
### `LyricPlayer.create(options)` (recommended)
|
|
84
|
+
|
|
85
|
+
Async factory that waits for CBR conversion (if needed) before returning
|
|
86
|
+
a ready-to-use player.
|
|
62
87
|
|
|
63
|
-
| Option
|
|
64
|
-
|
|
|
65
|
-
| `audio`
|
|
66
|
-
| `lyrics`
|
|
67
|
-
| `offsetMs`
|
|
88
|
+
| Option | Type | Description |
|
|
89
|
+
| ------------ | --------------------------------------------------- | ------------------------------------------------ |
|
|
90
|
+
| `audio` | `string \| HTMLAudioElement` | Audio source URL, or an existing element |
|
|
91
|
+
| `lyrics` | `string \| LyricLine[] \| ParsedLyrics \| LyricSource` | LRC text, JSON lines, or pre-parsed lyrics |
|
|
92
|
+
| `offsetMs` | `number` (optional) | Extra global offset on top of `[offset:]` |
|
|
93
|
+
| `skipCBR` | `boolean` (optional, default `false`) | Skip CBR conversion if your file is already CBR |
|
|
94
|
+
| `cbrBitrate` | `string` (optional, default `'128k'`) | Target bitrate for CBR conversion |
|
|
95
|
+
|
|
96
|
+
### `new LyricPlayer(options)` (advanced)
|
|
97
|
+
|
|
98
|
+
Synchronous constructor. The instance is returned immediately but
|
|
99
|
+
**is not ready until `await player.ready()` resolves**. Use this if you
|
|
100
|
+
need to attach listeners before initialization completes.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
const player = new LyricPlayer({ audio: '/song.mp3', lyrics: lrcText });
|
|
104
|
+
await player.ready();
|
|
105
|
+
player.play();
|
|
106
|
+
```
|
|
68
107
|
|
|
69
108
|
### Playback
|
|
70
109
|
|
|
@@ -90,13 +129,13 @@ new LyricPlayer({
|
|
|
90
129
|
|
|
91
130
|
`on(event, handler)` / `off(event, handler)`:
|
|
92
131
|
|
|
93
|
-
| Event | Payload
|
|
132
|
+
| Event | Payload |
|
|
94
133
|
| ------------ | ------------------------------------------ |
|
|
95
134
|
| `linechange` | `(line: LyricLine \| null, index: number)` |
|
|
96
135
|
| `timeupdate` | `(currentTime: number)` |
|
|
97
|
-
| `play` | —
|
|
98
|
-
| `pause` | —
|
|
99
|
-
| `ended` | —
|
|
136
|
+
| `play` | — |
|
|
137
|
+
| `pause` | — |
|
|
138
|
+
| `ended` | — |
|
|
100
139
|
| `error` | `(event: Event)` |
|
|
101
140
|
|
|
102
141
|
## Example: word-by-word highlighting
|
|
@@ -114,6 +153,16 @@ player.on('timeupdate', () => {
|
|
|
114
153
|
});
|
|
115
154
|
```
|
|
116
155
|
|
|
156
|
+
## Example: skip CBR for already-optimized files
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
const player = await LyricPlayer.create({
|
|
160
|
+
audio: '/song-cbr.mp3',
|
|
161
|
+
lyrics: lrcText,
|
|
162
|
+
skipCBR: true, // No conversion — instant load
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
117
166
|
## Development
|
|
118
167
|
|
|
119
168
|
```bash
|
|
@@ -122,3 +171,7 @@ npm run build # bundle to dist/ (cjs + esm + types)
|
|
|
122
171
|
npm test # run vitest
|
|
123
172
|
npm run typecheck # tsc --noEmit
|
|
124
173
|
```
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
MIT
|
|
@@ -90,7 +90,7 @@ function parseJSONLyrics(json) {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
// src/player.ts
|
|
93
|
-
var LyricPlayer = class {
|
|
93
|
+
var LyricPlayer = class _LyricPlayer {
|
|
94
94
|
constructor(options) {
|
|
95
95
|
this.currentIndex = -1;
|
|
96
96
|
this.listeners = {};
|
|
@@ -103,7 +103,32 @@ var LyricPlayer = class {
|
|
|
103
103
|
}
|
|
104
104
|
this.emit("timeupdate", time);
|
|
105
105
|
};
|
|
106
|
-
this.
|
|
106
|
+
this._readyPromise = this.initialize(options);
|
|
107
|
+
}
|
|
108
|
+
static async create(options) {
|
|
109
|
+
const player = new _LyricPlayer(options);
|
|
110
|
+
await player.ready();
|
|
111
|
+
return player;
|
|
112
|
+
}
|
|
113
|
+
async initialize(options) {
|
|
114
|
+
const { skipCBR = false, cbrBitrate = "128k" } = options;
|
|
115
|
+
let audioSrc;
|
|
116
|
+
let audioEl;
|
|
117
|
+
if (typeof options.audio === "string") {
|
|
118
|
+
audioSrc = options.audio;
|
|
119
|
+
audioEl = new Audio();
|
|
120
|
+
} else {
|
|
121
|
+
audioEl = options.audio;
|
|
122
|
+
audioSrc = options.audio.src || options.audio.currentSrc;
|
|
123
|
+
}
|
|
124
|
+
if (!skipCBR && audioSrc) {
|
|
125
|
+
const needsConversion = await this.detectNeedsCBR(audioSrc);
|
|
126
|
+
if (needsConversion) {
|
|
127
|
+
audioSrc = await this.convertToCBR(audioSrc, cbrBitrate);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
audioEl.src = audioSrc;
|
|
131
|
+
this.audio = audioEl;
|
|
107
132
|
const parsed = this.resolveLyrics(options.lyrics);
|
|
108
133
|
this.metadata = parsed.metadata;
|
|
109
134
|
this.lines = parsed.lines;
|
|
@@ -115,6 +140,71 @@ var LyricPlayer = class {
|
|
|
115
140
|
this.audio.addEventListener("ended", () => this.emit("ended"));
|
|
116
141
|
this.audio.addEventListener("error", (e) => this.emit("error", e));
|
|
117
142
|
}
|
|
143
|
+
/** Wait for initialization (CBR conversion, etc.) before playing. */
|
|
144
|
+
ready() {
|
|
145
|
+
return this._readyPromise;
|
|
146
|
+
}
|
|
147
|
+
/** Detect if file is VBR or lacks proper seek tables. */
|
|
148
|
+
async detectNeedsCBR(src) {
|
|
149
|
+
try {
|
|
150
|
+
const response = await fetch(src, { method: "HEAD" });
|
|
151
|
+
const contentType = response.headers.get("content-type") || "";
|
|
152
|
+
if (contentType.includes("audio/mpeg") || src.endsWith(".mp3")) {
|
|
153
|
+
const probe = await fetch(src, { headers: { Range: "bytes=0-1023" } });
|
|
154
|
+
const buffer = new Uint8Array(await probe.arrayBuffer());
|
|
155
|
+
return this.hasVBRHeader(buffer);
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
} catch {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
hasVBRHeader(buffer) {
|
|
163
|
+
const decoder = new TextDecoder("latin1");
|
|
164
|
+
const header = decoder.decode(buffer);
|
|
165
|
+
return /Xing|Info|VBRI/.test(header);
|
|
166
|
+
}
|
|
167
|
+
/** Convert audio to CBR using ffmpeg.wasm. */
|
|
168
|
+
async convertToCBR(src, bitrate) {
|
|
169
|
+
const [{ FFmpeg }, { fetchFile }] = await Promise.all([
|
|
170
|
+
import("@ffmpeg/ffmpeg"),
|
|
171
|
+
import("@ffmpeg/util")
|
|
172
|
+
]);
|
|
173
|
+
const ffmpeg = new FFmpeg();
|
|
174
|
+
await ffmpeg.load();
|
|
175
|
+
await ffmpeg.writeFile("input", await fetchFile(src));
|
|
176
|
+
await ffmpeg.exec([
|
|
177
|
+
"-i",
|
|
178
|
+
"input",
|
|
179
|
+
"-c:a",
|
|
180
|
+
"libmp3lame",
|
|
181
|
+
"-b:a",
|
|
182
|
+
bitrate,
|
|
183
|
+
"-minrate",
|
|
184
|
+
bitrate,
|
|
185
|
+
"-maxrate",
|
|
186
|
+
bitrate,
|
|
187
|
+
"-bufsize",
|
|
188
|
+
"256k",
|
|
189
|
+
"-preset",
|
|
190
|
+
"ultrafast",
|
|
191
|
+
"-fflags",
|
|
192
|
+
"+fastseek",
|
|
193
|
+
// Optimize for seeking
|
|
194
|
+
"-id3v2_version",
|
|
195
|
+
"3",
|
|
196
|
+
// Better metadata compatibility
|
|
197
|
+
"output.mp3"
|
|
198
|
+
]);
|
|
199
|
+
const data = await ffmpeg.readFile("output.mp3");
|
|
200
|
+
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
201
|
+
const blob = new Blob(
|
|
202
|
+
// @ts-ignore — Uint8Array is valid per spec, TS types are overly strict
|
|
203
|
+
[bytes],
|
|
204
|
+
{ type: "audio/mpeg" }
|
|
205
|
+
);
|
|
206
|
+
return URL.createObjectURL(blob);
|
|
207
|
+
}
|
|
118
208
|
// ---------------------------------------------------------------------
|
|
119
209
|
// Lyric source resolution
|
|
120
210
|
// ---------------------------------------------------------------------
|
|
@@ -156,7 +246,8 @@ var LyricPlayer = class {
|
|
|
156
246
|
// ---------------------------------------------------------------------
|
|
157
247
|
// Playback controls (thin wrappers over the audio element)
|
|
158
248
|
// ---------------------------------------------------------------------
|
|
159
|
-
play() {
|
|
249
|
+
async play() {
|
|
250
|
+
await this.ready();
|
|
160
251
|
return this.audio.play();
|
|
161
252
|
}
|
|
162
253
|
pause() {
|
package/dist/index.cjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
2
3
|
var __defProp = Object.defineProperty;
|
|
3
4
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
5
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
8
|
var __export = (target, all) => {
|
|
7
9
|
for (var name in all)
|
|
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
15
17
|
}
|
|
16
18
|
return to;
|
|
17
19
|
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
18
28
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
29
|
|
|
20
30
|
// src/index.ts
|
|
@@ -118,7 +128,7 @@ function parseJSONLyrics(json) {
|
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
// src/player.ts
|
|
121
|
-
var LyricPlayer = class {
|
|
131
|
+
var LyricPlayer = class _LyricPlayer {
|
|
122
132
|
constructor(options) {
|
|
123
133
|
this.currentIndex = -1;
|
|
124
134
|
this.listeners = {};
|
|
@@ -131,7 +141,32 @@ var LyricPlayer = class {
|
|
|
131
141
|
}
|
|
132
142
|
this.emit("timeupdate", time);
|
|
133
143
|
};
|
|
134
|
-
this.
|
|
144
|
+
this._readyPromise = this.initialize(options);
|
|
145
|
+
}
|
|
146
|
+
static async create(options) {
|
|
147
|
+
const player = new _LyricPlayer(options);
|
|
148
|
+
await player.ready();
|
|
149
|
+
return player;
|
|
150
|
+
}
|
|
151
|
+
async initialize(options) {
|
|
152
|
+
const { skipCBR = false, cbrBitrate = "128k" } = options;
|
|
153
|
+
let audioSrc;
|
|
154
|
+
let audioEl;
|
|
155
|
+
if (typeof options.audio === "string") {
|
|
156
|
+
audioSrc = options.audio;
|
|
157
|
+
audioEl = new Audio();
|
|
158
|
+
} else {
|
|
159
|
+
audioEl = options.audio;
|
|
160
|
+
audioSrc = options.audio.src || options.audio.currentSrc;
|
|
161
|
+
}
|
|
162
|
+
if (!skipCBR && audioSrc) {
|
|
163
|
+
const needsConversion = await this.detectNeedsCBR(audioSrc);
|
|
164
|
+
if (needsConversion) {
|
|
165
|
+
audioSrc = await this.convertToCBR(audioSrc, cbrBitrate);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
audioEl.src = audioSrc;
|
|
169
|
+
this.audio = audioEl;
|
|
135
170
|
const parsed = this.resolveLyrics(options.lyrics);
|
|
136
171
|
this.metadata = parsed.metadata;
|
|
137
172
|
this.lines = parsed.lines;
|
|
@@ -143,6 +178,71 @@ var LyricPlayer = class {
|
|
|
143
178
|
this.audio.addEventListener("ended", () => this.emit("ended"));
|
|
144
179
|
this.audio.addEventListener("error", (e) => this.emit("error", e));
|
|
145
180
|
}
|
|
181
|
+
/** Wait for initialization (CBR conversion, etc.) before playing. */
|
|
182
|
+
ready() {
|
|
183
|
+
return this._readyPromise;
|
|
184
|
+
}
|
|
185
|
+
/** Detect if file is VBR or lacks proper seek tables. */
|
|
186
|
+
async detectNeedsCBR(src) {
|
|
187
|
+
try {
|
|
188
|
+
const response = await fetch(src, { method: "HEAD" });
|
|
189
|
+
const contentType = response.headers.get("content-type") || "";
|
|
190
|
+
if (contentType.includes("audio/mpeg") || src.endsWith(".mp3")) {
|
|
191
|
+
const probe = await fetch(src, { headers: { Range: "bytes=0-1023" } });
|
|
192
|
+
const buffer = new Uint8Array(await probe.arrayBuffer());
|
|
193
|
+
return this.hasVBRHeader(buffer);
|
|
194
|
+
}
|
|
195
|
+
return true;
|
|
196
|
+
} catch {
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
hasVBRHeader(buffer) {
|
|
201
|
+
const decoder = new TextDecoder("latin1");
|
|
202
|
+
const header = decoder.decode(buffer);
|
|
203
|
+
return /Xing|Info|VBRI/.test(header);
|
|
204
|
+
}
|
|
205
|
+
/** Convert audio to CBR using ffmpeg.wasm. */
|
|
206
|
+
async convertToCBR(src, bitrate) {
|
|
207
|
+
const [{ FFmpeg }, { fetchFile }] = await Promise.all([
|
|
208
|
+
import("@ffmpeg/ffmpeg"),
|
|
209
|
+
import("@ffmpeg/util")
|
|
210
|
+
]);
|
|
211
|
+
const ffmpeg = new FFmpeg();
|
|
212
|
+
await ffmpeg.load();
|
|
213
|
+
await ffmpeg.writeFile("input", await fetchFile(src));
|
|
214
|
+
await ffmpeg.exec([
|
|
215
|
+
"-i",
|
|
216
|
+
"input",
|
|
217
|
+
"-c:a",
|
|
218
|
+
"libmp3lame",
|
|
219
|
+
"-b:a",
|
|
220
|
+
bitrate,
|
|
221
|
+
"-minrate",
|
|
222
|
+
bitrate,
|
|
223
|
+
"-maxrate",
|
|
224
|
+
bitrate,
|
|
225
|
+
"-bufsize",
|
|
226
|
+
"256k",
|
|
227
|
+
"-preset",
|
|
228
|
+
"ultrafast",
|
|
229
|
+
"-fflags",
|
|
230
|
+
"+fastseek",
|
|
231
|
+
// Optimize for seeking
|
|
232
|
+
"-id3v2_version",
|
|
233
|
+
"3",
|
|
234
|
+
// Better metadata compatibility
|
|
235
|
+
"output.mp3"
|
|
236
|
+
]);
|
|
237
|
+
const data = await ffmpeg.readFile("output.mp3");
|
|
238
|
+
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
239
|
+
const blob = new Blob(
|
|
240
|
+
// @ts-ignore — Uint8Array is valid per spec, TS types are overly strict
|
|
241
|
+
[bytes],
|
|
242
|
+
{ type: "audio/mpeg" }
|
|
243
|
+
);
|
|
244
|
+
return URL.createObjectURL(blob);
|
|
245
|
+
}
|
|
146
246
|
// ---------------------------------------------------------------------
|
|
147
247
|
// Lyric source resolution
|
|
148
248
|
// ---------------------------------------------------------------------
|
|
@@ -184,7 +284,8 @@ var LyricPlayer = class {
|
|
|
184
284
|
// ---------------------------------------------------------------------
|
|
185
285
|
// Playback controls (thin wrappers over the audio element)
|
|
186
286
|
// ---------------------------------------------------------------------
|
|
187
|
-
play() {
|
|
287
|
+
async play() {
|
|
288
|
+
await this.ready();
|
|
188
289
|
return this.audio.play();
|
|
189
290
|
}
|
|
190
291
|
pause() {
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { L as LyricLine, P as ParsedLyrics } from './player-
|
|
2
|
-
export { a as LyricMetadata, b as LyricPlayer, c as LyricPlayerEventName, d as LyricPlayerEvents, e as LyricPlayerOptions, f as LyricSource, g as LyricToken } from './player-
|
|
1
|
+
import { L as LyricLine, P as ParsedLyrics } from './player-sLsiwLVO.cjs';
|
|
2
|
+
export { a as LyricMetadata, b as LyricPlayer, c as LyricPlayerEventName, d as LyricPlayerEvents, e as LyricPlayerOptions, f as LyricSource, g as LyricToken } from './player-sLsiwLVO.cjs';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Parse an LRC-format lyric file into structured, time-sorted lines plus
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { L as LyricLine, P as ParsedLyrics } from './player-
|
|
2
|
-
export { a as LyricMetadata, b as LyricPlayer, c as LyricPlayerEventName, d as LyricPlayerEvents, e as LyricPlayerOptions, f as LyricSource, g as LyricToken } from './player-
|
|
1
|
+
import { L as LyricLine, P as ParsedLyrics } from './player-sLsiwLVO.js';
|
|
2
|
+
export { a as LyricMetadata, b as LyricPlayer, c as LyricPlayerEventName, d as LyricPlayerEvents, e as LyricPlayerOptions, f as LyricSource, g as LyricToken } from './player-sLsiwLVO.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Parse an LRC-format lyric file into structured, time-sorted lines plus
|
package/dist/index.js
CHANGED
|
@@ -88,6 +88,14 @@ interface LyricPlayerOptions {
|
|
|
88
88
|
* in the LRC file. Positive values shift lyrics later.
|
|
89
89
|
*/
|
|
90
90
|
offsetMs?: number;
|
|
91
|
+
/**
|
|
92
|
+
* Disable CBR conversion if you know your audio file is already
|
|
93
|
+
* constant bitrate (e.g., properly encoded MP3/AAC with seek tables).
|
|
94
|
+
* @default false
|
|
95
|
+
*/
|
|
96
|
+
skipCBR?: boolean;
|
|
97
|
+
/** Target bitrate for CBR conversion. @default '128k' */
|
|
98
|
+
cbrBitrate?: string;
|
|
91
99
|
}
|
|
92
100
|
type Listener<E extends LyricPlayerEventName> = LyricPlayerEvents[E];
|
|
93
101
|
/**
|
|
@@ -106,7 +114,17 @@ declare class LyricPlayer {
|
|
|
106
114
|
private offsetSeconds;
|
|
107
115
|
private currentIndex;
|
|
108
116
|
private listeners;
|
|
117
|
+
static create(options: LyricPlayerOptions): Promise<LyricPlayer>;
|
|
118
|
+
private _readyPromise;
|
|
109
119
|
constructor(options: LyricPlayerOptions);
|
|
120
|
+
private initialize;
|
|
121
|
+
/** Wait for initialization (CBR conversion, etc.) before playing. */
|
|
122
|
+
ready(): Promise<void>;
|
|
123
|
+
/** Detect if file is VBR or lacks proper seek tables. */
|
|
124
|
+
private detectNeedsCBR;
|
|
125
|
+
private hasVBRHeader;
|
|
126
|
+
/** Convert audio to CBR using ffmpeg.wasm. */
|
|
127
|
+
private convertToCBR;
|
|
110
128
|
private resolveLyrics;
|
|
111
129
|
/** Replace the loaded lyrics at any time (e.g. after fetching a file). */
|
|
112
130
|
setLyrics(lyrics: LyricPlayerOptions["lyrics"]): void;
|
|
@@ -88,6 +88,14 @@ interface LyricPlayerOptions {
|
|
|
88
88
|
* in the LRC file. Positive values shift lyrics later.
|
|
89
89
|
*/
|
|
90
90
|
offsetMs?: number;
|
|
91
|
+
/**
|
|
92
|
+
* Disable CBR conversion if you know your audio file is already
|
|
93
|
+
* constant bitrate (e.g., properly encoded MP3/AAC with seek tables).
|
|
94
|
+
* @default false
|
|
95
|
+
*/
|
|
96
|
+
skipCBR?: boolean;
|
|
97
|
+
/** Target bitrate for CBR conversion. @default '128k' */
|
|
98
|
+
cbrBitrate?: string;
|
|
91
99
|
}
|
|
92
100
|
type Listener<E extends LyricPlayerEventName> = LyricPlayerEvents[E];
|
|
93
101
|
/**
|
|
@@ -106,7 +114,17 @@ declare class LyricPlayer {
|
|
|
106
114
|
private offsetSeconds;
|
|
107
115
|
private currentIndex;
|
|
108
116
|
private listeners;
|
|
117
|
+
static create(options: LyricPlayerOptions): Promise<LyricPlayer>;
|
|
118
|
+
private _readyPromise;
|
|
109
119
|
constructor(options: LyricPlayerOptions);
|
|
120
|
+
private initialize;
|
|
121
|
+
/** Wait for initialization (CBR conversion, etc.) before playing. */
|
|
122
|
+
ready(): Promise<void>;
|
|
123
|
+
/** Detect if file is VBR or lacks proper seek tables. */
|
|
124
|
+
private detectNeedsCBR;
|
|
125
|
+
private hasVBRHeader;
|
|
126
|
+
/** Convert audio to CBR using ffmpeg.wasm. */
|
|
127
|
+
private convertToCBR;
|
|
110
128
|
private resolveLyrics;
|
|
111
129
|
/** Replace the loaded lyrics at any time (e.g. after fetching a file). */
|
|
112
130
|
setLyrics(lyrics: LyricPlayerOptions["lyrics"]): void;
|
package/dist/react.cjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
"use client";
|
|
3
|
+
var __create = Object.create;
|
|
3
4
|
var __defProp = Object.defineProperty;
|
|
4
5
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
6
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
6
8
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
9
|
var __export = (target, all) => {
|
|
8
10
|
for (var name in all)
|
|
@@ -16,6 +18,14 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
16
18
|
}
|
|
17
19
|
return to;
|
|
18
20
|
};
|
|
21
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
22
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
23
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
24
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
25
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
26
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
27
|
+
mod
|
|
28
|
+
));
|
|
19
29
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
30
|
|
|
21
31
|
// src/react.ts
|
|
@@ -118,7 +128,7 @@ function parseJSONLyrics(json) {
|
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
// src/player.ts
|
|
121
|
-
var LyricPlayer = class {
|
|
131
|
+
var LyricPlayer = class _LyricPlayer {
|
|
122
132
|
constructor(options) {
|
|
123
133
|
this.currentIndex = -1;
|
|
124
134
|
this.listeners = {};
|
|
@@ -131,7 +141,32 @@ var LyricPlayer = class {
|
|
|
131
141
|
}
|
|
132
142
|
this.emit("timeupdate", time);
|
|
133
143
|
};
|
|
134
|
-
this.
|
|
144
|
+
this._readyPromise = this.initialize(options);
|
|
145
|
+
}
|
|
146
|
+
static async create(options) {
|
|
147
|
+
const player = new _LyricPlayer(options);
|
|
148
|
+
await player.ready();
|
|
149
|
+
return player;
|
|
150
|
+
}
|
|
151
|
+
async initialize(options) {
|
|
152
|
+
const { skipCBR = false, cbrBitrate = "128k" } = options;
|
|
153
|
+
let audioSrc;
|
|
154
|
+
let audioEl;
|
|
155
|
+
if (typeof options.audio === "string") {
|
|
156
|
+
audioSrc = options.audio;
|
|
157
|
+
audioEl = new Audio();
|
|
158
|
+
} else {
|
|
159
|
+
audioEl = options.audio;
|
|
160
|
+
audioSrc = options.audio.src || options.audio.currentSrc;
|
|
161
|
+
}
|
|
162
|
+
if (!skipCBR && audioSrc) {
|
|
163
|
+
const needsConversion = await this.detectNeedsCBR(audioSrc);
|
|
164
|
+
if (needsConversion) {
|
|
165
|
+
audioSrc = await this.convertToCBR(audioSrc, cbrBitrate);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
audioEl.src = audioSrc;
|
|
169
|
+
this.audio = audioEl;
|
|
135
170
|
const parsed = this.resolveLyrics(options.lyrics);
|
|
136
171
|
this.metadata = parsed.metadata;
|
|
137
172
|
this.lines = parsed.lines;
|
|
@@ -143,6 +178,71 @@ var LyricPlayer = class {
|
|
|
143
178
|
this.audio.addEventListener("ended", () => this.emit("ended"));
|
|
144
179
|
this.audio.addEventListener("error", (e) => this.emit("error", e));
|
|
145
180
|
}
|
|
181
|
+
/** Wait for initialization (CBR conversion, etc.) before playing. */
|
|
182
|
+
ready() {
|
|
183
|
+
return this._readyPromise;
|
|
184
|
+
}
|
|
185
|
+
/** Detect if file is VBR or lacks proper seek tables. */
|
|
186
|
+
async detectNeedsCBR(src) {
|
|
187
|
+
try {
|
|
188
|
+
const response = await fetch(src, { method: "HEAD" });
|
|
189
|
+
const contentType = response.headers.get("content-type") || "";
|
|
190
|
+
if (contentType.includes("audio/mpeg") || src.endsWith(".mp3")) {
|
|
191
|
+
const probe = await fetch(src, { headers: { Range: "bytes=0-1023" } });
|
|
192
|
+
const buffer = new Uint8Array(await probe.arrayBuffer());
|
|
193
|
+
return this.hasVBRHeader(buffer);
|
|
194
|
+
}
|
|
195
|
+
return true;
|
|
196
|
+
} catch {
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
hasVBRHeader(buffer) {
|
|
201
|
+
const decoder = new TextDecoder("latin1");
|
|
202
|
+
const header = decoder.decode(buffer);
|
|
203
|
+
return /Xing|Info|VBRI/.test(header);
|
|
204
|
+
}
|
|
205
|
+
/** Convert audio to CBR using ffmpeg.wasm. */
|
|
206
|
+
async convertToCBR(src, bitrate) {
|
|
207
|
+
const [{ FFmpeg }, { fetchFile }] = await Promise.all([
|
|
208
|
+
import("@ffmpeg/ffmpeg"),
|
|
209
|
+
import("@ffmpeg/util")
|
|
210
|
+
]);
|
|
211
|
+
const ffmpeg = new FFmpeg();
|
|
212
|
+
await ffmpeg.load();
|
|
213
|
+
await ffmpeg.writeFile("input", await fetchFile(src));
|
|
214
|
+
await ffmpeg.exec([
|
|
215
|
+
"-i",
|
|
216
|
+
"input",
|
|
217
|
+
"-c:a",
|
|
218
|
+
"libmp3lame",
|
|
219
|
+
"-b:a",
|
|
220
|
+
bitrate,
|
|
221
|
+
"-minrate",
|
|
222
|
+
bitrate,
|
|
223
|
+
"-maxrate",
|
|
224
|
+
bitrate,
|
|
225
|
+
"-bufsize",
|
|
226
|
+
"256k",
|
|
227
|
+
"-preset",
|
|
228
|
+
"ultrafast",
|
|
229
|
+
"-fflags",
|
|
230
|
+
"+fastseek",
|
|
231
|
+
// Optimize for seeking
|
|
232
|
+
"-id3v2_version",
|
|
233
|
+
"3",
|
|
234
|
+
// Better metadata compatibility
|
|
235
|
+
"output.mp3"
|
|
236
|
+
]);
|
|
237
|
+
const data = await ffmpeg.readFile("output.mp3");
|
|
238
|
+
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
239
|
+
const blob = new Blob(
|
|
240
|
+
// @ts-ignore — Uint8Array is valid per spec, TS types are overly strict
|
|
241
|
+
[bytes],
|
|
242
|
+
{ type: "audio/mpeg" }
|
|
243
|
+
);
|
|
244
|
+
return URL.createObjectURL(blob);
|
|
245
|
+
}
|
|
146
246
|
// ---------------------------------------------------------------------
|
|
147
247
|
// Lyric source resolution
|
|
148
248
|
// ---------------------------------------------------------------------
|
|
@@ -184,7 +284,8 @@ var LyricPlayer = class {
|
|
|
184
284
|
// ---------------------------------------------------------------------
|
|
185
285
|
// Playback controls (thin wrappers over the audio element)
|
|
186
286
|
// ---------------------------------------------------------------------
|
|
187
|
-
play() {
|
|
287
|
+
async play() {
|
|
288
|
+
await this.ready();
|
|
188
289
|
return this.audio.play();
|
|
189
290
|
}
|
|
190
291
|
pause() {
|
package/dist/react.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { h as LyricPlayerOptions, b as LyricPlayer, L as LyricLine } from './player-
|
|
2
|
-
export { f as LyricSource } from './player-
|
|
1
|
+
import { h as LyricPlayerOptions, b as LyricPlayer, L as LyricLine } from './player-sLsiwLVO.cjs';
|
|
2
|
+
export { f as LyricSource } from './player-sLsiwLVO.cjs';
|
|
3
3
|
|
|
4
4
|
interface UseLyricPlayerOptions extends Omit<LyricPlayerOptions, "audio"> {
|
|
5
5
|
/** Audio source URL. Set on the bound <audio> element. */
|
package/dist/react.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { h as LyricPlayerOptions, b as LyricPlayer, L as LyricLine } from './player-
|
|
2
|
-
export { f as LyricSource } from './player-
|
|
1
|
+
import { h as LyricPlayerOptions, b as LyricPlayer, L as LyricLine } from './player-sLsiwLVO.js';
|
|
2
|
+
export { f as LyricSource } from './player-sLsiwLVO.js';
|
|
3
3
|
|
|
4
4
|
interface UseLyricPlayerOptions extends Omit<LyricPlayerOptions, "audio"> {
|
|
5
5
|
/** Audio source URL. Set on the bound <audio> element. */
|
package/dist/react.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lrc-audio-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Sync LRC/word-level lyrics to an HTML audio element with one constructor. Includes an optional React hook at lrc-audio-player/react.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"dist"
|
|
34
34
|
],
|
|
35
35
|
"scripts": {
|
|
36
|
-
"build": "tsup src/index.ts src/react.ts --format cjs,esm --dts --clean",
|
|
37
|
-
"dev": "tsup src/index.ts src/react.ts --format cjs,esm --dts --watch",
|
|
36
|
+
"build": "tsup src/index.ts src/react.ts --format cjs,esm --dts --clean --external @ffmpeg/ffmpeg,@ffmpeg/util",
|
|
37
|
+
"dev": "tsup src/index.ts src/react.ts --format cjs,esm --dts --watch --external @ffmpeg/ffmpeg,@ffmpeg/util",
|
|
38
38
|
"test": "vitest run",
|
|
39
39
|
"typecheck": "tsc --noEmit",
|
|
40
40
|
"prepublishOnly": "npm run typecheck && npm run test && npm run build"
|
|
@@ -48,15 +48,25 @@
|
|
|
48
48
|
"sync"
|
|
49
49
|
],
|
|
50
50
|
"peerDependencies": {
|
|
51
|
+
"@ffmpeg/ffmpeg": ">=0.12.0",
|
|
52
|
+
"@ffmpeg/util": ">=0.12.0",
|
|
51
53
|
"react": ">=19"
|
|
52
54
|
},
|
|
53
55
|
"peerDependenciesMeta": {
|
|
54
56
|
"react": {
|
|
55
57
|
"optional": true
|
|
58
|
+
},
|
|
59
|
+
"@ffmpeg/ffmpeg": {
|
|
60
|
+
"optional": true
|
|
61
|
+
},
|
|
62
|
+
"@ffmpeg/util": {
|
|
63
|
+
"optional": true
|
|
56
64
|
}
|
|
57
65
|
},
|
|
58
66
|
"license": "MIT",
|
|
59
67
|
"devDependencies": {
|
|
68
|
+
"@ffmpeg/ffmpeg": "^0.12.15",
|
|
69
|
+
"@ffmpeg/util": "^0.12.2",
|
|
60
70
|
"@types/react": "^19.2.17",
|
|
61
71
|
"react": "^19.2.7",
|
|
62
72
|
"tsup": "^8.5.1",
|