@viji-dev/core 0.3.20 → 0.3.21

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.
@@ -567,7 +567,7 @@ class IFrameManager {
567
567
  }
568
568
  function WorkerWrapper(options) {
569
569
  return new Worker(
570
- "" + new URL("assets/viji.worker-BnDb6mPh.js", import.meta.url).href,
570
+ "" + new URL("assets/viji.worker-bm-hvzXt.js", import.meta.url).href,
571
571
  {
572
572
  type: "module",
573
573
  name: options?.name
@@ -1481,13 +1481,17 @@ class MultiOnsetDetection {
1481
1481
  const displayName = bandName === "low" ? "low" : "high";
1482
1482
  if (onset) {
1483
1483
  if (bandName === "low") this.debugOnsetCount++;
1484
- if (this.consecutiveMisses[displayName] && this.consecutiveMisses[displayName].count > 3) {
1485
- console.log(` ⚠️ [${displayName}] ${this.consecutiveMisses[displayName].count} consecutive misses (${this.consecutiveMisses[displayName].lastReason})`);
1484
+ if (this.debugMode) {
1485
+ if (this.consecutiveMisses[displayName] && this.consecutiveMisses[displayName].count > 3) {
1486
+ console.log(` ⚠️ [${displayName}] ${this.consecutiveMisses[displayName].count} consecutive misses (${this.consecutiveMisses[displayName].lastReason})`);
1487
+ }
1486
1488
  }
1487
1489
  this.consecutiveMisses[displayName] = { count: 0, lastReason: "" };
1488
- const logCutoff = bandName === "high" ? adjustedCutoff : state.cutoff;
1489
- const emoji = bandName === "low" ? "🔥" : "✨";
1490
- console.log(`${emoji} [${displayName}] ONSET${bandName === "low" ? " #" + this.debugOnsetCount : ""}! flux=${flux.toFixed(3)} energy=${energy.toFixed(2)} cutoff=${logCutoff.toFixed(2)}`);
1490
+ if (this.debugMode) {
1491
+ const logCutoff = bandName === "high" ? adjustedCutoff : state.cutoff;
1492
+ const emoji = bandName === "low" ? "🔥" : "";
1493
+ console.log(`${emoji} [${displayName}] ONSET${bandName === "low" ? " #" + this.debugOnsetCount : ""}! flux=${flux.toFixed(3)} energy=${energy.toFixed(2)} cutoff=${logCutoff.toFixed(2)}`);
1494
+ }
1491
1495
  if (this.debugMode) {
1492
1496
  const sixteenthNote = 6e4 / this.currentBPM / 4;
1493
1497
  console.log(` 📊 Sharpness: ${sharpness.toFixed(3)} | Raw Flux: ${flux.toFixed(3)} | Emphasized: ${emphasizedFlux.toFixed(3)}`);
@@ -1503,7 +1507,7 @@ class MultiOnsetDetection {
1503
1507
  }
1504
1508
  this.consecutiveMisses[displayName].count++;
1505
1509
  this.consecutiveMisses[displayName].lastReason = failReason;
1506
- if (this.consecutiveMisses[displayName].count === 1) {
1510
+ if (this.debugMode && this.consecutiveMisses[displayName].count === 1) {
1507
1511
  const timeSince = (now - state.lastOnsetTime).toFixed(0);
1508
1512
  const logCutoff = bandName === "high" ? adjustedCutoff : state.cutoff;
1509
1513
  console.log(`⚠️ [${displayName}] MISS (${failReason}) flux=${flux.toFixed(3)} energy=${energy.toFixed(2)} cutoff=${logCutoff.toFixed(2)} lastOnset=${timeSince}ms ago`);
@@ -1549,7 +1553,7 @@ class MultiOnsetDetection {
1549
1553
  const state = this.bandStates.get(band);
1550
1554
  if (state) {
1551
1555
  state.primaryOnsetHistory.push(now);
1552
- if (band === "low") {
1556
+ if (band === "low" && this.debugMode) {
1553
1557
  console.log(` ✓ PRIMARY [low] onset recorded (total: ${state.primaryOnsetHistory.length})`);
1554
1558
  }
1555
1559
  }
@@ -1916,7 +1920,7 @@ class EssentiaOnsetDetection {
1916
1920
  this.initPromise = (async () => {
1917
1921
  try {
1918
1922
  const essentiaModule = await import("./essentia.js-core.es-DnrJE0uR.js");
1919
- const wasmModule = await import("./essentia-wasm.web-MJg8deal.js").then((n) => n.e);
1923
+ const wasmModule = await import("./essentia-wasm.web-C1URJxCY.js").then((n) => n.e);
1920
1924
  const EssentiaClass = essentiaModule.Essentia || essentiaModule.default?.Essentia || essentiaModule.default;
1921
1925
  let WASMModule = wasmModule.default || wasmModule.EssentiaWASM || wasmModule.default?.EssentiaWASM;
1922
1926
  if (!WASMModule) {
@@ -2118,43 +2122,32 @@ class EssentiaOnsetDetection {
2118
2122
  onset: globalOnset,
2119
2123
  strength: clampedStrength,
2120
2124
  bands: {
2121
- // Low bands: Use complex domain (best for kicks)
2122
2125
  low: {
2123
2126
  onset: kickOnset,
2124
- // INDEPENDENT detection!
2125
2127
  strength: clampedComplex,
2126
2128
  energy: bandEnergies.low,
2127
2129
  sharpness: kickSharpness
2128
- // Attack sharpness for kick/bassline discrimination
2129
2130
  },
2130
2131
  lowMid: {
2131
2132
  onset: kickOnset,
2132
- // Follows kick detection (kick harmonics)
2133
2133
  strength: clampedComplex,
2134
2134
  energy: bandEnergies.lowMid,
2135
2135
  sharpness: kickSharpness
2136
2136
  },
2137
- // Mid band: Use spectral flux (best for snares)
2138
2137
  mid: {
2139
2138
  onset: snareOnset,
2140
- // INDEPENDENT detection!
2141
2139
  strength: clampedFlux,
2142
2140
  energy: bandEnergies.mid,
2143
2141
  sharpness: snareSharpness
2144
- // Transient quality for snare detection
2145
2142
  },
2146
- // High bands: Use HFC (best for hats)
2147
2143
  highMid: {
2148
2144
  onset: hatOnset,
2149
- // INDEPENDENT detection!
2150
2145
  strength: clampedHFC,
2151
2146
  energy: bandEnergies.highMid,
2152
2147
  sharpness: hatSharpness
2153
- // Transient quality for hat detection
2154
2148
  },
2155
2149
  high: {
2156
2150
  onset: hatOnset,
2157
- // INDEPENDENT detection!
2158
2151
  strength: clampedHFC,
2159
2152
  energy: bandEnergies.high,
2160
2153
  sharpness: hatSharpness
@@ -10233,7 +10226,7 @@ const CONFIDENCE_LOCKED_THRESHOLD = 0.6;
10233
10226
  const BREAKDOWN_THRESHOLD_MS = 2e3;
10234
10227
  const LOST_THRESHOLD_MS = 3e4;
10235
10228
  const GRID_INVALID_THRESHOLD = 0.25;
10236
- const SAMPLE_RATE = 60;
10229
+ const SAMPLE_RATE$1 = 60;
10237
10230
  class BeatStateManager {
10238
10231
  // State machine
10239
10232
  state = "TRACKING";
@@ -10270,13 +10263,14 @@ class BeatStateManager {
10270
10263
  // Running average of onset strength
10271
10264
  // Envelope followers for beat energy curves
10272
10265
  envelopes = {
10273
- kick: new EnvelopeFollower(0, 300, SAMPLE_RATE),
10274
- snare: new EnvelopeFollower(0, 300, SAMPLE_RATE),
10275
- hat: new EnvelopeFollower(0, 300, SAMPLE_RATE),
10276
- any: new EnvelopeFollower(0, 300, SAMPLE_RATE),
10277
- kickSmoothed: new EnvelopeFollower(5, 500, SAMPLE_RATE),
10278
- snareSmoothed: new EnvelopeFollower(5, 500, SAMPLE_RATE),
10279
- anySmoothed: new EnvelopeFollower(5, 500, SAMPLE_RATE)
10266
+ kick: new EnvelopeFollower(0, 300, SAMPLE_RATE$1),
10267
+ snare: new EnvelopeFollower(0, 300, SAMPLE_RATE$1),
10268
+ hat: new EnvelopeFollower(0, 300, SAMPLE_RATE$1),
10269
+ any: new EnvelopeFollower(0, 300, SAMPLE_RATE$1),
10270
+ kickSmoothed: new EnvelopeFollower(5, 500, SAMPLE_RATE$1),
10271
+ snareSmoothed: new EnvelopeFollower(5, 500, SAMPLE_RATE$1),
10272
+ hatSmoothed: new EnvelopeFollower(5, 500, SAMPLE_RATE$1),
10273
+ anySmoothed: new EnvelopeFollower(5, 500, SAMPLE_RATE$1)
10280
10274
  };
10281
10275
  // Confidence factors
10282
10276
  tempoMethodAgreement = 0;
@@ -10517,10 +10511,11 @@ class BeatStateManager {
10517
10511
  this.debugKickCount++;
10518
10512
  const kickEvent = detectorEvents.find((e) => e.type === "kick");
10519
10513
  const bands = kickEvent && "bands" in kickEvent ? kickEvent.bands : kickBands;
10514
+ const rawKickStrength = kickEvent?.strength || onsets.bands.low.strength;
10520
10515
  const event = {
10521
10516
  type: "kick",
10522
10517
  time: now,
10523
- strength: kickEvent?.strength || onsets.bands.low.strength,
10518
+ strength: Math.min(1, rawKickStrength / 5),
10524
10519
  phase: pll.getPhase(),
10525
10520
  bar: pll.getBar(),
10526
10521
  isLayered: classification.snare || classification.hat,
@@ -10541,10 +10536,11 @@ class BeatStateManager {
10541
10536
  this.debugSnareCount++;
10542
10537
  const snareEvent = detectorEvents.find((e) => e.type === "snare");
10543
10538
  const bands = snareEvent && "bands" in snareEvent ? snareEvent.bands : snareBands;
10539
+ const rawSnareStrength = snareEvent?.strength || onsets.bands.mid.strength;
10544
10540
  const event = {
10545
10541
  type: "snare",
10546
10542
  time: now,
10547
- strength: snareEvent?.strength || onsets.bands.mid.strength,
10543
+ strength: Math.min(1, rawSnareStrength / 5),
10548
10544
  phase: pll.getPhase(),
10549
10545
  bar: pll.getBar(),
10550
10546
  isLayered: classification.kick || classification.hat,
@@ -10565,10 +10561,11 @@ class BeatStateManager {
10565
10561
  this.debugHatCount++;
10566
10562
  const hatEvent = detectorEvents.find((e) => e.type === "hat");
10567
10563
  const bands = hatEvent && "bands" in hatEvent ? hatEvent.bands : hatBands;
10564
+ const rawHatStrength = hatEvent?.strength || onsets.bands.high.strength;
10568
10565
  const event = {
10569
10566
  type: "hat",
10570
10567
  time: now,
10571
- strength: hatEvent?.strength || onsets.bands.high.strength,
10568
+ strength: Math.min(1, rawHatStrength / 3e3),
10572
10569
  phase: pll.getPhase(),
10573
10570
  bar: pll.getBar(),
10574
10571
  isLayered: classification.kick || classification.snare,
@@ -10599,6 +10596,7 @@ class BeatStateManager {
10599
10596
  any: this.envelopes.any.getValue(),
10600
10597
  kickSmoothed: this.envelopes.kickSmoothed.getValue(),
10601
10598
  snareSmoothed: this.envelopes.snareSmoothed.getValue(),
10599
+ hatSmoothed: this.envelopes.hatSmoothed.getValue(),
10602
10600
  anySmoothed: this.envelopes.anySmoothed.getValue(),
10603
10601
  events: bufferedEvents,
10604
10602
  // Buffered events (persisted for 20ms, reliable for async access)
@@ -11187,9 +11185,7 @@ class BeatStateManager {
11187
11185
  if (snare) {
11188
11186
  const isLayered = timeSinceLastPrimaryKick <= 80;
11189
11187
  const midSharpness2 = bands.mid.sharpness ?? 1;
11190
- const isHighQuality = midSharpness2 > 0.25 && // Sharp attack (basic transient check)
11191
- bands.mid.strength > 0.3 && // Strong enough to be reliable
11192
- midRatio > 0.1;
11188
+ const isHighQuality = midSharpness2 > 0.25 && bands.mid.strength > 0.3 && midRatio > 0.1;
11193
11189
  if (isHighQuality) {
11194
11190
  if (isLayered) {
11195
11191
  this.adaptiveProfiles.snareLayered.samples.push({
@@ -11419,6 +11415,7 @@ class BeatStateManager {
11419
11415
  }
11420
11416
  if (classification.hat) {
11421
11417
  this.envelopes.hat.trigger(1);
11418
+ this.envelopes.hatSmoothed.trigger(1);
11422
11419
  this.envelopes.any.trigger(0.5);
11423
11420
  }
11424
11421
  if (classification.any && !classification.kick && !classification.snare && !classification.hat) {
@@ -11431,8 +11428,32 @@ class BeatStateManager {
11431
11428
  this.envelopes.any.process(0, dtMs);
11432
11429
  this.envelopes.kickSmoothed.process(0, dtMs);
11433
11430
  this.envelopes.snareSmoothed.process(0, dtMs);
11431
+ this.envelopes.hatSmoothed.process(0, dtMs);
11434
11432
  this.envelopes.anySmoothed.process(0, dtMs);
11435
11433
  }
11434
+ /**
11435
+ * Decay envelopes without triggering new beats (used during silent frames)
11436
+ */
11437
+ processEnvelopeDecay(dtMs) {
11438
+ this.envelopes.kick.process(0, dtMs);
11439
+ this.envelopes.snare.process(0, dtMs);
11440
+ this.envelopes.hat.process(0, dtMs);
11441
+ this.envelopes.any.process(0, dtMs);
11442
+ this.envelopes.kickSmoothed.process(0, dtMs);
11443
+ this.envelopes.snareSmoothed.process(0, dtMs);
11444
+ this.envelopes.hatSmoothed.process(0, dtMs);
11445
+ this.envelopes.anySmoothed.process(0, dtMs);
11446
+ return {
11447
+ kick: this.envelopes.kick.getValue(),
11448
+ snare: this.envelopes.snare.getValue(),
11449
+ hat: this.envelopes.hat.getValue(),
11450
+ any: this.envelopes.any.getValue(),
11451
+ kickSmoothed: this.envelopes.kickSmoothed.getValue(),
11452
+ snareSmoothed: this.envelopes.snareSmoothed.getValue(),
11453
+ hatSmoothed: this.envelopes.hatSmoothed.getValue(),
11454
+ anySmoothed: this.envelopes.anySmoothed.getValue()
11455
+ };
11456
+ }
11436
11457
  // ========== INDUSTRY-STANDARD HELPER METHODS ==========
11437
11458
  // NOTE: calculateGridAlignment() and calculateIOIConsistency() removed in Phase 1
11438
11459
  // These were part of advanced filtering that we disabled to maintain compatibility
@@ -12351,6 +12372,382 @@ FFT.prototype._singleRealTransform4 = function _singleRealTransform4(outOff, off
12351
12372
  out[outOff + 7] = FDi;
12352
12373
  };
12353
12374
  const FFT$1 = /* @__PURE__ */ getDefaultExportFromCjs(fft);
12375
+ const INSTRUMENTS = ["kick", "snare", "hat"];
12376
+ const SAMPLE_RATE = 60;
12377
+ const TAP_TIMEOUT_MS = 5e3;
12378
+ const MAX_CYCLE_LENGTH = 8;
12379
+ const FUZZY_TOLERANCE = 0.18;
12380
+ const MIN_REPETITIONS = 3;
12381
+ const MAX_TAP_INTERVAL_MS = 3e3;
12382
+ const MIN_TAP_INTERVAL_MS = 100;
12383
+ const MAX_TAP_HISTORY = 64;
12384
+ const MIN_EMA_ALPHA = 0.05;
12385
+ const PATTERN_SAME_TOLERANCE = 0.15;
12386
+ function createInstrumentState() {
12387
+ return {
12388
+ mode: "auto",
12389
+ muted: false,
12390
+ mutedAt: 0,
12391
+ tapIOIs: [],
12392
+ lastTapTime: 0,
12393
+ pattern: null,
12394
+ refinementIndex: 0,
12395
+ refinementCounts: [],
12396
+ replayLastEventTime: 0,
12397
+ replayIndex: 0,
12398
+ pendingTapEvents: [],
12399
+ envelope: new EnvelopeFollower(0, 300, SAMPLE_RATE),
12400
+ envelopeSmoothed: new EnvelopeFollower(5, 500, SAMPLE_RATE)
12401
+ };
12402
+ }
12403
+ class OnsetTapManager {
12404
+ state = {
12405
+ kick: createInstrumentState(),
12406
+ snare: createInstrumentState(),
12407
+ hat: createInstrumentState()
12408
+ };
12409
+ tap(instrument) {
12410
+ const s = this.state[instrument];
12411
+ const now = performance.now();
12412
+ if (s.muted) {
12413
+ s.muted = false;
12414
+ s.mutedAt = 0;
12415
+ }
12416
+ let ioi = -1;
12417
+ if (s.lastTapTime > 0) {
12418
+ ioi = now - s.lastTapTime;
12419
+ if (ioi < MIN_TAP_INTERVAL_MS) return;
12420
+ if (ioi > MAX_TAP_INTERVAL_MS) {
12421
+ s.tapIOIs = [];
12422
+ ioi = -1;
12423
+ } else {
12424
+ s.tapIOIs.push(ioi);
12425
+ if (s.tapIOIs.length > MAX_TAP_HISTORY) s.tapIOIs.shift();
12426
+ }
12427
+ }
12428
+ s.lastTapTime = now;
12429
+ s.pendingTapEvents.push(now);
12430
+ if (s.mode === "auto") {
12431
+ s.mode = "tapping";
12432
+ if (ioi > 0) {
12433
+ const pattern = this.tryRecognizePattern(instrument);
12434
+ if (pattern) this.applyPattern(instrument, pattern);
12435
+ }
12436
+ } else if (s.mode === "tapping") {
12437
+ if (ioi > 0) {
12438
+ const pattern = this.tryRecognizePattern(instrument);
12439
+ if (pattern) this.applyPattern(instrument, pattern);
12440
+ }
12441
+ } else if (s.mode === "pattern") {
12442
+ if (ioi > 0) {
12443
+ this.handlePatternTap(instrument, ioi, now);
12444
+ }
12445
+ }
12446
+ }
12447
+ clear(instrument) {
12448
+ const s = this.state[instrument];
12449
+ s.mode = "auto";
12450
+ s.muted = false;
12451
+ s.mutedAt = 0;
12452
+ s.tapIOIs = [];
12453
+ s.lastTapTime = 0;
12454
+ s.pattern = null;
12455
+ s.refinementIndex = 0;
12456
+ s.refinementCounts = [];
12457
+ s.replayLastEventTime = 0;
12458
+ s.replayIndex = 0;
12459
+ s.pendingTapEvents = [];
12460
+ s.envelope.reset();
12461
+ s.envelopeSmoothed.reset();
12462
+ }
12463
+ getMode(instrument) {
12464
+ return this.state[instrument].mode;
12465
+ }
12466
+ getPatternInfo(instrument) {
12467
+ const s = this.state[instrument];
12468
+ if (!s.pattern) return null;
12469
+ return {
12470
+ length: s.pattern.length,
12471
+ intervals: [...s.pattern],
12472
+ confidence: 1
12473
+ };
12474
+ }
12475
+ setMuted(instrument, muted) {
12476
+ const s = this.state[instrument];
12477
+ if (s.muted === muted) return;
12478
+ const now = performance.now();
12479
+ if (muted) {
12480
+ s.muted = true;
12481
+ s.mutedAt = now;
12482
+ } else {
12483
+ const pauseDuration = now - s.mutedAt;
12484
+ s.muted = false;
12485
+ s.mutedAt = 0;
12486
+ if (s.mode === "tapping" && s.lastTapTime > 0) {
12487
+ s.lastTapTime += pauseDuration;
12488
+ }
12489
+ }
12490
+ }
12491
+ isMuted(instrument) {
12492
+ return this.state[instrument].muted;
12493
+ }
12494
+ /**
12495
+ * Post-process a beat state produced by BeatStateManager.
12496
+ * For instruments in auto mode, values pass through unchanged.
12497
+ * For tapping/pattern instruments, auto events are suppressed and
12498
+ * tap/pattern events + envelopes are injected instead.
12499
+ */
12500
+ processFrame(beatState, now, dtMs) {
12501
+ const result = { ...beatState, events: [...beatState.events] };
12502
+ const newEvents = [];
12503
+ for (const inst of INSTRUMENTS) {
12504
+ const s = this.state[inst];
12505
+ if (s.muted) {
12506
+ result.events = result.events.filter((e) => e.type !== inst);
12507
+ s.pendingTapEvents = [];
12508
+ if (s.mode === "pattern" && s.pattern && s.replayLastEventTime > 0) {
12509
+ for (let i = 0; i < 3; i++) {
12510
+ const interval = s.pattern[s.replayIndex % s.pattern.length];
12511
+ if (now - s.replayLastEventTime >= interval) {
12512
+ s.replayLastEventTime += interval;
12513
+ s.replayIndex = (s.replayIndex + 1) % s.pattern.length;
12514
+ } else {
12515
+ break;
12516
+ }
12517
+ }
12518
+ }
12519
+ s.envelope.process(0, dtMs);
12520
+ s.envelopeSmoothed.process(0, dtMs);
12521
+ const envKey2 = inst;
12522
+ const smoothKey2 = `${inst}Smoothed`;
12523
+ result[envKey2] = s.envelope.getValue();
12524
+ result[smoothKey2] = s.envelopeSmoothed.getValue();
12525
+ continue;
12526
+ }
12527
+ if (s.mode === "auto") {
12528
+ s.envelope.process(0, dtMs);
12529
+ s.envelopeSmoothed.process(0, dtMs);
12530
+ continue;
12531
+ }
12532
+ result.events = result.events.filter((e) => e.type !== inst);
12533
+ if (s.mode === "tapping") {
12534
+ if (now - s.lastTapTime > TAP_TIMEOUT_MS) {
12535
+ s.tapIOIs = [];
12536
+ s.pendingTapEvents = [];
12537
+ if (s.pattern) {
12538
+ s.mode = "pattern";
12539
+ s.replayLastEventTime = now;
12540
+ s.replayIndex = 0;
12541
+ } else {
12542
+ s.mode = "auto";
12543
+ }
12544
+ continue;
12545
+ }
12546
+ while (s.pendingTapEvents.length > 0) {
12547
+ const tapTime = s.pendingTapEvents.shift();
12548
+ newEvents.push(this.createTapEvent(inst, tapTime, result.bpm));
12549
+ s.envelope.trigger(1);
12550
+ s.envelopeSmoothed.trigger(1);
12551
+ s.replayLastEventTime = tapTime;
12552
+ s.replayIndex = 0;
12553
+ }
12554
+ }
12555
+ if (s.mode === "pattern") {
12556
+ while (s.pendingTapEvents.length > 0) {
12557
+ const tapTime = s.pendingTapEvents.shift();
12558
+ newEvents.push(this.createTapEvent(inst, tapTime, result.bpm));
12559
+ s.envelope.trigger(1);
12560
+ s.envelopeSmoothed.trigger(1);
12561
+ }
12562
+ const isUserActive = now - s.lastTapTime < 500;
12563
+ if (!isUserActive && s.pattern) {
12564
+ const scheduled = this.checkPatternReplay(s, inst, now, result.bpm);
12565
+ for (const ev of scheduled) {
12566
+ newEvents.push(ev);
12567
+ s.envelope.trigger(1);
12568
+ s.envelopeSmoothed.trigger(1);
12569
+ }
12570
+ }
12571
+ }
12572
+ s.envelope.process(0, dtMs);
12573
+ s.envelopeSmoothed.process(0, dtMs);
12574
+ const envKey = inst;
12575
+ const smoothKey = `${inst}Smoothed`;
12576
+ result[envKey] = s.envelope.getValue();
12577
+ result[smoothKey] = s.envelopeSmoothed.getValue();
12578
+ }
12579
+ result.events.push(...newEvents);
12580
+ const anyMax = Math.max(
12581
+ result.kick,
12582
+ result.snare,
12583
+ result.hat
12584
+ );
12585
+ const anySmoothedMax = Math.max(
12586
+ result.kickSmoothed,
12587
+ result.snareSmoothed,
12588
+ result.hatSmoothed
12589
+ );
12590
+ if (anyMax > result.any) result.any = anyMax;
12591
+ if (anySmoothedMax > result.anySmoothed) result.anySmoothed = anySmoothedMax;
12592
+ return result;
12593
+ }
12594
+ // ---------------------------------------------------------------------------
12595
+ // Private helpers
12596
+ // ---------------------------------------------------------------------------
12597
+ /**
12598
+ * Handle a tap that arrives while already in pattern mode.
12599
+ * Matching taps refine the pattern via EMA and re-anchor phase.
12600
+ * Non-matching taps trigger new pattern detection on the full IOI history.
12601
+ */
12602
+ handlePatternTap(instrument, ioi, now) {
12603
+ const s = this.state[instrument];
12604
+ if (!s.pattern) return;
12605
+ const matchedPos = this.findMatchingPosition(s, ioi);
12606
+ if (matchedPos >= 0) {
12607
+ s.refinementCounts[matchedPos] = (s.refinementCounts[matchedPos] || 0) + 1;
12608
+ const alpha = Math.max(MIN_EMA_ALPHA, 1 / s.refinementCounts[matchedPos]);
12609
+ s.pattern[matchedPos] = (1 - alpha) * s.pattern[matchedPos] + alpha * ioi;
12610
+ s.refinementIndex = (matchedPos + 1) % s.pattern.length;
12611
+ s.replayLastEventTime = now;
12612
+ s.replayIndex = s.refinementIndex;
12613
+ } else {
12614
+ const currentPattern = [...s.pattern];
12615
+ const newPattern = this.tryRecognizePattern(instrument);
12616
+ if (newPattern && !this.isPatternSame(newPattern, currentPattern)) {
12617
+ this.applyPattern(instrument, newPattern);
12618
+ }
12619
+ }
12620
+ }
12621
+ /**
12622
+ * Find which pattern position the given IOI matches.
12623
+ * Tries the expected refinementIndex first, then scans all positions.
12624
+ * Returns the matched position index, or -1 if no match.
12625
+ */
12626
+ findMatchingPosition(s, ioi) {
12627
+ if (!s.pattern) return -1;
12628
+ const expectedPos = s.refinementIndex % s.pattern.length;
12629
+ const expectedInterval = s.pattern[expectedPos];
12630
+ if (Math.abs(ioi - expectedInterval) / expectedInterval <= FUZZY_TOLERANCE) {
12631
+ return expectedPos;
12632
+ }
12633
+ let bestPos = -1;
12634
+ let bestDeviation = Infinity;
12635
+ for (let i = 0; i < s.pattern.length; i++) {
12636
+ if (i === expectedPos) continue;
12637
+ const deviation = Math.abs(ioi - s.pattern[i]) / s.pattern[i];
12638
+ if (deviation <= FUZZY_TOLERANCE && deviation < bestDeviation) {
12639
+ bestDeviation = deviation;
12640
+ bestPos = i;
12641
+ }
12642
+ }
12643
+ return bestPos;
12644
+ }
12645
+ /**
12646
+ * Compare two patterns for equivalence.
12647
+ * Same length and each interval within PATTERN_SAME_TOLERANCE → same.
12648
+ */
12649
+ isPatternSame(a, b) {
12650
+ if (a.length !== b.length) return false;
12651
+ for (let i = 0; i < a.length; i++) {
12652
+ if (Math.abs(a[i] - b[i]) / Math.max(a[i], b[i]) > PATTERN_SAME_TOLERANCE) return false;
12653
+ }
12654
+ return true;
12655
+ }
12656
+ /**
12657
+ * Pattern recognition: find the shortest repeating IOI cycle
12658
+ * that has been tapped at least MIN_REPETITIONS times.
12659
+ * Returns the recognized pattern as float intervals, or null.
12660
+ * Does NOT mutate state — caller decides what to do with the result.
12661
+ */
12662
+ tryRecognizePattern(instrument) {
12663
+ const s = this.state[instrument];
12664
+ const iois = s.tapIOIs;
12665
+ if (iois.length < MIN_REPETITIONS) return null;
12666
+ const maxL = Math.min(MAX_CYCLE_LENGTH, Math.floor(iois.length / MIN_REPETITIONS));
12667
+ for (let L = 1; L <= maxL; L++) {
12668
+ const needed = MIN_REPETITIONS * L;
12669
+ if (iois.length < needed) continue;
12670
+ const tolerance = L <= 2 ? FUZZY_TOLERANCE : FUZZY_TOLERANCE * (2 / L);
12671
+ const recent = iois.slice(-needed);
12672
+ const groups = [];
12673
+ for (let g = 0; g < MIN_REPETITIONS; g++) {
12674
+ groups.push(recent.slice(g * L, (g + 1) * L));
12675
+ }
12676
+ const avgGroup = groups[0].map((_, i) => {
12677
+ let sum = 0;
12678
+ for (const grp of groups) sum += grp[i];
12679
+ return sum / groups.length;
12680
+ });
12681
+ let allMatch = true;
12682
+ for (const grp of groups) {
12683
+ for (let i = 0; i < L; i++) {
12684
+ const deviation = Math.abs(grp[i] - avgGroup[i]) / avgGroup[i];
12685
+ if (deviation > tolerance) {
12686
+ allMatch = false;
12687
+ break;
12688
+ }
12689
+ }
12690
+ if (!allMatch) break;
12691
+ }
12692
+ if (allMatch) {
12693
+ return avgGroup;
12694
+ }
12695
+ }
12696
+ return null;
12697
+ }
12698
+ /**
12699
+ * Apply a recognized pattern to the instrument state.
12700
+ * Sets pattern, mode, and initializes replay anchor.
12701
+ */
12702
+ applyPattern(instrument, pattern) {
12703
+ const s = this.state[instrument];
12704
+ s.pattern = pattern;
12705
+ s.mode = "pattern";
12706
+ s.refinementIndex = 0;
12707
+ s.refinementCounts = new Array(pattern.length).fill(MIN_REPETITIONS);
12708
+ if (s.replayLastEventTime === 0 || s.replayLastEventTime < s.lastTapTime) {
12709
+ s.replayLastEventTime = s.lastTapTime;
12710
+ s.replayIndex = 0;
12711
+ }
12712
+ }
12713
+ createTapEvent(instrument, time, bpm) {
12714
+ return {
12715
+ type: instrument,
12716
+ time,
12717
+ strength: 0.85,
12718
+ isPredicted: false,
12719
+ bpm
12720
+ };
12721
+ }
12722
+ checkPatternReplay(s, instrument, now, bpm) {
12723
+ if (!s.pattern || s.pattern.length === 0) return [];
12724
+ const events = [];
12725
+ const maxEventsPerFrame = 3;
12726
+ for (let safety = 0; safety < maxEventsPerFrame; safety++) {
12727
+ const expectedInterval = s.pattern[s.replayIndex % s.pattern.length];
12728
+ const elapsed = now - s.replayLastEventTime;
12729
+ if (elapsed >= expectedInterval) {
12730
+ s.replayLastEventTime += expectedInterval;
12731
+ s.replayIndex = (s.replayIndex + 1) % s.pattern.length;
12732
+ events.push({
12733
+ type: instrument,
12734
+ time: s.replayLastEventTime,
12735
+ strength: 0.8,
12736
+ isPredicted: true,
12737
+ bpm
12738
+ });
12739
+ } else {
12740
+ break;
12741
+ }
12742
+ }
12743
+ return events;
12744
+ }
12745
+ reset() {
12746
+ for (const inst of INSTRUMENTS) {
12747
+ this.clear(inst);
12748
+ }
12749
+ }
12750
+ }
12354
12751
  class AudioSystem {
12355
12752
  // Audio context and analysis nodes
12356
12753
  audioContext = null;
@@ -12360,14 +12757,8 @@ class AudioSystem {
12360
12757
  analysisMode = "analyser";
12361
12758
  workletNode = null;
12362
12759
  workletReady = false;
12760
+ workletRegistered = false;
12363
12761
  currentSampleRate = 44100;
12364
- pcmRing = null;
12365
- pcmWriteIndex = 0;
12366
- pcmFilled = 0;
12367
- pcmHistorySeconds = 8;
12368
- lastTempoExtraction = 0;
12369
- tempoExtractionIntervalMs = 2e3;
12370
- analysisBackend = "auto";
12371
12762
  bandNoiseFloor = {
12372
12763
  low: 1e-4,
12373
12764
  lowMid: 1e-4,
@@ -12379,16 +12770,11 @@ class AudioSystem {
12379
12770
  lastNonZeroBpm = 120;
12380
12771
  /** Tracks which source provided the current BPM (pll | tempo | carry | default) */
12381
12772
  lastBpmSource = "default";
12382
- aubioWarningLogged = false;
12383
- useEssentiaTempo = false;
12384
- // disabled by default to avoid WASM exception in some builds
12385
12773
  workletFrameCount = 0;
12386
- forceAnalyser = false;
12387
- statusLogTimer = null;
12388
- isAudioLab = typeof window !== "undefined" && typeof window.location !== "undefined" && window.location.href.includes("audio-lab");
12774
+ lastFrameTime = 0;
12775
+ stalenessTimer = null;
12776
+ static STALENESS_THRESHOLD_MS = 500;
12389
12777
  analysisTicks = 0;
12390
- lastLoopWarn = 0;
12391
- verboseLabLogs = this.isAudioLab;
12392
12778
  lastPhaseLogTime = 0;
12393
12779
  onsetLogBuffer = [];
12394
12780
  // Debug logging control
@@ -12417,21 +12803,14 @@ class AudioSystem {
12417
12803
  };
12418
12804
  /** Last dt used in analysis loop (ms) */
12419
12805
  lastDtMs = 0;
12420
- /** Flag to disable auto-gain for debugging */
12421
- debugDisableAutoGain = false;
12422
12806
  /** Flag to track if we've logged detection method (to avoid spam) */
12423
12807
  bandNames = ["low", "lowMid", "mid", "highMid", "high"];
12424
12808
  essentiaBandHistories = /* @__PURE__ */ new Map();
12425
12809
  essentiaHistoryWindowMs = 5e3;
12426
- // Manual BPM mode
12427
- beatMode = "auto";
12428
- manualBPM = null;
12429
- tapHistory = [];
12430
- tapTimeout = null;
12810
+ // Per-instrument onset tap manager
12811
+ onsetTapManager;
12431
12812
  // Envelope followers for smooth energy curves
12432
12813
  envelopeFollowers;
12433
- // Global sensitivity multiplier
12434
- sensitivity = 1;
12435
12814
  // Feature enable flags
12436
12815
  beatDetectionEnabled = true;
12437
12816
  onsetDetectionEnabled = true;
@@ -12446,13 +12825,9 @@ class AudioSystem {
12446
12825
  this.tempoInduction.setDebugMode(enabled);
12447
12826
  this.stateManager.setDebugMode(enabled);
12448
12827
  this.essentiaOnsetDetection?.setDebugMode(enabled);
12449
- this.toggleStatusLogger(enabled);
12450
- this.verboseLabLogs = this.isAudioLab || this.debugMode;
12451
12828
  if (enabled) {
12452
12829
  this.diagnosticLogger.start();
12453
- }
12454
- if (enabled) {
12455
- console.log("🔬 [AudioSystem] Debug mode enabled");
12830
+ this.debugLog("[AudioSystem] Debug mode enabled");
12456
12831
  }
12457
12832
  }
12458
12833
  /**
@@ -12490,42 +12865,6 @@ class AudioSystem {
12490
12865
  }
12491
12866
  return clone;
12492
12867
  }
12493
- logVerbose(label, payload) {
12494
- if (this.debugMode || this.verboseLabLogs) {
12495
- console.log(label, payload);
12496
- }
12497
- }
12498
- /**
12499
- * Start/stop periodic status logging. If maxSamples provided, stops after that many ticks.
12500
- */
12501
- toggleStatusLogger(enabled, maxSamples) {
12502
- if (enabled && !this.statusLogTimer) {
12503
- let samples = 0;
12504
- this.statusLogTimer = setInterval(() => {
12505
- samples++;
12506
- const a = this.audioState;
12507
- const maxBand = Math.max(a.bands.low, a.bands.lowMid, a.bands.mid, a.bands.highMid, a.bands.high);
12508
- console.log("[AudioSystem][status]", {
12509
- mode: this.analysisMode,
12510
- workletReady: this.workletReady,
12511
- workletFrames: this.workletFrameCount,
12512
- isConnected: a.isConnected,
12513
- vol: { cur: a.volume.current.toFixed(3), peak: a.volume.peak.toFixed(3) },
12514
- maxBand: maxBand.toFixed(3),
12515
- bpm: a.beat.bpm.toFixed(1),
12516
- bpmSource: this.lastBpmSource,
12517
- locked: a.beat.isLocked,
12518
- events: a.beat.events.length
12519
- });
12520
- if (maxSamples && samples >= maxSamples) {
12521
- this.toggleStatusLogger(false);
12522
- }
12523
- }, 1e3);
12524
- } else if (!enabled && this.statusLogTimer) {
12525
- clearInterval(this.statusLogTimer);
12526
- this.statusLogTimer = null;
12527
- }
12528
- }
12529
12868
  /**
12530
12869
  * Handle frames pushed from AudioWorklet
12531
12870
  */
@@ -12539,8 +12878,9 @@ class AudioSystem {
12539
12878
  */
12540
12879
  analyzeFrame(frame, sampleRate, timestampMs) {
12541
12880
  if (!frame || frame.length === 0) return;
12881
+ this.lastFrameTime = performance.now();
12542
12882
  if (this.workletFrameCount === 1) {
12543
- console.log("[AudioSystem] First frame received", { sampleRate, mode: this.analysisMode });
12883
+ this.debugLog("[AudioSystem] First frame received", { sampleRate, mode: this.analysisMode });
12544
12884
  }
12545
12885
  const dtMs = this.lastAnalysisTimestamp > 0 ? timestampMs - this.lastAnalysisTimestamp : 1e3 / 60;
12546
12886
  this.lastAnalysisTimestamp = timestampMs;
@@ -12549,15 +12889,11 @@ class AudioSystem {
12549
12889
  const { rms, peak } = this.calculateVolumeMetrics(frame);
12550
12890
  this.audioState.volume.current = rms;
12551
12891
  this.audioState.volume.peak = peak;
12552
- this.appendPcmFrame(frame, sampleRate);
12553
- if (this.useEssentiaTempo && timestampMs - this.lastTempoExtraction > this.tempoExtractionIntervalMs) {
12554
- this.runEssentiaTempoEstimate(sampleRate);
12555
- this.lastTempoExtraction = timestampMs;
12556
- }
12892
+ this.lastWaveformFrame = frame;
12557
12893
  const fftResult = this.computeFFT(frame);
12558
12894
  if (!fftResult) {
12559
- if (this.analysisTicks === 1 || this.analysisTicks % 240 === 0) {
12560
- console.warn("[AudioSystem][analyzeFrame] computeFFT returned null at tick", this.analysisTicks);
12895
+ if (this.debugMode && (this.analysisTicks === 1 || this.analysisTicks % 240 === 0)) {
12896
+ this.debugLog("[AudioSystem][analyzeFrame] computeFFT returned null at tick", this.analysisTicks);
12561
12897
  }
12562
12898
  return;
12563
12899
  }
@@ -12578,80 +12914,76 @@ class AudioSystem {
12578
12914
  high: 0
12579
12915
  };
12580
12916
  this.rawBandsPreGain = { low: 0, lowMid: 0, mid: 0, highMid: 0, high: 0 };
12917
+ this.audioState.spectral = { brightness: 0, flatness: 0 };
12918
+ const decayed = this.stateManager.processEnvelopeDecay(dtMs);
12919
+ this.audioState.beat.kick = decayed.kick;
12920
+ this.audioState.beat.snare = decayed.snare;
12921
+ this.audioState.beat.hat = decayed.hat;
12922
+ this.audioState.beat.any = decayed.any;
12923
+ this.audioState.beat.kickSmoothed = decayed.kickSmoothed;
12924
+ this.audioState.beat.snareSmoothed = decayed.snareSmoothed;
12925
+ this.audioState.beat.hatSmoothed = decayed.hatSmoothed;
12926
+ this.audioState.beat.anySmoothed = decayed.anySmoothed;
12927
+ this.audioState.beat.events = [];
12581
12928
  this.updateSmoothBands(dtMs);
12582
12929
  this.sendAnalysisResultsToWorker();
12583
12930
  return;
12584
12931
  }
12585
- const rawBandsForOnsets = {
12586
- low: bandEnergies.low * this.sensitivity,
12587
- lowMid: bandEnergies.lowMid * this.sensitivity,
12588
- mid: bandEnergies.mid * this.sensitivity,
12589
- highMid: bandEnergies.highMid * this.sensitivity,
12590
- high: bandEnergies.high * this.sensitivity
12591
- };
12592
12932
  this.rawBandsPreGain = { ...bandEnergies };
12593
12933
  if (this.autoGainEnabled) {
12594
12934
  this.applyAutoGain();
12595
12935
  }
12596
12936
  this.calculateSpectralFeaturesFromMagnitude(magnitudes, sampleRate, maxMagnitude);
12597
12937
  if (this.onsetDetectionEnabled && this.beatDetectionEnabled) {
12598
- const beatState = this.runBeatPipeline(rawBandsForOnsets, dtMs, timestampMs, sampleRate);
12938
+ const beatState = this.runBeatPipeline(bandEnergies, dtMs, timestampMs, sampleRate);
12599
12939
  if (beatState) {
12600
- this.audioState.beat = beatState;
12601
- const pllState = this.pll.getState();
12602
- const currentState = this.stateManager.getState();
12603
- const trackingData = {
12604
- timeSinceKick: this.stateManager.getTimeSinceLastKick(),
12605
- phaseError: this.pll.getLastPhaseError(),
12606
- adaptiveFloors: this.stateManager.getAdaptiveFloors()
12607
- };
12608
- const kickPattern = this.stateManager.getKickPattern();
12609
- if (kickPattern !== void 0) {
12610
- trackingData.kickPattern = kickPattern;
12611
- }
12612
- const bpmVariance = this.stateManager.getBPMVariance();
12613
- if (bpmVariance !== void 0) {
12614
- trackingData.bpmVariance = bpmVariance;
12615
- }
12616
- const compressed = this.stateManager.isCompressed();
12617
- if (compressed) {
12618
- trackingData.compressed = compressed;
12619
- }
12620
- if (beatState.events.some((e) => e.type === "kick")) {
12621
- trackingData.kickPhase = pllState.phase;
12622
- }
12623
- if (beatState.debug?.pllGain !== void 0) {
12624
- trackingData.pllGain = beatState.debug.pllGain;
12625
- }
12626
- if (beatState.debug?.tempoConfidence !== void 0) {
12627
- trackingData.tempoConf = beatState.debug.tempoConfidence;
12628
- }
12629
- if (beatState.debug?.trackingConfidence !== void 0) {
12630
- trackingData.trackingConf = beatState.debug.trackingConfidence;
12631
- }
12632
- this.diagnosticLogger.logFrame(
12633
- timestampMs,
12634
- {
12635
- bpm: beatState.bpm,
12636
- phase: pllState.phase,
12637
- bar: pllState.bar,
12638
- confidence: beatState.confidence,
12639
- kick: beatState.events.some((e) => e.type === "kick"),
12640
- snare: beatState.events.some((e) => e.type === "snare"),
12641
- hat: beatState.events.some((e) => e.type === "hat"),
12642
- isLocked: currentState === "LOCKED",
12643
- state: currentState
12644
- },
12645
- {
12646
- low: this.audioState.bands.low,
12647
- lowMid: this.audioState.bands.lowMid,
12648
- mid: this.audioState.bands.mid,
12649
- highMid: this.audioState.bands.highMid,
12650
- high: this.audioState.bands.high
12651
- },
12652
- this.stateManager.getLastRejections(),
12653
- trackingData
12654
- );
12940
+ const { phase: _phase, bar: _bar, debug: _debug, ...beatForState } = beatState;
12941
+ const now = performance.now();
12942
+ this.audioState.beat = this.onsetTapManager.processFrame(beatForState, now, dtMs);
12943
+ if (this.debugMode) {
12944
+ const pllState = this.pll.getState();
12945
+ const currentState = this.stateManager.getState();
12946
+ const trackingData = {
12947
+ timeSinceKick: this.stateManager.getTimeSinceLastKick(),
12948
+ phaseError: this.pll.getLastPhaseError(),
12949
+ adaptiveFloors: this.stateManager.getAdaptiveFloors()
12950
+ };
12951
+ const kickPattern = this.stateManager.getKickPattern();
12952
+ if (kickPattern !== void 0) trackingData.kickPattern = kickPattern;
12953
+ const bpmVariance = this.stateManager.getBPMVariance();
12954
+ if (bpmVariance !== void 0) trackingData.bpmVariance = bpmVariance;
12955
+ const compressed = this.stateManager.isCompressed();
12956
+ if (compressed) trackingData.compressed = compressed;
12957
+ if (beatState.events.some((e) => e.type === "kick")) {
12958
+ trackingData.kickPhase = pllState.phase;
12959
+ }
12960
+ if (beatState.debug?.pllGain !== void 0) trackingData.pllGain = beatState.debug.pllGain;
12961
+ if (beatState.debug?.tempoConfidence !== void 0) trackingData.tempoConf = beatState.debug.tempoConfidence;
12962
+ if (beatState.debug?.trackingConfidence !== void 0) trackingData.trackingConf = beatState.debug.trackingConfidence;
12963
+ this.diagnosticLogger.logFrame(
12964
+ timestampMs,
12965
+ {
12966
+ bpm: beatState.bpm,
12967
+ phase: pllState.phase,
12968
+ bar: pllState.bar,
12969
+ confidence: beatState.confidence,
12970
+ kick: beatState.events.some((e) => e.type === "kick"),
12971
+ snare: beatState.events.some((e) => e.type === "snare"),
12972
+ hat: beatState.events.some((e) => e.type === "hat"),
12973
+ isLocked: currentState === "LOCKED",
12974
+ state: currentState
12975
+ },
12976
+ {
12977
+ low: this.audioState.bands.low,
12978
+ lowMid: this.audioState.bands.lowMid,
12979
+ mid: this.audioState.bands.mid,
12980
+ highMid: this.audioState.bands.highMid,
12981
+ high: this.audioState.bands.high
12982
+ },
12983
+ this.stateManager.getLastRejections(),
12984
+ trackingData
12985
+ );
12986
+ }
12655
12987
  }
12656
12988
  }
12657
12989
  this.updateSmoothBands(dtMs);
@@ -12661,30 +12993,9 @@ class AudioSystem {
12661
12993
  * Compute FFT and derived arrays
12662
12994
  */
12663
12995
  computeFFT(frame) {
12664
- if (this.analysisTicks === 1) {
12665
- console.log("[AudioSystem][computeFFT] FIRST CALL", {
12666
- hasEngine: !!this.fftEngine,
12667
- hasInput: !!this.fftInput,
12668
- hasOutput: !!this.fftOutput,
12669
- hasWindow: !!this.hannWindow,
12670
- hasFreqData: !!this.frequencyData,
12671
- hasMagnitude: !!this.fftMagnitude,
12672
- hasMagnitudeDb: !!this.fftMagnitudeDb,
12673
- hasPhase: !!this.fftPhase
12674
- });
12675
- }
12676
12996
  if (!this.fftEngine || !this.fftInput || !this.fftOutput || !this.hannWindow || !this.frequencyData || !this.fftMagnitude || !this.fftMagnitudeDb || !this.fftPhase) {
12677
- if (this.analysisTicks === 1 || this.analysisTicks % 240 === 0) {
12678
- console.warn("[AudioSystem][computeFFT] RETURNING NULL - ONE OR MORE RESOURCES MISSING");
12679
- console.warn(" tick:", this.analysisTicks);
12680
- console.warn(" hasEngine:", !!this.fftEngine);
12681
- console.warn(" hasInput:", !!this.fftInput);
12682
- console.warn(" hasOutput:", !!this.fftOutput);
12683
- console.warn(" hasWindow:", !!this.hannWindow);
12684
- console.warn(" hasFreqData:", !!this.frequencyData);
12685
- console.warn(" hasMagnitude:", !!this.fftMagnitude);
12686
- console.warn(" hasMagnitudeDb:", !!this.fftMagnitudeDb);
12687
- console.warn(" hasPhase:", !!this.fftPhase);
12997
+ if (this.debugMode && (this.analysisTicks === 1 || this.analysisTicks % 240 === 0)) {
12998
+ this.debugLog("[AudioSystem][computeFFT] RETURNING NULL - resources missing at tick", this.analysisTicks);
12688
12999
  }
12689
13000
  return null;
12690
13001
  }
@@ -12841,63 +13152,13 @@ class AudioSystem {
12841
13152
  } else {
12842
13153
  this.audioState.spectral.flatness = 0;
12843
13154
  }
12844
- const centroidChange = Math.abs(centroid - this.prevSpectralCentroid);
12845
- this.audioState.spectral.flux = Math.min(1, centroidChange / 1e3);
12846
- this.prevSpectralCentroid = centroid;
12847
- }
12848
- /**
12849
- * Append PCM frame to ring buffer for periodic Essentia tempo extraction
12850
- */
12851
- appendPcmFrame(frame, sampleRate) {
12852
- const requiredSize = Math.max(this.fftSize, Math.floor(sampleRate * this.pcmHistorySeconds));
12853
- if (!this.pcmRing || this.pcmRing.length !== requiredSize) {
12854
- this.pcmRing = new Float32Array(requiredSize);
12855
- this.pcmWriteIndex = 0;
12856
- this.pcmFilled = 0;
12857
- }
12858
- for (let i = 0; i < frame.length; i++) {
12859
- this.pcmRing[this.pcmWriteIndex] = frame[i];
12860
- this.pcmWriteIndex = (this.pcmWriteIndex + 1) % this.pcmRing.length;
12861
- if (this.pcmFilled < this.pcmRing.length) {
12862
- this.pcmFilled++;
12863
- }
12864
- }
12865
- }
12866
- /**
12867
- * Periodically estimate tempo using Essentia's RhythmExtractor2013 (offline chunk)
12868
- */
12869
- runEssentiaTempoEstimate(sampleRate) {
12870
- if (!this.essentiaOnsetDetection?.isReady() || !this.pcmRing) {
12871
- return;
12872
- }
12873
- const maxSamples = Math.min(this.pcmRing.length, Math.floor(sampleRate * this.pcmHistorySeconds));
12874
- const available = Math.min(this.pcmFilled, maxSamples);
12875
- if (available < sampleRate * 2) return;
12876
- const window2 = new Float32Array(available);
12877
- let idx = (this.pcmWriteIndex - available + this.pcmRing.length) % this.pcmRing.length;
12878
- for (let i = 0; i < available; i++) {
12879
- window2[i] = this.pcmRing[idx];
12880
- idx = (idx + 1) % this.pcmRing.length;
12881
- }
12882
- const tempo = this.essentiaOnsetDetection.detectTempoFromPCM(window2, sampleRate);
12883
- if (tempo?.bpm) {
12884
- const bpm = Math.max(60, Math.min(200, tempo.bpm));
12885
- this.pll.setBPM(bpm);
12886
- this.tempoInduction.setManualBPM(bpm);
12887
- }
12888
13155
  }
12889
13156
  /**
12890
13157
  * Run onset + beat detection pipeline and return BeatState
12891
13158
  */
12892
13159
  runBeatPipeline(rawBandsForOnsets, dtMs, timestampMs, sampleRate) {
12893
13160
  let onsets;
12894
- const backend = this.analysisBackend;
12895
- const allowEssentia = backend === "auto" || backend === "essentia";
12896
- if (backend === "aubio" && !this.aubioWarningLogged) {
12897
- console.warn("[AudioSystem] Aubio backend selected but not wired yet; using custom pipeline.");
12898
- this.aubioWarningLogged = true;
12899
- }
12900
- const essentiaReady = allowEssentia && (this.essentiaOnsetDetection?.isReady() || false) && !!this.fftMagnitudeDb && !!this.fftPhase;
13161
+ const essentiaReady = (this.essentiaOnsetDetection?.isReady() || false) && !!this.fftMagnitudeDb && !!this.fftPhase;
12901
13162
  const hasFftData = !!(this.fftMagnitudeDb && this.fftPhase);
12902
13163
  if (essentiaReady && hasFftData) {
12903
13164
  const essentiaResult = this.essentiaOnsetDetection.detectFromSpectrum(
@@ -12920,9 +13181,6 @@ class AudioSystem {
12920
13181
  } else {
12921
13182
  onsets = this.onsetDetection.detect(rawBandsForOnsets, dtMs);
12922
13183
  }
12923
- if (this.beatMode === "manual" && this.manualBPM !== null) {
12924
- this.tempoInduction.setManualBPM(this.manualBPM);
12925
- }
12926
13184
  const tempo = this.tempoInduction.update(onsets, dtMs);
12927
13185
  this.onsetDetection.setBPM(tempo.bpm);
12928
13186
  const trackingConf = this.stateManager.calculateTrackingConfidence(tempo);
@@ -13001,7 +13259,7 @@ class AudioSystem {
13001
13259
  const prevSource = this.lastBpmSource;
13002
13260
  this.lastNonZeroBpm = resolvedBpm;
13003
13261
  this.lastBpmSource = selected.source;
13004
- if ((this.debugMode || this.verboseLabLogs) && prevSource !== this.lastBpmSource) {
13262
+ if (this.debugMode && prevSource !== this.lastBpmSource) {
13005
13263
  console.log(`[AudioSystem][bpm] Source changed: ${prevSource} -> ${this.lastBpmSource} (resolved=${resolvedBpm.toFixed(2)}, pll=${beatState.bpm?.toFixed?.(2) ?? beatState.bpm}, tempo=${tempo.bpm?.toFixed?.(2) ?? tempo.bpm})`);
13006
13264
  }
13007
13265
  const mergedBeatState = beatState.bpm === resolvedBpm ? beatState : { ...beatState, bpm: resolvedBpm };
@@ -13019,8 +13277,6 @@ class AudioSystem {
13019
13277
  // Analysis configuration (optimized for onset detection)
13020
13278
  fftSize = 2048;
13021
13279
  // Good balance for quality vs performance
13022
- smoothingTimeConstant = 0;
13023
- // NO smoothing for onset detection (was 0.5 which killed transients!)
13024
13280
  // High-speed analysis for onset detection (separate from RAF)
13025
13281
  analysisInterval = null;
13026
13282
  analysisIntervalMs = 8;
@@ -13063,28 +13319,26 @@ class AudioSystem {
13063
13319
  any: 0,
13064
13320
  kickSmoothed: 0,
13065
13321
  snareSmoothed: 0,
13322
+ hatSmoothed: 0,
13066
13323
  anySmoothed: 0,
13067
13324
  events: [],
13068
13325
  bpm: 120,
13069
- phase: 0,
13070
- bar: 0,
13071
13326
  confidence: 0,
13072
13327
  isLocked: false
13073
13328
  },
13074
13329
  spectral: {
13075
13330
  brightness: 0,
13076
- flatness: 0,
13077
- flux: 0
13331
+ flatness: 0
13078
13332
  }
13079
13333
  };
13334
+ // Waveform data for transfer to worker
13335
+ lastWaveformFrame = null;
13080
13336
  // Analysis loop
13081
13337
  analysisLoopId = null;
13082
13338
  isAnalysisRunning = false;
13083
13339
  lastAnalysisTimestamp = 0;
13084
13340
  // Callback to send results to worker
13085
13341
  sendAnalysisResults = null;
13086
- // Previous spectral centroid for change calculation
13087
- prevSpectralCentroid = 0;
13088
13342
  constructor(sendAnalysisResultsCallback) {
13089
13343
  this.handleAudioStreamUpdate = this.handleAudioStreamUpdate.bind(this);
13090
13344
  this.performAnalysis = this.performAnalysis.bind(this);
@@ -13095,6 +13349,7 @@ class AudioSystem {
13095
13349
  this.tempoInduction = new TempoInduction();
13096
13350
  this.pll = new PhaseLockedLoop();
13097
13351
  this.stateManager = new BeatStateManager();
13352
+ this.onsetTapManager = new OnsetTapManager();
13098
13353
  this.volumeAutoGain = new AutoGain(3e3, 60);
13099
13354
  this.bandAutoGain = {
13100
13355
  low: new AutoGain(3e3, 60),
@@ -13112,6 +13367,7 @@ class AudioSystem {
13112
13367
  any: new EnvelopeFollower(0, 300, sampleRate),
13113
13368
  kickSmoothed: new EnvelopeFollower(5, 500, sampleRate),
13114
13369
  snareSmoothed: new EnvelopeFollower(5, 500, sampleRate),
13370
+ hatSmoothed: new EnvelopeFollower(5, 500, sampleRate),
13115
13371
  anySmoothed: new EnvelopeFollower(5, 500, sampleRate),
13116
13372
  // Volume smoothing
13117
13373
  volumeSmoothed: new EnvelopeFollower(50, 200, sampleRate),
@@ -13124,10 +13380,6 @@ class AudioSystem {
13124
13380
  };
13125
13381
  this.refreshFFTResources();
13126
13382
  this.resetEssentiaBandHistories();
13127
- if (this.isAudioLab) {
13128
- this.forceAnalyser = true;
13129
- console.warn("[AudioSystem] Audio-lab context detected: forcing analyser path for stability.");
13130
- }
13131
13383
  }
13132
13384
  /**
13133
13385
  * Prepare FFT buffers and windowing for the selected fftSize
@@ -13163,9 +13415,7 @@ class AudioSystem {
13163
13415
  if (!this.essentiaOnsetDetection) return;
13164
13416
  try {
13165
13417
  await this.essentiaOnsetDetection.initialize();
13166
- if (this.debugMode) {
13167
- console.log("✅ [AudioSystem] Essentia.js initialized successfully!");
13168
- }
13418
+ this.debugLog("[AudioSystem] Essentia.js initialized successfully");
13169
13419
  } catch (error) {
13170
13420
  console.warn("⚠️ [AudioSystem] Essentia.js initialization failed, using custom onset detection:", error);
13171
13421
  }
@@ -13195,9 +13445,6 @@ class AudioSystem {
13195
13445
  } else {
13196
13446
  this.disconnectAudioStream();
13197
13447
  }
13198
- if (data.analysisConfig) {
13199
- this.updateAnalysisConfig(data.analysisConfig);
13200
- }
13201
13448
  } catch (error) {
13202
13449
  console.error("Error handling audio stream update:", error);
13203
13450
  this.audioState.isConnected = false;
@@ -13228,7 +13475,7 @@ class AudioSystem {
13228
13475
  }
13229
13476
  }
13230
13477
  this.mediaStreamSource = this.audioContext.createMediaStreamSource(audioStream);
13231
- this.workletReady = !this.forceAnalyser && await this.setupAudioWorklet();
13478
+ this.workletReady = await this.setupAudioWorklet();
13232
13479
  if (this.workletReady && this.workletNode) {
13233
13480
  this.analysisMode = "worklet";
13234
13481
  this.currentSampleRate = this.audioContext.sampleRate;
@@ -13237,7 +13484,7 @@ class AudioSystem {
13237
13484
  this.debugLog("Audio worklet analysis enabled");
13238
13485
  setTimeout(() => {
13239
13486
  if (this.analysisMode === "worklet" && this.workletFrameCount === 0) {
13240
- console.warn("[AudioSystem] Worklet produced no frames, falling back to analyser.");
13487
+ this.debugLog("[AudioSystem] Worklet produced no frames, falling back to analyser.");
13241
13488
  this.analysisMode = "analyser";
13242
13489
  if (this.workletNode) {
13243
13490
  try {
@@ -13250,7 +13497,7 @@ class AudioSystem {
13250
13497
  this.workletReady = false;
13251
13498
  this.analyser = this.audioContext.createAnalyser();
13252
13499
  this.analyser.fftSize = this.fftSize;
13253
- this.analyser.smoothingTimeConstant = this.smoothingTimeConstant;
13500
+ this.analyser.smoothingTimeConstant = 0;
13254
13501
  this.mediaStreamSource.connect(this.analyser);
13255
13502
  bufferLength = this.analyser.frequencyBinCount;
13256
13503
  this.frequencyData = new Uint8Array(bufferLength);
@@ -13262,7 +13509,7 @@ class AudioSystem {
13262
13509
  this.analysisMode = "analyser";
13263
13510
  this.analyser = this.audioContext.createAnalyser();
13264
13511
  this.analyser.fftSize = this.fftSize;
13265
- this.analyser.smoothingTimeConstant = this.smoothingTimeConstant;
13512
+ this.analyser.smoothingTimeConstant = 0;
13266
13513
  this.mediaStreamSource.connect(this.analyser);
13267
13514
  this.debugLog("Audio worklet unavailable, using analyser fallback");
13268
13515
  bufferLength = this.analyser.frequencyBinCount;
@@ -13278,17 +13525,17 @@ class AudioSystem {
13278
13525
  this.startAnalysisLoop();
13279
13526
  }
13280
13527
  if (this.analysisMode === "analyser" && !this.isAnalysisRunning) {
13281
- console.warn("[AudioSystem] Analysis loop not running after setup, forcing start.");
13528
+ this.debugLog("[AudioSystem] Analysis loop not running after setup, forcing start.");
13282
13529
  this.startAnalysisLoop();
13283
13530
  }
13284
13531
  const tracks = audioStream.getAudioTracks();
13285
- this.logVerbose("[AudioSystem] Stream info", {
13532
+ this.debugLog("[AudioSystem] Stream info", {
13286
13533
  trackCount: tracks.length,
13287
13534
  trackSettings: tracks.map((t) => t.getSettings?.() || {}),
13288
13535
  trackMuted: tracks.map((t) => t.muted),
13289
- forceAnalyser: this.forceAnalyser,
13290
13536
  mode: this.analysisMode
13291
13537
  });
13538
+ this.startStalenessTimer();
13292
13539
  this.debugLog("Audio stream connected successfully (host-side)", {
13293
13540
  sampleRate: this.audioContext.sampleRate,
13294
13541
  fftSize: this.fftSize,
@@ -13306,6 +13553,7 @@ class AudioSystem {
13306
13553
  */
13307
13554
  disconnectAudioStream() {
13308
13555
  this.stopAnalysisLoop();
13556
+ this.stopStalenessTimer();
13309
13557
  this.resetEssentiaBandHistories();
13310
13558
  if (this.mediaStreamSource) {
13311
13559
  this.mediaStreamSource.disconnect();
@@ -13336,27 +13584,6 @@ class AudioSystem {
13336
13584
  this.sendAnalysisResultsToWorker();
13337
13585
  this.debugLog("Audio stream disconnected (host-side)");
13338
13586
  }
13339
- /**
13340
- * Update analysis configuration
13341
- */
13342
- updateAnalysisConfig(config) {
13343
- let needsReconnect = false;
13344
- if (config.fftSize && config.fftSize !== this.fftSize) {
13345
- this.fftSize = config.fftSize;
13346
- this.refreshFFTResources();
13347
- needsReconnect = true;
13348
- }
13349
- if (config.smoothing !== void 0) {
13350
- this.smoothingTimeConstant = config.smoothing;
13351
- if (this.analyser) {
13352
- this.analyser.smoothingTimeConstant = this.smoothingTimeConstant;
13353
- }
13354
- }
13355
- if (needsReconnect && this.currentStream) {
13356
- const stream = this.currentStream;
13357
- this.setAudioStream(stream);
13358
- }
13359
- }
13360
13587
  /**
13361
13588
  * Initialize audio worklet for high-quality capture (complex STFT path)
13362
13589
  */
@@ -13365,50 +13592,54 @@ class AudioSystem {
13365
13592
  return false;
13366
13593
  }
13367
13594
  try {
13368
- const workletSource = `
13369
- class AudioAnalysisProcessor extends AudioWorkletProcessor {
13370
- constructor(options) {
13371
- super();
13372
- const opts = (options && options.processorOptions) || {};
13373
- this.windowSize = opts.fftSize || 2048;
13374
- this.hopSize = opts.hopSize || this.windowSize / 2;
13375
- this.buffer = new Float32Array(this.windowSize);
13376
- this.writeIndex = 0;
13377
- }
13378
- process(inputs, outputs) {
13379
- const input = inputs[0];
13380
- const output = outputs[0];
13381
- const channel = input && input[0];
13382
- if (output) {
13383
- for (let ch = 0; ch < output.length; ch++) {
13384
- output[ch].fill(0);
13385
- }
13595
+ if (!this.workletRegistered) {
13596
+ const workletSource = `
13597
+ class AudioAnalysisProcessor extends AudioWorkletProcessor {
13598
+ constructor(options) {
13599
+ super();
13600
+ const opts = (options && options.processorOptions) || {};
13601
+ this.windowSize = opts.fftSize || 2048;
13602
+ this.hopSize = opts.hopSize || this.windowSize / 2;
13603
+ this.buffer = new Float32Array(this.windowSize);
13604
+ this.writeIndex = 0;
13386
13605
  }
13387
- if (!channel) return true;
13388
- for (let i = 0; i < channel.length; i++) {
13389
- this.buffer[this.writeIndex++] = channel[i];
13390
- if (this.writeIndex >= this.windowSize) {
13391
- const frame = new Float32Array(this.windowSize);
13392
- frame.set(this.buffer);
13393
- this.port.postMessage(
13394
- { type: 'audio-frame', samples: frame, sampleRate, timestamp: currentTime * 1000 },
13395
- [frame.buffer]
13396
- );
13397
- const overlap = this.windowSize - this.hopSize;
13398
- if (overlap > 0) {
13399
- this.buffer.set(this.buffer.subarray(this.hopSize, this.windowSize), 0);
13606
+ process(inputs, outputs) {
13607
+ const input = inputs[0];
13608
+ const output = outputs[0];
13609
+ const channel = input && input[0];
13610
+ if (output) {
13611
+ for (let ch = 0; ch < output.length; ch++) {
13612
+ output[ch].fill(0);
13400
13613
  }
13401
- this.writeIndex = overlap > 0 ? overlap : 0;
13402
13614
  }
13615
+ if (!channel) return true;
13616
+ for (let i = 0; i < channel.length; i++) {
13617
+ this.buffer[this.writeIndex++] = channel[i];
13618
+ if (this.writeIndex >= this.windowSize) {
13619
+ const frame = new Float32Array(this.windowSize);
13620
+ frame.set(this.buffer);
13621
+ this.port.postMessage(
13622
+ { type: 'audio-frame', samples: frame, sampleRate, timestamp: currentTime * 1000 },
13623
+ [frame.buffer]
13624
+ );
13625
+ const overlap = this.windowSize - this.hopSize;
13626
+ if (overlap > 0) {
13627
+ this.buffer.set(this.buffer.subarray(this.hopSize, this.windowSize), 0);
13628
+ }
13629
+ this.writeIndex = overlap > 0 ? overlap : 0;
13630
+ }
13631
+ }
13632
+ return true;
13403
13633
  }
13404
- return true;
13405
13634
  }
13406
- }
13407
- registerProcessor('audio-analysis-processor', AudioAnalysisProcessor);
13408
- `;
13409
- const blob = new Blob([workletSource], { type: "application/javascript" });
13410
- const workletUrl = URL.createObjectURL(blob);
13411
- await this.audioContext.audioWorklet.addModule(workletUrl);
13635
+ registerProcessor('audio-analysis-processor', AudioAnalysisProcessor);
13636
+ `;
13637
+ const blob = new Blob([workletSource], { type: "application/javascript" });
13638
+ const workletUrl = URL.createObjectURL(blob);
13639
+ await this.audioContext.audioWorklet.addModule(workletUrl);
13640
+ URL.revokeObjectURL(workletUrl);
13641
+ this.workletRegistered = true;
13642
+ }
13412
13643
  this.workletNode = new AudioWorkletNode(this.audioContext, "audio-analysis-processor", {
13413
13644
  numberOfOutputs: 1,
13414
13645
  outputChannelCount: [1],
@@ -13434,26 +13665,15 @@ class AudioSystem {
13434
13665
  * Start the audio analysis loop at high speed (8ms intervals for transient capture)
13435
13666
  */
13436
13667
  startAnalysisLoop() {
13437
- console.log("[AudioSystem] startAnalysisLoop called", {
13438
- isAnalysisRunning: this.isAnalysisRunning,
13439
- analysisMode: this.analysisMode,
13440
- hasAnalyser: !!this.analyser,
13441
- hasContext: !!this.audioContext
13442
- });
13443
13668
  if (this.isAnalysisRunning || this.analysisMode !== "analyser") {
13444
- console.warn("[AudioSystem] startAnalysisLoop SKIPPED", {
13445
- isAnalysisRunning: this.isAnalysisRunning,
13446
- analysisMode: this.analysisMode
13447
- });
13448
13669
  return;
13449
13670
  }
13450
13671
  this.isAnalysisRunning = true;
13451
13672
  this.analysisInterval = window.setInterval(() => {
13452
13673
  this.performAnalysis();
13453
13674
  }, this.analysisIntervalMs);
13454
- console.log("[AudioSystem] Analysis loop started", {
13455
- intervalMs: this.analysisIntervalMs,
13456
- intervalId: this.analysisInterval
13675
+ this.debugLog("[AudioSystem] Analysis loop started", {
13676
+ intervalMs: this.analysisIntervalMs
13457
13677
  });
13458
13678
  }
13459
13679
  /**
@@ -13470,6 +13690,24 @@ class AudioSystem {
13470
13690
  this.analysisLoopId = null;
13471
13691
  }
13472
13692
  }
13693
+ startStalenessTimer() {
13694
+ this.stopStalenessTimer();
13695
+ this.lastFrameTime = performance.now();
13696
+ this.stalenessTimer = setInterval(() => {
13697
+ if (!this.audioState.isConnected || this.lastFrameTime === 0) return;
13698
+ const elapsed = performance.now() - this.lastFrameTime;
13699
+ if (elapsed > AudioSystem.STALENESS_THRESHOLD_MS) {
13700
+ this.resetAudioValues();
13701
+ this.sendAnalysisResultsToWorker();
13702
+ }
13703
+ }, 250);
13704
+ }
13705
+ stopStalenessTimer() {
13706
+ if (this.stalenessTimer !== null) {
13707
+ clearInterval(this.stalenessTimer);
13708
+ this.stalenessTimer = null;
13709
+ }
13710
+ }
13473
13711
  /**
13474
13712
  * Pause audio analysis (for tests or temporary suspension)
13475
13713
  * The setInterval continues but performAnalysis() exits early
@@ -13492,25 +13730,7 @@ class AudioSystem {
13492
13730
  * Layer 4: BeatStateManager (state machine + confidence)
13493
13731
  */
13494
13732
  performAnalysis() {
13495
- if (this.analysisTicks === 0) {
13496
- console.log("[AudioSystem] performAnalysis FIRST CALL", {
13497
- isAnalysisRunning: this.isAnalysisRunning,
13498
- mode: this.analysisMode,
13499
- hasContext: !!this.audioContext,
13500
- hasAnalyser: !!this.analyser
13501
- });
13502
- }
13503
13733
  if (!this.isAnalysisRunning || this.analysisMode !== "analyser" || !this.audioContext || !this.analyser) {
13504
- const now = performance.now();
13505
- if (now - this.lastLoopWarn > 2e3) {
13506
- console.warn("[AudioSystem] performAnalysis skipped", {
13507
- isAnalysisRunning: this.isAnalysisRunning,
13508
- mode: this.analysisMode,
13509
- hasContext: !!this.audioContext,
13510
- hasAnalyser: !!this.analyser
13511
- });
13512
- this.lastLoopWarn = now;
13513
- }
13514
13734
  return;
13515
13735
  }
13516
13736
  if (!this.timeDomainData || this.timeDomainData.length !== this.fftSize) {
@@ -13518,14 +13738,6 @@ class AudioSystem {
13518
13738
  }
13519
13739
  this.analyser.getFloatTimeDomainData(this.timeDomainData);
13520
13740
  this.analysisTicks++;
13521
- if (this.analysisTicks === 1) {
13522
- console.log("[AudioSystem][analyser] first tick", {
13523
- fftSize: this.fftSize,
13524
- sampleRate: this.audioContext.sampleRate,
13525
- bufLen: this.timeDomainData.length
13526
- });
13527
- this.logVerbose("[AudioSystem][analyser] first 8 samples", Array.from(this.timeDomainData.slice(0, 8)));
13528
- }
13529
13741
  for (let i = 0; i < 32 && i < this.timeDomainData.length; i++) {
13530
13742
  Math.abs(this.timeDomainData[i]);
13531
13743
  }
@@ -13533,9 +13745,6 @@ class AudioSystem {
13533
13745
  this.analyzeFrame(this.timeDomainData, this.audioContext.sampleRate, detectionTimeMs);
13534
13746
  }
13535
13747
  applyAutoGain() {
13536
- if (this.debugDisableAutoGain) {
13537
- return;
13538
- }
13539
13748
  this.audioState.volume.current = this.volumeAutoGain.process(this.audioState.volume.current);
13540
13749
  this.audioState.volume.peak = this.volumeAutoGain.process(this.audioState.volume.peak);
13541
13750
  const bands = this.audioState.bands;
@@ -13581,12 +13790,17 @@ class AudioSystem {
13581
13790
  sendAnalysisResultsToWorker() {
13582
13791
  if (this.sendAnalysisResults) {
13583
13792
  const frequencyData = this.frequencyData ? new Uint8Array(this.frequencyData) : new Uint8Array(0);
13793
+ const waveformData = this.lastWaveformFrame ? new Float32Array(this.lastWaveformFrame) : new Float32Array(0);
13584
13794
  this.sendAnalysisResults({
13585
13795
  type: "audio-analysis-update",
13586
13796
  data: {
13587
- ...this.audioState,
13797
+ isConnected: this.audioState.isConnected,
13798
+ volume: this.audioState.volume,
13799
+ bands: this.audioState.bands,
13800
+ beat: this.audioState.beat,
13801
+ spectral: this.audioState.spectral,
13588
13802
  frequencyData,
13589
- // For getFrequencyData() access
13803
+ waveformData,
13590
13804
  timestamp: performance.now()
13591
13805
  }
13592
13806
  });
@@ -13610,18 +13824,16 @@ class AudioSystem {
13610
13824
  any: 0,
13611
13825
  kickSmoothed: 0,
13612
13826
  snareSmoothed: 0,
13827
+ hatSmoothed: 0,
13613
13828
  anySmoothed: 0,
13614
13829
  events: [],
13615
13830
  bpm: 120,
13616
- phase: 0,
13617
- bar: 0,
13618
13831
  confidence: 0,
13619
13832
  isLocked: false
13620
13833
  };
13621
13834
  this.audioState.spectral = {
13622
13835
  brightness: 0,
13623
- flatness: 0,
13624
- flux: 0
13836
+ flatness: 0
13625
13837
  };
13626
13838
  }
13627
13839
  /**
@@ -13636,6 +13848,7 @@ class AudioSystem {
13636
13848
  }
13637
13849
  this.workletNode = null;
13638
13850
  this.workletReady = false;
13851
+ this.workletRegistered = false;
13639
13852
  this.analysisMode = "analyser";
13640
13853
  this.onsetDetection.reset();
13641
13854
  this.tempoInduction.reset();
@@ -13643,183 +13856,48 @@ class AudioSystem {
13643
13856
  this.stateManager.reset();
13644
13857
  this.volumeAutoGain.reset();
13645
13858
  Object.values(this.bandAutoGain).forEach((g) => g.reset());
13646
- this.beatMode = "auto";
13647
- this.manualBPM = null;
13648
- this.tapHistory = [];
13649
- if (this.tapTimeout) {
13650
- clearTimeout(this.tapTimeout);
13651
- this.tapTimeout = null;
13652
- }
13859
+ this.onsetTapManager.reset();
13653
13860
  Object.values(this.envelopeFollowers).forEach((env) => env.reset());
13654
- this.pcmRing = null;
13655
- this.pcmWriteIndex = 0;
13656
- this.pcmFilled = 0;
13657
- this.lastTempoExtraction = 0;
13658
13861
  this.resetAudioValues();
13659
13862
  }
13660
- /**
13661
- * Get current analysis configuration
13662
- */
13663
- getAnalysisConfig() {
13664
- return {
13665
- fftSize: this.fftSize,
13666
- smoothing: this.smoothingTimeConstant
13667
- };
13668
- }
13669
- /**
13670
- * Set analysis backend preference
13671
- */
13672
- setAnalysisBackend(backend) {
13673
- this.analysisBackend = backend;
13674
- }
13675
- getAnalysisBackend() {
13676
- return this.analysisBackend;
13677
- }
13678
- /**
13679
- * Force analyser path (skip worklet) for debugging
13680
- */
13681
- setForceAnalyser(enabled) {
13682
- this.forceAnalyser = enabled;
13683
- if (enabled) {
13684
- console.warn("[AudioSystem] forceAnalyser enabled - worklet will be skipped.");
13685
- }
13686
- }
13687
- isForceAnalyser() {
13688
- return this.forceAnalyser;
13689
- }
13690
- /**
13691
- * Enable/disable Essentia tempo extraction (disabled by default due to WASM exception config)
13692
- */
13693
- setEssentiaTempoEnabled(enabled) {
13694
- this.useEssentiaTempo = enabled;
13695
- }
13696
- isEssentiaTempoEnabled() {
13697
- return this.useEssentiaTempo;
13698
- }
13699
13863
  // ═══════════════════════════════════════════════════════════
13700
13864
  // Public API Methods for Audio Analysis Configuration
13701
13865
  // ═══════════════════════════════════════════════════════════
13702
13866
  /**
13703
- * Set global sensitivity multiplier
13704
- * @param value - Sensitivity (0.5-2.0, default 1.0)
13867
+ * Record a tap for the specified instrument onset
13705
13868
  */
13706
- setSensitivity(value) {
13707
- this.sensitivity = Math.max(0.5, Math.min(2, value));
13869
+ tapOnset(instrument) {
13870
+ this.onsetTapManager.tap(instrument);
13708
13871
  }
13709
13872
  /**
13710
- * Get current sensitivity
13873
+ * Clear tap pattern for an instrument, restoring auto-detection
13711
13874
  */
13712
- getSensitivity() {
13713
- return this.sensitivity;
13875
+ clearOnsetTap(instrument) {
13876
+ this.onsetTapManager.clear(instrument);
13714
13877
  }
13715
13878
  /**
13716
- * Tap tempo - record tap for manual BPM
13879
+ * Get the current onset mode for an instrument
13717
13880
  */
13718
- tapTempo() {
13719
- const now = performance.now();
13720
- if (this.tapTimeout) {
13721
- clearTimeout(this.tapTimeout);
13722
- }
13723
- if (this.tapHistory.length > 0) {
13724
- const lastTap = this.tapHistory[this.tapHistory.length - 1];
13725
- const interval = now - lastTap;
13726
- if (interval < 200) {
13727
- this.startTapClearTimeout();
13728
- return;
13729
- }
13730
- if (interval > 2e3) {
13731
- this.tapHistory = [];
13732
- }
13733
- }
13734
- this.tapHistory.push(now);
13735
- if (this.tapHistory.length > 8) {
13736
- this.tapHistory.shift();
13737
- }
13738
- if (this.tapHistory.length >= 2) {
13739
- const bpm = this.calculateBPMFromTaps();
13740
- this.manualBPM = bpm;
13741
- this.beatMode = "manual";
13742
- this.pll.setBPM(bpm);
13743
- this.tempoInduction.setManualBPM(bpm);
13744
- }
13745
- this.startTapClearTimeout();
13746
- }
13747
- /**
13748
- * Calculate BPM from tap history
13749
- */
13750
- calculateBPMFromTaps() {
13751
- if (this.tapHistory.length < 2) return 120;
13752
- const intervals = [];
13753
- for (let i = 1; i < this.tapHistory.length; i++) {
13754
- intervals.push(this.tapHistory[i] - this.tapHistory[i - 1]);
13755
- }
13756
- if (intervals.length >= 4) {
13757
- const sorted = [...intervals].sort((a, b) => a - b);
13758
- const median = sorted[Math.floor(sorted.length / 2)];
13759
- const filtered = intervals.filter((val) => {
13760
- const deviation = Math.abs(val - median) / median;
13761
- return deviation < 0.3;
13762
- });
13763
- if (filtered.length >= 2) {
13764
- intervals.splice(0, intervals.length, ...filtered);
13765
- }
13766
- }
13767
- const avgInterval = intervals.reduce((sum, val) => sum + val, 0) / intervals.length;
13768
- let bpm = 6e4 / avgInterval;
13769
- while (bpm > 200) bpm /= 2;
13770
- while (bpm < 60) bpm *= 2;
13771
- return Math.round(bpm * 10) / 10;
13772
- }
13773
- /**
13774
- * Start auto-clear timeout for tap tempo
13775
- */
13776
- startTapClearTimeout() {
13777
- this.tapTimeout = setTimeout(() => {
13778
- this.tapHistory = [];
13779
- }, 2e3);
13780
- }
13781
- /**
13782
- * Clear tap tempo history
13783
- */
13784
- clearTaps() {
13785
- this.tapHistory = [];
13786
- if (this.tapTimeout) {
13787
- clearTimeout(this.tapTimeout);
13788
- this.tapTimeout = null;
13789
- }
13881
+ getOnsetMode(instrument) {
13882
+ return this.onsetTapManager.getMode(instrument);
13790
13883
  }
13791
13884
  /**
13792
- * Get tap count
13885
+ * Get pattern info for an instrument (null if no pattern recognized)
13793
13886
  */
13794
- getTapCount() {
13795
- return this.tapHistory.length;
13796
- }
13797
- /**
13798
- * Set beat sync mode
13799
- */
13800
- setBeatMode(mode) {
13801
- this.beatMode = mode;
13802
- if (mode === "auto") {
13803
- this.clearTaps();
13804
- this.manualBPM = null;
13805
- }
13887
+ getOnsetPatternInfo(instrument) {
13888
+ return this.onsetTapManager.getPatternInfo(instrument);
13806
13889
  }
13807
13890
  /**
13808
- * Get beat sync mode
13891
+ * Mute/unmute an instrument onset (suppresses output without destroying state)
13809
13892
  */
13810
- getBeatMode() {
13811
- return this.beatMode;
13893
+ setOnsetMuted(instrument, muted) {
13894
+ this.onsetTapManager.setMuted(instrument, muted);
13812
13895
  }
13813
13896
  /**
13814
- * Set manual BPM
13897
+ * Check if an instrument onset is muted
13815
13898
  */
13816
- setManualBPM(bpm) {
13817
- bpm = Math.max(60, Math.min(200, bpm));
13818
- this.manualBPM = bpm;
13819
- this.beatMode = "manual";
13820
- this.pll.setBPM(bpm);
13821
- this.tempoInduction.setManualBPM(bpm);
13822
- this.clearTaps();
13899
+ isOnsetMuted(instrument) {
13900
+ return this.onsetTapManager.isMuted(instrument);
13823
13901
  }
13824
13902
  /**
13825
13903
  * Get current BPM (manual or auto-detected)
@@ -13851,21 +13929,11 @@ class AudioSystem {
13851
13929
  this.setAudioStream(stream);
13852
13930
  }
13853
13931
  }
13854
- /**
13855
- * Set smoothing time constant
13856
- */
13857
- setSmoothing(value) {
13858
- this.smoothingTimeConstant = Math.max(0, Math.min(1, value));
13859
- if (this.analyser) {
13860
- this.analyser.smoothingTimeConstant = this.smoothingTimeConstant;
13861
- }
13862
- }
13863
13932
  /**
13864
13933
  * Enable/disable auto-gain
13865
13934
  */
13866
13935
  setAutoGain(enabled) {
13867
13936
  this.autoGainEnabled = enabled;
13868
- this.debugDisableAutoGain = !enabled;
13869
13937
  if (!enabled) {
13870
13938
  this.volumeAutoGain.reset();
13871
13939
  Object.values(this.bandAutoGain).forEach((g) => g.reset());
@@ -13899,11 +13967,13 @@ class AudioSystem {
13899
13967
  isConnected: this.audioState.isConnected,
13900
13968
  currentBPM: this.pll.getBPM(),
13901
13969
  confidence: this.tempoInduction.getConfidence(),
13902
- mode: this.beatMode,
13903
- tapCount: this.tapHistory.length,
13904
13970
  isLocked: this.stateManager.isLocked(),
13905
- sensitivity: this.sensitivity,
13906
- trackingState: this.stateManager.getState()
13971
+ trackingState: this.stateManager.getState(),
13972
+ onsetModes: {
13973
+ kick: this.onsetTapManager.getMode("kick"),
13974
+ snare: this.onsetTapManager.getMode("snare"),
13975
+ hat: this.onsetTapManager.getMode("hat")
13976
+ }
13907
13977
  };
13908
13978
  }
13909
13979
  /**
@@ -13982,7 +14052,6 @@ class AudioSystem {
13982
14052
  volumeGain: this.volumeAutoGain.getGain(),
13983
14053
  // Timing info
13984
14054
  dtMs: this.lastDtMs,
13985
- smoothingTimeConstant: this.smoothingTimeConstant,
13986
14055
  analysisIntervalMs: this.analysisIntervalMs,
13987
14056
  // Current events
13988
14057
  events: [...this.audioState.beat.events]
@@ -14541,15 +14610,25 @@ class VijiCore {
14541
14610
  currentAudioStream = null;
14542
14611
  // ═══════════════════════════════════════════════════════════════════════════
14543
14612
  // VIDEO STREAM INDEX CONTRACT:
14544
- // Index 0: ALWAYS reserved for main video (with CV) - even when absent
14545
- // Index 1..N: Additional streams (no CV) - where N = videoStreams.length
14546
- // Index N+1..: Device streams - dynamically allocated, tracked in deviceVideoStreamIndices
14613
+ // Each stream category uses a fixed, independent index range in the worker's
14614
+ // videoSystems[] array. Categories cannot collide regardless of add/remove order.
14615
+ //
14616
+ // Index 0: Main video (with CV) - always reserved, even when absent
14617
+ // Index 1..99: Additional MediaStreams (no CV)
14618
+ // Index 100..199: Device video streams
14619
+ // Index 200..299: Direct frame injection (compositor pipeline)
14620
+ //
14621
+ // The worker filters by streamType when building artist-facing arrays
14622
+ // (viji.streams[], viji.devices[].video), so absolute indices are internal.
14547
14623
  // ═══════════════════════════════════════════════════════════════════════════
14624
+ static ADDITIONAL_STREAM_BASE = 1;
14625
+ static DEVICE_VIDEO_BASE = 100;
14626
+ static DIRECT_FRAME_BASE = 200;
14548
14627
  // Separated video stream management
14549
14628
  videoStream = null;
14550
14629
  // Main stream (CV enabled) - always index 0
14551
14630
  videoStreams = [];
14552
- // Additional streams (no CV) - indices 1..N
14631
+ // Additional streams (no CV)
14553
14632
  // Video coordinators
14554
14633
  mainVideoCoordinator = null;
14555
14634
  additionalCoordinators = [];
@@ -14557,7 +14636,6 @@ class VijiCore {
14557
14636
  directFrameSlots = 0;
14558
14637
  // Device video management (coordinators only, for cleanup)
14559
14638
  deviceVideoCoordinators = /* @__PURE__ */ new Map();
14560
- // Track assigned stream indices to prevent collisions (indices start at 1 + videoStreams.length)
14561
14639
  deviceVideoStreamIndices = /* @__PURE__ */ new Map();
14562
14640
  // Auto-capture frame buffer (zero-copy transfer)
14563
14641
  latestFrameBuffer = null;
@@ -14598,7 +14676,6 @@ class VijiCore {
14598
14676
  this.config = {
14599
14677
  ...config,
14600
14678
  frameRateMode: config.frameRateMode || "full",
14601
- parameters: config.parameters || [],
14602
14679
  noInputs: config.noInputs ?? false,
14603
14680
  // Canvas DOM interaction disabled in headless by default
14604
14681
  // InteractionManager (viji.mouse/etc) always exists regardless
@@ -14635,7 +14712,8 @@ class VijiCore {
14635
14712
  this.latestFrameBuffer = bitmap;
14636
14713
  }
14637
14714
  /**
14638
- * Get latest frame (transfers ownership, zero-copy)
14715
+ * Get latest frame (transfers ownership, zero-copy).
14716
+ * Internal: consumed by getLatestFramesFromSources().
14639
14717
  */
14640
14718
  getLatestFrame() {
14641
14719
  const frame = this.latestFrameBuffer;
@@ -14691,7 +14769,6 @@ class VijiCore {
14691
14769
  if (this.interactionManager && "setDebugMode" in this.interactionManager) {
14692
14770
  this.interactionManager.setDebugMode(enabled);
14693
14771
  }
14694
- if (this.audioSystem && "setDebugMode" in this.audioSystem) ;
14695
14772
  if (this.mainVideoCoordinator && "setDebugMode" in this.mainVideoCoordinator) {
14696
14773
  this.mainVideoCoordinator.setDebugMode(enabled);
14697
14774
  }
@@ -14725,15 +14802,6 @@ class VijiCore {
14725
14802
  hide() {
14726
14803
  this.iframeManager?.hide();
14727
14804
  }
14728
- /**
14729
- * Select audio analysis backend
14730
- */
14731
- setAudioAnalysisBackend(backend) {
14732
- this.audioSystem?.setAnalysisBackend(backend);
14733
- }
14734
- getAudioAnalysisBackend() {
14735
- return this.audioSystem?.getAnalysisBackend() ?? "auto";
14736
- }
14737
14805
  /**
14738
14806
  * Initializes the core components in sequence
14739
14807
  */
@@ -14796,7 +14864,7 @@ class VijiCore {
14796
14864
  });
14797
14865
  }
14798
14866
  for (let i = 0; i < this.videoStreams.length; i++) {
14799
- const streamIndex = 1 + i;
14867
+ const streamIndex = VijiCore.ADDITIONAL_STREAM_BASE + i;
14800
14868
  const coordinator = new VideoCoordinator((message, transfer) => {
14801
14869
  if (this.workerManager) {
14802
14870
  if (message.type === "video-canvas-setup") {
@@ -15212,13 +15280,6 @@ class VijiCore {
15212
15280
  // ═══════════════════════════════════════════════════════════════════════════
15213
15281
  // Direct Frame Injection API (Compositor Pipeline)
15214
15282
  // ═══════════════════════════════════════════════════════════════════════════
15215
- /**
15216
- * Worker-side stream index offset for direct frame slots.
15217
- * Accounts for main video stream and additional media streams.
15218
- */
15219
- get directFrameStartIndex() {
15220
- return (this.videoStream ? 1 : 0) + this.videoStreams.length;
15221
- }
15222
15283
  /**
15223
15284
  * Ensure the worker has enough direct frame slots prepared.
15224
15285
  * Auto-grows as needed; skips if already sufficient.
@@ -15227,8 +15288,7 @@ class VijiCore {
15227
15288
  if (count <= this.directFrameSlots) return;
15228
15289
  this.directFrameSlots = count;
15229
15290
  this.workerManager.postMessage("video-streams-prepare", {
15230
- mainStream: !!this.videoStream,
15231
- mediaStreamCount: this.videoStreams.length,
15291
+ directFrameBaseIndex: VijiCore.DIRECT_FRAME_BASE,
15232
15292
  directFrameCount: count
15233
15293
  });
15234
15294
  this.debugLog(`[Compositor] Prepared ${count} direct frame slot(s)`);
@@ -15245,27 +15305,10 @@ class VijiCore {
15245
15305
  this.ensureDirectFrameSlots(streamIndex + 1);
15246
15306
  this.workerManager.postMessage("video-frame-direct", {
15247
15307
  imageBitmap: bitmap,
15248
- streamIndex: this.directFrameStartIndex + streamIndex,
15308
+ streamIndex: VijiCore.DIRECT_FRAME_BASE + streamIndex,
15249
15309
  timestamp: performance.now()
15250
15310
  }, [bitmap]);
15251
15311
  }
15252
- /**
15253
- * Inject frames for all direct-frame stream slots at once (compositor pipeline).
15254
- * Auto-prepares slots on first call. Transfers ownership of all bitmaps (zero-copy).
15255
- */
15256
- injectFrames(bitmaps) {
15257
- if (!this.workerManager?.ready) {
15258
- throw new VijiCoreError("Core not ready", "NOT_READY");
15259
- }
15260
- this.ensureDirectFrameSlots(bitmaps.length);
15261
- bitmaps.forEach((bitmap, index) => {
15262
- this.workerManager.postMessage("video-frame-direct", {
15263
- imageBitmap: bitmap,
15264
- streamIndex: this.directFrameStartIndex + index,
15265
- timestamp: performance.now()
15266
- }, [bitmap]);
15267
- });
15268
- }
15269
15312
  /**
15270
15313
  * Link this core to receive events from a source core
15271
15314
  * @param syncResolution Smart default: true for headless, false for visible
@@ -15600,7 +15643,6 @@ class VijiCore {
15600
15643
  if (this.audioSystem) {
15601
15644
  this.audioSystem.handleAudioStreamUpdate({
15602
15645
  audioStream,
15603
- ...this.config.analysisConfig && { analysisConfig: this.config.analysisConfig },
15604
15646
  timestamp: performance.now()
15605
15647
  });
15606
15648
  }
@@ -15660,31 +15702,10 @@ class VijiCore {
15660
15702
  return this.videoStream;
15661
15703
  }
15662
15704
  // ═══════════════════════════════════════════════════════
15663
- // ADDITIONAL VIDEO STREAMS API (new)
15705
+ // ADDITIONAL VIDEO STREAMS API
15664
15706
  // ═══════════════════════════════════════════════════════
15665
15707
  /**
15666
- * Sets all additional video streams (no CV)
15667
- */
15668
- async setVideoStreams(streams) {
15669
- this.validateReady();
15670
- this.videoStreams = [...streams];
15671
- await this.reinitializeAdditionalCoordinators();
15672
- this.debugLog(`Additional video streams updated: ${streams.length} streams`);
15673
- }
15674
- /**
15675
- * Gets all additional video streams
15676
- */
15677
- getVideoStreams() {
15678
- return [...this.videoStreams];
15679
- }
15680
- /**
15681
- * Gets video stream at specific index
15682
- */
15683
- getVideoStreamAt(index) {
15684
- return this.videoStreams[index] || null;
15685
- }
15686
- /**
15687
- * Adds a video stream
15708
+ * Adds an additional video stream (no CV). Returns its index in viji.streams[].
15688
15709
  */
15689
15710
  async addVideoStream(stream) {
15690
15711
  this.validateReady();
@@ -15692,7 +15713,7 @@ class VijiCore {
15692
15713
  if (existingIndex !== -1) return existingIndex;
15693
15714
  this.videoStreams.push(stream);
15694
15715
  const newIndex = this.videoStreams.length - 1;
15695
- const streamIndex = 1 + newIndex;
15716
+ const streamIndex = VijiCore.ADDITIONAL_STREAM_BASE + newIndex;
15696
15717
  const coordinator = new VideoCoordinator((message, transfer) => {
15697
15718
  if (this.workerManager) {
15698
15719
  if (message.type === "video-canvas-setup") {
@@ -15717,9 +15738,9 @@ class VijiCore {
15717
15738
  return newIndex;
15718
15739
  }
15719
15740
  /**
15720
- * Removes video stream at index
15741
+ * Removes an additional video stream by index.
15721
15742
  */
15722
- async removeVideoStreamAt(index) {
15743
+ async removeVideoStream(index) {
15723
15744
  this.validateReady();
15724
15745
  if (index < 0 || index >= this.videoStreams.length) {
15725
15746
  throw new VijiCoreError(`Invalid stream index: ${index}`, "INVALID_INDEX");
@@ -15729,43 +15750,19 @@ class VijiCore {
15729
15750
  await this.reinitializeAdditionalCoordinators();
15730
15751
  }
15731
15752
  /**
15732
- * Removes video stream by reference
15753
+ * Gets the number of additional video streams.
15733
15754
  */
15734
- async removeVideoStream(stream) {
15735
- const index = this.videoStreams.indexOf(stream);
15736
- if (index === -1) return false;
15737
- await this.removeVideoStreamAt(index);
15738
- return true;
15755
+ getVideoStreamCount() {
15756
+ return this.videoStreams.length;
15739
15757
  }
15740
15758
  /**
15741
- * Updates video stream at index
15742
- */
15743
- async setVideoStreamAt(index, stream) {
15744
- this.validateReady();
15745
- if (index < 0 || index >= this.videoStreams.length) {
15746
- throw new VijiCoreError(`Invalid stream index: ${index}`, "INVALID_INDEX");
15747
- }
15748
- this.videoStreams[index].getTracks().forEach((track) => track.stop());
15749
- this.videoStreams[index] = stream;
15750
- if (this.additionalCoordinators[index]) {
15751
- this.additionalCoordinators[index].resetVideoState();
15752
- this.additionalCoordinators[index].handleVideoStreamUpdate({
15753
- videoStream: stream,
15754
- streamIndex: 1 + index,
15755
- streamType: "additional",
15756
- targetFrameRate: 30,
15757
- timestamp: performance.now()
15758
- });
15759
- }
15760
- }
15761
- /**
15762
- * Reinitializes all additional coordinators
15759
+ * Reinitializes all additional coordinators after array mutation.
15763
15760
  */
15764
15761
  async reinitializeAdditionalCoordinators() {
15765
15762
  this.additionalCoordinators.forEach((coord) => coord.resetVideoState());
15766
15763
  this.additionalCoordinators = [];
15767
15764
  for (let i = 0; i < this.videoStreams.length; i++) {
15768
- const streamIndex = 1 + i;
15765
+ const streamIndex = VijiCore.ADDITIONAL_STREAM_BASE + i;
15769
15766
  const coordinator = new VideoCoordinator((message, transfer) => {
15770
15767
  if (this.workerManager) {
15771
15768
  if (message.type === "video-canvas-setup") {
@@ -15825,98 +15822,82 @@ class VijiCore {
15825
15822
  // Audio Analysis API (Namespace-based)
15826
15823
  // ═══════════════════════════════════════════════════════════
15827
15824
  audio = {
15828
- /**
15829
- * Set global audio sensitivity (0.5-2.0, default 1.0)
15830
- */
15831
- setSensitivity: (value) => {
15832
- this.validateReady();
15833
- if (value < 0.5 || value > 2) {
15834
- throw new VijiCoreError("Sensitivity must be between 0.5 and 2.0", "INVALID_VALUE");
15835
- }
15836
- this.audioSystem?.setSensitivity(value);
15837
- this.debugLog(`Audio sensitivity set to ${value} (${this.instanceId})`);
15838
- },
15839
- /**
15840
- * Get current sensitivity
15841
- */
15842
- getSensitivity: () => {
15843
- this.validateReady();
15844
- return this.audioSystem?.getSensitivity() ?? 1;
15845
- },
15846
15825
  /**
15847
15826
  * Beat control namespace
15848
15827
  */
15849
15828
  beat: {
15850
15829
  /**
15851
- * Tap tempo - record tap for manual BPM
15830
+ * Get current BPM (auto-detected)
15852
15831
  */
15853
- tap: () => {
15832
+ getBPM: () => {
15854
15833
  this.validateReady();
15855
- this.audioSystem?.tapTempo();
15856
- this.debugLog(`Tap tempo recorded (${this.instanceId})`);
15834
+ return this.audioSystem?.getCurrentBPM() ?? 120;
15857
15835
  },
15858
15836
  /**
15859
- * Clear tap tempo history
15837
+ * Nudge beat phase
15860
15838
  */
15861
- clearTaps: () => {
15839
+ nudge: (amount) => {
15862
15840
  this.validateReady();
15863
- this.audioSystem?.clearTaps();
15864
- this.debugLog(`Tap tempo cleared (${this.instanceId})`);
15841
+ this.audioSystem?.nudgeBeatPhase(amount);
15865
15842
  },
15866
15843
  /**
15867
- * Get tap count
15844
+ * Reset beat phase to next beat
15868
15845
  */
15869
- getTapCount: () => {
15846
+ resetPhase: () => {
15870
15847
  this.validateReady();
15871
- return this.audioSystem?.getTapCount() ?? 0;
15872
- },
15848
+ this.audioSystem?.resetBeatPhase();
15849
+ this.debugLog(`Beat phase reset (${this.instanceId})`);
15850
+ }
15851
+ },
15852
+ /**
15853
+ * Per-instrument onset tap control
15854
+ */
15855
+ onset: {
15873
15856
  /**
15874
- * Set beat sync mode
15857
+ * Tap an onset for a specific instrument.
15858
+ * First tap switches the instrument from auto to tapping mode.
15859
+ * If a repeating pattern is recognized, it continues after tapping stops.
15875
15860
  */
15876
- setMode: (mode) => {
15861
+ tap: (instrument) => {
15877
15862
  this.validateReady();
15878
- this.audioSystem?.setBeatMode(mode);
15879
- this.debugLog(`Beat mode set to ${mode} (${this.instanceId})`);
15863
+ this.audioSystem?.tapOnset(instrument);
15880
15864
  },
15881
15865
  /**
15882
- * Get beat sync mode
15866
+ * Clear the tap pattern for an instrument, restoring auto-detection
15883
15867
  */
15884
- getMode: () => {
15868
+ clear: (instrument) => {
15885
15869
  this.validateReady();
15886
- return this.audioSystem?.getBeatMode() ?? "auto";
15870
+ this.audioSystem?.clearOnsetTap(instrument);
15887
15871
  },
15888
15872
  /**
15889
- * Set manual BPM
15873
+ * Get onset mode for an instrument: 'auto' | 'tapping' | 'pattern'
15890
15874
  */
15891
- setBPM: (bpm) => {
15875
+ getMode: (instrument) => {
15892
15876
  this.validateReady();
15893
- if (bpm < 60 || bpm > 240) {
15894
- throw new VijiCoreError("BPM must be between 60 and 240", "INVALID_VALUE");
15895
- }
15896
- this.audioSystem?.setManualBPM(bpm);
15897
- this.debugLog(`Manual BPM set to ${bpm} (${this.instanceId})`);
15877
+ return this.audioSystem?.getOnsetMode(instrument) ?? "auto";
15898
15878
  },
15899
15879
  /**
15900
- * Get current BPM (manual or auto-detected)
15880
+ * Get recognized pattern info for an instrument, or null
15901
15881
  */
15902
- getBPM: () => {
15882
+ getPatternInfo: (instrument) => {
15903
15883
  this.validateReady();
15904
- return this.audioSystem?.getCurrentBPM() ?? 120;
15884
+ return this.audioSystem?.getOnsetPatternInfo(instrument) ?? null;
15905
15885
  },
15906
15886
  /**
15907
- * Nudge beat phase
15887
+ * Mute/unmute an instrument onset.
15888
+ * Suppresses all output (auto, tap, pattern) without destroying state.
15889
+ * Unmuting resumes pattern replay in-phase.
15908
15890
  */
15909
- nudge: (amount) => {
15891
+ setMuted: (instrument, muted) => {
15910
15892
  this.validateReady();
15911
- this.audioSystem?.nudgeBeatPhase(amount);
15893
+ this.audioSystem?.setOnsetMuted(instrument, muted);
15912
15894
  },
15913
15895
  /**
15914
- * Reset beat phase to next beat
15896
+ * Check if an instrument onset is muted
15915
15897
  */
15916
- resetPhase: () => {
15898
+ isMuted: (instrument) => {
15917
15899
  this.validateReady();
15918
- this.audioSystem?.resetBeatPhase();
15919
- this.debugLog(`Beat phase reset (${this.instanceId})`);
15900
+ return this.audioSystem?.isOnsetMuted(instrument) ?? false;
15920
15901
  }
15921
15902
  },
15922
15903
  /**
@@ -15931,17 +15912,6 @@ class VijiCore {
15931
15912
  this.audioSystem?.setFFTSize(size);
15932
15913
  this.debugLog(`FFT size set to ${size} (${this.instanceId})`);
15933
15914
  },
15934
- /**
15935
- * Set smoothing time constant (0-1)
15936
- */
15937
- setSmoothing: (value) => {
15938
- this.validateReady();
15939
- if (value < 0 || value > 1) {
15940
- throw new VijiCoreError("Smoothing must be between 0 and 1", "INVALID_VALUE");
15941
- }
15942
- this.audioSystem?.setSmoothing(value);
15943
- this.debugLog(`Smoothing set to ${value} (${this.instanceId})`);
15944
- },
15945
15915
  /**
15946
15916
  * Enable/disable auto-gain
15947
15917
  */
@@ -15951,20 +15921,10 @@ class VijiCore {
15951
15921
  this.debugLog(`Auto-gain ${enabled ? "enabled" : "disabled"} (${this.instanceId})`);
15952
15922
  },
15953
15923
  /**
15954
- * Enable/disable beat detection
15924
+ * Enable/disable audio analysis debug mode (independent from core debug mode)
15955
15925
  */
15956
- setBeatDetection: (enabled) => {
15957
- this.validateReady();
15958
- this.audioSystem?.setBeatDetection(enabled);
15959
- this.debugLog(`Beat detection ${enabled ? "enabled" : "disabled"} (${this.instanceId})`);
15960
- },
15961
- /**
15962
- * Enable/disable onset detection
15963
- */
15964
- setOnsetDetection: (enabled) => {
15965
- this.validateReady();
15966
- this.audioSystem?.setOnsetDetection(enabled);
15967
- this.debugLog(`Onset detection ${enabled ? "enabled" : "disabled"} (${this.instanceId})`);
15926
+ setDebugMode: (enabled) => {
15927
+ this.audioSystem?.setDebugMode(enabled);
15968
15928
  }
15969
15929
  },
15970
15930
  /**
@@ -15976,10 +15936,9 @@ class VijiCore {
15976
15936
  isConnected: false,
15977
15937
  currentBPM: 120,
15978
15938
  confidence: 0,
15979
- mode: "auto",
15980
- tapCount: 0,
15981
15939
  isLocked: false,
15982
- sensitivity: 1
15940
+ trackingState: "LEARNING",
15941
+ onsetModes: { kick: "auto", snare: "auto", hat: "auto" }
15983
15942
  };
15984
15943
  },
15985
15944
  /**
@@ -16011,15 +15970,14 @@ class VijiCore {
16011
15970
  any: 0,
16012
15971
  kickSmoothed: 0,
16013
15972
  snareSmoothed: 0,
15973
+ hatSmoothed: 0,
16014
15974
  anySmoothed: 0,
16015
15975
  triggers: { any: false, kick: false, snare: false, hat: false },
16016
15976
  bpm: 120,
16017
- phase: 0,
16018
- bar: 0,
16019
15977
  confidence: 0,
16020
15978
  isLocked: false
16021
15979
  },
16022
- spectral: { brightness: 0, flatness: 0, flux: 0 }
15980
+ spectral: { brightness: 0, flatness: 0 }
16023
15981
  };
16024
15982
  }
16025
15983
  };
@@ -16155,9 +16113,8 @@ class VijiCore {
16155
16113
  async setDeviceVideo(deviceId, stream) {
16156
16114
  this.validateReady();
16157
16115
  await this.clearDeviceVideo(deviceId);
16158
- const baseOffset = 1 + this.videoStreams.length;
16159
16116
  const usedIndices = new Set(this.deviceVideoStreamIndices.values());
16160
- let streamIndex = baseOffset;
16117
+ let streamIndex = VijiCore.DEVICE_VIDEO_BASE;
16161
16118
  while (usedIndices.has(streamIndex)) {
16162
16119
  streamIndex++;
16163
16120
  }
@@ -16320,4 +16277,4 @@ export {
16320
16277
  VijiCoreError as b,
16321
16278
  getDefaultExportFromCjs as g
16322
16279
  };
16323
- //# sourceMappingURL=index-DW8r2Eux.js.map
16280
+ //# sourceMappingURL=index-trkn0FNW.js.map