lrc-audio-player 0.1.1 → 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 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
- 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`.
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
- const player = new LyricPlayer({
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 = new LyricPlayer({ audio: audioEl, lyrics: lrcText });
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
- new LyricPlayer({
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
- ### `new LyricPlayer(options)`
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 | 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:]` |
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
@@ -0,0 +1,365 @@
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(
71
+ timeToSeconds(leadingMatch[1], leadingMatch[2], leadingMatch[3])
72
+ );
73
+ rest = rest.slice(leadingMatch[0].length);
74
+ }
75
+ if (times.length === 0) {
76
+ continue;
77
+ }
78
+ for (const time of times) {
79
+ const { text, tokens } = parseTokens(rest, time);
80
+ lines.push({ time, text, tokens });
81
+ }
82
+ }
83
+ lines.sort((a, b) => a.time - b.time);
84
+ return { metadata, lines };
85
+ }
86
+ function parseJSONLyrics(json) {
87
+ const lines = typeof json === "string" ? JSON.parse(json) : json;
88
+ const sorted = [...lines].sort((a, b) => a.time - b.time);
89
+ return { metadata: {}, lines: sorted };
90
+ }
91
+
92
+ // src/player.ts
93
+ var LyricPlayer = class _LyricPlayer {
94
+ constructor(options) {
95
+ this.currentIndex = -1;
96
+ this.listeners = {};
97
+ this.handleTimeUpdate = () => {
98
+ const time = this.audio.currentTime;
99
+ const newIndex = this.findLineIndexAtTime(time);
100
+ if (newIndex !== this.currentIndex) {
101
+ this.currentIndex = newIndex;
102
+ this.emit("linechange", this.getCurrentLine(), newIndex);
103
+ }
104
+ this.emit("timeupdate", time);
105
+ };
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;
132
+ const parsed = this.resolveLyrics(options.lyrics);
133
+ this.metadata = parsed.metadata;
134
+ this.lines = parsed.lines;
135
+ const tagOffsetMs = parsed.metadata.offset ?? 0;
136
+ this.offsetSeconds = (tagOffsetMs + (options.offsetMs ?? 0)) / 1e3;
137
+ this.audio.addEventListener("timeupdate", this.handleTimeUpdate);
138
+ this.audio.addEventListener("play", () => this.emit("play"));
139
+ this.audio.addEventListener("pause", () => this.emit("pause"));
140
+ this.audio.addEventListener("ended", () => this.emit("ended"));
141
+ this.audio.addEventListener("error", (e) => this.emit("error", e));
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
+ }
208
+ // ---------------------------------------------------------------------
209
+ // Lyric source resolution
210
+ // ---------------------------------------------------------------------
211
+ resolveLyrics(lyrics) {
212
+ if (!lyrics) return { metadata: {}, lines: [] };
213
+ if (typeof lyrics === "string") {
214
+ return parseLRC(lyrics);
215
+ }
216
+ if (Array.isArray(lyrics)) {
217
+ return parseJSONLyrics(lyrics);
218
+ }
219
+ if ("type" in lyrics) {
220
+ switch (lyrics.type) {
221
+ case "lrc":
222
+ return parseLRC(lyrics.data);
223
+ case "json":
224
+ return parseJSONLyrics(lyrics.data);
225
+ case "parsed":
226
+ return lyrics.data;
227
+ }
228
+ }
229
+ return lyrics;
230
+ }
231
+ /** Replace the loaded lyrics at any time (e.g. after fetching a file). */
232
+ setLyrics(lyrics) {
233
+ const parsed = this.resolveLyrics(lyrics);
234
+ this.metadata = parsed.metadata;
235
+ this.lines.length = 0;
236
+ this.lines.push(...parsed.lines);
237
+ this.currentIndex = -1;
238
+ this.handleTimeUpdate();
239
+ }
240
+ /** Adjust the global lyric offset (in milliseconds) at runtime. */
241
+ setOffset(offsetMs) {
242
+ this.offsetSeconds = offsetMs / 1e3;
243
+ this.currentIndex = -1;
244
+ this.handleTimeUpdate();
245
+ }
246
+ // ---------------------------------------------------------------------
247
+ // Playback controls (thin wrappers over the audio element)
248
+ // ---------------------------------------------------------------------
249
+ async play() {
250
+ await this.ready();
251
+ return this.audio.play();
252
+ }
253
+ pause() {
254
+ this.audio.pause();
255
+ }
256
+ toggle() {
257
+ return this.audio.paused ? this.audio.play() : this.audio.pause();
258
+ }
259
+ seek(timeSeconds) {
260
+ this.audio.currentTime = Math.max(0, timeSeconds);
261
+ this.handleTimeUpdate();
262
+ }
263
+ /** Seek directly to the start of a given lyric line. */
264
+ seekToLine(index) {
265
+ const line = this.lines[index];
266
+ if (line) this.seek(line.time + this.offsetSeconds);
267
+ }
268
+ get currentTime() {
269
+ return this.audio.currentTime;
270
+ }
271
+ get duration() {
272
+ return this.audio.duration;
273
+ }
274
+ get paused() {
275
+ return this.audio.paused;
276
+ }
277
+ set volume(value) {
278
+ this.audio.volume = value;
279
+ }
280
+ get volume() {
281
+ return this.audio.volume;
282
+ }
283
+ // ---------------------------------------------------------------------
284
+ // Lyric lookup
285
+ // ---------------------------------------------------------------------
286
+ /** The currently active lyric line, or null if before the first line. */
287
+ getCurrentLine() {
288
+ return this.currentIndex >= 0 ? this.lines[this.currentIndex] : null;
289
+ }
290
+ /** The index of the currently active lyric line (-1 if none yet). */
291
+ getCurrentIndex() {
292
+ return this.currentIndex;
293
+ }
294
+ /** The next lyric line after the current one, if any. */
295
+ getNextLine() {
296
+ const next = this.lines[this.currentIndex + 1];
297
+ return next ?? null;
298
+ }
299
+ /**
300
+ * For lines with word-level timing, returns the index of the active
301
+ * token within the current line (-1 if no tokens or none active yet).
302
+ */
303
+ getCurrentTokenIndex() {
304
+ const line = this.getCurrentLine();
305
+ if (!line?.tokens?.length) return -1;
306
+ const t = this.audio.currentTime - this.offsetSeconds;
307
+ let idx = -1;
308
+ for (let i = 0; i < line.tokens.length; i++) {
309
+ if (line.tokens[i].time <= t) idx = i;
310
+ else break;
311
+ }
312
+ return idx;
313
+ }
314
+ getCurrentToken() {
315
+ const line = this.getCurrentLine();
316
+ const idx = this.getCurrentTokenIndex();
317
+ return line?.tokens && idx >= 0 ? line.tokens[idx] : null;
318
+ }
319
+ /**
320
+ * Find the line index active at an arbitrary time, without affecting
321
+ * playback or the cached current index. Uses binary search.
322
+ */
323
+ findLineIndexAtTime(timeSeconds) {
324
+ const t = timeSeconds - this.offsetSeconds;
325
+ const lines = this.lines;
326
+ if (lines.length === 0 || t < lines[0].time) return -1;
327
+ let lo = 0;
328
+ let hi = lines.length - 1;
329
+ while (lo < hi) {
330
+ const mid = lo + hi + 1 >> 1;
331
+ if (lines[mid].time <= t) lo = mid;
332
+ else hi = mid - 1;
333
+ }
334
+ return lo;
335
+ }
336
+ // ---------------------------------------------------------------------
337
+ // Event handling
338
+ // ---------------------------------------------------------------------
339
+ on(event, listener) {
340
+ let set = this.listeners[event];
341
+ if (!set) {
342
+ set = /* @__PURE__ */ new Set();
343
+ this.listeners[event] = set;
344
+ }
345
+ set.add(listener);
346
+ }
347
+ off(event, listener) {
348
+ this.listeners[event]?.delete(listener);
349
+ }
350
+ emit(event, ...args) {
351
+ this.listeners[event]?.forEach((listener) => listener(...args));
352
+ }
353
+ /** Remove all listeners and detach from the underlying audio element. */
354
+ destroy() {
355
+ this.audio.removeEventListener("timeupdate", this.handleTimeUpdate);
356
+ this.audio.pause();
357
+ this.listeners = {};
358
+ }
359
+ };
360
+
361
+ export {
362
+ parseLRC,
363
+ parseJSONLyrics,
364
+ LyricPlayer
365
+ };
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.audio = typeof options.audio === "string" ? new Audio(options.audio) : options.audio;
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() {