@viji-dev/core 0.3.19 → 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.
@@ -73,8 +73,11 @@ class IFrameManager {
73
73
  document.body.appendChild(hiddenContainer);
74
74
  hiddenContainer.appendChild(iframe);
75
75
  } else {
76
+ if (getComputedStyle(this.hostContainer).position === "static") {
77
+ this.hostContainer.style.position = "relative";
78
+ }
76
79
  const visibility = this.initiallyVisible ? "" : "visibility:hidden;";
77
- iframe.style.cssText = `width:100%;height:100%;border:none;display:block;${visibility}`;
80
+ iframe.style.cssText = `position:absolute;top:0;left:0;width:100%;height:100%;border:none;${visibility}`;
78
81
  this.hostContainer.appendChild(iframe);
79
82
  }
80
83
  const iframeContent = this.generateIFrameHTML();
@@ -564,7 +567,7 @@ class IFrameManager {
564
567
  }
565
568
  function WorkerWrapper(options) {
566
569
  return new Worker(
567
- "" + new URL("assets/viji.worker-BnDb6mPh.js", import.meta.url).href,
570
+ "" + new URL("assets/viji.worker-bm-hvzXt.js", import.meta.url).href,
568
571
  {
569
572
  type: "module",
570
573
  name: options?.name
@@ -1478,13 +1481,17 @@ class MultiOnsetDetection {
1478
1481
  const displayName = bandName === "low" ? "low" : "high";
1479
1482
  if (onset) {
1480
1483
  if (bandName === "low") this.debugOnsetCount++;
1481
- if (this.consecutiveMisses[displayName] && this.consecutiveMisses[displayName].count > 3) {
1482
- 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
+ }
1483
1488
  }
1484
1489
  this.consecutiveMisses[displayName] = { count: 0, lastReason: "" };
1485
- const logCutoff = bandName === "high" ? adjustedCutoff : state.cutoff;
1486
- const emoji = bandName === "low" ? "🔥" : "✨";
1487
- 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
+ }
1488
1495
  if (this.debugMode) {
1489
1496
  const sixteenthNote = 6e4 / this.currentBPM / 4;
1490
1497
  console.log(` 📊 Sharpness: ${sharpness.toFixed(3)} | Raw Flux: ${flux.toFixed(3)} | Emphasized: ${emphasizedFlux.toFixed(3)}`);
@@ -1500,7 +1507,7 @@ class MultiOnsetDetection {
1500
1507
  }
1501
1508
  this.consecutiveMisses[displayName].count++;
1502
1509
  this.consecutiveMisses[displayName].lastReason = failReason;
1503
- if (this.consecutiveMisses[displayName].count === 1) {
1510
+ if (this.debugMode && this.consecutiveMisses[displayName].count === 1) {
1504
1511
  const timeSince = (now - state.lastOnsetTime).toFixed(0);
1505
1512
  const logCutoff = bandName === "high" ? adjustedCutoff : state.cutoff;
1506
1513
  console.log(`⚠️ [${displayName}] MISS (${failReason}) flux=${flux.toFixed(3)} energy=${energy.toFixed(2)} cutoff=${logCutoff.toFixed(2)} lastOnset=${timeSince}ms ago`);
@@ -1546,7 +1553,7 @@ class MultiOnsetDetection {
1546
1553
  const state = this.bandStates.get(band);
1547
1554
  if (state) {
1548
1555
  state.primaryOnsetHistory.push(now);
1549
- if (band === "low") {
1556
+ if (band === "low" && this.debugMode) {
1550
1557
  console.log(` ✓ PRIMARY [low] onset recorded (total: ${state.primaryOnsetHistory.length})`);
1551
1558
  }
1552
1559
  }
@@ -1913,7 +1920,7 @@ class EssentiaOnsetDetection {
1913
1920
  this.initPromise = (async () => {
1914
1921
  try {
1915
1922
  const essentiaModule = await import("./essentia.js-core.es-DnrJE0uR.js");
1916
- const wasmModule = await import("./essentia-wasm.web-C7QoUtrj.js").then((n) => n.e);
1923
+ const wasmModule = await import("./essentia-wasm.web-C1URJxCY.js").then((n) => n.e);
1917
1924
  const EssentiaClass = essentiaModule.Essentia || essentiaModule.default?.Essentia || essentiaModule.default;
1918
1925
  let WASMModule = wasmModule.default || wasmModule.EssentiaWASM || wasmModule.default?.EssentiaWASM;
1919
1926
  if (!WASMModule) {
@@ -2115,43 +2122,32 @@ class EssentiaOnsetDetection {
2115
2122
  onset: globalOnset,
2116
2123
  strength: clampedStrength,
2117
2124
  bands: {
2118
- // Low bands: Use complex domain (best for kicks)
2119
2125
  low: {
2120
2126
  onset: kickOnset,
2121
- // INDEPENDENT detection!
2122
2127
  strength: clampedComplex,
2123
2128
  energy: bandEnergies.low,
2124
2129
  sharpness: kickSharpness
2125
- // Attack sharpness for kick/bassline discrimination
2126
2130
  },
2127
2131
  lowMid: {
2128
2132
  onset: kickOnset,
2129
- // Follows kick detection (kick harmonics)
2130
2133
  strength: clampedComplex,
2131
2134
  energy: bandEnergies.lowMid,
2132
2135
  sharpness: kickSharpness
2133
2136
  },
2134
- // Mid band: Use spectral flux (best for snares)
2135
2137
  mid: {
2136
2138
  onset: snareOnset,
2137
- // INDEPENDENT detection!
2138
2139
  strength: clampedFlux,
2139
2140
  energy: bandEnergies.mid,
2140
2141
  sharpness: snareSharpness
2141
- // Transient quality for snare detection
2142
2142
  },
2143
- // High bands: Use HFC (best for hats)
2144
2143
  highMid: {
2145
2144
  onset: hatOnset,
2146
- // INDEPENDENT detection!
2147
2145
  strength: clampedHFC,
2148
2146
  energy: bandEnergies.highMid,
2149
2147
  sharpness: hatSharpness
2150
- // Transient quality for hat detection
2151
2148
  },
2152
2149
  high: {
2153
2150
  onset: hatOnset,
2154
- // INDEPENDENT detection!
2155
2151
  strength: clampedHFC,
2156
2152
  energy: bandEnergies.high,
2157
2153
  sharpness: hatSharpness
@@ -10230,7 +10226,7 @@ const CONFIDENCE_LOCKED_THRESHOLD = 0.6;
10230
10226
  const BREAKDOWN_THRESHOLD_MS = 2e3;
10231
10227
  const LOST_THRESHOLD_MS = 3e4;
10232
10228
  const GRID_INVALID_THRESHOLD = 0.25;
10233
- const SAMPLE_RATE = 60;
10229
+ const SAMPLE_RATE$1 = 60;
10234
10230
  class BeatStateManager {
10235
10231
  // State machine
10236
10232
  state = "TRACKING";
@@ -10267,13 +10263,14 @@ class BeatStateManager {
10267
10263
  // Running average of onset strength
10268
10264
  // Envelope followers for beat energy curves
10269
10265
  envelopes = {
10270
- kick: new EnvelopeFollower(0, 300, SAMPLE_RATE),
10271
- snare: new EnvelopeFollower(0, 300, SAMPLE_RATE),
10272
- hat: new EnvelopeFollower(0, 300, SAMPLE_RATE),
10273
- any: new EnvelopeFollower(0, 300, SAMPLE_RATE),
10274
- kickSmoothed: new EnvelopeFollower(5, 500, SAMPLE_RATE),
10275
- snareSmoothed: new EnvelopeFollower(5, 500, SAMPLE_RATE),
10276
- 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)
10277
10274
  };
10278
10275
  // Confidence factors
10279
10276
  tempoMethodAgreement = 0;
@@ -10514,10 +10511,11 @@ class BeatStateManager {
10514
10511
  this.debugKickCount++;
10515
10512
  const kickEvent = detectorEvents.find((e) => e.type === "kick");
10516
10513
  const bands = kickEvent && "bands" in kickEvent ? kickEvent.bands : kickBands;
10514
+ const rawKickStrength = kickEvent?.strength || onsets.bands.low.strength;
10517
10515
  const event = {
10518
10516
  type: "kick",
10519
10517
  time: now,
10520
- strength: kickEvent?.strength || onsets.bands.low.strength,
10518
+ strength: Math.min(1, rawKickStrength / 5),
10521
10519
  phase: pll.getPhase(),
10522
10520
  bar: pll.getBar(),
10523
10521
  isLayered: classification.snare || classification.hat,
@@ -10538,10 +10536,11 @@ class BeatStateManager {
10538
10536
  this.debugSnareCount++;
10539
10537
  const snareEvent = detectorEvents.find((e) => e.type === "snare");
10540
10538
  const bands = snareEvent && "bands" in snareEvent ? snareEvent.bands : snareBands;
10539
+ const rawSnareStrength = snareEvent?.strength || onsets.bands.mid.strength;
10541
10540
  const event = {
10542
10541
  type: "snare",
10543
10542
  time: now,
10544
- strength: snareEvent?.strength || onsets.bands.mid.strength,
10543
+ strength: Math.min(1, rawSnareStrength / 5),
10545
10544
  phase: pll.getPhase(),
10546
10545
  bar: pll.getBar(),
10547
10546
  isLayered: classification.kick || classification.hat,
@@ -10562,10 +10561,11 @@ class BeatStateManager {
10562
10561
  this.debugHatCount++;
10563
10562
  const hatEvent = detectorEvents.find((e) => e.type === "hat");
10564
10563
  const bands = hatEvent && "bands" in hatEvent ? hatEvent.bands : hatBands;
10564
+ const rawHatStrength = hatEvent?.strength || onsets.bands.high.strength;
10565
10565
  const event = {
10566
10566
  type: "hat",
10567
10567
  time: now,
10568
- strength: hatEvent?.strength || onsets.bands.high.strength,
10568
+ strength: Math.min(1, rawHatStrength / 3e3),
10569
10569
  phase: pll.getPhase(),
10570
10570
  bar: pll.getBar(),
10571
10571
  isLayered: classification.kick || classification.snare,
@@ -10596,6 +10596,7 @@ class BeatStateManager {
10596
10596
  any: this.envelopes.any.getValue(),
10597
10597
  kickSmoothed: this.envelopes.kickSmoothed.getValue(),
10598
10598
  snareSmoothed: this.envelopes.snareSmoothed.getValue(),
10599
+ hatSmoothed: this.envelopes.hatSmoothed.getValue(),
10599
10600
  anySmoothed: this.envelopes.anySmoothed.getValue(),
10600
10601
  events: bufferedEvents,
10601
10602
  // Buffered events (persisted for 20ms, reliable for async access)
@@ -11184,9 +11185,7 @@ class BeatStateManager {
11184
11185
  if (snare) {
11185
11186
  const isLayered = timeSinceLastPrimaryKick <= 80;
11186
11187
  const midSharpness2 = bands.mid.sharpness ?? 1;
11187
- const isHighQuality = midSharpness2 > 0.25 && // Sharp attack (basic transient check)
11188
- bands.mid.strength > 0.3 && // Strong enough to be reliable
11189
- midRatio > 0.1;
11188
+ const isHighQuality = midSharpness2 > 0.25 && bands.mid.strength > 0.3 && midRatio > 0.1;
11190
11189
  if (isHighQuality) {
11191
11190
  if (isLayered) {
11192
11191
  this.adaptiveProfiles.snareLayered.samples.push({
@@ -11416,6 +11415,7 @@ class BeatStateManager {
11416
11415
  }
11417
11416
  if (classification.hat) {
11418
11417
  this.envelopes.hat.trigger(1);
11418
+ this.envelopes.hatSmoothed.trigger(1);
11419
11419
  this.envelopes.any.trigger(0.5);
11420
11420
  }
11421
11421
  if (classification.any && !classification.kick && !classification.snare && !classification.hat) {
@@ -11428,8 +11428,32 @@ class BeatStateManager {
11428
11428
  this.envelopes.any.process(0, dtMs);
11429
11429
  this.envelopes.kickSmoothed.process(0, dtMs);
11430
11430
  this.envelopes.snareSmoothed.process(0, dtMs);
11431
+ this.envelopes.hatSmoothed.process(0, dtMs);
11431
11432
  this.envelopes.anySmoothed.process(0, dtMs);
11432
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
+ }
11433
11457
  // ========== INDUSTRY-STANDARD HELPER METHODS ==========
11434
11458
  // NOTE: calculateGridAlignment() and calculateIOIConsistency() removed in Phase 1
11435
11459
  // These were part of advanced filtering that we disabled to maintain compatibility
@@ -12348,6 +12372,382 @@ FFT.prototype._singleRealTransform4 = function _singleRealTransform4(outOff, off
12348
12372
  out[outOff + 7] = FDi;
12349
12373
  };
12350
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
+ }
12351
12751
  class AudioSystem {
12352
12752
  // Audio context and analysis nodes
12353
12753
  audioContext = null;
@@ -12357,14 +12757,8 @@ class AudioSystem {
12357
12757
  analysisMode = "analyser";
12358
12758
  workletNode = null;
12359
12759
  workletReady = false;
12760
+ workletRegistered = false;
12360
12761
  currentSampleRate = 44100;
12361
- pcmRing = null;
12362
- pcmWriteIndex = 0;
12363
- pcmFilled = 0;
12364
- pcmHistorySeconds = 8;
12365
- lastTempoExtraction = 0;
12366
- tempoExtractionIntervalMs = 2e3;
12367
- analysisBackend = "auto";
12368
12762
  bandNoiseFloor = {
12369
12763
  low: 1e-4,
12370
12764
  lowMid: 1e-4,
@@ -12376,16 +12770,11 @@ class AudioSystem {
12376
12770
  lastNonZeroBpm = 120;
12377
12771
  /** Tracks which source provided the current BPM (pll | tempo | carry | default) */
12378
12772
  lastBpmSource = "default";
12379
- aubioWarningLogged = false;
12380
- useEssentiaTempo = false;
12381
- // disabled by default to avoid WASM exception in some builds
12382
12773
  workletFrameCount = 0;
12383
- forceAnalyser = false;
12384
- statusLogTimer = null;
12385
- 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;
12386
12777
  analysisTicks = 0;
12387
- lastLoopWarn = 0;
12388
- verboseLabLogs = this.isAudioLab;
12389
12778
  lastPhaseLogTime = 0;
12390
12779
  onsetLogBuffer = [];
12391
12780
  // Debug logging control
@@ -12414,21 +12803,14 @@ class AudioSystem {
12414
12803
  };
12415
12804
  /** Last dt used in analysis loop (ms) */
12416
12805
  lastDtMs = 0;
12417
- /** Flag to disable auto-gain for debugging */
12418
- debugDisableAutoGain = false;
12419
12806
  /** Flag to track if we've logged detection method (to avoid spam) */
12420
12807
  bandNames = ["low", "lowMid", "mid", "highMid", "high"];
12421
12808
  essentiaBandHistories = /* @__PURE__ */ new Map();
12422
12809
  essentiaHistoryWindowMs = 5e3;
12423
- // Manual BPM mode
12424
- beatMode = "auto";
12425
- manualBPM = null;
12426
- tapHistory = [];
12427
- tapTimeout = null;
12810
+ // Per-instrument onset tap manager
12811
+ onsetTapManager;
12428
12812
  // Envelope followers for smooth energy curves
12429
12813
  envelopeFollowers;
12430
- // Global sensitivity multiplier
12431
- sensitivity = 1;
12432
12814
  // Feature enable flags
12433
12815
  beatDetectionEnabled = true;
12434
12816
  onsetDetectionEnabled = true;
@@ -12443,13 +12825,9 @@ class AudioSystem {
12443
12825
  this.tempoInduction.setDebugMode(enabled);
12444
12826
  this.stateManager.setDebugMode(enabled);
12445
12827
  this.essentiaOnsetDetection?.setDebugMode(enabled);
12446
- this.toggleStatusLogger(enabled);
12447
- this.verboseLabLogs = this.isAudioLab || this.debugMode;
12448
12828
  if (enabled) {
12449
12829
  this.diagnosticLogger.start();
12450
- }
12451
- if (enabled) {
12452
- console.log("🔬 [AudioSystem] Debug mode enabled");
12830
+ this.debugLog("[AudioSystem] Debug mode enabled");
12453
12831
  }
12454
12832
  }
12455
12833
  /**
@@ -12487,42 +12865,6 @@ class AudioSystem {
12487
12865
  }
12488
12866
  return clone;
12489
12867
  }
12490
- logVerbose(label, payload) {
12491
- if (this.debugMode || this.verboseLabLogs) {
12492
- console.log(label, payload);
12493
- }
12494
- }
12495
- /**
12496
- * Start/stop periodic status logging. If maxSamples provided, stops after that many ticks.
12497
- */
12498
- toggleStatusLogger(enabled, maxSamples) {
12499
- if (enabled && !this.statusLogTimer) {
12500
- let samples = 0;
12501
- this.statusLogTimer = setInterval(() => {
12502
- samples++;
12503
- const a = this.audioState;
12504
- const maxBand = Math.max(a.bands.low, a.bands.lowMid, a.bands.mid, a.bands.highMid, a.bands.high);
12505
- console.log("[AudioSystem][status]", {
12506
- mode: this.analysisMode,
12507
- workletReady: this.workletReady,
12508
- workletFrames: this.workletFrameCount,
12509
- isConnected: a.isConnected,
12510
- vol: { cur: a.volume.current.toFixed(3), peak: a.volume.peak.toFixed(3) },
12511
- maxBand: maxBand.toFixed(3),
12512
- bpm: a.beat.bpm.toFixed(1),
12513
- bpmSource: this.lastBpmSource,
12514
- locked: a.beat.isLocked,
12515
- events: a.beat.events.length
12516
- });
12517
- if (maxSamples && samples >= maxSamples) {
12518
- this.toggleStatusLogger(false);
12519
- }
12520
- }, 1e3);
12521
- } else if (!enabled && this.statusLogTimer) {
12522
- clearInterval(this.statusLogTimer);
12523
- this.statusLogTimer = null;
12524
- }
12525
- }
12526
12868
  /**
12527
12869
  * Handle frames pushed from AudioWorklet
12528
12870
  */
@@ -12536,8 +12878,9 @@ class AudioSystem {
12536
12878
  */
12537
12879
  analyzeFrame(frame, sampleRate, timestampMs) {
12538
12880
  if (!frame || frame.length === 0) return;
12881
+ this.lastFrameTime = performance.now();
12539
12882
  if (this.workletFrameCount === 1) {
12540
- console.log("[AudioSystem] First frame received", { sampleRate, mode: this.analysisMode });
12883
+ this.debugLog("[AudioSystem] First frame received", { sampleRate, mode: this.analysisMode });
12541
12884
  }
12542
12885
  const dtMs = this.lastAnalysisTimestamp > 0 ? timestampMs - this.lastAnalysisTimestamp : 1e3 / 60;
12543
12886
  this.lastAnalysisTimestamp = timestampMs;
@@ -12546,15 +12889,11 @@ class AudioSystem {
12546
12889
  const { rms, peak } = this.calculateVolumeMetrics(frame);
12547
12890
  this.audioState.volume.current = rms;
12548
12891
  this.audioState.volume.peak = peak;
12549
- this.appendPcmFrame(frame, sampleRate);
12550
- if (this.useEssentiaTempo && timestampMs - this.lastTempoExtraction > this.tempoExtractionIntervalMs) {
12551
- this.runEssentiaTempoEstimate(sampleRate);
12552
- this.lastTempoExtraction = timestampMs;
12553
- }
12892
+ this.lastWaveformFrame = frame;
12554
12893
  const fftResult = this.computeFFT(frame);
12555
12894
  if (!fftResult) {
12556
- if (this.analysisTicks === 1 || this.analysisTicks % 240 === 0) {
12557
- 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);
12558
12897
  }
12559
12898
  return;
12560
12899
  }
@@ -12575,80 +12914,76 @@ class AudioSystem {
12575
12914
  high: 0
12576
12915
  };
12577
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 = [];
12578
12928
  this.updateSmoothBands(dtMs);
12579
12929
  this.sendAnalysisResultsToWorker();
12580
12930
  return;
12581
12931
  }
12582
- const rawBandsForOnsets = {
12583
- low: bandEnergies.low * this.sensitivity,
12584
- lowMid: bandEnergies.lowMid * this.sensitivity,
12585
- mid: bandEnergies.mid * this.sensitivity,
12586
- highMid: bandEnergies.highMid * this.sensitivity,
12587
- high: bandEnergies.high * this.sensitivity
12588
- };
12589
12932
  this.rawBandsPreGain = { ...bandEnergies };
12590
12933
  if (this.autoGainEnabled) {
12591
12934
  this.applyAutoGain();
12592
12935
  }
12593
12936
  this.calculateSpectralFeaturesFromMagnitude(magnitudes, sampleRate, maxMagnitude);
12594
12937
  if (this.onsetDetectionEnabled && this.beatDetectionEnabled) {
12595
- const beatState = this.runBeatPipeline(rawBandsForOnsets, dtMs, timestampMs, sampleRate);
12938
+ const beatState = this.runBeatPipeline(bandEnergies, dtMs, timestampMs, sampleRate);
12596
12939
  if (beatState) {
12597
- this.audioState.beat = beatState;
12598
- const pllState = this.pll.getState();
12599
- const currentState = this.stateManager.getState();
12600
- const trackingData = {
12601
- timeSinceKick: this.stateManager.getTimeSinceLastKick(),
12602
- phaseError: this.pll.getLastPhaseError(),
12603
- adaptiveFloors: this.stateManager.getAdaptiveFloors()
12604
- };
12605
- const kickPattern = this.stateManager.getKickPattern();
12606
- if (kickPattern !== void 0) {
12607
- trackingData.kickPattern = kickPattern;
12608
- }
12609
- const bpmVariance = this.stateManager.getBPMVariance();
12610
- if (bpmVariance !== void 0) {
12611
- trackingData.bpmVariance = bpmVariance;
12612
- }
12613
- const compressed = this.stateManager.isCompressed();
12614
- if (compressed) {
12615
- trackingData.compressed = compressed;
12616
- }
12617
- if (beatState.events.some((e) => e.type === "kick")) {
12618
- trackingData.kickPhase = pllState.phase;
12619
- }
12620
- if (beatState.debug?.pllGain !== void 0) {
12621
- trackingData.pllGain = beatState.debug.pllGain;
12622
- }
12623
- if (beatState.debug?.tempoConfidence !== void 0) {
12624
- trackingData.tempoConf = beatState.debug.tempoConfidence;
12625
- }
12626
- if (beatState.debug?.trackingConfidence !== void 0) {
12627
- trackingData.trackingConf = beatState.debug.trackingConfidence;
12628
- }
12629
- this.diagnosticLogger.logFrame(
12630
- timestampMs,
12631
- {
12632
- bpm: beatState.bpm,
12633
- phase: pllState.phase,
12634
- bar: pllState.bar,
12635
- confidence: beatState.confidence,
12636
- kick: beatState.events.some((e) => e.type === "kick"),
12637
- snare: beatState.events.some((e) => e.type === "snare"),
12638
- hat: beatState.events.some((e) => e.type === "hat"),
12639
- isLocked: currentState === "LOCKED",
12640
- state: currentState
12641
- },
12642
- {
12643
- low: this.audioState.bands.low,
12644
- lowMid: this.audioState.bands.lowMid,
12645
- mid: this.audioState.bands.mid,
12646
- highMid: this.audioState.bands.highMid,
12647
- high: this.audioState.bands.high
12648
- },
12649
- this.stateManager.getLastRejections(),
12650
- trackingData
12651
- );
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
+ }
12652
12987
  }
12653
12988
  }
12654
12989
  this.updateSmoothBands(dtMs);
@@ -12658,30 +12993,9 @@ class AudioSystem {
12658
12993
  * Compute FFT and derived arrays
12659
12994
  */
12660
12995
  computeFFT(frame) {
12661
- if (this.analysisTicks === 1) {
12662
- console.log("[AudioSystem][computeFFT] FIRST CALL", {
12663
- hasEngine: !!this.fftEngine,
12664
- hasInput: !!this.fftInput,
12665
- hasOutput: !!this.fftOutput,
12666
- hasWindow: !!this.hannWindow,
12667
- hasFreqData: !!this.frequencyData,
12668
- hasMagnitude: !!this.fftMagnitude,
12669
- hasMagnitudeDb: !!this.fftMagnitudeDb,
12670
- hasPhase: !!this.fftPhase
12671
- });
12672
- }
12673
12996
  if (!this.fftEngine || !this.fftInput || !this.fftOutput || !this.hannWindow || !this.frequencyData || !this.fftMagnitude || !this.fftMagnitudeDb || !this.fftPhase) {
12674
- if (this.analysisTicks === 1 || this.analysisTicks % 240 === 0) {
12675
- console.warn("[AudioSystem][computeFFT] RETURNING NULL - ONE OR MORE RESOURCES MISSING");
12676
- console.warn(" tick:", this.analysisTicks);
12677
- console.warn(" hasEngine:", !!this.fftEngine);
12678
- console.warn(" hasInput:", !!this.fftInput);
12679
- console.warn(" hasOutput:", !!this.fftOutput);
12680
- console.warn(" hasWindow:", !!this.hannWindow);
12681
- console.warn(" hasFreqData:", !!this.frequencyData);
12682
- console.warn(" hasMagnitude:", !!this.fftMagnitude);
12683
- console.warn(" hasMagnitudeDb:", !!this.fftMagnitudeDb);
12684
- 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);
12685
12999
  }
12686
13000
  return null;
12687
13001
  }
@@ -12838,63 +13152,13 @@ class AudioSystem {
12838
13152
  } else {
12839
13153
  this.audioState.spectral.flatness = 0;
12840
13154
  }
12841
- const centroidChange = Math.abs(centroid - this.prevSpectralCentroid);
12842
- this.audioState.spectral.flux = Math.min(1, centroidChange / 1e3);
12843
- this.prevSpectralCentroid = centroid;
12844
- }
12845
- /**
12846
- * Append PCM frame to ring buffer for periodic Essentia tempo extraction
12847
- */
12848
- appendPcmFrame(frame, sampleRate) {
12849
- const requiredSize = Math.max(this.fftSize, Math.floor(sampleRate * this.pcmHistorySeconds));
12850
- if (!this.pcmRing || this.pcmRing.length !== requiredSize) {
12851
- this.pcmRing = new Float32Array(requiredSize);
12852
- this.pcmWriteIndex = 0;
12853
- this.pcmFilled = 0;
12854
- }
12855
- for (let i = 0; i < frame.length; i++) {
12856
- this.pcmRing[this.pcmWriteIndex] = frame[i];
12857
- this.pcmWriteIndex = (this.pcmWriteIndex + 1) % this.pcmRing.length;
12858
- if (this.pcmFilled < this.pcmRing.length) {
12859
- this.pcmFilled++;
12860
- }
12861
- }
12862
- }
12863
- /**
12864
- * Periodically estimate tempo using Essentia's RhythmExtractor2013 (offline chunk)
12865
- */
12866
- runEssentiaTempoEstimate(sampleRate) {
12867
- if (!this.essentiaOnsetDetection?.isReady() || !this.pcmRing) {
12868
- return;
12869
- }
12870
- const maxSamples = Math.min(this.pcmRing.length, Math.floor(sampleRate * this.pcmHistorySeconds));
12871
- const available = Math.min(this.pcmFilled, maxSamples);
12872
- if (available < sampleRate * 2) return;
12873
- const window2 = new Float32Array(available);
12874
- let idx = (this.pcmWriteIndex - available + this.pcmRing.length) % this.pcmRing.length;
12875
- for (let i = 0; i < available; i++) {
12876
- window2[i] = this.pcmRing[idx];
12877
- idx = (idx + 1) % this.pcmRing.length;
12878
- }
12879
- const tempo = this.essentiaOnsetDetection.detectTempoFromPCM(window2, sampleRate);
12880
- if (tempo?.bpm) {
12881
- const bpm = Math.max(60, Math.min(200, tempo.bpm));
12882
- this.pll.setBPM(bpm);
12883
- this.tempoInduction.setManualBPM(bpm);
12884
- }
12885
13155
  }
12886
13156
  /**
12887
13157
  * Run onset + beat detection pipeline and return BeatState
12888
13158
  */
12889
13159
  runBeatPipeline(rawBandsForOnsets, dtMs, timestampMs, sampleRate) {
12890
13160
  let onsets;
12891
- const backend = this.analysisBackend;
12892
- const allowEssentia = backend === "auto" || backend === "essentia";
12893
- if (backend === "aubio" && !this.aubioWarningLogged) {
12894
- console.warn("[AudioSystem] Aubio backend selected but not wired yet; using custom pipeline.");
12895
- this.aubioWarningLogged = true;
12896
- }
12897
- const essentiaReady = allowEssentia && (this.essentiaOnsetDetection?.isReady() || false) && !!this.fftMagnitudeDb && !!this.fftPhase;
13161
+ const essentiaReady = (this.essentiaOnsetDetection?.isReady() || false) && !!this.fftMagnitudeDb && !!this.fftPhase;
12898
13162
  const hasFftData = !!(this.fftMagnitudeDb && this.fftPhase);
12899
13163
  if (essentiaReady && hasFftData) {
12900
13164
  const essentiaResult = this.essentiaOnsetDetection.detectFromSpectrum(
@@ -12917,9 +13181,6 @@ class AudioSystem {
12917
13181
  } else {
12918
13182
  onsets = this.onsetDetection.detect(rawBandsForOnsets, dtMs);
12919
13183
  }
12920
- if (this.beatMode === "manual" && this.manualBPM !== null) {
12921
- this.tempoInduction.setManualBPM(this.manualBPM);
12922
- }
12923
13184
  const tempo = this.tempoInduction.update(onsets, dtMs);
12924
13185
  this.onsetDetection.setBPM(tempo.bpm);
12925
13186
  const trackingConf = this.stateManager.calculateTrackingConfidence(tempo);
@@ -12998,7 +13259,7 @@ class AudioSystem {
12998
13259
  const prevSource = this.lastBpmSource;
12999
13260
  this.lastNonZeroBpm = resolvedBpm;
13000
13261
  this.lastBpmSource = selected.source;
13001
- if ((this.debugMode || this.verboseLabLogs) && prevSource !== this.lastBpmSource) {
13262
+ if (this.debugMode && prevSource !== this.lastBpmSource) {
13002
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})`);
13003
13264
  }
13004
13265
  const mergedBeatState = beatState.bpm === resolvedBpm ? beatState : { ...beatState, bpm: resolvedBpm };
@@ -13016,8 +13277,6 @@ class AudioSystem {
13016
13277
  // Analysis configuration (optimized for onset detection)
13017
13278
  fftSize = 2048;
13018
13279
  // Good balance for quality vs performance
13019
- smoothingTimeConstant = 0;
13020
- // NO smoothing for onset detection (was 0.5 which killed transients!)
13021
13280
  // High-speed analysis for onset detection (separate from RAF)
13022
13281
  analysisInterval = null;
13023
13282
  analysisIntervalMs = 8;
@@ -13060,28 +13319,26 @@ class AudioSystem {
13060
13319
  any: 0,
13061
13320
  kickSmoothed: 0,
13062
13321
  snareSmoothed: 0,
13322
+ hatSmoothed: 0,
13063
13323
  anySmoothed: 0,
13064
13324
  events: [],
13065
13325
  bpm: 120,
13066
- phase: 0,
13067
- bar: 0,
13068
13326
  confidence: 0,
13069
13327
  isLocked: false
13070
13328
  },
13071
13329
  spectral: {
13072
13330
  brightness: 0,
13073
- flatness: 0,
13074
- flux: 0
13331
+ flatness: 0
13075
13332
  }
13076
13333
  };
13334
+ // Waveform data for transfer to worker
13335
+ lastWaveformFrame = null;
13077
13336
  // Analysis loop
13078
13337
  analysisLoopId = null;
13079
13338
  isAnalysisRunning = false;
13080
13339
  lastAnalysisTimestamp = 0;
13081
13340
  // Callback to send results to worker
13082
13341
  sendAnalysisResults = null;
13083
- // Previous spectral centroid for change calculation
13084
- prevSpectralCentroid = 0;
13085
13342
  constructor(sendAnalysisResultsCallback) {
13086
13343
  this.handleAudioStreamUpdate = this.handleAudioStreamUpdate.bind(this);
13087
13344
  this.performAnalysis = this.performAnalysis.bind(this);
@@ -13092,6 +13349,7 @@ class AudioSystem {
13092
13349
  this.tempoInduction = new TempoInduction();
13093
13350
  this.pll = new PhaseLockedLoop();
13094
13351
  this.stateManager = new BeatStateManager();
13352
+ this.onsetTapManager = new OnsetTapManager();
13095
13353
  this.volumeAutoGain = new AutoGain(3e3, 60);
13096
13354
  this.bandAutoGain = {
13097
13355
  low: new AutoGain(3e3, 60),
@@ -13109,6 +13367,7 @@ class AudioSystem {
13109
13367
  any: new EnvelopeFollower(0, 300, sampleRate),
13110
13368
  kickSmoothed: new EnvelopeFollower(5, 500, sampleRate),
13111
13369
  snareSmoothed: new EnvelopeFollower(5, 500, sampleRate),
13370
+ hatSmoothed: new EnvelopeFollower(5, 500, sampleRate),
13112
13371
  anySmoothed: new EnvelopeFollower(5, 500, sampleRate),
13113
13372
  // Volume smoothing
13114
13373
  volumeSmoothed: new EnvelopeFollower(50, 200, sampleRate),
@@ -13121,10 +13380,6 @@ class AudioSystem {
13121
13380
  };
13122
13381
  this.refreshFFTResources();
13123
13382
  this.resetEssentiaBandHistories();
13124
- if (this.isAudioLab) {
13125
- this.forceAnalyser = true;
13126
- console.warn("[AudioSystem] Audio-lab context detected: forcing analyser path for stability.");
13127
- }
13128
13383
  }
13129
13384
  /**
13130
13385
  * Prepare FFT buffers and windowing for the selected fftSize
@@ -13160,9 +13415,7 @@ class AudioSystem {
13160
13415
  if (!this.essentiaOnsetDetection) return;
13161
13416
  try {
13162
13417
  await this.essentiaOnsetDetection.initialize();
13163
- if (this.debugMode) {
13164
- console.log("✅ [AudioSystem] Essentia.js initialized successfully!");
13165
- }
13418
+ this.debugLog("[AudioSystem] Essentia.js initialized successfully");
13166
13419
  } catch (error) {
13167
13420
  console.warn("⚠️ [AudioSystem] Essentia.js initialization failed, using custom onset detection:", error);
13168
13421
  }
@@ -13192,9 +13445,6 @@ class AudioSystem {
13192
13445
  } else {
13193
13446
  this.disconnectAudioStream();
13194
13447
  }
13195
- if (data.analysisConfig) {
13196
- this.updateAnalysisConfig(data.analysisConfig);
13197
- }
13198
13448
  } catch (error) {
13199
13449
  console.error("Error handling audio stream update:", error);
13200
13450
  this.audioState.isConnected = false;
@@ -13225,7 +13475,7 @@ class AudioSystem {
13225
13475
  }
13226
13476
  }
13227
13477
  this.mediaStreamSource = this.audioContext.createMediaStreamSource(audioStream);
13228
- this.workletReady = !this.forceAnalyser && await this.setupAudioWorklet();
13478
+ this.workletReady = await this.setupAudioWorklet();
13229
13479
  if (this.workletReady && this.workletNode) {
13230
13480
  this.analysisMode = "worklet";
13231
13481
  this.currentSampleRate = this.audioContext.sampleRate;
@@ -13234,7 +13484,7 @@ class AudioSystem {
13234
13484
  this.debugLog("Audio worklet analysis enabled");
13235
13485
  setTimeout(() => {
13236
13486
  if (this.analysisMode === "worklet" && this.workletFrameCount === 0) {
13237
- console.warn("[AudioSystem] Worklet produced no frames, falling back to analyser.");
13487
+ this.debugLog("[AudioSystem] Worklet produced no frames, falling back to analyser.");
13238
13488
  this.analysisMode = "analyser";
13239
13489
  if (this.workletNode) {
13240
13490
  try {
@@ -13247,7 +13497,7 @@ class AudioSystem {
13247
13497
  this.workletReady = false;
13248
13498
  this.analyser = this.audioContext.createAnalyser();
13249
13499
  this.analyser.fftSize = this.fftSize;
13250
- this.analyser.smoothingTimeConstant = this.smoothingTimeConstant;
13500
+ this.analyser.smoothingTimeConstant = 0;
13251
13501
  this.mediaStreamSource.connect(this.analyser);
13252
13502
  bufferLength = this.analyser.frequencyBinCount;
13253
13503
  this.frequencyData = new Uint8Array(bufferLength);
@@ -13259,7 +13509,7 @@ class AudioSystem {
13259
13509
  this.analysisMode = "analyser";
13260
13510
  this.analyser = this.audioContext.createAnalyser();
13261
13511
  this.analyser.fftSize = this.fftSize;
13262
- this.analyser.smoothingTimeConstant = this.smoothingTimeConstant;
13512
+ this.analyser.smoothingTimeConstant = 0;
13263
13513
  this.mediaStreamSource.connect(this.analyser);
13264
13514
  this.debugLog("Audio worklet unavailable, using analyser fallback");
13265
13515
  bufferLength = this.analyser.frequencyBinCount;
@@ -13275,17 +13525,17 @@ class AudioSystem {
13275
13525
  this.startAnalysisLoop();
13276
13526
  }
13277
13527
  if (this.analysisMode === "analyser" && !this.isAnalysisRunning) {
13278
- console.warn("[AudioSystem] Analysis loop not running after setup, forcing start.");
13528
+ this.debugLog("[AudioSystem] Analysis loop not running after setup, forcing start.");
13279
13529
  this.startAnalysisLoop();
13280
13530
  }
13281
13531
  const tracks = audioStream.getAudioTracks();
13282
- this.logVerbose("[AudioSystem] Stream info", {
13532
+ this.debugLog("[AudioSystem] Stream info", {
13283
13533
  trackCount: tracks.length,
13284
13534
  trackSettings: tracks.map((t) => t.getSettings?.() || {}),
13285
13535
  trackMuted: tracks.map((t) => t.muted),
13286
- forceAnalyser: this.forceAnalyser,
13287
13536
  mode: this.analysisMode
13288
13537
  });
13538
+ this.startStalenessTimer();
13289
13539
  this.debugLog("Audio stream connected successfully (host-side)", {
13290
13540
  sampleRate: this.audioContext.sampleRate,
13291
13541
  fftSize: this.fftSize,
@@ -13303,6 +13553,7 @@ class AudioSystem {
13303
13553
  */
13304
13554
  disconnectAudioStream() {
13305
13555
  this.stopAnalysisLoop();
13556
+ this.stopStalenessTimer();
13306
13557
  this.resetEssentiaBandHistories();
13307
13558
  if (this.mediaStreamSource) {
13308
13559
  this.mediaStreamSource.disconnect();
@@ -13333,27 +13584,6 @@ class AudioSystem {
13333
13584
  this.sendAnalysisResultsToWorker();
13334
13585
  this.debugLog("Audio stream disconnected (host-side)");
13335
13586
  }
13336
- /**
13337
- * Update analysis configuration
13338
- */
13339
- updateAnalysisConfig(config) {
13340
- let needsReconnect = false;
13341
- if (config.fftSize && config.fftSize !== this.fftSize) {
13342
- this.fftSize = config.fftSize;
13343
- this.refreshFFTResources();
13344
- needsReconnect = true;
13345
- }
13346
- if (config.smoothing !== void 0) {
13347
- this.smoothingTimeConstant = config.smoothing;
13348
- if (this.analyser) {
13349
- this.analyser.smoothingTimeConstant = this.smoothingTimeConstant;
13350
- }
13351
- }
13352
- if (needsReconnect && this.currentStream) {
13353
- const stream = this.currentStream;
13354
- this.setAudioStream(stream);
13355
- }
13356
- }
13357
13587
  /**
13358
13588
  * Initialize audio worklet for high-quality capture (complex STFT path)
13359
13589
  */
@@ -13362,50 +13592,54 @@ class AudioSystem {
13362
13592
  return false;
13363
13593
  }
13364
13594
  try {
13365
- const workletSource = `
13366
- class AudioAnalysisProcessor extends AudioWorkletProcessor {
13367
- constructor(options) {
13368
- super();
13369
- const opts = (options && options.processorOptions) || {};
13370
- this.windowSize = opts.fftSize || 2048;
13371
- this.hopSize = opts.hopSize || this.windowSize / 2;
13372
- this.buffer = new Float32Array(this.windowSize);
13373
- this.writeIndex = 0;
13374
- }
13375
- process(inputs, outputs) {
13376
- const input = inputs[0];
13377
- const output = outputs[0];
13378
- const channel = input && input[0];
13379
- if (output) {
13380
- for (let ch = 0; ch < output.length; ch++) {
13381
- output[ch].fill(0);
13382
- }
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;
13383
13605
  }
13384
- if (!channel) return true;
13385
- for (let i = 0; i < channel.length; i++) {
13386
- this.buffer[this.writeIndex++] = channel[i];
13387
- if (this.writeIndex >= this.windowSize) {
13388
- const frame = new Float32Array(this.windowSize);
13389
- frame.set(this.buffer);
13390
- this.port.postMessage(
13391
- { type: 'audio-frame', samples: frame, sampleRate, timestamp: currentTime * 1000 },
13392
- [frame.buffer]
13393
- );
13394
- const overlap = this.windowSize - this.hopSize;
13395
- if (overlap > 0) {
13396
- 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);
13613
+ }
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;
13397
13630
  }
13398
- this.writeIndex = overlap > 0 ? overlap : 0;
13399
13631
  }
13632
+ return true;
13400
13633
  }
13401
- return true;
13402
13634
  }
13403
- }
13404
- registerProcessor('audio-analysis-processor', AudioAnalysisProcessor);
13405
- `;
13406
- const blob = new Blob([workletSource], { type: "application/javascript" });
13407
- const workletUrl = URL.createObjectURL(blob);
13408
- 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
+ }
13409
13643
  this.workletNode = new AudioWorkletNode(this.audioContext, "audio-analysis-processor", {
13410
13644
  numberOfOutputs: 1,
13411
13645
  outputChannelCount: [1],
@@ -13431,26 +13665,15 @@ class AudioSystem {
13431
13665
  * Start the audio analysis loop at high speed (8ms intervals for transient capture)
13432
13666
  */
13433
13667
  startAnalysisLoop() {
13434
- console.log("[AudioSystem] startAnalysisLoop called", {
13435
- isAnalysisRunning: this.isAnalysisRunning,
13436
- analysisMode: this.analysisMode,
13437
- hasAnalyser: !!this.analyser,
13438
- hasContext: !!this.audioContext
13439
- });
13440
13668
  if (this.isAnalysisRunning || this.analysisMode !== "analyser") {
13441
- console.warn("[AudioSystem] startAnalysisLoop SKIPPED", {
13442
- isAnalysisRunning: this.isAnalysisRunning,
13443
- analysisMode: this.analysisMode
13444
- });
13445
13669
  return;
13446
13670
  }
13447
13671
  this.isAnalysisRunning = true;
13448
13672
  this.analysisInterval = window.setInterval(() => {
13449
13673
  this.performAnalysis();
13450
13674
  }, this.analysisIntervalMs);
13451
- console.log("[AudioSystem] Analysis loop started", {
13452
- intervalMs: this.analysisIntervalMs,
13453
- intervalId: this.analysisInterval
13675
+ this.debugLog("[AudioSystem] Analysis loop started", {
13676
+ intervalMs: this.analysisIntervalMs
13454
13677
  });
13455
13678
  }
13456
13679
  /**
@@ -13467,6 +13690,24 @@ class AudioSystem {
13467
13690
  this.analysisLoopId = null;
13468
13691
  }
13469
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
+ }
13470
13711
  /**
13471
13712
  * Pause audio analysis (for tests or temporary suspension)
13472
13713
  * The setInterval continues but performAnalysis() exits early
@@ -13489,25 +13730,7 @@ class AudioSystem {
13489
13730
  * Layer 4: BeatStateManager (state machine + confidence)
13490
13731
  */
13491
13732
  performAnalysis() {
13492
- if (this.analysisTicks === 0) {
13493
- console.log("[AudioSystem] performAnalysis FIRST CALL", {
13494
- isAnalysisRunning: this.isAnalysisRunning,
13495
- mode: this.analysisMode,
13496
- hasContext: !!this.audioContext,
13497
- hasAnalyser: !!this.analyser
13498
- });
13499
- }
13500
13733
  if (!this.isAnalysisRunning || this.analysisMode !== "analyser" || !this.audioContext || !this.analyser) {
13501
- const now = performance.now();
13502
- if (now - this.lastLoopWarn > 2e3) {
13503
- console.warn("[AudioSystem] performAnalysis skipped", {
13504
- isAnalysisRunning: this.isAnalysisRunning,
13505
- mode: this.analysisMode,
13506
- hasContext: !!this.audioContext,
13507
- hasAnalyser: !!this.analyser
13508
- });
13509
- this.lastLoopWarn = now;
13510
- }
13511
13734
  return;
13512
13735
  }
13513
13736
  if (!this.timeDomainData || this.timeDomainData.length !== this.fftSize) {
@@ -13515,14 +13738,6 @@ class AudioSystem {
13515
13738
  }
13516
13739
  this.analyser.getFloatTimeDomainData(this.timeDomainData);
13517
13740
  this.analysisTicks++;
13518
- if (this.analysisTicks === 1) {
13519
- console.log("[AudioSystem][analyser] first tick", {
13520
- fftSize: this.fftSize,
13521
- sampleRate: this.audioContext.sampleRate,
13522
- bufLen: this.timeDomainData.length
13523
- });
13524
- this.logVerbose("[AudioSystem][analyser] first 8 samples", Array.from(this.timeDomainData.slice(0, 8)));
13525
- }
13526
13741
  for (let i = 0; i < 32 && i < this.timeDomainData.length; i++) {
13527
13742
  Math.abs(this.timeDomainData[i]);
13528
13743
  }
@@ -13530,9 +13745,6 @@ class AudioSystem {
13530
13745
  this.analyzeFrame(this.timeDomainData, this.audioContext.sampleRate, detectionTimeMs);
13531
13746
  }
13532
13747
  applyAutoGain() {
13533
- if (this.debugDisableAutoGain) {
13534
- return;
13535
- }
13536
13748
  this.audioState.volume.current = this.volumeAutoGain.process(this.audioState.volume.current);
13537
13749
  this.audioState.volume.peak = this.volumeAutoGain.process(this.audioState.volume.peak);
13538
13750
  const bands = this.audioState.bands;
@@ -13578,12 +13790,17 @@ class AudioSystem {
13578
13790
  sendAnalysisResultsToWorker() {
13579
13791
  if (this.sendAnalysisResults) {
13580
13792
  const frequencyData = this.frequencyData ? new Uint8Array(this.frequencyData) : new Uint8Array(0);
13793
+ const waveformData = this.lastWaveformFrame ? new Float32Array(this.lastWaveformFrame) : new Float32Array(0);
13581
13794
  this.sendAnalysisResults({
13582
13795
  type: "audio-analysis-update",
13583
13796
  data: {
13584
- ...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,
13585
13802
  frequencyData,
13586
- // For getFrequencyData() access
13803
+ waveformData,
13587
13804
  timestamp: performance.now()
13588
13805
  }
13589
13806
  });
@@ -13607,18 +13824,16 @@ class AudioSystem {
13607
13824
  any: 0,
13608
13825
  kickSmoothed: 0,
13609
13826
  snareSmoothed: 0,
13827
+ hatSmoothed: 0,
13610
13828
  anySmoothed: 0,
13611
13829
  events: [],
13612
13830
  bpm: 120,
13613
- phase: 0,
13614
- bar: 0,
13615
13831
  confidence: 0,
13616
13832
  isLocked: false
13617
13833
  };
13618
13834
  this.audioState.spectral = {
13619
13835
  brightness: 0,
13620
- flatness: 0,
13621
- flux: 0
13836
+ flatness: 0
13622
13837
  };
13623
13838
  }
13624
13839
  /**
@@ -13633,6 +13848,7 @@ class AudioSystem {
13633
13848
  }
13634
13849
  this.workletNode = null;
13635
13850
  this.workletReady = false;
13851
+ this.workletRegistered = false;
13636
13852
  this.analysisMode = "analyser";
13637
13853
  this.onsetDetection.reset();
13638
13854
  this.tempoInduction.reset();
@@ -13640,183 +13856,48 @@ class AudioSystem {
13640
13856
  this.stateManager.reset();
13641
13857
  this.volumeAutoGain.reset();
13642
13858
  Object.values(this.bandAutoGain).forEach((g) => g.reset());
13643
- this.beatMode = "auto";
13644
- this.manualBPM = null;
13645
- this.tapHistory = [];
13646
- if (this.tapTimeout) {
13647
- clearTimeout(this.tapTimeout);
13648
- this.tapTimeout = null;
13649
- }
13859
+ this.onsetTapManager.reset();
13650
13860
  Object.values(this.envelopeFollowers).forEach((env) => env.reset());
13651
- this.pcmRing = null;
13652
- this.pcmWriteIndex = 0;
13653
- this.pcmFilled = 0;
13654
- this.lastTempoExtraction = 0;
13655
13861
  this.resetAudioValues();
13656
13862
  }
13657
- /**
13658
- * Get current analysis configuration
13659
- */
13660
- getAnalysisConfig() {
13661
- return {
13662
- fftSize: this.fftSize,
13663
- smoothing: this.smoothingTimeConstant
13664
- };
13665
- }
13666
- /**
13667
- * Set analysis backend preference
13668
- */
13669
- setAnalysisBackend(backend) {
13670
- this.analysisBackend = backend;
13671
- }
13672
- getAnalysisBackend() {
13673
- return this.analysisBackend;
13674
- }
13675
- /**
13676
- * Force analyser path (skip worklet) for debugging
13677
- */
13678
- setForceAnalyser(enabled) {
13679
- this.forceAnalyser = enabled;
13680
- if (enabled) {
13681
- console.warn("[AudioSystem] forceAnalyser enabled - worklet will be skipped.");
13682
- }
13683
- }
13684
- isForceAnalyser() {
13685
- return this.forceAnalyser;
13686
- }
13687
- /**
13688
- * Enable/disable Essentia tempo extraction (disabled by default due to WASM exception config)
13689
- */
13690
- setEssentiaTempoEnabled(enabled) {
13691
- this.useEssentiaTempo = enabled;
13692
- }
13693
- isEssentiaTempoEnabled() {
13694
- return this.useEssentiaTempo;
13695
- }
13696
13863
  // ═══════════════════════════════════════════════════════════
13697
13864
  // Public API Methods for Audio Analysis Configuration
13698
13865
  // ═══════════════════════════════════════════════════════════
13699
13866
  /**
13700
- * Set global sensitivity multiplier
13701
- * @param value - Sensitivity (0.5-2.0, default 1.0)
13867
+ * Record a tap for the specified instrument onset
13702
13868
  */
13703
- setSensitivity(value) {
13704
- this.sensitivity = Math.max(0.5, Math.min(2, value));
13869
+ tapOnset(instrument) {
13870
+ this.onsetTapManager.tap(instrument);
13705
13871
  }
13706
13872
  /**
13707
- * Get current sensitivity
13873
+ * Clear tap pattern for an instrument, restoring auto-detection
13708
13874
  */
13709
- getSensitivity() {
13710
- return this.sensitivity;
13875
+ clearOnsetTap(instrument) {
13876
+ this.onsetTapManager.clear(instrument);
13711
13877
  }
13712
13878
  /**
13713
- * Tap tempo - record tap for manual BPM
13879
+ * Get the current onset mode for an instrument
13714
13880
  */
13715
- tapTempo() {
13716
- const now = performance.now();
13717
- if (this.tapTimeout) {
13718
- clearTimeout(this.tapTimeout);
13719
- }
13720
- if (this.tapHistory.length > 0) {
13721
- const lastTap = this.tapHistory[this.tapHistory.length - 1];
13722
- const interval = now - lastTap;
13723
- if (interval < 200) {
13724
- this.startTapClearTimeout();
13725
- return;
13726
- }
13727
- if (interval > 2e3) {
13728
- this.tapHistory = [];
13729
- }
13730
- }
13731
- this.tapHistory.push(now);
13732
- if (this.tapHistory.length > 8) {
13733
- this.tapHistory.shift();
13734
- }
13735
- if (this.tapHistory.length >= 2) {
13736
- const bpm = this.calculateBPMFromTaps();
13737
- this.manualBPM = bpm;
13738
- this.beatMode = "manual";
13739
- this.pll.setBPM(bpm);
13740
- this.tempoInduction.setManualBPM(bpm);
13741
- }
13742
- this.startTapClearTimeout();
13743
- }
13744
- /**
13745
- * Calculate BPM from tap history
13746
- */
13747
- calculateBPMFromTaps() {
13748
- if (this.tapHistory.length < 2) return 120;
13749
- const intervals = [];
13750
- for (let i = 1; i < this.tapHistory.length; i++) {
13751
- intervals.push(this.tapHistory[i] - this.tapHistory[i - 1]);
13752
- }
13753
- if (intervals.length >= 4) {
13754
- const sorted = [...intervals].sort((a, b) => a - b);
13755
- const median = sorted[Math.floor(sorted.length / 2)];
13756
- const filtered = intervals.filter((val) => {
13757
- const deviation = Math.abs(val - median) / median;
13758
- return deviation < 0.3;
13759
- });
13760
- if (filtered.length >= 2) {
13761
- intervals.splice(0, intervals.length, ...filtered);
13762
- }
13763
- }
13764
- const avgInterval = intervals.reduce((sum, val) => sum + val, 0) / intervals.length;
13765
- let bpm = 6e4 / avgInterval;
13766
- while (bpm > 200) bpm /= 2;
13767
- while (bpm < 60) bpm *= 2;
13768
- return Math.round(bpm * 10) / 10;
13769
- }
13770
- /**
13771
- * Start auto-clear timeout for tap tempo
13772
- */
13773
- startTapClearTimeout() {
13774
- this.tapTimeout = setTimeout(() => {
13775
- this.tapHistory = [];
13776
- }, 2e3);
13881
+ getOnsetMode(instrument) {
13882
+ return this.onsetTapManager.getMode(instrument);
13777
13883
  }
13778
13884
  /**
13779
- * Clear tap tempo history
13885
+ * Get pattern info for an instrument (null if no pattern recognized)
13780
13886
  */
13781
- clearTaps() {
13782
- this.tapHistory = [];
13783
- if (this.tapTimeout) {
13784
- clearTimeout(this.tapTimeout);
13785
- this.tapTimeout = null;
13786
- }
13887
+ getOnsetPatternInfo(instrument) {
13888
+ return this.onsetTapManager.getPatternInfo(instrument);
13787
13889
  }
13788
13890
  /**
13789
- * Get tap count
13891
+ * Mute/unmute an instrument onset (suppresses output without destroying state)
13790
13892
  */
13791
- getTapCount() {
13792
- return this.tapHistory.length;
13893
+ setOnsetMuted(instrument, muted) {
13894
+ this.onsetTapManager.setMuted(instrument, muted);
13793
13895
  }
13794
13896
  /**
13795
- * Set beat sync mode
13897
+ * Check if an instrument onset is muted
13796
13898
  */
13797
- setBeatMode(mode) {
13798
- this.beatMode = mode;
13799
- if (mode === "auto") {
13800
- this.clearTaps();
13801
- this.manualBPM = null;
13802
- }
13803
- }
13804
- /**
13805
- * Get beat sync mode
13806
- */
13807
- getBeatMode() {
13808
- return this.beatMode;
13809
- }
13810
- /**
13811
- * Set manual BPM
13812
- */
13813
- setManualBPM(bpm) {
13814
- bpm = Math.max(60, Math.min(200, bpm));
13815
- this.manualBPM = bpm;
13816
- this.beatMode = "manual";
13817
- this.pll.setBPM(bpm);
13818
- this.tempoInduction.setManualBPM(bpm);
13819
- this.clearTaps();
13899
+ isOnsetMuted(instrument) {
13900
+ return this.onsetTapManager.isMuted(instrument);
13820
13901
  }
13821
13902
  /**
13822
13903
  * Get current BPM (manual or auto-detected)
@@ -13848,21 +13929,11 @@ class AudioSystem {
13848
13929
  this.setAudioStream(stream);
13849
13930
  }
13850
13931
  }
13851
- /**
13852
- * Set smoothing time constant
13853
- */
13854
- setSmoothing(value) {
13855
- this.smoothingTimeConstant = Math.max(0, Math.min(1, value));
13856
- if (this.analyser) {
13857
- this.analyser.smoothingTimeConstant = this.smoothingTimeConstant;
13858
- }
13859
- }
13860
13932
  /**
13861
13933
  * Enable/disable auto-gain
13862
13934
  */
13863
13935
  setAutoGain(enabled) {
13864
13936
  this.autoGainEnabled = enabled;
13865
- this.debugDisableAutoGain = !enabled;
13866
13937
  if (!enabled) {
13867
13938
  this.volumeAutoGain.reset();
13868
13939
  Object.values(this.bandAutoGain).forEach((g) => g.reset());
@@ -13896,11 +13967,13 @@ class AudioSystem {
13896
13967
  isConnected: this.audioState.isConnected,
13897
13968
  currentBPM: this.pll.getBPM(),
13898
13969
  confidence: this.tempoInduction.getConfidence(),
13899
- mode: this.beatMode,
13900
- tapCount: this.tapHistory.length,
13901
13970
  isLocked: this.stateManager.isLocked(),
13902
- sensitivity: this.sensitivity,
13903
- 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
+ }
13904
13977
  };
13905
13978
  }
13906
13979
  /**
@@ -13979,7 +14052,6 @@ class AudioSystem {
13979
14052
  volumeGain: this.volumeAutoGain.getGain(),
13980
14053
  // Timing info
13981
14054
  dtMs: this.lastDtMs,
13982
- smoothingTimeConstant: this.smoothingTimeConstant,
13983
14055
  analysisIntervalMs: this.analysisIntervalMs,
13984
14056
  // Current events
13985
14057
  events: [...this.audioState.beat.events]
@@ -14538,15 +14610,25 @@ class VijiCore {
14538
14610
  currentAudioStream = null;
14539
14611
  // ═══════════════════════════════════════════════════════════════════════════
14540
14612
  // VIDEO STREAM INDEX CONTRACT:
14541
- // Index 0: ALWAYS reserved for main video (with CV) - even when absent
14542
- // Index 1..N: Additional streams (no CV) - where N = videoStreams.length
14543
- // 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.
14544
14623
  // ═══════════════════════════════════════════════════════════════════════════
14624
+ static ADDITIONAL_STREAM_BASE = 1;
14625
+ static DEVICE_VIDEO_BASE = 100;
14626
+ static DIRECT_FRAME_BASE = 200;
14545
14627
  // Separated video stream management
14546
14628
  videoStream = null;
14547
14629
  // Main stream (CV enabled) - always index 0
14548
14630
  videoStreams = [];
14549
- // Additional streams (no CV) - indices 1..N
14631
+ // Additional streams (no CV)
14550
14632
  // Video coordinators
14551
14633
  mainVideoCoordinator = null;
14552
14634
  additionalCoordinators = [];
@@ -14554,7 +14636,6 @@ class VijiCore {
14554
14636
  directFrameSlots = 0;
14555
14637
  // Device video management (coordinators only, for cleanup)
14556
14638
  deviceVideoCoordinators = /* @__PURE__ */ new Map();
14557
- // Track assigned stream indices to prevent collisions (indices start at 1 + videoStreams.length)
14558
14639
  deviceVideoStreamIndices = /* @__PURE__ */ new Map();
14559
14640
  // Auto-capture frame buffer (zero-copy transfer)
14560
14641
  latestFrameBuffer = null;
@@ -14595,7 +14676,6 @@ class VijiCore {
14595
14676
  this.config = {
14596
14677
  ...config,
14597
14678
  frameRateMode: config.frameRateMode || "full",
14598
- parameters: config.parameters || [],
14599
14679
  noInputs: config.noInputs ?? false,
14600
14680
  // Canvas DOM interaction disabled in headless by default
14601
14681
  // InteractionManager (viji.mouse/etc) always exists regardless
@@ -14632,7 +14712,8 @@ class VijiCore {
14632
14712
  this.latestFrameBuffer = bitmap;
14633
14713
  }
14634
14714
  /**
14635
- * Get latest frame (transfers ownership, zero-copy)
14715
+ * Get latest frame (transfers ownership, zero-copy).
14716
+ * Internal: consumed by getLatestFramesFromSources().
14636
14717
  */
14637
14718
  getLatestFrame() {
14638
14719
  const frame = this.latestFrameBuffer;
@@ -14688,7 +14769,6 @@ class VijiCore {
14688
14769
  if (this.interactionManager && "setDebugMode" in this.interactionManager) {
14689
14770
  this.interactionManager.setDebugMode(enabled);
14690
14771
  }
14691
- if (this.audioSystem && "setDebugMode" in this.audioSystem) ;
14692
14772
  if (this.mainVideoCoordinator && "setDebugMode" in this.mainVideoCoordinator) {
14693
14773
  this.mainVideoCoordinator.setDebugMode(enabled);
14694
14774
  }
@@ -14722,15 +14802,6 @@ class VijiCore {
14722
14802
  hide() {
14723
14803
  this.iframeManager?.hide();
14724
14804
  }
14725
- /**
14726
- * Select audio analysis backend
14727
- */
14728
- setAudioAnalysisBackend(backend) {
14729
- this.audioSystem?.setAnalysisBackend(backend);
14730
- }
14731
- getAudioAnalysisBackend() {
14732
- return this.audioSystem?.getAnalysisBackend() ?? "auto";
14733
- }
14734
14805
  /**
14735
14806
  * Initializes the core components in sequence
14736
14807
  */
@@ -14793,7 +14864,7 @@ class VijiCore {
14793
14864
  });
14794
14865
  }
14795
14866
  for (let i = 0; i < this.videoStreams.length; i++) {
14796
- const streamIndex = 1 + i;
14867
+ const streamIndex = VijiCore.ADDITIONAL_STREAM_BASE + i;
14797
14868
  const coordinator = new VideoCoordinator((message, transfer) => {
14798
14869
  if (this.workerManager) {
14799
14870
  if (message.type === "video-canvas-setup") {
@@ -15209,13 +15280,6 @@ class VijiCore {
15209
15280
  // ═══════════════════════════════════════════════════════════════════════════
15210
15281
  // Direct Frame Injection API (Compositor Pipeline)
15211
15282
  // ═══════════════════════════════════════════════════════════════════════════
15212
- /**
15213
- * Worker-side stream index offset for direct frame slots.
15214
- * Accounts for main video stream and additional media streams.
15215
- */
15216
- get directFrameStartIndex() {
15217
- return (this.videoStream ? 1 : 0) + this.videoStreams.length;
15218
- }
15219
15283
  /**
15220
15284
  * Ensure the worker has enough direct frame slots prepared.
15221
15285
  * Auto-grows as needed; skips if already sufficient.
@@ -15224,8 +15288,7 @@ class VijiCore {
15224
15288
  if (count <= this.directFrameSlots) return;
15225
15289
  this.directFrameSlots = count;
15226
15290
  this.workerManager.postMessage("video-streams-prepare", {
15227
- mainStream: !!this.videoStream,
15228
- mediaStreamCount: this.videoStreams.length,
15291
+ directFrameBaseIndex: VijiCore.DIRECT_FRAME_BASE,
15229
15292
  directFrameCount: count
15230
15293
  });
15231
15294
  this.debugLog(`[Compositor] Prepared ${count} direct frame slot(s)`);
@@ -15242,27 +15305,10 @@ class VijiCore {
15242
15305
  this.ensureDirectFrameSlots(streamIndex + 1);
15243
15306
  this.workerManager.postMessage("video-frame-direct", {
15244
15307
  imageBitmap: bitmap,
15245
- streamIndex: this.directFrameStartIndex + streamIndex,
15308
+ streamIndex: VijiCore.DIRECT_FRAME_BASE + streamIndex,
15246
15309
  timestamp: performance.now()
15247
15310
  }, [bitmap]);
15248
15311
  }
15249
- /**
15250
- * Inject frames for all direct-frame stream slots at once (compositor pipeline).
15251
- * Auto-prepares slots on first call. Transfers ownership of all bitmaps (zero-copy).
15252
- */
15253
- injectFrames(bitmaps) {
15254
- if (!this.workerManager?.ready) {
15255
- throw new VijiCoreError("Core not ready", "NOT_READY");
15256
- }
15257
- this.ensureDirectFrameSlots(bitmaps.length);
15258
- bitmaps.forEach((bitmap, index) => {
15259
- this.workerManager.postMessage("video-frame-direct", {
15260
- imageBitmap: bitmap,
15261
- streamIndex: this.directFrameStartIndex + index,
15262
- timestamp: performance.now()
15263
- }, [bitmap]);
15264
- });
15265
- }
15266
15312
  /**
15267
15313
  * Link this core to receive events from a source core
15268
15314
  * @param syncResolution Smart default: true for headless, false for visible
@@ -15597,7 +15643,6 @@ class VijiCore {
15597
15643
  if (this.audioSystem) {
15598
15644
  this.audioSystem.handleAudioStreamUpdate({
15599
15645
  audioStream,
15600
- ...this.config.analysisConfig && { analysisConfig: this.config.analysisConfig },
15601
15646
  timestamp: performance.now()
15602
15647
  });
15603
15648
  }
@@ -15657,31 +15702,10 @@ class VijiCore {
15657
15702
  return this.videoStream;
15658
15703
  }
15659
15704
  // ═══════════════════════════════════════════════════════
15660
- // ADDITIONAL VIDEO STREAMS API (new)
15705
+ // ADDITIONAL VIDEO STREAMS API
15661
15706
  // ═══════════════════════════════════════════════════════
15662
15707
  /**
15663
- * Sets all additional video streams (no CV)
15664
- */
15665
- async setVideoStreams(streams) {
15666
- this.validateReady();
15667
- this.videoStreams = [...streams];
15668
- await this.reinitializeAdditionalCoordinators();
15669
- this.debugLog(`Additional video streams updated: ${streams.length} streams`);
15670
- }
15671
- /**
15672
- * Gets all additional video streams
15673
- */
15674
- getVideoStreams() {
15675
- return [...this.videoStreams];
15676
- }
15677
- /**
15678
- * Gets video stream at specific index
15679
- */
15680
- getVideoStreamAt(index) {
15681
- return this.videoStreams[index] || null;
15682
- }
15683
- /**
15684
- * Adds a video stream
15708
+ * Adds an additional video stream (no CV). Returns its index in viji.streams[].
15685
15709
  */
15686
15710
  async addVideoStream(stream) {
15687
15711
  this.validateReady();
@@ -15689,7 +15713,7 @@ class VijiCore {
15689
15713
  if (existingIndex !== -1) return existingIndex;
15690
15714
  this.videoStreams.push(stream);
15691
15715
  const newIndex = this.videoStreams.length - 1;
15692
- const streamIndex = 1 + newIndex;
15716
+ const streamIndex = VijiCore.ADDITIONAL_STREAM_BASE + newIndex;
15693
15717
  const coordinator = new VideoCoordinator((message, transfer) => {
15694
15718
  if (this.workerManager) {
15695
15719
  if (message.type === "video-canvas-setup") {
@@ -15714,9 +15738,9 @@ class VijiCore {
15714
15738
  return newIndex;
15715
15739
  }
15716
15740
  /**
15717
- * Removes video stream at index
15741
+ * Removes an additional video stream by index.
15718
15742
  */
15719
- async removeVideoStreamAt(index) {
15743
+ async removeVideoStream(index) {
15720
15744
  this.validateReady();
15721
15745
  if (index < 0 || index >= this.videoStreams.length) {
15722
15746
  throw new VijiCoreError(`Invalid stream index: ${index}`, "INVALID_INDEX");
@@ -15726,43 +15750,19 @@ class VijiCore {
15726
15750
  await this.reinitializeAdditionalCoordinators();
15727
15751
  }
15728
15752
  /**
15729
- * Removes video stream by reference
15753
+ * Gets the number of additional video streams.
15730
15754
  */
15731
- async removeVideoStream(stream) {
15732
- const index = this.videoStreams.indexOf(stream);
15733
- if (index === -1) return false;
15734
- await this.removeVideoStreamAt(index);
15735
- return true;
15755
+ getVideoStreamCount() {
15756
+ return this.videoStreams.length;
15736
15757
  }
15737
15758
  /**
15738
- * Updates video stream at index
15739
- */
15740
- async setVideoStreamAt(index, stream) {
15741
- this.validateReady();
15742
- if (index < 0 || index >= this.videoStreams.length) {
15743
- throw new VijiCoreError(`Invalid stream index: ${index}`, "INVALID_INDEX");
15744
- }
15745
- this.videoStreams[index].getTracks().forEach((track) => track.stop());
15746
- this.videoStreams[index] = stream;
15747
- if (this.additionalCoordinators[index]) {
15748
- this.additionalCoordinators[index].resetVideoState();
15749
- this.additionalCoordinators[index].handleVideoStreamUpdate({
15750
- videoStream: stream,
15751
- streamIndex: 1 + index,
15752
- streamType: "additional",
15753
- targetFrameRate: 30,
15754
- timestamp: performance.now()
15755
- });
15756
- }
15757
- }
15758
- /**
15759
- * Reinitializes all additional coordinators
15759
+ * Reinitializes all additional coordinators after array mutation.
15760
15760
  */
15761
15761
  async reinitializeAdditionalCoordinators() {
15762
15762
  this.additionalCoordinators.forEach((coord) => coord.resetVideoState());
15763
15763
  this.additionalCoordinators = [];
15764
15764
  for (let i = 0; i < this.videoStreams.length; i++) {
15765
- const streamIndex = 1 + i;
15765
+ const streamIndex = VijiCore.ADDITIONAL_STREAM_BASE + i;
15766
15766
  const coordinator = new VideoCoordinator((message, transfer) => {
15767
15767
  if (this.workerManager) {
15768
15768
  if (message.type === "video-canvas-setup") {
@@ -15822,98 +15822,82 @@ class VijiCore {
15822
15822
  // Audio Analysis API (Namespace-based)
15823
15823
  // ═══════════════════════════════════════════════════════════
15824
15824
  audio = {
15825
- /**
15826
- * Set global audio sensitivity (0.5-2.0, default 1.0)
15827
- */
15828
- setSensitivity: (value) => {
15829
- this.validateReady();
15830
- if (value < 0.5 || value > 2) {
15831
- throw new VijiCoreError("Sensitivity must be between 0.5 and 2.0", "INVALID_VALUE");
15832
- }
15833
- this.audioSystem?.setSensitivity(value);
15834
- this.debugLog(`Audio sensitivity set to ${value} (${this.instanceId})`);
15835
- },
15836
- /**
15837
- * Get current sensitivity
15838
- */
15839
- getSensitivity: () => {
15840
- this.validateReady();
15841
- return this.audioSystem?.getSensitivity() ?? 1;
15842
- },
15843
15825
  /**
15844
15826
  * Beat control namespace
15845
15827
  */
15846
15828
  beat: {
15847
15829
  /**
15848
- * Tap tempo - record tap for manual BPM
15830
+ * Get current BPM (auto-detected)
15849
15831
  */
15850
- tap: () => {
15832
+ getBPM: () => {
15851
15833
  this.validateReady();
15852
- this.audioSystem?.tapTempo();
15853
- this.debugLog(`Tap tempo recorded (${this.instanceId})`);
15834
+ return this.audioSystem?.getCurrentBPM() ?? 120;
15854
15835
  },
15855
15836
  /**
15856
- * Clear tap tempo history
15837
+ * Nudge beat phase
15857
15838
  */
15858
- clearTaps: () => {
15839
+ nudge: (amount) => {
15859
15840
  this.validateReady();
15860
- this.audioSystem?.clearTaps();
15861
- this.debugLog(`Tap tempo cleared (${this.instanceId})`);
15841
+ this.audioSystem?.nudgeBeatPhase(amount);
15862
15842
  },
15863
15843
  /**
15864
- * Get tap count
15844
+ * Reset beat phase to next beat
15865
15845
  */
15866
- getTapCount: () => {
15846
+ resetPhase: () => {
15867
15847
  this.validateReady();
15868
- return this.audioSystem?.getTapCount() ?? 0;
15869
- },
15848
+ this.audioSystem?.resetBeatPhase();
15849
+ this.debugLog(`Beat phase reset (${this.instanceId})`);
15850
+ }
15851
+ },
15852
+ /**
15853
+ * Per-instrument onset tap control
15854
+ */
15855
+ onset: {
15870
15856
  /**
15871
- * 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.
15872
15860
  */
15873
- setMode: (mode) => {
15861
+ tap: (instrument) => {
15874
15862
  this.validateReady();
15875
- this.audioSystem?.setBeatMode(mode);
15876
- this.debugLog(`Beat mode set to ${mode} (${this.instanceId})`);
15863
+ this.audioSystem?.tapOnset(instrument);
15877
15864
  },
15878
15865
  /**
15879
- * Get beat sync mode
15866
+ * Clear the tap pattern for an instrument, restoring auto-detection
15880
15867
  */
15881
- getMode: () => {
15868
+ clear: (instrument) => {
15882
15869
  this.validateReady();
15883
- return this.audioSystem?.getBeatMode() ?? "auto";
15870
+ this.audioSystem?.clearOnsetTap(instrument);
15884
15871
  },
15885
15872
  /**
15886
- * Set manual BPM
15873
+ * Get onset mode for an instrument: 'auto' | 'tapping' | 'pattern'
15887
15874
  */
15888
- setBPM: (bpm) => {
15875
+ getMode: (instrument) => {
15889
15876
  this.validateReady();
15890
- if (bpm < 60 || bpm > 240) {
15891
- throw new VijiCoreError("BPM must be between 60 and 240", "INVALID_VALUE");
15892
- }
15893
- this.audioSystem?.setManualBPM(bpm);
15894
- this.debugLog(`Manual BPM set to ${bpm} (${this.instanceId})`);
15877
+ return this.audioSystem?.getOnsetMode(instrument) ?? "auto";
15895
15878
  },
15896
15879
  /**
15897
- * Get current BPM (manual or auto-detected)
15880
+ * Get recognized pattern info for an instrument, or null
15898
15881
  */
15899
- getBPM: () => {
15882
+ getPatternInfo: (instrument) => {
15900
15883
  this.validateReady();
15901
- return this.audioSystem?.getCurrentBPM() ?? 120;
15884
+ return this.audioSystem?.getOnsetPatternInfo(instrument) ?? null;
15902
15885
  },
15903
15886
  /**
15904
- * 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.
15905
15890
  */
15906
- nudge: (amount) => {
15891
+ setMuted: (instrument, muted) => {
15907
15892
  this.validateReady();
15908
- this.audioSystem?.nudgeBeatPhase(amount);
15893
+ this.audioSystem?.setOnsetMuted(instrument, muted);
15909
15894
  },
15910
15895
  /**
15911
- * Reset beat phase to next beat
15896
+ * Check if an instrument onset is muted
15912
15897
  */
15913
- resetPhase: () => {
15898
+ isMuted: (instrument) => {
15914
15899
  this.validateReady();
15915
- this.audioSystem?.resetBeatPhase();
15916
- this.debugLog(`Beat phase reset (${this.instanceId})`);
15900
+ return this.audioSystem?.isOnsetMuted(instrument) ?? false;
15917
15901
  }
15918
15902
  },
15919
15903
  /**
@@ -15928,17 +15912,6 @@ class VijiCore {
15928
15912
  this.audioSystem?.setFFTSize(size);
15929
15913
  this.debugLog(`FFT size set to ${size} (${this.instanceId})`);
15930
15914
  },
15931
- /**
15932
- * Set smoothing time constant (0-1)
15933
- */
15934
- setSmoothing: (value) => {
15935
- this.validateReady();
15936
- if (value < 0 || value > 1) {
15937
- throw new VijiCoreError("Smoothing must be between 0 and 1", "INVALID_VALUE");
15938
- }
15939
- this.audioSystem?.setSmoothing(value);
15940
- this.debugLog(`Smoothing set to ${value} (${this.instanceId})`);
15941
- },
15942
15915
  /**
15943
15916
  * Enable/disable auto-gain
15944
15917
  */
@@ -15948,20 +15921,10 @@ class VijiCore {
15948
15921
  this.debugLog(`Auto-gain ${enabled ? "enabled" : "disabled"} (${this.instanceId})`);
15949
15922
  },
15950
15923
  /**
15951
- * Enable/disable beat detection
15924
+ * Enable/disable audio analysis debug mode (independent from core debug mode)
15952
15925
  */
15953
- setBeatDetection: (enabled) => {
15954
- this.validateReady();
15955
- this.audioSystem?.setBeatDetection(enabled);
15956
- this.debugLog(`Beat detection ${enabled ? "enabled" : "disabled"} (${this.instanceId})`);
15957
- },
15958
- /**
15959
- * Enable/disable onset detection
15960
- */
15961
- setOnsetDetection: (enabled) => {
15962
- this.validateReady();
15963
- this.audioSystem?.setOnsetDetection(enabled);
15964
- this.debugLog(`Onset detection ${enabled ? "enabled" : "disabled"} (${this.instanceId})`);
15926
+ setDebugMode: (enabled) => {
15927
+ this.audioSystem?.setDebugMode(enabled);
15965
15928
  }
15966
15929
  },
15967
15930
  /**
@@ -15973,10 +15936,9 @@ class VijiCore {
15973
15936
  isConnected: false,
15974
15937
  currentBPM: 120,
15975
15938
  confidence: 0,
15976
- mode: "auto",
15977
- tapCount: 0,
15978
15939
  isLocked: false,
15979
- sensitivity: 1
15940
+ trackingState: "LEARNING",
15941
+ onsetModes: { kick: "auto", snare: "auto", hat: "auto" }
15980
15942
  };
15981
15943
  },
15982
15944
  /**
@@ -16008,15 +15970,14 @@ class VijiCore {
16008
15970
  any: 0,
16009
15971
  kickSmoothed: 0,
16010
15972
  snareSmoothed: 0,
15973
+ hatSmoothed: 0,
16011
15974
  anySmoothed: 0,
16012
15975
  triggers: { any: false, kick: false, snare: false, hat: false },
16013
15976
  bpm: 120,
16014
- phase: 0,
16015
- bar: 0,
16016
15977
  confidence: 0,
16017
15978
  isLocked: false
16018
15979
  },
16019
- spectral: { brightness: 0, flatness: 0, flux: 0 }
15980
+ spectral: { brightness: 0, flatness: 0 }
16020
15981
  };
16021
15982
  }
16022
15983
  };
@@ -16152,9 +16113,8 @@ class VijiCore {
16152
16113
  async setDeviceVideo(deviceId, stream) {
16153
16114
  this.validateReady();
16154
16115
  await this.clearDeviceVideo(deviceId);
16155
- const baseOffset = 1 + this.videoStreams.length;
16156
16116
  const usedIndices = new Set(this.deviceVideoStreamIndices.values());
16157
- let streamIndex = baseOffset;
16117
+ let streamIndex = VijiCore.DEVICE_VIDEO_BASE;
16158
16118
  while (usedIndices.has(streamIndex)) {
16159
16119
  streamIndex++;
16160
16120
  }
@@ -16317,4 +16277,4 @@ export {
16317
16277
  VijiCoreError as b,
16318
16278
  getDefaultExportFromCjs as g
16319
16279
  };
16320
- //# sourceMappingURL=index-BKGarA3m.js.map
16280
+ //# sourceMappingURL=index-trkn0FNW.js.map