chart-preview 1.0.0 → 1.0.1

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.
@@ -5,7 +5,7 @@
5
5
  "fixed": [],
6
6
  "linked": [],
7
7
  "access": "public",
8
- "baseBranch": "main",
8
+ "baseBranch": "master",
9
9
  "updateInternalDependencies": "patch",
10
10
  "ignore": []
11
11
  }
@@ -3,7 +3,7 @@ name: Release
3
3
  on:
4
4
  push:
5
5
  branches:
6
- - main
6
+ - master
7
7
 
8
8
  concurrency: ${{ github.workflow }}-${{ github.ref }}
9
9
 
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # chart-preview
2
+
3
+ ## 1.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 0bbccfe: Enhance audio management by adding fallback audio length and improving start delay handling. Update AudioManager to use fallback length if no audio files are provided, and ensure end event timeout is managed correctly during playback and seeking. Refactor start delay calculation for clarity in main.ts.
package/RELEASING.md ADDED
@@ -0,0 +1,43 @@
1
+ # Releasing
2
+
3
+ This project uses [Changesets](https://github.com/changesets/changesets) for versioning and publishing.
4
+
5
+ ## How to Release
6
+
7
+ ### 1. Create a Changeset
8
+
9
+ After making changes you want to release:
10
+
11
+ ```bash
12
+ npx changeset
13
+ ```
14
+
15
+ - Select the version bump: `patch` (bug fix), `minor` (new feature), or `major` (breaking change)
16
+ - Write a short summary of the changes
17
+
18
+ This creates a file in `.changeset/` — commit it with your changes.
19
+
20
+ ### 2. Push to `main`
21
+
22
+ ```bash
23
+ git add .
24
+ git commit -m "Your commit message"
25
+ git push
26
+ ```
27
+
28
+ ### 3. Merge the Version PR
29
+
30
+ GitHub Actions will create a **"Version Packages"** PR that:
31
+
32
+ - Bumps the version in `package.json`
33
+ - Updates `CHANGELOG.md`
34
+
35
+ Merge this PR to publish to npm automatically.
36
+
37
+ ## Version Bump Guide
38
+
39
+ | Type | When to Use | Example |
40
+ | ------- | ----------------------------------- | ------------- |
41
+ | `patch` | Bug fixes, minor tweaks | 1.0.0 → 1.0.1 |
42
+ | `minor` | New features (backwards compatible) | 1.0.0 → 1.1.0 |
43
+ | `major` | Breaking changes | 1.0.0 → 2.0.0 |
package/dev/main.ts CHANGED
@@ -471,9 +471,16 @@ loadBtn.addEventListener("click", async () => {
471
471
  const audioLengthMs = currentScannedChart.song_length || lastNote + 5000;
472
472
 
473
473
  // Get start delay from chart metadata
474
- const startDelayMs =
475
- (currentScannedChart.delay || 0) +
476
- (currentScannedChart.chart_offset || 0) * 1000;
474
+ // delay takes priority over chart_offset if both are present
475
+ // Both use the same sign convention:
476
+ // - Positive value = audio starts earlier (audio is ahead of chart time)
477
+ // - Negative value = audio starts later (audio is delayed)
478
+ // We negate to convert to startDelayMs (internal representation):
479
+ // - startDelayMs < 0 = audio is ahead (at chart time 0, audio is already playing)
480
+ // - startDelayMs > 0 = audio is delayed (audio starts after chart time 0)
481
+ const startDelayMs = currentScannedChart.delay
482
+ ? -currentScannedChart.delay
483
+ : -(currentScannedChart.chart_offset || 0);
477
484
 
478
485
  // Calculate initial seek position from preview_start_time if available
479
486
  const initialSeekPercent = currentScannedChart.preview_start_time
package/dist/index.js CHANGED
@@ -148,7 +148,11 @@ var _ChartPreview = class {
148
148
  chartPreview.progressIntervalMs = config.progressIntervalMs ?? DEFAULT_PROGRESS_INTERVAL_MS;
149
149
  chartPreview.camera = new ChartCamera(config.container);
150
150
  chartPreview.renderer = new ChartRenderer(config.container);
151
- chartPreview.audioManager = await (isAudioContextSupported() ? AudioManager.create(config.audioFiles, config.startDelayMs) : SilentAudioManager.create(config.startDelayMs, config.audioLengthMs));
151
+ chartPreview.audioManager = await (isAudioContextSupported() ? AudioManager.create(
152
+ config.audioFiles,
153
+ config.startDelayMs,
154
+ config.audioLengthMs
155
+ ) : SilentAudioManager.create(config.startDelayMs, config.audioLengthMs));
152
156
  chartPreview.audioManager.on(
153
157
  "end",
154
158
  () => chartPreview.eventEmitter.emit("end")
@@ -326,9 +330,10 @@ var ChartRenderer = class extends THREE.WebGLRenderer {
326
330
  }
327
331
  };
328
332
  var AudioManager = class {
329
- constructor(audioFiles, startDelayMs) {
333
+ constructor(audioFiles, startDelayMs, fallbackAudioLengthMs) {
330
334
  this.audioFiles = audioFiles;
331
335
  this.startDelayMs = startDelayMs;
336
+ this.fallbackAudioLengthMs = fallbackAudioLengthMs;
332
337
  this.eventEmitter = new import_eventemitter3.EventEmitter();
333
338
  this.gainNode = null;
334
339
  this.audioSources = [];
@@ -337,6 +342,7 @@ var AudioManager = class {
337
342
  this.lastAudioCtxCurrentTime = 0;
338
343
  // Necessary because audioCtx.currentTime doesn't reset to 0 on seek
339
344
  this.audioLengthMs = 0;
345
+ this.endEventTimeout = null;
340
346
  const ctx = getSharedAudioContext();
341
347
  if (!ctx) {
342
348
  throw new Error("AudioContext is not supported in this environment");
@@ -347,9 +353,14 @@ var AudioManager = class {
347
353
  /**
348
354
  * @param audioFiles The `Uint8Array[]` of the audio files to be played.
349
355
  * @param startDelayMs The amount of time to delay the start of the audio. (can be negative)
356
+ * @param fallbackAudioLengthMs The fallback audio length to use if no audio files are provided.
350
357
  */
351
- static async create(audioFiles, startDelayMs) {
352
- const audioManager = new AudioManager(audioFiles, startDelayMs);
358
+ static async create(audioFiles, startDelayMs, fallbackAudioLengthMs) {
359
+ const audioManager = new AudioManager(
360
+ audioFiles,
361
+ startDelayMs,
362
+ fallbackAudioLengthMs
363
+ );
353
364
  await audioManager.initAudio();
354
365
  return audioManager;
355
366
  }
@@ -381,7 +392,13 @@ var AudioManager = class {
381
392
  }
382
393
  /** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
383
394
  get chartEndTimeMs() {
384
- return Math.max(this.startDelayMs + this.audioLengthMs, 0);
395
+ const theoreticalEndTime = this.startDelayMs + this.audioLengthMs;
396
+ return Math.max(
397
+ theoreticalEndTime,
398
+ this.audioLengthMs,
399
+ this.fallbackAudioLengthMs,
400
+ 0
401
+ );
385
402
  }
386
403
  async play() {
387
404
  if (this.gainNode === null) {
@@ -390,14 +407,30 @@ var AudioManager = class {
390
407
  if (this.audioCtx.state === "suspended") {
391
408
  await this.audioCtx.resume();
392
409
  }
410
+ if (this.audioSources.length === 0 && !this.endEventTimeout) {
411
+ const remainingMs = this.chartEndTimeMs - this.chartCurrentTimeMs;
412
+ if (remainingMs > 0) {
413
+ this.endEventTimeout = setTimeout(() => {
414
+ this.eventEmitter.emit("end");
415
+ }, remainingMs);
416
+ }
417
+ }
393
418
  }
394
419
  async pause() {
395
420
  if (this.audioCtx.state === "running") {
396
421
  await this.audioCtx.suspend();
397
422
  }
423
+ if (this.endEventTimeout) {
424
+ clearTimeout(this.endEventTimeout);
425
+ this.endEventTimeout = null;
426
+ }
398
427
  }
399
428
  closeAudio() {
400
429
  this.eventEmitter.removeAllListeners();
430
+ if (this.endEventTimeout) {
431
+ clearTimeout(this.endEventTimeout);
432
+ this.endEventTimeout = null;
433
+ }
401
434
  for (const source of this.audioSources) {
402
435
  source.onended = null;
403
436
  try {
@@ -427,12 +460,25 @@ var AudioManager = class {
427
460
  (file) => this.audioCtx.decodeAudioData(file.slice(0).buffer)
428
461
  )
429
462
  );
430
- this.audioLengthMs = Math.max(...audioBuffers.map((b) => b.duration)) * 1e3;
463
+ const calculatedLengthMs = audioBuffers.length > 0 ? Math.max(...audioBuffers.map((b) => b.duration)) * 1e3 : 0;
464
+ this.audioLengthMs = calculatedLengthMs > 0 ? calculatedLengthMs : this.fallbackAudioLengthMs;
431
465
  this.gainNode = this.audioCtx.createGain();
432
466
  this.gainNode.gain.value = this._volume;
433
467
  this.gainNode.connect(this.audioCtx.destination);
434
468
  let endedCount = 0;
435
469
  const audioStartOffsetSeconds = (this.lastSeekChartTimeMs - this.startDelayMs) / 1e3;
470
+ if (audioBuffers.length === 0) {
471
+ if (this.endEventTimeout) {
472
+ clearTimeout(this.endEventTimeout);
473
+ this.endEventTimeout = null;
474
+ }
475
+ const remainingMs = this.chartEndTimeMs - this.lastSeekChartTimeMs;
476
+ if (remainingMs > 0) {
477
+ this.endEventTimeout = setTimeout(() => {
478
+ this.eventEmitter.emit("end");
479
+ }, remainingMs);
480
+ }
481
+ }
436
482
  for (const audioBuffer of audioBuffers) {
437
483
  const source = this.audioCtx.createBufferSource();
438
484
  source.buffer = audioBuffer;
@@ -459,6 +505,10 @@ var AudioManager = class {
459
505
  if (this.audioCtx.state === "running") {
460
506
  await this.audioCtx.suspend();
461
507
  }
508
+ if (this.endEventTimeout) {
509
+ clearTimeout(this.endEventTimeout);
510
+ this.endEventTimeout = null;
511
+ }
462
512
  for (const source of this.audioSources) {
463
513
  source.onended = null;
464
514
  try {
@@ -517,7 +567,8 @@ var SilentAudioManager = class {
517
567
  }
518
568
  /** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
519
569
  get chartEndTimeMs() {
520
- return Math.max(this.startDelayMs + this.audioLengthMs, 0);
570
+ const theoreticalEndTime = this.startDelayMs + this.audioLengthMs;
571
+ return Math.max(theoreticalEndTime, this.audioLengthMs, 0);
521
572
  }
522
573
  async play() {
523
574
  if (this.lastResumeChartTimeMs >= this.chartEndTimeMs - 2) {
package/dist/index.mjs CHANGED
@@ -109,7 +109,11 @@ var _ChartPreview = class {
109
109
  chartPreview.progressIntervalMs = config.progressIntervalMs ?? DEFAULT_PROGRESS_INTERVAL_MS;
110
110
  chartPreview.camera = new ChartCamera(config.container);
111
111
  chartPreview.renderer = new ChartRenderer(config.container);
112
- chartPreview.audioManager = await (isAudioContextSupported() ? AudioManager.create(config.audioFiles, config.startDelayMs) : SilentAudioManager.create(config.startDelayMs, config.audioLengthMs));
112
+ chartPreview.audioManager = await (isAudioContextSupported() ? AudioManager.create(
113
+ config.audioFiles,
114
+ config.startDelayMs,
115
+ config.audioLengthMs
116
+ ) : SilentAudioManager.create(config.startDelayMs, config.audioLengthMs));
113
117
  chartPreview.audioManager.on(
114
118
  "end",
115
119
  () => chartPreview.eventEmitter.emit("end")
@@ -287,9 +291,10 @@ var ChartRenderer = class extends THREE.WebGLRenderer {
287
291
  }
288
292
  };
289
293
  var AudioManager = class {
290
- constructor(audioFiles, startDelayMs) {
294
+ constructor(audioFiles, startDelayMs, fallbackAudioLengthMs) {
291
295
  this.audioFiles = audioFiles;
292
296
  this.startDelayMs = startDelayMs;
297
+ this.fallbackAudioLengthMs = fallbackAudioLengthMs;
293
298
  this.eventEmitter = new EventEmitter();
294
299
  this.gainNode = null;
295
300
  this.audioSources = [];
@@ -298,6 +303,7 @@ var AudioManager = class {
298
303
  this.lastAudioCtxCurrentTime = 0;
299
304
  // Necessary because audioCtx.currentTime doesn't reset to 0 on seek
300
305
  this.audioLengthMs = 0;
306
+ this.endEventTimeout = null;
301
307
  const ctx = getSharedAudioContext();
302
308
  if (!ctx) {
303
309
  throw new Error("AudioContext is not supported in this environment");
@@ -308,9 +314,14 @@ var AudioManager = class {
308
314
  /**
309
315
  * @param audioFiles The `Uint8Array[]` of the audio files to be played.
310
316
  * @param startDelayMs The amount of time to delay the start of the audio. (can be negative)
317
+ * @param fallbackAudioLengthMs The fallback audio length to use if no audio files are provided.
311
318
  */
312
- static async create(audioFiles, startDelayMs) {
313
- const audioManager = new AudioManager(audioFiles, startDelayMs);
319
+ static async create(audioFiles, startDelayMs, fallbackAudioLengthMs) {
320
+ const audioManager = new AudioManager(
321
+ audioFiles,
322
+ startDelayMs,
323
+ fallbackAudioLengthMs
324
+ );
314
325
  await audioManager.initAudio();
315
326
  return audioManager;
316
327
  }
@@ -342,7 +353,13 @@ var AudioManager = class {
342
353
  }
343
354
  /** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
344
355
  get chartEndTimeMs() {
345
- return Math.max(this.startDelayMs + this.audioLengthMs, 0);
356
+ const theoreticalEndTime = this.startDelayMs + this.audioLengthMs;
357
+ return Math.max(
358
+ theoreticalEndTime,
359
+ this.audioLengthMs,
360
+ this.fallbackAudioLengthMs,
361
+ 0
362
+ );
346
363
  }
347
364
  async play() {
348
365
  if (this.gainNode === null) {
@@ -351,14 +368,30 @@ var AudioManager = class {
351
368
  if (this.audioCtx.state === "suspended") {
352
369
  await this.audioCtx.resume();
353
370
  }
371
+ if (this.audioSources.length === 0 && !this.endEventTimeout) {
372
+ const remainingMs = this.chartEndTimeMs - this.chartCurrentTimeMs;
373
+ if (remainingMs > 0) {
374
+ this.endEventTimeout = setTimeout(() => {
375
+ this.eventEmitter.emit("end");
376
+ }, remainingMs);
377
+ }
378
+ }
354
379
  }
355
380
  async pause() {
356
381
  if (this.audioCtx.state === "running") {
357
382
  await this.audioCtx.suspend();
358
383
  }
384
+ if (this.endEventTimeout) {
385
+ clearTimeout(this.endEventTimeout);
386
+ this.endEventTimeout = null;
387
+ }
359
388
  }
360
389
  closeAudio() {
361
390
  this.eventEmitter.removeAllListeners();
391
+ if (this.endEventTimeout) {
392
+ clearTimeout(this.endEventTimeout);
393
+ this.endEventTimeout = null;
394
+ }
362
395
  for (const source of this.audioSources) {
363
396
  source.onended = null;
364
397
  try {
@@ -388,12 +421,25 @@ var AudioManager = class {
388
421
  (file) => this.audioCtx.decodeAudioData(file.slice(0).buffer)
389
422
  )
390
423
  );
391
- this.audioLengthMs = Math.max(...audioBuffers.map((b) => b.duration)) * 1e3;
424
+ const calculatedLengthMs = audioBuffers.length > 0 ? Math.max(...audioBuffers.map((b) => b.duration)) * 1e3 : 0;
425
+ this.audioLengthMs = calculatedLengthMs > 0 ? calculatedLengthMs : this.fallbackAudioLengthMs;
392
426
  this.gainNode = this.audioCtx.createGain();
393
427
  this.gainNode.gain.value = this._volume;
394
428
  this.gainNode.connect(this.audioCtx.destination);
395
429
  let endedCount = 0;
396
430
  const audioStartOffsetSeconds = (this.lastSeekChartTimeMs - this.startDelayMs) / 1e3;
431
+ if (audioBuffers.length === 0) {
432
+ if (this.endEventTimeout) {
433
+ clearTimeout(this.endEventTimeout);
434
+ this.endEventTimeout = null;
435
+ }
436
+ const remainingMs = this.chartEndTimeMs - this.lastSeekChartTimeMs;
437
+ if (remainingMs > 0) {
438
+ this.endEventTimeout = setTimeout(() => {
439
+ this.eventEmitter.emit("end");
440
+ }, remainingMs);
441
+ }
442
+ }
397
443
  for (const audioBuffer of audioBuffers) {
398
444
  const source = this.audioCtx.createBufferSource();
399
445
  source.buffer = audioBuffer;
@@ -420,6 +466,10 @@ var AudioManager = class {
420
466
  if (this.audioCtx.state === "running") {
421
467
  await this.audioCtx.suspend();
422
468
  }
469
+ if (this.endEventTimeout) {
470
+ clearTimeout(this.endEventTimeout);
471
+ this.endEventTimeout = null;
472
+ }
423
473
  for (const source of this.audioSources) {
424
474
  source.onended = null;
425
475
  try {
@@ -478,7 +528,8 @@ var SilentAudioManager = class {
478
528
  }
479
529
  /** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
480
530
  get chartEndTimeMs() {
481
- return Math.max(this.startDelayMs + this.audioLengthMs, 0);
531
+ const theoreticalEndTime = this.startDelayMs + this.audioLengthMs;
532
+ return Math.max(theoreticalEndTime, this.audioLengthMs, 0);
482
533
  }
483
534
  async play() {
484
535
  if (this.lastResumeChartTimeMs >= this.chartEndTimeMs - 2) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chart-preview",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "author": "Geo",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -167,7 +167,11 @@ export class ChartPreview {
167
167
  chartPreview.camera = new ChartCamera(config.container);
168
168
  chartPreview.renderer = new ChartRenderer(config.container);
169
169
  chartPreview.audioManager = await (isAudioContextSupported()
170
- ? AudioManager.create(config.audioFiles, config.startDelayMs)
170
+ ? AudioManager.create(
171
+ config.audioFiles,
172
+ config.startDelayMs,
173
+ config.audioLengthMs
174
+ )
171
175
  : SilentAudioManager.create(config.startDelayMs, config.audioLengthMs));
172
176
  chartPreview.audioManager.on("end", () =>
173
177
  chartPreview.eventEmitter.emit("end")
@@ -389,10 +393,12 @@ class AudioManager {
389
393
  private lastSeekChartTimeMs = 0;
390
394
  private lastAudioCtxCurrentTime = 0; // Necessary because audioCtx.currentTime doesn't reset to 0 on seek
391
395
  private audioLengthMs: number = 0;
396
+ private endEventTimeout: ReturnType<typeof setTimeout> | null = null;
392
397
 
393
398
  private constructor(
394
399
  private audioFiles: Uint8Array[],
395
- private startDelayMs: number
400
+ private startDelayMs: number,
401
+ private fallbackAudioLengthMs: number
396
402
  ) {
397
403
  const ctx = getSharedAudioContext();
398
404
  if (!ctx) {
@@ -405,9 +411,18 @@ class AudioManager {
405
411
  /**
406
412
  * @param audioFiles The `Uint8Array[]` of the audio files to be played.
407
413
  * @param startDelayMs The amount of time to delay the start of the audio. (can be negative)
414
+ * @param fallbackAudioLengthMs The fallback audio length to use if no audio files are provided.
408
415
  */
409
- static async create(audioFiles: Uint8Array[], startDelayMs: number) {
410
- const audioManager = new AudioManager(audioFiles, startDelayMs);
416
+ static async create(
417
+ audioFiles: Uint8Array[],
418
+ startDelayMs: number,
419
+ fallbackAudioLengthMs: number
420
+ ) {
421
+ const audioManager = new AudioManager(
422
+ audioFiles,
423
+ startDelayMs,
424
+ fallbackAudioLengthMs
425
+ );
411
426
  await audioManager.initAudio();
412
427
  return audioManager;
413
428
  }
@@ -456,7 +471,16 @@ class AudioManager {
456
471
 
457
472
  /** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
458
473
  get chartEndTimeMs() {
459
- return Math.max(this.startDelayMs + this.audioLengthMs, 0);
474
+ // Calculate the theoretical end time based on start delay + audio length
475
+ const theoreticalEndTime = this.startDelayMs + this.audioLengthMs;
476
+ // Ensure we always have a positive end time - use audio length as minimum if startDelay is extremely negative
477
+ // This handles edge cases where chart metadata has invalid/extreme delay values
478
+ return Math.max(
479
+ theoreticalEndTime,
480
+ this.audioLengthMs,
481
+ this.fallbackAudioLengthMs,
482
+ 0
483
+ );
460
484
  }
461
485
 
462
486
  async play() {
@@ -468,15 +492,34 @@ class AudioManager {
468
492
  if (this.audioCtx.state === "suspended") {
469
493
  await this.audioCtx.resume();
470
494
  }
495
+ // Restart end event timeout for charts without audio
496
+ if (this.audioSources.length === 0 && !this.endEventTimeout) {
497
+ const remainingMs = this.chartEndTimeMs - this.chartCurrentTimeMs;
498
+ if (remainingMs > 0) {
499
+ this.endEventTimeout = setTimeout(() => {
500
+ this.eventEmitter.emit("end");
501
+ }, remainingMs);
502
+ }
503
+ }
471
504
  }
472
505
  async pause() {
473
506
  if (this.audioCtx.state === "running") {
474
507
  await this.audioCtx.suspend();
475
508
  }
509
+ // Clear end event timeout when pausing
510
+ if (this.endEventTimeout) {
511
+ clearTimeout(this.endEventTimeout);
512
+ this.endEventTimeout = null;
513
+ }
476
514
  }
477
515
 
478
516
  closeAudio() {
479
517
  this.eventEmitter.removeAllListeners();
518
+ // Clear end event timeout
519
+ if (this.endEventTimeout) {
520
+ clearTimeout(this.endEventTimeout);
521
+ this.endEventTimeout = null;
522
+ }
480
523
  // Stop all audio sources
481
524
  for (const source of this.audioSources) {
482
525
  // Remove onended handler before stopping to prevent false "end" events
@@ -515,8 +558,14 @@ class AudioManager {
515
558
  this.audioCtx.decodeAudioData(file.slice(0).buffer)
516
559
  )
517
560
  );
561
+
562
+ // Use fallback if no audio files or if calculated duration is invalid
563
+ const calculatedLengthMs =
564
+ audioBuffers.length > 0
565
+ ? Math.max(...audioBuffers.map((b) => b.duration)) * 1000
566
+ : 0;
518
567
  this.audioLengthMs =
519
- Math.max(...audioBuffers.map((b) => b.duration)) * 1000;
568
+ calculatedLengthMs > 0 ? calculatedLengthMs : this.fallbackAudioLengthMs;
520
569
 
521
570
  this.gainNode = this.audioCtx.createGain();
522
571
  this.gainNode.gain.value = this._volume;
@@ -525,6 +574,23 @@ class AudioManager {
525
574
  let endedCount = 0;
526
575
  const audioStartOffsetSeconds =
527
576
  (this.lastSeekChartTimeMs - this.startDelayMs) / 1000;
577
+
578
+ // If no audio files, use a timeout to emit "end" event like SilentAudioManager does
579
+ if (audioBuffers.length === 0) {
580
+ // Clear any existing timeout
581
+ if (this.endEventTimeout) {
582
+ clearTimeout(this.endEventTimeout);
583
+ this.endEventTimeout = null;
584
+ }
585
+ // Schedule end event based on remaining time
586
+ const remainingMs = this.chartEndTimeMs - this.lastSeekChartTimeMs;
587
+ if (remainingMs > 0) {
588
+ this.endEventTimeout = setTimeout(() => {
589
+ this.eventEmitter.emit("end");
590
+ }, remainingMs);
591
+ }
592
+ }
593
+
528
594
  for (const audioBuffer of audioBuffers) {
529
595
  const source = this.audioCtx.createBufferSource();
530
596
  source.buffer = audioBuffer;
@@ -537,6 +603,10 @@ class AudioManager {
537
603
  source.connect(this.gainNode!);
538
604
  // When using a shared AudioContext, we need to schedule relative to the current time,
539
605
  // not absolute time 0, since the context may have been running for a while
606
+ //
607
+ // audioStartOffsetSeconds calculation:
608
+ // - Positive value: Start audio from this position (audio is "behind" chart time)
609
+ // - Negative value: Delay audio start by |value| seconds (audio hasn't started yet at chart time 0)
540
610
  const delaySeconds = Math.abs(Math.min(audioStartOffsetSeconds, 0));
541
611
  const when = this.audioCtx.currentTime + delaySeconds;
542
612
  const offset = Math.max(audioStartOffsetSeconds, 0);
@@ -557,6 +627,12 @@ class AudioManager {
557
627
  await this.audioCtx.suspend();
558
628
  }
559
629
 
630
+ // Clear end event timeout when seeking
631
+ if (this.endEventTimeout) {
632
+ clearTimeout(this.endEventTimeout);
633
+ this.endEventTimeout = null;
634
+ }
635
+
560
636
  // Stop existing audio sources
561
637
  for (const source of this.audioSources) {
562
638
  // Remove onended handler before stopping to prevent false "end" events
@@ -635,7 +711,10 @@ class SilentAudioManager {
635
711
  }
636
712
  /** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
637
713
  get chartEndTimeMs() {
638
- return Math.max(this.startDelayMs + this.audioLengthMs, 0);
714
+ // Calculate the theoretical end time based on start delay + audio length
715
+ const theoreticalEndTime = this.startDelayMs + this.audioLengthMs;
716
+ // Ensure we always have a positive end time - use audio length as minimum if startDelay is extremely negative
717
+ return Math.max(theoreticalEndTime, this.audioLengthMs, 0);
639
718
  }
640
719
 
641
720
  async play() {