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 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
@@ -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.audio = typeof options.audio === "string" ? new Audio(options.audio) : options.audio;
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.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() {
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { L as LyricLine, P as ParsedLyrics } from './player-C3ZCZJVs.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-C3ZCZJVs.cjs';
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-C3ZCZJVs.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-C3ZCZJVs.js';
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
@@ -2,7 +2,7 @@ import {
2
2
  LyricPlayer,
3
3
  parseJSONLyrics,
4
4
  parseLRC
5
- } from "./chunk-O4Y7SZ2V.js";
5
+ } from "./chunk-3A2M5KTA.js";
6
6
  export {
7
7
  LyricPlayer,
8
8
  parseJSONLyrics,
@@ -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.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() {
package/dist/react.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { h as LyricPlayerOptions, b as LyricPlayer, L as LyricLine } from './player-C3ZCZJVs.cjs';
2
- export { f as LyricSource } from './player-C3ZCZJVs.cjs';
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-C3ZCZJVs.js';
2
- export { f as LyricSource } from './player-C3ZCZJVs.js';
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
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import {
3
3
  LyricPlayer
4
- } from "./chunk-O4Y7SZ2V.js";
4
+ } from "./chunk-3A2M5KTA.js";
5
5
 
6
6
  // src/react.ts
7
7
  import { useEffect, useRef, useState } from "react";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lrc-audio-player",
3
- "version": "0.1.2",
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",