@viji-dev/core 0.5.0 → 0.5.2

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.
@@ -640,7 +640,7 @@ class IFrameManager {
640
640
  }
641
641
  }
642
642
  }
643
- const workerUrl = "" + new URL("assets/viji.worker-CWKkFyOs.js", import.meta.url).href;
643
+ const workerUrl = "" + new URL("assets/viji.worker-DwYMDyfQ.js", import.meta.url).href;
644
644
  const simdLoaderJs = new URL("assets/wasm/vision_wasm_internal.js", import.meta.url).href;
645
645
  const simdBinary = new URL("assets/wasm/vision_wasm_internal.wasm", import.meta.url).href;
646
646
  const nosimdLoaderJs = new URL("assets/wasm/vision_wasm_nosimd_internal.js", import.meta.url).href;
@@ -1897,7 +1897,7 @@ class EssentiaOnsetDetection {
1897
1897
  this.initPromise = (async () => {
1898
1898
  try {
1899
1899
  const essentiaModule = await import("./essentia.js-core.es-DnrJE0uR.js");
1900
- const wasmModule = await import("./essentia-wasm.web-CmPC-AKu.js").then((n) => n.e);
1900
+ const wasmModule = await import("./essentia-wasm.web-DE6gem4m.js").then((n) => n.e);
1901
1901
  const EssentiaClass = essentiaModule.Essentia || essentiaModule.default?.Essentia || essentiaModule.default;
1902
1902
  let WASMModule = wasmModule.default || wasmModule.EssentiaWASM || wasmModule.default?.EssentiaWASM;
1903
1903
  if (!WASMModule) {
@@ -3558,10 +3558,20 @@ class TempoInduction {
3558
3558
  setDebugMode(_enabled) {
3559
3559
  }
3560
3560
  /**
3561
- * Reset tempo detection state
3561
+ * Reset tempo detection state.
3562
+ *
3563
+ * Note on coverage: this clears every mutable runtime field that affects
3564
+ * subsequent detection. Earlier versions of `reset()` missed several fields
3565
+ * (per-band envelopes, onset history, grid-lock, warmup, fpsEstimate); they
3566
+ * are included here for correctness — without them, a re-used instance
3567
+ * could carry stale signal-history into a fresh detection session.
3562
3568
  */
3563
3569
  reset() {
3564
3570
  this.onsetEnvelope = [];
3571
+ this.lowBandEnvelope = [];
3572
+ this.midBandEnvelope = [];
3573
+ this.highBandEnvelope = [];
3574
+ this.fpsEstimate = 60;
3565
3575
  this.currentBPM = 120;
3566
3576
  this.confidence = 0;
3567
3577
  this.method = "autocorr";
@@ -3574,6 +3584,107 @@ class TempoInduction {
3574
3584
  this.bpmDriftHistory = [];
3575
3585
  this.driftRate = 0;
3576
3586
  this.tempoChangeConfirmCount = 0;
3587
+ this.onsetHistory = [];
3588
+ this.gridLockBPM = null;
3589
+ this.gridLockScore = 0;
3590
+ this.gridLockTime = 0;
3591
+ this.gridLockMemoryBPM = null;
3592
+ this.gridLockMemoryTime = 0;
3593
+ this.syncopationLevel = 0;
3594
+ this.warmupStartTimeMs = 0;
3595
+ this.warmupComplete = false;
3596
+ }
3597
+ // ─────────────────────────────────────────────────────────────────────────
3598
+ // State serialization (for VijiCore.exportFullState same-process transfer)
3599
+ // ─────────────────────────────────────────────────────────────────────────
3600
+ /**
3601
+ * Snapshot continuity-relevant runtime state. Wall-clock fields are in
3602
+ * this instance's `performance.now()` clock space.
3603
+ */
3604
+ exportSessionState() {
3605
+ return {
3606
+ onsetEnvelope: [...this.onsetEnvelope],
3607
+ lowBandEnvelope: [...this.lowBandEnvelope],
3608
+ midBandEnvelope: [...this.midBandEnvelope],
3609
+ highBandEnvelope: [...this.highBandEnvelope],
3610
+ fpsEstimate: this.fpsEstimate,
3611
+ currentBPM: this.currentBPM,
3612
+ confidence: this.confidence,
3613
+ method: this.method,
3614
+ anchorBand: this.anchorBand,
3615
+ methodAgreement: this.methodAgreement,
3616
+ bpmHistory: [...this.bpmHistory],
3617
+ lastUpdateTime: this.lastUpdateTime > 0 ? this.lastUpdateTime : null,
3618
+ hypotheses: this.hypotheses.map((h) => ({
3619
+ bpm: h.bpm,
3620
+ likelihood: h.likelihood,
3621
+ age: h.age,
3622
+ lastEvidence: h.lastEvidence,
3623
+ type: h.type
3624
+ })),
3625
+ inTransition: this.inTransition,
3626
+ bpmDriftHistory: this.bpmDriftHistory.map((e) => ({ time: e.time, bpm: e.bpm })),
3627
+ driftRate: this.driftRate,
3628
+ tempoChangeConfirmCount: this.tempoChangeConfirmCount,
3629
+ onsetHistory: this.onsetHistory.map((e) => ({
3630
+ time: e.time,
3631
+ strength: e.strength,
3632
+ type: e.type
3633
+ })),
3634
+ gridLockBPM: this.gridLockBPM,
3635
+ gridLockScore: this.gridLockScore,
3636
+ gridLockTime: this.gridLockTime,
3637
+ gridLockMemoryBPM: this.gridLockMemoryBPM,
3638
+ gridLockMemoryTime: this.gridLockMemoryTime,
3639
+ syncopationLevel: this.syncopationLevel,
3640
+ warmupStartTimeMs: this.warmupStartTimeMs,
3641
+ warmupComplete: this.warmupComplete
3642
+ };
3643
+ }
3644
+ /**
3645
+ * Replace runtime state from a serialized snapshot. `clockOffset` is added
3646
+ * to all wall-clock fields (zero / null sentinels are left untranslated).
3647
+ */
3648
+ importSessionState(state, clockOffset) {
3649
+ this.onsetEnvelope = [...state.onsetEnvelope];
3650
+ this.lowBandEnvelope = [...state.lowBandEnvelope];
3651
+ this.midBandEnvelope = [...state.midBandEnvelope];
3652
+ this.highBandEnvelope = [...state.highBandEnvelope];
3653
+ this.fpsEstimate = state.fpsEstimate;
3654
+ this.currentBPM = state.currentBPM;
3655
+ this.confidence = state.confidence;
3656
+ this.method = state.method;
3657
+ this.anchorBand = state.anchorBand;
3658
+ this.methodAgreement = state.methodAgreement;
3659
+ this.bpmHistory = [...state.bpmHistory];
3660
+ this.lastUpdateTime = state.lastUpdateTime !== null ? state.lastUpdateTime + clockOffset : 0;
3661
+ this.hypotheses = state.hypotheses.map((h) => ({
3662
+ bpm: h.bpm,
3663
+ likelihood: h.likelihood,
3664
+ age: h.age,
3665
+ lastEvidence: h.lastEvidence + clockOffset,
3666
+ type: h.type
3667
+ }));
3668
+ this.inTransition = state.inTransition;
3669
+ this.bpmDriftHistory = state.bpmDriftHistory.map((e) => ({
3670
+ time: e.time + clockOffset,
3671
+ bpm: e.bpm
3672
+ }));
3673
+ this.driftRate = state.driftRate;
3674
+ this.tempoChangeConfirmCount = state.tempoChangeConfirmCount;
3675
+ this.onsetHistory = state.onsetHistory.map((e) => ({
3676
+ time: e.time + clockOffset,
3677
+ strength: e.strength,
3678
+ type: e.type
3679
+ }));
3680
+ this.gridLockBPM = state.gridLockBPM;
3681
+ this.gridLockScore = state.gridLockScore;
3682
+ this.gridLockTime = state.gridLockTime > 0 ? state.gridLockTime + clockOffset : 0;
3683
+ this.gridLockMemoryBPM = state.gridLockMemoryBPM;
3684
+ this.gridLockMemoryTime = state.gridLockMemoryTime > 0 ? state.gridLockMemoryTime + clockOffset : 0;
3685
+ this.syncopationLevel = state.syncopationLevel;
3686
+ this.warmupStartTimeMs = state.warmupStartTimeMs > 0 ? state.warmupStartTimeMs + clockOffset : 0;
3687
+ this.warmupComplete = state.warmupComplete;
3577
3688
  }
3578
3689
  /**
3579
3690
  * Get detailed debug info for testing/debugging
@@ -4093,6 +4204,75 @@ class PhaseLockedLoop {
4093
4204
  this.phaseOffsetCalibrated = 0;
4094
4205
  this.lastCalibrationTime = 0;
4095
4206
  }
4207
+ // ─────────────────────────────────────────────────────────────────────────
4208
+ // State serialization (for VijiCore.exportFullState same-process transfer)
4209
+ // ─────────────────────────────────────────────────────────────────────────
4210
+ /**
4211
+ * Snapshot continuity-relevant runtime state. Wall-clock fields are in this
4212
+ * instance's `performance.now()` clock space; receiver translates by
4213
+ * `clockOffset` on import.
4214
+ */
4215
+ exportSessionState() {
4216
+ return {
4217
+ phase: this.phase,
4218
+ lastBeatTime: this.lastBeatTime,
4219
+ periodMs: this.periodMs,
4220
+ trackedBPM: this.trackedBPM,
4221
+ beatCounter: this.beatCounter,
4222
+ lastOnsetTime: this.lastOnsetTime,
4223
+ bpmHistory: this.bpmHistory.map((e) => ({ time: e.time, bpm: e.bpm })),
4224
+ driftRate: this.driftRate,
4225
+ inBreakdown: this.inBreakdown,
4226
+ tempoConfidence: this.tempoConfidence,
4227
+ inTransition: this.inTransition,
4228
+ currentGain: this.currentGain,
4229
+ lastBarAdvanceTime: this.lastBarAdvanceTime,
4230
+ pendingBarAdvance: this.pendingBarAdvance,
4231
+ lastPhaseWrapTime: this.lastPhaseWrapTime,
4232
+ consecutiveAlignedKicks: this.consecutiveAlignedKicks,
4233
+ lastHardSyncTime: this.lastHardSyncTime,
4234
+ trackingStartTime: this.trackingStartTime,
4235
+ kickPhaseHistory: [...this.kickPhaseHistory],
4236
+ phaseOffsetCalibrated: this.phaseOffsetCalibrated,
4237
+ lastCalibrationTime: this.lastCalibrationTime,
4238
+ lastPhaseError: this.lastPhaseError
4239
+ };
4240
+ }
4241
+ /**
4242
+ * Replace runtime state from a serialized snapshot. `clockOffset` is added
4243
+ * to all wall-clock fields (zero values are treated as "never set" sentinels
4244
+ * and left at zero — same convention as `reset()`).
4245
+ */
4246
+ importSessionState(state, clockOffset) {
4247
+ this.phase = state.phase;
4248
+ this.lastBeatTime = translateTimestamp$1(state.lastBeatTime, clockOffset);
4249
+ this.periodMs = state.periodMs;
4250
+ this.trackedBPM = state.trackedBPM;
4251
+ this.beatCounter = state.beatCounter;
4252
+ this.lastOnsetTime = translateTimestamp$1(state.lastOnsetTime, clockOffset);
4253
+ this.bpmHistory = state.bpmHistory.map((e) => ({
4254
+ time: translateTimestamp$1(e.time, clockOffset),
4255
+ bpm: e.bpm
4256
+ }));
4257
+ this.driftRate = state.driftRate;
4258
+ this.inBreakdown = state.inBreakdown;
4259
+ this.tempoConfidence = state.tempoConfidence;
4260
+ this.inTransition = state.inTransition;
4261
+ this.currentGain = state.currentGain;
4262
+ this.lastBarAdvanceTime = translateTimestamp$1(state.lastBarAdvanceTime, clockOffset);
4263
+ this.pendingBarAdvance = state.pendingBarAdvance;
4264
+ this.lastPhaseWrapTime = translateTimestamp$1(state.lastPhaseWrapTime, clockOffset);
4265
+ this.consecutiveAlignedKicks = state.consecutiveAlignedKicks;
4266
+ this.lastHardSyncTime = translateTimestamp$1(state.lastHardSyncTime, clockOffset);
4267
+ this.trackingStartTime = translateTimestamp$1(state.trackingStartTime, clockOffset);
4268
+ this.kickPhaseHistory = [...state.kickPhaseHistory];
4269
+ this.phaseOffsetCalibrated = state.phaseOffsetCalibrated;
4270
+ this.lastCalibrationTime = translateTimestamp$1(state.lastCalibrationTime, clockOffset);
4271
+ this.lastPhaseError = state.lastPhaseError;
4272
+ }
4273
+ }
4274
+ function translateTimestamp$1(t, clockOffset) {
4275
+ return t > 0 ? t + clockOffset : 0;
4096
4276
  }
4097
4277
  const FAST_WINDOW_MS = 2e3;
4098
4278
  const MEDIUM_WINDOW_MS = 1e4;
@@ -5437,7 +5617,12 @@ class BeatStateManager {
5437
5617
  return this.state === "LOCKED" || this.state === "BREAKDOWN";
5438
5618
  }
5439
5619
  /**
5440
- * Reset all state
5620
+ * Reset all state.
5621
+ *
5622
+ * Coverage extended over earlier versions to also clear: per-class
5623
+ * cooldown/rate-cap state, kick interval/BPM history, onset-strength
5624
+ * tracking, band-energy history. Without these, a re-used instance
5625
+ * would carry stale signal-history into a fresh detection session.
5441
5626
  */
5442
5627
  reset() {
5443
5628
  this.state = "TRACKING";
@@ -5450,6 +5635,7 @@ class BeatStateManager {
5450
5635
  this.recentGridScores = [];
5451
5636
  this.lockedBPM = 120;
5452
5637
  this.lastOnsetTime = 0;
5638
+ this.lastKickTime = 0;
5453
5639
  this.eventBuffer = [];
5454
5640
  Object.values(this.envelopes).forEach((env) => env.reset());
5455
5641
  this.debugKickCount = 0;
@@ -5460,6 +5646,153 @@ class BeatStateManager {
5460
5646
  snare: { intervals: [], lastOnsetTime: 0 },
5461
5647
  hat: { intervals: [], lastOnsetTime: 0 }
5462
5648
  };
5649
+ this.recentOnsetStrengths = [];
5650
+ this.averageOnsetStrength = 0.5;
5651
+ this.kickIntervals = [];
5652
+ this.bpmHistory = [];
5653
+ this.bandEnergyHistory = { low: [], mid: [], high: [] };
5654
+ this.eventTimestamps = { kick: [], snare: [], hat: [] };
5655
+ this.lastEventTime = { kick: 0, snare: 0, hat: 0 };
5656
+ }
5657
+ // ─────────────────────────────────────────────────────────────────────────
5658
+ // State serialization (for VijiCore.exportFullState same-process transfer)
5659
+ // ─────────────────────────────────────────────────────────────────────────
5660
+ /**
5661
+ * Snapshot continuity-relevant runtime state. Wall-clock fields are in
5662
+ * this instance's `performance.now()` clock space. Envelope follower state
5663
+ * is intentionally omitted — receiver re-triggers envelopes naturally as
5664
+ * detected events flow.
5665
+ */
5666
+ exportSessionState() {
5667
+ return {
5668
+ state: this.state,
5669
+ stateEnteredTime: this.stateEnteredTime,
5670
+ kickProfile: { ...this.kickProfile },
5671
+ snareProfile: { ...this.snareProfile },
5672
+ hatProfile: { ...this.hatProfile },
5673
+ recentOnsetStrengths: [...this.recentOnsetStrengths],
5674
+ averageOnsetStrength: this.averageOnsetStrength,
5675
+ tempoMethodAgreement: this.tempoMethodAgreement,
5676
+ gridScore: this.gridScore,
5677
+ consistencyScore: this.consistencyScore,
5678
+ anchorClarity: this.anchorClarity,
5679
+ recentGridScores: [...this.recentGridScores],
5680
+ lockedBPM: this.lockedBPM,
5681
+ lastOnsetTime: this.lastOnsetTime,
5682
+ lastKickTime: this.lastKickTime,
5683
+ kickIntervals: [...this.kickIntervals],
5684
+ bpmHistory: [...this.bpmHistory],
5685
+ adaptiveProfiles: {
5686
+ kick: { samples: this.adaptiveProfiles.kick.samples.map((s) => ({ ...s })) },
5687
+ snareIndependent: { samples: this.adaptiveProfiles.snareIndependent.samples.map((s) => ({ ...s })) },
5688
+ snareLayered: { samples: this.adaptiveProfiles.snareLayered.samples.map((s) => ({ ...s })) },
5689
+ hatIndependent: { samples: this.adaptiveProfiles.hatIndependent.samples.map((s) => ({ ...s })) },
5690
+ hatLayered: { samples: this.adaptiveProfiles.hatLayered.samples.map((s) => ({ ...s })) }
5691
+ },
5692
+ adaptiveThresholds: {
5693
+ snareMinMidRatio: this.adaptiveThresholds.snareMinMidRatio,
5694
+ snareMinMidToBass: this.adaptiveThresholds.snareMinMidToBass,
5695
+ kickMaxMidRatio: this.adaptiveThresholds.kickMaxMidRatio,
5696
+ snareIndependent: { ...this.adaptiveThresholds.snareIndependent },
5697
+ snareLayered: { ...this.adaptiveThresholds.snareLayered },
5698
+ hatIndependent: { ...this.adaptiveThresholds.hatIndependent },
5699
+ hatLayered: { ...this.adaptiveThresholds.hatLayered }
5700
+ },
5701
+ ioiTrackers: {
5702
+ kick: { intervals: [...this.ioiTrackers.kick.intervals], lastOnsetTime: this.ioiTrackers.kick.lastOnsetTime },
5703
+ snare: { intervals: [...this.ioiTrackers.snare.intervals], lastOnsetTime: this.ioiTrackers.snare.lastOnsetTime },
5704
+ hat: { intervals: [...this.ioiTrackers.hat.intervals], lastOnsetTime: this.ioiTrackers.hat.lastOnsetTime }
5705
+ },
5706
+ eventBuffer: this.eventBuffer.map((e) => ({
5707
+ event: { ...e.event },
5708
+ expiresAt: e.expiresAt
5709
+ })),
5710
+ bandEnergyHistory: {
5711
+ low: this.bandEnergyHistory.low.map((e) => ({ ...e })),
5712
+ mid: this.bandEnergyHistory.mid.map((e) => ({ ...e })),
5713
+ high: this.bandEnergyHistory.high.map((e) => ({ ...e }))
5714
+ },
5715
+ eventTimestamps: {
5716
+ kick: [...this.eventTimestamps.kick],
5717
+ snare: [...this.eventTimestamps.snare],
5718
+ hat: [...this.eventTimestamps.hat]
5719
+ },
5720
+ lastEventTime: { ...this.lastEventTime }
5721
+ };
5722
+ }
5723
+ /**
5724
+ * Replace runtime state from a serialized snapshot. `clockOffset` is added
5725
+ * to all wall-clock fields (zero values are treated as "never set" sentinels
5726
+ * and left at zero). Envelopes are NOT restored; they re-trigger from
5727
+ * subsequent events.
5728
+ */
5729
+ importSessionState(state, clockOffset) {
5730
+ this.state = state.state;
5731
+ this.stateEnteredTime = translateTimestamp(state.stateEnteredTime, clockOffset);
5732
+ this.kickProfile = { ...state.kickProfile };
5733
+ this.snareProfile = { ...state.snareProfile };
5734
+ this.hatProfile = { ...state.hatProfile };
5735
+ this.recentOnsetStrengths = [...state.recentOnsetStrengths];
5736
+ this.averageOnsetStrength = state.averageOnsetStrength;
5737
+ this.tempoMethodAgreement = state.tempoMethodAgreement;
5738
+ this.gridScore = state.gridScore;
5739
+ this.consistencyScore = state.consistencyScore;
5740
+ this.anchorClarity = state.anchorClarity;
5741
+ this.recentGridScores = [...state.recentGridScores];
5742
+ this.lockedBPM = state.lockedBPM;
5743
+ this.lastOnsetTime = translateTimestamp(state.lastOnsetTime, clockOffset);
5744
+ this.lastKickTime = translateTimestamp(state.lastKickTime, clockOffset);
5745
+ this.kickIntervals = [...state.kickIntervals];
5746
+ this.bpmHistory = [...state.bpmHistory];
5747
+ this.adaptiveProfiles = {
5748
+ kick: { samples: state.adaptiveProfiles.kick.samples.map((s) => ({ ...s, time: s.time + clockOffset })) },
5749
+ snareIndependent: { samples: state.adaptiveProfiles.snareIndependent.samples.map((s) => ({ ...s, time: s.time + clockOffset })) },
5750
+ snareLayered: { samples: state.adaptiveProfiles.snareLayered.samples.map((s) => ({ ...s, time: s.time + clockOffset })) },
5751
+ hatIndependent: { samples: state.adaptiveProfiles.hatIndependent.samples.map((s) => ({ ...s, time: s.time + clockOffset })) },
5752
+ hatLayered: { samples: state.adaptiveProfiles.hatLayered.samples.map((s) => ({ ...s, time: s.time + clockOffset })) }
5753
+ };
5754
+ this.adaptiveThresholds = {
5755
+ snareMinMidRatio: state.adaptiveThresholds.snareMinMidRatio,
5756
+ snareMinMidToBass: state.adaptiveThresholds.snareMinMidToBass,
5757
+ kickMaxMidRatio: state.adaptiveThresholds.kickMaxMidRatio,
5758
+ snareIndependent: { ...state.adaptiveThresholds.snareIndependent },
5759
+ snareLayered: { ...state.adaptiveThresholds.snareLayered },
5760
+ hatIndependent: { ...state.adaptiveThresholds.hatIndependent },
5761
+ hatLayered: { ...state.adaptiveThresholds.hatLayered }
5762
+ };
5763
+ this.ioiTrackers = {
5764
+ kick: {
5765
+ intervals: [...state.ioiTrackers.kick.intervals],
5766
+ lastOnsetTime: translateTimestamp(state.ioiTrackers.kick.lastOnsetTime, clockOffset)
5767
+ },
5768
+ snare: {
5769
+ intervals: [...state.ioiTrackers.snare.intervals],
5770
+ lastOnsetTime: translateTimestamp(state.ioiTrackers.snare.lastOnsetTime, clockOffset)
5771
+ },
5772
+ hat: {
5773
+ intervals: [...state.ioiTrackers.hat.intervals],
5774
+ lastOnsetTime: translateTimestamp(state.ioiTrackers.hat.lastOnsetTime, clockOffset)
5775
+ }
5776
+ };
5777
+ this.eventBuffer = state.eventBuffer.map((e) => ({
5778
+ event: { ...e.event, time: e.event.time + clockOffset },
5779
+ expiresAt: e.expiresAt + clockOffset
5780
+ }));
5781
+ this.bandEnergyHistory = {
5782
+ low: state.bandEnergyHistory.low.map((e) => ({ time: e.time + clockOffset, value: e.value })),
5783
+ mid: state.bandEnergyHistory.mid.map((e) => ({ time: e.time + clockOffset, value: e.value })),
5784
+ high: state.bandEnergyHistory.high.map((e) => ({ time: e.time + clockOffset, value: e.value }))
5785
+ };
5786
+ this.eventTimestamps = {
5787
+ kick: state.eventTimestamps.kick.map((t) => t + clockOffset),
5788
+ snare: state.eventTimestamps.snare.map((t) => t + clockOffset),
5789
+ hat: state.eventTimestamps.hat.map((t) => t + clockOffset)
5790
+ };
5791
+ this.lastEventTime = {
5792
+ kick: translateTimestamp(state.lastEventTime.kick, clockOffset),
5793
+ snare: translateTimestamp(state.lastEventTime.snare, clockOffset),
5794
+ hat: translateTimestamp(state.lastEventTime.hat, clockOffset)
5795
+ };
5463
5796
  }
5464
5797
  /**
5465
5798
  * Enable/disable enhanced debug mode for comprehensive testing
@@ -5474,6 +5807,9 @@ class BeatStateManager {
5474
5807
  printSummary(durationSec) {
5475
5808
  }
5476
5809
  }
5810
+ function translateTimestamp(t, clockOffset) {
5811
+ return t > 0 ? t + clockOffset : 0;
5812
+ }
5477
5813
  class DiagnosticLogger {
5478
5814
  events = [];
5479
5815
  sessionStart = 0;
@@ -5804,6 +6140,7 @@ Rejection rate: ${(rejections / duration * 60).toFixed(1)} per minute
5804
6140
  const INSTRUMENTS = ["kick", "snare", "hat"];
5805
6141
  const SAMPLE_RATE = 60;
5806
6142
  const TAP_TIMEOUT_MS = 5e3;
6143
+ const SESSION_END_IDLE_MS = 500;
5807
6144
  const MAX_CYCLE_LENGTH = 8;
5808
6145
  const FUZZY_TOLERANCE = 0.18;
5809
6146
  const MIN_REPETITIONS = 3;
@@ -5812,6 +6149,7 @@ const MIN_TAP_INTERVAL_MS = 100;
5812
6149
  const MAX_TAP_HISTORY = 64;
5813
6150
  const MIN_EMA_ALPHA = 0.05;
5814
6151
  const PATTERN_SAME_TOLERANCE = 0.15;
6152
+ const STATE_SCHEMA_VERSION = 1;
5815
6153
  function createInstrumentState() {
5816
6154
  return {
5817
6155
  mode: "auto",
@@ -5826,7 +6164,10 @@ function createInstrumentState() {
5826
6164
  replayIndex: 0,
5827
6165
  pendingTapEvents: [],
5828
6166
  envelope: new EnvelopeFollower(0, 300, SAMPLE_RATE),
5829
- envelopeSmoothed: new EnvelopeFollower(5, 500, SAMPLE_RATE)
6167
+ envelopeSmoothed: new EnvelopeFollower(5, 500, SAMPLE_RATE),
6168
+ sessionActive: false,
6169
+ sessionEndTimer: null,
6170
+ tapTimeoutTimer: null
5830
6171
  };
5831
6172
  }
5832
6173
  class OnsetTapManager {
@@ -5835,12 +6176,19 @@ class OnsetTapManager {
5835
6176
  snare: createInstrumentState(),
5836
6177
  hat: createInstrumentState()
5837
6178
  };
6179
+ modeChangeListeners = /* @__PURE__ */ new Set();
6180
+ sessionEndListeners = /* @__PURE__ */ new Set();
6181
+ muteChangeListeners = /* @__PURE__ */ new Set();
6182
+ suppressEmissions = false;
5838
6183
  tap(instrument) {
5839
6184
  const s = this.state[instrument];
5840
6185
  const now = performance.now();
5841
6186
  if (s.muted) {
5842
6187
  s.muted = false;
5843
6188
  s.mutedAt = 0;
6189
+ if (!this.suppressEmissions) {
6190
+ this.fireMuteChange({ instrument, prevMuted: true, muted: false });
6191
+ }
5844
6192
  }
5845
6193
  let ioi = -1;
5846
6194
  if (s.lastTapTime > 0) {
@@ -5856,8 +6204,10 @@ class OnsetTapManager {
5856
6204
  }
5857
6205
  s.lastTapTime = now;
5858
6206
  s.pendingTapEvents.push(now);
6207
+ s.sessionActive = true;
6208
+ this.scheduleSessionTimers(instrument);
5859
6209
  if (s.mode === "auto") {
5860
- s.mode = "tapping";
6210
+ this.setMode(instrument, "tapping");
5861
6211
  if (ioi > 0) {
5862
6212
  const pattern = this.tryRecognizePattern(instrument);
5863
6213
  if (pattern) this.applyPattern(instrument, pattern);
@@ -5875,7 +6225,10 @@ class OnsetTapManager {
5875
6225
  }
5876
6226
  clear(instrument) {
5877
6227
  const s = this.state[instrument];
5878
- s.mode = "auto";
6228
+ const prevMuted = s.muted;
6229
+ this.cancelSessionTimers(s);
6230
+ s.sessionActive = false;
6231
+ this.setMode(instrument, "auto");
5879
6232
  s.muted = false;
5880
6233
  s.mutedAt = 0;
5881
6234
  s.tapIOIs = [];
@@ -5888,6 +6241,9 @@ class OnsetTapManager {
5888
6241
  s.pendingTapEvents = [];
5889
6242
  s.envelope.reset();
5890
6243
  s.envelopeSmoothed.reset();
6244
+ if (prevMuted && !this.suppressEmissions) {
6245
+ this.fireMuteChange({ instrument, prevMuted: true, muted: false });
6246
+ }
5891
6247
  }
5892
6248
  getMode(instrument) {
5893
6249
  return this.state[instrument].mode;
@@ -5904,6 +6260,7 @@ class OnsetTapManager {
5904
6260
  setMuted(instrument, muted) {
5905
6261
  const s = this.state[instrument];
5906
6262
  if (s.muted === muted) return;
6263
+ const prevMuted = s.muted;
5907
6264
  const now = performance.now();
5908
6265
  if (muted) {
5909
6266
  s.muted = true;
@@ -5916,15 +6273,116 @@ class OnsetTapManager {
5916
6273
  s.lastTapTime += pauseDuration;
5917
6274
  }
5918
6275
  }
6276
+ if (!this.suppressEmissions) {
6277
+ this.fireMuteChange({ instrument, prevMuted, muted: s.muted });
6278
+ }
5919
6279
  }
5920
6280
  isMuted(instrument) {
5921
6281
  return this.state[instrument].muted;
5922
6282
  }
6283
+ // ─────────────────────────────────────────────────────────────────────────
6284
+ // Listener registration
6285
+ // ─────────────────────────────────────────────────────────────────────────
6286
+ onModeChange(listener) {
6287
+ this.modeChangeListeners.add(listener);
6288
+ return () => {
6289
+ this.modeChangeListeners.delete(listener);
6290
+ };
6291
+ }
6292
+ onSessionEnd(listener) {
6293
+ this.sessionEndListeners.add(listener);
6294
+ return () => {
6295
+ this.sessionEndListeners.delete(listener);
6296
+ };
6297
+ }
6298
+ onMuteChange(listener) {
6299
+ this.muteChangeListeners.add(listener);
6300
+ return () => {
6301
+ this.muteChangeListeners.delete(listener);
6302
+ };
6303
+ }
6304
+ // ─────────────────────────────────────────────────────────────────────────
6305
+ // State serialization
6306
+ // ─────────────────────────────────────────────────────────────────────────
6307
+ /**
6308
+ * Serialize per-instrument onset state for cross-instance transfer.
6309
+ * Wall-clock fields are in this instance's `performance.now()` clock space.
6310
+ */
6311
+ exportSessionState() {
6312
+ return {
6313
+ version: STATE_SCHEMA_VERSION,
6314
+ senderTime: performance.now(),
6315
+ instruments: {
6316
+ kick: this.serializeInstrument("kick"),
6317
+ snare: this.serializeInstrument("snare"),
6318
+ hat: this.serializeInstrument("hat")
6319
+ }
6320
+ };
6321
+ }
6322
+ /**
6323
+ * Replace per-instrument state from a serialized payload. Wall-clock fields
6324
+ * in the payload are translated by `clockOffset` (added to non-null sender
6325
+ * timestamps to map them into the receiver's `performance.now()` clock).
6326
+ * For same-process transfer, `clockOffset = 0`.
6327
+ *
6328
+ * `replayLastEventTime` is rebased forward by whole pattern cycles to
6329
+ * eliminate the catch-up burst that would otherwise fire if the payload
6330
+ * is older than one pattern period (phase-preserving — events still land on
6331
+ * the original beat positions modulo `patternSum`).
6332
+ *
6333
+ * Mutation is synchronous; no events are emitted (state replacement is not
6334
+ * a transition). Throws nothing — malformed payloads should be filtered by
6335
+ * the caller via the `version` field.
6336
+ */
6337
+ importSessionState(state, clockOffset) {
6338
+ if (state.version !== STATE_SCHEMA_VERSION) return;
6339
+ this.suppressEmissions = true;
6340
+ try {
6341
+ const now = performance.now();
6342
+ for (const inst of INSTRUMENTS) {
6343
+ const payload = state.instruments[inst];
6344
+ if (!payload) continue;
6345
+ this.applyInstrumentPayload(inst, payload, clockOffset, now);
6346
+ }
6347
+ } finally {
6348
+ this.suppressEmissions = false;
6349
+ }
6350
+ }
6351
+ /**
6352
+ * Validate a serialized payload's shape before importing. Returns null on
6353
+ * success or a `StateImportError` describing the first failure.
6354
+ */
6355
+ static validateOnsetPayload(state) {
6356
+ if (state === null || typeof state !== "object") {
6357
+ return { code: "malformed", details: "payload is not an object" };
6358
+ }
6359
+ const s = state;
6360
+ if (s.version !== STATE_SCHEMA_VERSION) {
6361
+ return {
6362
+ code: "version-mismatch",
6363
+ details: `expected version ${STATE_SCHEMA_VERSION}, got ${String(s.version)}`,
6364
+ ...typeof s.version === "number" ? { payloadVersion: s.version } : {},
6365
+ expectedVersion: STATE_SCHEMA_VERSION
6366
+ };
6367
+ }
6368
+ if (typeof s.senderTime !== "number") {
6369
+ return { code: "invalid-field", details: "senderTime must be a number", field: "senderTime" };
6370
+ }
6371
+ if (!s.instruments || typeof s.instruments !== "object") {
6372
+ return { code: "invalid-field", details: "instruments must be an object", field: "instruments" };
6373
+ }
6374
+ return null;
6375
+ }
5923
6376
  /**
5924
6377
  * Post-process a beat state produced by BeatStateManager.
5925
6378
  * For instruments in auto mode, values pass through unchanged.
5926
6379
  * For tapping/pattern instruments, auto events are suppressed and
5927
6380
  * tap/pattern events + envelopes are injected instead.
6381
+ *
6382
+ * Safe to call with an empty `beatState` (no audio frames available) —
6383
+ * the host's idle ticker drives this in the no-audio scenario so the
6384
+ * artist API receives tap-driven envelopes and events without an audio
6385
+ * source.
5928
6386
  */
5929
6387
  processFrame(beatState, now, dtMs) {
5930
6388
  const result = { ...beatState, events: [...beatState.events] };
@@ -5964,11 +6422,11 @@ class OnsetTapManager {
5964
6422
  s.tapIOIs = [];
5965
6423
  s.pendingTapEvents = [];
5966
6424
  if (s.pattern) {
5967
- s.mode = "pattern";
6425
+ this.setMode(inst, "pattern");
5968
6426
  s.replayLastEventTime = now;
5969
6427
  s.replayIndex = 0;
5970
6428
  } else {
5971
- s.mode = "auto";
6429
+ this.setMode(inst, "auto");
5972
6430
  }
5973
6431
  continue;
5974
6432
  }
@@ -6023,6 +6481,168 @@ class OnsetTapManager {
6023
6481
  // ---------------------------------------------------------------------------
6024
6482
  // Private helpers
6025
6483
  // ---------------------------------------------------------------------------
6484
+ /**
6485
+ * Single mutation point for `s.mode`. Fires `onModeChange` (when not
6486
+ * suppressed) so listeners stay consistent regardless of which code path
6487
+ * triggered the transition.
6488
+ */
6489
+ setMode(instrument, newMode) {
6490
+ const s = this.state[instrument];
6491
+ const prevMode = s.mode;
6492
+ if (prevMode === newMode) return;
6493
+ s.mode = newMode;
6494
+ if (!this.suppressEmissions) {
6495
+ this.fireModeChange({ instrument, prevMode, newMode });
6496
+ }
6497
+ }
6498
+ fireModeChange(ev) {
6499
+ for (const listener of this.modeChangeListeners) {
6500
+ try {
6501
+ listener(ev);
6502
+ } catch (err) {
6503
+ console.error("Error in onModeChange listener:", err);
6504
+ }
6505
+ }
6506
+ }
6507
+ fireSessionEnd(ev) {
6508
+ for (const listener of this.sessionEndListeners) {
6509
+ try {
6510
+ listener(ev);
6511
+ } catch (err) {
6512
+ console.error("Error in onSessionEnd listener:", err);
6513
+ }
6514
+ }
6515
+ }
6516
+ fireMuteChange(ev) {
6517
+ for (const listener of this.muteChangeListeners) {
6518
+ try {
6519
+ listener(ev);
6520
+ } catch (err) {
6521
+ console.error("Error in onMuteChange listener:", err);
6522
+ }
6523
+ }
6524
+ }
6525
+ /**
6526
+ * Schedule (or reschedule) the per-instrument session timers on every tap.
6527
+ * 500ms timer fires `'pattern'` outcome if instrument is in pattern mode at
6528
+ * fire time. 5s timer fires `'cleared'` outcome if a tapping session
6529
+ * never reached pattern recognition. Either timer transitions mode if the
6530
+ * audio-driven `processFrame` hasn't already done so (controller-side has
6531
+ * no audio path).
6532
+ */
6533
+ scheduleSessionTimers(instrument) {
6534
+ const s = this.state[instrument];
6535
+ this.cancelSessionTimers(s);
6536
+ s.sessionEndTimer = setTimeout(() => this.onSessionEndTimer(instrument), SESSION_END_IDLE_MS);
6537
+ s.tapTimeoutTimer = setTimeout(() => this.onTapTimeoutTimer(instrument), TAP_TIMEOUT_MS);
6538
+ }
6539
+ cancelSessionTimers(s) {
6540
+ if (s.sessionEndTimer !== null) {
6541
+ clearTimeout(s.sessionEndTimer);
6542
+ s.sessionEndTimer = null;
6543
+ }
6544
+ if (s.tapTimeoutTimer !== null) {
6545
+ clearTimeout(s.tapTimeoutTimer);
6546
+ s.tapTimeoutTimer = null;
6547
+ }
6548
+ }
6549
+ /**
6550
+ * 500ms idle timer. Fires `'pattern'` outcome iff the instrument is in
6551
+ * `'pattern'` mode (meaning a recognized pattern survived the idle window).
6552
+ * Other modes are handled by the 5s timer.
6553
+ */
6554
+ onSessionEndTimer(instrument) {
6555
+ const s = this.state[instrument];
6556
+ s.sessionEndTimer = null;
6557
+ if (!s.sessionActive) return;
6558
+ if (s.mode === "pattern" && s.pattern) {
6559
+ s.sessionActive = false;
6560
+ this.fireSessionEnd({ instrument, outcome: "pattern" });
6561
+ if (s.tapTimeoutTimer !== null) {
6562
+ clearTimeout(s.tapTimeoutTimer);
6563
+ s.tapTimeoutTimer = null;
6564
+ }
6565
+ }
6566
+ }
6567
+ /**
6568
+ * 5s tap-idle timer. Resolves the `'cleared'` outcome whether or not
6569
+ * `processFrame` has already transitioned the mode (audio-active path can
6570
+ * race ahead and transition `'tapping' → 'auto'` without firing the event;
6571
+ * we detect that case via `sessionActive` and fire here regardless).
6572
+ */
6573
+ onTapTimeoutTimer(instrument) {
6574
+ const s = this.state[instrument];
6575
+ s.tapTimeoutTimer = null;
6576
+ if (!s.sessionActive) return;
6577
+ if (s.mode === "tapping") {
6578
+ s.tapIOIs = [];
6579
+ s.pendingTapEvents = [];
6580
+ if (s.pattern) {
6581
+ this.setMode(instrument, "pattern");
6582
+ s.replayLastEventTime = performance.now();
6583
+ s.replayIndex = 0;
6584
+ s.sessionActive = false;
6585
+ this.fireSessionEnd({ instrument, outcome: "pattern" });
6586
+ } else {
6587
+ this.setMode(instrument, "auto");
6588
+ s.sessionActive = false;
6589
+ this.fireSessionEnd({ instrument, outcome: "cleared" });
6590
+ }
6591
+ } else if (s.mode === "auto") {
6592
+ s.sessionActive = false;
6593
+ this.fireSessionEnd({ instrument, outcome: "cleared" });
6594
+ } else {
6595
+ s.sessionActive = false;
6596
+ }
6597
+ }
6598
+ serializeInstrument(instrument) {
6599
+ const s = this.state[instrument];
6600
+ return {
6601
+ mode: s.mode,
6602
+ muted: s.muted,
6603
+ pattern: s.pattern ? [...s.pattern] : null,
6604
+ replayLastEventTime: s.replayLastEventTime > 0 ? s.replayLastEventTime : null,
6605
+ replayIndex: s.replayIndex,
6606
+ tapIOIs: [...s.tapIOIs],
6607
+ lastTapTime: s.lastTapTime > 0 ? s.lastTapTime : null,
6608
+ mutedAt: s.mutedAt > 0 ? s.mutedAt : null,
6609
+ refinementIndex: s.refinementIndex,
6610
+ refinementCounts: [...s.refinementCounts]
6611
+ };
6612
+ }
6613
+ applyInstrumentPayload(instrument, payload, clockOffset, now) {
6614
+ const s = this.state[instrument];
6615
+ this.cancelSessionTimers(s);
6616
+ s.sessionActive = false;
6617
+ const translatedReplayLast = payload.replayLastEventTime !== null ? payload.replayLastEventTime + clockOffset : 0;
6618
+ const translatedLastTap = payload.lastTapTime !== null ? payload.lastTapTime + clockOffset : 0;
6619
+ const translatedMutedAt = payload.mutedAt !== null ? payload.mutedAt + clockOffset : 0;
6620
+ let rebasedReplayLast = translatedReplayLast;
6621
+ if (payload.pattern && payload.pattern.length > 0 && translatedReplayLast > 0) {
6622
+ const patternSum = payload.pattern.reduce((a, b) => a + b, 0);
6623
+ if (patternSum > 0) {
6624
+ const elapsed = now - translatedReplayLast;
6625
+ if (elapsed > patternSum) {
6626
+ const cycles = Math.floor(elapsed / patternSum);
6627
+ rebasedReplayLast = translatedReplayLast + cycles * patternSum;
6628
+ }
6629
+ }
6630
+ }
6631
+ s.mode;
6632
+ s.mode = payload.mode;
6633
+ s.muted = payload.muted;
6634
+ s.mutedAt = translatedMutedAt;
6635
+ s.tapIOIs = [...payload.tapIOIs];
6636
+ s.lastTapTime = translatedLastTap;
6637
+ s.pattern = payload.pattern ? [...payload.pattern] : null;
6638
+ s.refinementIndex = payload.refinementIndex;
6639
+ s.refinementCounts = [...payload.refinementCounts];
6640
+ s.replayLastEventTime = rebasedReplayLast;
6641
+ s.replayIndex = payload.replayIndex;
6642
+ s.pendingTapEvents = [];
6643
+ s.envelope.reset();
6644
+ s.envelopeSmoothed.reset();
6645
+ }
6026
6646
  /**
6027
6647
  * Handle a tap that arrives while already in pattern mode.
6028
6648
  * Matching taps refine the pattern via EMA and re-anchor phase.
@@ -6131,7 +6751,7 @@ class OnsetTapManager {
6131
6751
  applyPattern(instrument, pattern) {
6132
6752
  const s = this.state[instrument];
6133
6753
  s.pattern = pattern;
6134
- s.mode = "pattern";
6754
+ this.setMode(instrument, "pattern");
6135
6755
  s.refinementIndex = 0;
6136
6756
  s.refinementCounts = new Array(pattern.length).fill(MIN_REPETITIONS);
6137
6757
  if (s.replayLastEventTime === 0 || s.replayLastEventTime < s.lastTapTime) {
@@ -6840,6 +7460,13 @@ class AudioSystem {
6840
7460
  beatDetectionEnabled = true;
6841
7461
  onsetDetectionEnabled = true;
6842
7462
  autoGainEnabled = true;
7463
+ // Idle ticker — drives `OnsetTapManager.processFrame` via rAF when no audio
7464
+ // is connected, so taps still produce envelope/event output through the
7465
+ // artist API. Paused when audio connects (worklet/analyser path takes over);
7466
+ // resumed on disconnect. See lifecycle methods below for the strict
7467
+ // start-stop ordering invariant.
7468
+ idleTickerHandle = null;
7469
+ idleTickerLastTime = 0;
6843
7470
  /**
6844
7471
  * Enable or disable comprehensive debug logging for all layers
6845
7472
  * Enables enhanced logging in: MultiOnsetDetection, BeatStateManager
@@ -7304,7 +7931,10 @@ class AudioSystem {
7304
7931
  }
7305
7932
  // Analysis configuration
7306
7933
  fftSize = 2048;
7307
- // Beat state for main channel (not on AudioChannel because it's main-only)
7934
+ // Beat state for main channel (not on AudioChannel because it's main-only).
7935
+ // `bpm: 0` is the documented "no signal" sentinel; `runBeatPipeline`'s
7936
+ // `lastNonZeroBpm` carry-over fills in a meaningful value as soon as audio
7937
+ // is connected and the first BPM hypothesis is formed.
7308
7938
  audioStateBeat = {
7309
7939
  kick: 0,
7310
7940
  snare: 0,
@@ -7315,7 +7945,7 @@ class AudioSystem {
7315
7945
  hatSmoothed: 0,
7316
7946
  anySmoothed: 0,
7317
7947
  events: [],
7318
- bpm: 120,
7948
+ bpm: 0,
7319
7949
  confidence: 0,
7320
7950
  isLocked: false
7321
7951
  };
@@ -7345,6 +7975,8 @@ class AudioSystem {
7345
7975
  anySmoothed: new EnvelopeFollower(5, 500, sampleRate)
7346
7976
  };
7347
7977
  this.resetEssentiaBandHistories();
7978
+ this.tickIdle = this.tickIdle.bind(this);
7979
+ this.startIdleTicker();
7348
7980
  }
7349
7981
  /**
7350
7982
  * Get the current audio analysis state (for host-side usage)
@@ -7410,8 +8042,17 @@ class AudioSystem {
7410
8042
  /**
7411
8043
  * Connect a channel to Web Audio nodes (source, worklet/analyser).
7412
8044
  * Used for both main and additional channels.
8045
+ *
8046
+ * Lifecycle ordering invariant: when wiring the main channel, the idle
8047
+ * ticker MUST be stopped before the first `await` so that an in-flight
8048
+ * tick cannot race the audio path coming online. The ticker callback
8049
+ * additionally guards on `mainChannel.audioState.isConnected` for
8050
+ * belt-and-braces.
7413
8051
  */
7414
8052
  async connectChannel(ch, audioStream, isMain) {
8053
+ if (isMain) {
8054
+ this.stopIdleTicker();
8055
+ }
7415
8056
  ch.disconnectNodes();
7416
8057
  ch.refreshFFTResources();
7417
8058
  ch.workletFrameCount = 0;
@@ -7420,6 +8061,7 @@ class AudioSystem {
7420
8061
  console.warn(`No audio tracks in stream for channel ${ch.streamIndex}`);
7421
8062
  ch.audioState.isConnected = false;
7422
8063
  this.sendChannelResults(ch, isMain);
8064
+ if (isMain) this.startIdleTicker();
7423
8065
  return;
7424
8066
  }
7425
8067
  try {
@@ -7482,6 +8124,7 @@ class AudioSystem {
7482
8124
  console.error(`Failed to set up audio for channel ${ch.streamIndex}:`, error);
7483
8125
  ch.audioState.isConnected = false;
7484
8126
  ch.disconnectNodes();
8127
+ if (isMain) this.startIdleTicker();
7485
8128
  }
7486
8129
  this.sendChannelResults(ch, isMain);
7487
8130
  }
@@ -7495,17 +8138,30 @@ class AudioSystem {
7495
8138
  }
7496
8139
  /**
7497
8140
  * Disconnect the main audio stream (does NOT close AudioContext -- additional channels may still be active).
8141
+ *
8142
+ * Lifecycle ordering invariant: tear down the audio nodes first, clear any
8143
+ * stale frequency / waveform buffers, then restart the idle ticker. The
8144
+ * ticker callback's defensive `isConnected` guard prevents any stale tick
8145
+ * from observing a half-disconnected state.
7498
8146
  */
7499
8147
  disconnectMainStream() {
7500
8148
  this.mainChannel.disconnectNodes();
7501
8149
  this.mainChannel.audioState.isConnected = false;
7502
8150
  this.mainChannel.isAnalysisRunning = false;
7503
8151
  this.mainChannel.currentStream = null;
8152
+ if (this.mainChannel.frequencyData) {
8153
+ this.mainChannel.frequencyData.fill(0);
8154
+ }
8155
+ if (this.mainChannel.timeDomainData) {
8156
+ this.mainChannel.timeDomainData.fill(0);
8157
+ }
8158
+ this.mainChannel.lastWaveformFrame = null;
7504
8159
  this.resetAudioValues();
7505
8160
  if (this.additionalChannels.size === 0) {
7506
8161
  this.stopAnalysisLoop();
7507
8162
  this.stopStalenessTimer();
7508
8163
  }
8164
+ this.startIdleTicker();
7509
8165
  this.sendChannelResults(this.mainChannel, true);
7510
8166
  this.debugLog("Main audio stream disconnected (host-side)");
7511
8167
  }
@@ -7739,7 +8395,7 @@ class AudioSystem {
7739
8395
  hatSmoothed: 0,
7740
8396
  anySmoothed: 0,
7741
8397
  events: [],
7742
- bpm: 120,
8398
+ bpm: 0,
7743
8399
  confidence: 0,
7744
8400
  isLocked: false
7745
8401
  };
@@ -7751,6 +8407,7 @@ class AudioSystem {
7751
8407
  resetAudioState() {
7752
8408
  this.stopAnalysisLoop();
7753
8409
  this.stopStalenessTimer();
8410
+ this.stopIdleTicker();
7754
8411
  this.resetEssentiaBandHistories();
7755
8412
  for (const ch of this.additionalChannels.values()) {
7756
8413
  ch.destroy();
@@ -7875,6 +8532,97 @@ class AudioSystem {
7875
8532
  isOnsetMuted(instrument) {
7876
8533
  return this.onsetTapManager.isMuted(instrument);
7877
8534
  }
8535
+ // ─────────────────────────────────────────────────────────────────────────
8536
+ // Onset event subscriptions (forward to OnsetTapManager).
8537
+ // Returned `Unsubscribe` removes the listener.
8538
+ // ─────────────────────────────────────────────────────────────────────────
8539
+ onOnsetModeChange(listener) {
8540
+ return this.onsetTapManager.onModeChange(listener);
8541
+ }
8542
+ onOnsetSessionEnd(listener) {
8543
+ return this.onsetTapManager.onSessionEnd(listener);
8544
+ }
8545
+ onOnsetMuteChange(listener) {
8546
+ return this.onsetTapManager.onMuteChange(listener);
8547
+ }
8548
+ // ─────────────────────────────────────────────────────────────────────────
8549
+ // State serialization. Onset-only export is cross-device-safe; the full
8550
+ // audio block is for same-process scene-switch transfer (sender's audio
8551
+ // analysis state would corrupt receiver's tracking on a different source).
8552
+ // ─────────────────────────────────────────────────────────────────────────
8553
+ exportOnsetSessionState() {
8554
+ return this.onsetTapManager.exportSessionState();
8555
+ }
8556
+ importOnsetSessionState(state, clockOffset) {
8557
+ this.onsetTapManager.importSessionState(state, clockOffset);
8558
+ }
8559
+ exportAudioAnalysisState() {
8560
+ return {
8561
+ onset: this.onsetTapManager.exportSessionState().instruments,
8562
+ bpmTracker: this.tempoInduction.exportSessionState(),
8563
+ pll: this.pll.exportSessionState(),
8564
+ beatState: this.stateManager.exportSessionState()
8565
+ };
8566
+ }
8567
+ importAudioAnalysisState(state, clockOffset) {
8568
+ this.onsetTapManager.importSessionState(
8569
+ { version: 1, senderTime: performance.now(), instruments: state.onset },
8570
+ clockOffset
8571
+ );
8572
+ this.tempoInduction.importSessionState(state.bpmTracker, clockOffset);
8573
+ this.pll.importSessionState(state.pll, clockOffset);
8574
+ this.stateManager.importSessionState(state.beatState, clockOffset);
8575
+ }
8576
+ // ─────────────────────────────────────────────────────────────────────────
8577
+ // Idle ticker — drives `OnsetTapManager.processFrame` via rAF when no audio
8578
+ // is connected. Constructs an empty `beatState` and reuses the existing
8579
+ // `processFrame` (verified correctness-preserving on empty input), so taps
8580
+ // produce envelope/event output through the artist API the same way they
8581
+ // do when audio is active.
8582
+ // ─────────────────────────────────────────────────────────────────────────
8583
+ startIdleTicker() {
8584
+ if (this.idleTickerHandle !== null) return;
8585
+ if (typeof requestAnimationFrame === "undefined") return;
8586
+ this.idleTickerLastTime = performance.now();
8587
+ this.idleTickerHandle = requestAnimationFrame(this.tickIdle);
8588
+ }
8589
+ stopIdleTicker() {
8590
+ if (this.idleTickerHandle === null) return;
8591
+ if (typeof cancelAnimationFrame !== "undefined") {
8592
+ cancelAnimationFrame(this.idleTickerHandle);
8593
+ }
8594
+ this.idleTickerHandle = null;
8595
+ }
8596
+ tickIdle(now) {
8597
+ this.idleTickerHandle = null;
8598
+ if (this.mainChannel.audioState.isConnected) return;
8599
+ const dtMs = Math.min(100, now - this.idleTickerLastTime);
8600
+ this.idleTickerLastTime = now;
8601
+ if (this.onsetDetectionEnabled && this.beatDetectionEnabled) {
8602
+ const empty = this.makeEmptyBeatState();
8603
+ this.audioStateBeat = this.onsetTapManager.processFrame(empty, now, dtMs);
8604
+ this.sendChannelResults(this.mainChannel, true);
8605
+ }
8606
+ if (typeof requestAnimationFrame !== "undefined") {
8607
+ this.idleTickerHandle = requestAnimationFrame(this.tickIdle);
8608
+ }
8609
+ }
8610
+ makeEmptyBeatState() {
8611
+ return {
8612
+ kick: 0,
8613
+ snare: 0,
8614
+ hat: 0,
8615
+ any: 0,
8616
+ kickSmoothed: 0,
8617
+ snareSmoothed: 0,
8618
+ hatSmoothed: 0,
8619
+ anySmoothed: 0,
8620
+ events: [],
8621
+ bpm: 0,
8622
+ confidence: 0,
8623
+ isLocked: false
8624
+ };
8625
+ }
7878
8626
  /**
7879
8627
  * Get current BPM (manual or auto-detected)
7880
8628
  */
@@ -8913,6 +9661,10 @@ class VijiCore {
8913
9661
  parameterDefinedListeners = /* @__PURE__ */ new Set();
8914
9662
  parameterErrorListeners = /* @__PURE__ */ new Set();
8915
9663
  capabilitiesChangeListeners = /* @__PURE__ */ new Set();
9664
+ // State-import error listeners. Fire on `importFullState` payloads that fail
9665
+ // version or shape validation; consumers surface meaningful UI feedback
9666
+ // rather than relying on console.warn.
9667
+ stateImportErrorListeners = /* @__PURE__ */ new Set();
8916
9668
  // Performance tracking (basic for Phase 1)
8917
9669
  stats = {
8918
9670
  frameTime: 0,
@@ -9885,6 +10637,86 @@ class VijiCore {
9885
10637
  offParameterError(listener) {
9886
10638
  this.parameterErrorListeners.delete(listener);
9887
10639
  }
10640
+ // ─────────────────────────────────────────────────────────────────────────
10641
+ // State serialization (cross-instance transfer)
10642
+ // ─────────────────────────────────────────────────────────────────────────
10643
+ /**
10644
+ * Snapshot the full audio analysis + onset state for cross-instance
10645
+ * transfer. Use for **same-process scene-switch** continuity (a fresh
10646
+ * `VijiCore` can resume detection where the previous one left off).
10647
+ *
10648
+ * For **cross-device** transfer, prefer `audio.onset.exportSessionState`
10649
+ * — the controller's audio analysis state doesn't apply to the host's
10650
+ * audio source and would corrupt detection.
10651
+ *
10652
+ * Wall-clock fields are in this instance's `performance.now()` clock space.
10653
+ * Receiver applies `clockOffset` on import (use `0` for same-process).
10654
+ *
10655
+ * **Staleness caveat**: the audio block is robust to scene-load gaps under
10656
+ * ~1 second. Longer gaps (>5s, e.g. cold cache + slow network) may cause
10657
+ * stale-timestamp drift in `gridLockMemoryTime` / `warmupStartTimeMs`;
10658
+ * callers should treat that as a degraded path and skip the audio block.
10659
+ */
10660
+ exportFullState() {
10661
+ this.validateReady();
10662
+ const senderTime = performance.now();
10663
+ const out = { version: 1, senderTime };
10664
+ if (this.audioSystem) {
10665
+ const audio = this.audioSystem.exportAudioAnalysisState();
10666
+ out.onset = audio.onset;
10667
+ out.audio = audio;
10668
+ }
10669
+ return out;
10670
+ }
10671
+ /**
10672
+ * Replace the audio analysis + onset state from a serialized payload.
10673
+ * `clockOffset` is added to all sender-clocked fields (`0` for same-process,
10674
+ * NTP-derived for cross-device). Mutation is synchronous; no `onModeChange`
10675
+ * or `onSessionEnd` events are emitted (state replacement is not a transition).
10676
+ *
10677
+ * Validates `version`; on mismatch, fires `onStateImportError` and leaves
10678
+ * existing state intact.
10679
+ */
10680
+ importFullState(state, clockOffset) {
10681
+ this.validateReady();
10682
+ const validation = validateCoreStatePayload(state);
10683
+ if (validation) {
10684
+ this.fireStateImportError(validation);
10685
+ return;
10686
+ }
10687
+ if (!this.audioSystem) return;
10688
+ if (state.audio) {
10689
+ this.audioSystem.importAudioAnalysisState(state.audio, clockOffset);
10690
+ } else if (state.onset) {
10691
+ this.audioSystem.importOnsetSessionState(
10692
+ { version: 1, senderTime: state.senderTime, instruments: state.onset },
10693
+ clockOffset
10694
+ );
10695
+ }
10696
+ }
10697
+ /**
10698
+ * Listen for state-import validation errors. Fires when `importFullState`
10699
+ * or `audio.onset.importSessionState` rejects a payload (version mismatch,
10700
+ * malformed shape, invalid field). Existing state is left intact when
10701
+ * an error fires.
10702
+ *
10703
+ * @returns Unsubscribe function. Call to remove the listener.
10704
+ */
10705
+ onStateImportError(listener) {
10706
+ this.stateImportErrorListeners.add(listener);
10707
+ return () => {
10708
+ this.stateImportErrorListeners.delete(listener);
10709
+ };
10710
+ }
10711
+ fireStateImportError(error) {
10712
+ for (const listener of this.stateImportErrorListeners) {
10713
+ try {
10714
+ listener(error);
10715
+ } catch (err) {
10716
+ console.error("Error in onStateImportError listener:", err);
10717
+ }
10718
+ }
10719
+ }
9888
10720
  /**
9889
10721
  * Notify parameter change listeners
9890
10722
  */
@@ -10313,6 +11145,91 @@ class VijiCore {
10313
11145
  isMuted: (instrument) => {
10314
11146
  this.validateReady();
10315
11147
  return this.audioSystem?.isOnsetMuted(instrument) ?? false;
11148
+ },
11149
+ /**
11150
+ * Listen for instrument mode transitions (`'auto' | 'tapping' | 'pattern'`).
11151
+ * Fires on every transition including the first tap (`'auto' → 'tapping'`)
11152
+ * and pattern recognition (`'tapping' → 'pattern'`). Imported state does
11153
+ * NOT fire mode-change events — state replacement is not a transition.
11154
+ *
11155
+ * @returns Unsubscribe function. Call to remove the listener.
11156
+ */
11157
+ onModeChange: (listener) => {
11158
+ this.validateReady();
11159
+ return this.audioSystem?.onOnsetModeChange(listener) ?? (() => {
11160
+ });
11161
+ },
11162
+ /**
11163
+ * Listen for natural session-end events. Fires when:
11164
+ * - 500ms elapse since last tap with instrument in `'pattern'` mode
11165
+ * (outcome `'pattern'`), or
11166
+ * - 5s elapse in `'tapping'` mode without a recognized pattern
11167
+ * (outcome `'cleared'`; instrument transitions to `'auto'`).
11168
+ *
11169
+ * Explicit `clear()` calls do NOT fire this event (caller-initiated;
11170
+ * caller already knows). Imported state does NOT fire either.
11171
+ *
11172
+ * @returns Unsubscribe function. Call to remove the listener.
11173
+ */
11174
+ onSessionEnd: (listener) => {
11175
+ this.validateReady();
11176
+ return this.audioSystem?.onOnsetSessionEnd(listener) ?? (() => {
11177
+ });
11178
+ },
11179
+ /**
11180
+ * Listen for instrument mute-state transitions (`true ↔ false`). Fires
11181
+ * whenever the underlying mute state actually changes, regardless of
11182
+ * which method triggered it:
11183
+ * - `setMuted(instrument, muted)` when `prev !== next`
11184
+ * - `tap(instrument)` auto-unmute on first tap of a muted instrument
11185
+ * - `clear(instrument)` when the instrument was muted
11186
+ *
11187
+ * Idempotent calls (e.g. `setMuted(true)` on an already-muted instrument)
11188
+ * do not fire. Imported state does NOT fire either.
11189
+ *
11190
+ * @returns Unsubscribe function. Call to remove the listener.
11191
+ */
11192
+ onMuteChange: (listener) => {
11193
+ this.validateReady();
11194
+ return this.audioSystem?.onOnsetMuteChange(listener) ?? (() => {
11195
+ });
11196
+ },
11197
+ /**
11198
+ * Snapshot per-instrument onset state for cross-instance transfer.
11199
+ * Cross-device-safe (no audio analysis state included). Pair with
11200
+ * `importSessionState` on a receiver. Wall-clock fields are in this
11201
+ * instance's `performance.now()` clock space.
11202
+ */
11203
+ exportSessionState: () => {
11204
+ this.validateReady();
11205
+ return this.audioSystem?.exportOnsetSessionState() ?? {
11206
+ version: 1,
11207
+ senderTime: performance.now(),
11208
+ instruments: {}
11209
+ };
11210
+ },
11211
+ /**
11212
+ * Replace per-instrument onset state from a serialized payload.
11213
+ * `clockOffset` is added to all sender-clocked fields during import
11214
+ * (use `0` for same-process transfer; NTP-derived offset for cross-device).
11215
+ * Mutation is synchronous; no events are emitted.
11216
+ *
11217
+ * Patterns are rebased forward by whole pattern cycles to eliminate
11218
+ * the catch-up burst that would otherwise occur on stale payloads
11219
+ * (phase-preserving — events still land on the original beat positions
11220
+ * modulo pattern length).
11221
+ *
11222
+ * Validates `version`; on mismatch, fires `onStateImportError` and
11223
+ * leaves existing state intact.
11224
+ */
11225
+ importSessionState: (state, clockOffset) => {
11226
+ this.validateReady();
11227
+ const validation = OnsetTapManager.validateOnsetPayload(state);
11228
+ if (validation) {
11229
+ this.fireStateImportError(validation);
11230
+ return;
11231
+ }
11232
+ this.audioSystem?.importOnsetSessionState(state, clockOffset);
10316
11233
  }
10317
11234
  },
10318
11235
  /**
@@ -10602,6 +11519,7 @@ class VijiCore {
10602
11519
  this.parameterDefinedListeners.clear();
10603
11520
  this.parameterErrorListeners.clear();
10604
11521
  this.capabilitiesChangeListeners.clear();
11522
+ this.stateImportErrorListeners.clear();
10605
11523
  this.unlinkEventSource();
10606
11524
  this.unlinkFrameSources();
10607
11525
  for (const [deviceId] of this.deviceVideoCoordinators) {
@@ -10693,7 +11611,25 @@ class VijiCore {
10693
11611
  }
10694
11612
  }
10695
11613
  }
10696
- const VERSION = "0.5.0";
11614
+ function validateCoreStatePayload(state) {
11615
+ if (state === null || typeof state !== "object") {
11616
+ return { code: "malformed", details: "payload is not an object" };
11617
+ }
11618
+ const s = state;
11619
+ if (s.version !== 1) {
11620
+ return {
11621
+ code: "version-mismatch",
11622
+ details: `expected version 1, got ${String(s.version)}`,
11623
+ ...typeof s.version === "number" ? { payloadVersion: s.version } : {},
11624
+ expectedVersion: 1
11625
+ };
11626
+ }
11627
+ if (typeof s.senderTime !== "number") {
11628
+ return { code: "invalid-field", details: "senderTime must be a number", field: "senderTime" };
11629
+ }
11630
+ return null;
11631
+ }
11632
+ const VERSION = "0.5.2";
10697
11633
  export {
10698
11634
  AudioSystem as A,
10699
11635
  VERSION as V,
@@ -10701,4 +11637,4 @@ export {
10701
11637
  VijiCoreError as b,
10702
11638
  getDefaultExportFromCjs as g
10703
11639
  };
10704
- //# sourceMappingURL=index-Cp9G0z4E.js.map
11640
+ //# sourceMappingURL=index-B8LJ9m47.js.map