@viji-dev/core 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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-x6zu4Vib.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,6 +6176,9 @@ class OnsetTapManager {
5835
6176
  snare: createInstrumentState(),
5836
6177
  hat: createInstrumentState()
5837
6178
  };
6179
+ modeChangeListeners = /* @__PURE__ */ new Set();
6180
+ sessionEndListeners = /* @__PURE__ */ new Set();
6181
+ suppressEmissions = false;
5838
6182
  tap(instrument) {
5839
6183
  const s = this.state[instrument];
5840
6184
  const now = performance.now();
@@ -5856,8 +6200,10 @@ class OnsetTapManager {
5856
6200
  }
5857
6201
  s.lastTapTime = now;
5858
6202
  s.pendingTapEvents.push(now);
6203
+ s.sessionActive = true;
6204
+ this.scheduleSessionTimers(instrument);
5859
6205
  if (s.mode === "auto") {
5860
- s.mode = "tapping";
6206
+ this.setMode(instrument, "tapping");
5861
6207
  if (ioi > 0) {
5862
6208
  const pattern = this.tryRecognizePattern(instrument);
5863
6209
  if (pattern) this.applyPattern(instrument, pattern);
@@ -5875,7 +6221,9 @@ class OnsetTapManager {
5875
6221
  }
5876
6222
  clear(instrument) {
5877
6223
  const s = this.state[instrument];
5878
- s.mode = "auto";
6224
+ this.cancelSessionTimers(s);
6225
+ s.sessionActive = false;
6226
+ this.setMode(instrument, "auto");
5879
6227
  s.muted = false;
5880
6228
  s.mutedAt = 0;
5881
6229
  s.tapIOIs = [];
@@ -5920,11 +6268,103 @@ class OnsetTapManager {
5920
6268
  isMuted(instrument) {
5921
6269
  return this.state[instrument].muted;
5922
6270
  }
6271
+ // ─────────────────────────────────────────────────────────────────────────
6272
+ // Listener registration
6273
+ // ─────────────────────────────────────────────────────────────────────────
6274
+ onModeChange(listener) {
6275
+ this.modeChangeListeners.add(listener);
6276
+ return () => {
6277
+ this.modeChangeListeners.delete(listener);
6278
+ };
6279
+ }
6280
+ onSessionEnd(listener) {
6281
+ this.sessionEndListeners.add(listener);
6282
+ return () => {
6283
+ this.sessionEndListeners.delete(listener);
6284
+ };
6285
+ }
6286
+ // ─────────────────────────────────────────────────────────────────────────
6287
+ // State serialization
6288
+ // ─────────────────────────────────────────────────────────────────────────
6289
+ /**
6290
+ * Serialize per-instrument onset state for cross-instance transfer.
6291
+ * Wall-clock fields are in this instance's `performance.now()` clock space.
6292
+ */
6293
+ exportSessionState() {
6294
+ return {
6295
+ version: STATE_SCHEMA_VERSION,
6296
+ senderTime: performance.now(),
6297
+ instruments: {
6298
+ kick: this.serializeInstrument("kick"),
6299
+ snare: this.serializeInstrument("snare"),
6300
+ hat: this.serializeInstrument("hat")
6301
+ }
6302
+ };
6303
+ }
6304
+ /**
6305
+ * Replace per-instrument state from a serialized payload. Wall-clock fields
6306
+ * in the payload are translated by `clockOffset` (added to non-null sender
6307
+ * timestamps to map them into the receiver's `performance.now()` clock).
6308
+ * For same-process transfer, `clockOffset = 0`.
6309
+ *
6310
+ * `replayLastEventTime` is rebased forward by whole pattern cycles to
6311
+ * eliminate the catch-up burst that would otherwise fire if the payload
6312
+ * is older than one pattern period (phase-preserving — events still land on
6313
+ * the original beat positions modulo `patternSum`).
6314
+ *
6315
+ * Mutation is synchronous; no events are emitted (state replacement is not
6316
+ * a transition). Throws nothing — malformed payloads should be filtered by
6317
+ * the caller via the `version` field.
6318
+ */
6319
+ importSessionState(state, clockOffset) {
6320
+ if (state.version !== STATE_SCHEMA_VERSION) return;
6321
+ this.suppressEmissions = true;
6322
+ try {
6323
+ const now = performance.now();
6324
+ for (const inst of INSTRUMENTS) {
6325
+ const payload = state.instruments[inst];
6326
+ if (!payload) continue;
6327
+ this.applyInstrumentPayload(inst, payload, clockOffset, now);
6328
+ }
6329
+ } finally {
6330
+ this.suppressEmissions = false;
6331
+ }
6332
+ }
6333
+ /**
6334
+ * Validate a serialized payload's shape before importing. Returns null on
6335
+ * success or a `StateImportError` describing the first failure.
6336
+ */
6337
+ static validateOnsetPayload(state) {
6338
+ if (state === null || typeof state !== "object") {
6339
+ return { code: "malformed", details: "payload is not an object" };
6340
+ }
6341
+ const s = state;
6342
+ if (s.version !== STATE_SCHEMA_VERSION) {
6343
+ return {
6344
+ code: "version-mismatch",
6345
+ details: `expected version ${STATE_SCHEMA_VERSION}, got ${String(s.version)}`,
6346
+ ...typeof s.version === "number" ? { payloadVersion: s.version } : {},
6347
+ expectedVersion: STATE_SCHEMA_VERSION
6348
+ };
6349
+ }
6350
+ if (typeof s.senderTime !== "number") {
6351
+ return { code: "invalid-field", details: "senderTime must be a number", field: "senderTime" };
6352
+ }
6353
+ if (!s.instruments || typeof s.instruments !== "object") {
6354
+ return { code: "invalid-field", details: "instruments must be an object", field: "instruments" };
6355
+ }
6356
+ return null;
6357
+ }
5923
6358
  /**
5924
6359
  * Post-process a beat state produced by BeatStateManager.
5925
6360
  * For instruments in auto mode, values pass through unchanged.
5926
6361
  * For tapping/pattern instruments, auto events are suppressed and
5927
6362
  * tap/pattern events + envelopes are injected instead.
6363
+ *
6364
+ * Safe to call with an empty `beatState` (no audio frames available) —
6365
+ * the host's idle ticker drives this in the no-audio scenario so the
6366
+ * artist API receives tap-driven envelopes and events without an audio
6367
+ * source.
5928
6368
  */
5929
6369
  processFrame(beatState, now, dtMs) {
5930
6370
  const result = { ...beatState, events: [...beatState.events] };
@@ -5964,11 +6404,11 @@ class OnsetTapManager {
5964
6404
  s.tapIOIs = [];
5965
6405
  s.pendingTapEvents = [];
5966
6406
  if (s.pattern) {
5967
- s.mode = "pattern";
6407
+ this.setMode(inst, "pattern");
5968
6408
  s.replayLastEventTime = now;
5969
6409
  s.replayIndex = 0;
5970
6410
  } else {
5971
- s.mode = "auto";
6411
+ this.setMode(inst, "auto");
5972
6412
  }
5973
6413
  continue;
5974
6414
  }
@@ -6023,6 +6463,159 @@ class OnsetTapManager {
6023
6463
  // ---------------------------------------------------------------------------
6024
6464
  // Private helpers
6025
6465
  // ---------------------------------------------------------------------------
6466
+ /**
6467
+ * Single mutation point for `s.mode`. Fires `onModeChange` (when not
6468
+ * suppressed) so listeners stay consistent regardless of which code path
6469
+ * triggered the transition.
6470
+ */
6471
+ setMode(instrument, newMode) {
6472
+ const s = this.state[instrument];
6473
+ const prevMode = s.mode;
6474
+ if (prevMode === newMode) return;
6475
+ s.mode = newMode;
6476
+ if (!this.suppressEmissions) {
6477
+ this.fireModeChange({ instrument, prevMode, newMode });
6478
+ }
6479
+ }
6480
+ fireModeChange(ev) {
6481
+ for (const listener of this.modeChangeListeners) {
6482
+ try {
6483
+ listener(ev);
6484
+ } catch (err) {
6485
+ console.error("Error in onModeChange listener:", err);
6486
+ }
6487
+ }
6488
+ }
6489
+ fireSessionEnd(ev) {
6490
+ for (const listener of this.sessionEndListeners) {
6491
+ try {
6492
+ listener(ev);
6493
+ } catch (err) {
6494
+ console.error("Error in onSessionEnd listener:", err);
6495
+ }
6496
+ }
6497
+ }
6498
+ /**
6499
+ * Schedule (or reschedule) the per-instrument session timers on every tap.
6500
+ * 500ms timer fires `'pattern'` outcome if instrument is in pattern mode at
6501
+ * fire time. 5s timer fires `'cleared'` outcome if a tapping session
6502
+ * never reached pattern recognition. Either timer transitions mode if the
6503
+ * audio-driven `processFrame` hasn't already done so (controller-side has
6504
+ * no audio path).
6505
+ */
6506
+ scheduleSessionTimers(instrument) {
6507
+ const s = this.state[instrument];
6508
+ this.cancelSessionTimers(s);
6509
+ s.sessionEndTimer = setTimeout(() => this.onSessionEndTimer(instrument), SESSION_END_IDLE_MS);
6510
+ s.tapTimeoutTimer = setTimeout(() => this.onTapTimeoutTimer(instrument), TAP_TIMEOUT_MS);
6511
+ }
6512
+ cancelSessionTimers(s) {
6513
+ if (s.sessionEndTimer !== null) {
6514
+ clearTimeout(s.sessionEndTimer);
6515
+ s.sessionEndTimer = null;
6516
+ }
6517
+ if (s.tapTimeoutTimer !== null) {
6518
+ clearTimeout(s.tapTimeoutTimer);
6519
+ s.tapTimeoutTimer = null;
6520
+ }
6521
+ }
6522
+ /**
6523
+ * 500ms idle timer. Fires `'pattern'` outcome iff the instrument is in
6524
+ * `'pattern'` mode (meaning a recognized pattern survived the idle window).
6525
+ * Other modes are handled by the 5s timer.
6526
+ */
6527
+ onSessionEndTimer(instrument) {
6528
+ const s = this.state[instrument];
6529
+ s.sessionEndTimer = null;
6530
+ if (!s.sessionActive) return;
6531
+ if (s.mode === "pattern" && s.pattern) {
6532
+ s.sessionActive = false;
6533
+ this.fireSessionEnd({ instrument, outcome: "pattern" });
6534
+ if (s.tapTimeoutTimer !== null) {
6535
+ clearTimeout(s.tapTimeoutTimer);
6536
+ s.tapTimeoutTimer = null;
6537
+ }
6538
+ }
6539
+ }
6540
+ /**
6541
+ * 5s tap-idle timer. Resolves the `'cleared'` outcome whether or not
6542
+ * `processFrame` has already transitioned the mode (audio-active path can
6543
+ * race ahead and transition `'tapping' → 'auto'` without firing the event;
6544
+ * we detect that case via `sessionActive` and fire here regardless).
6545
+ */
6546
+ onTapTimeoutTimer(instrument) {
6547
+ const s = this.state[instrument];
6548
+ s.tapTimeoutTimer = null;
6549
+ if (!s.sessionActive) return;
6550
+ if (s.mode === "tapping") {
6551
+ s.tapIOIs = [];
6552
+ s.pendingTapEvents = [];
6553
+ if (s.pattern) {
6554
+ this.setMode(instrument, "pattern");
6555
+ s.replayLastEventTime = performance.now();
6556
+ s.replayIndex = 0;
6557
+ s.sessionActive = false;
6558
+ this.fireSessionEnd({ instrument, outcome: "pattern" });
6559
+ } else {
6560
+ this.setMode(instrument, "auto");
6561
+ s.sessionActive = false;
6562
+ this.fireSessionEnd({ instrument, outcome: "cleared" });
6563
+ }
6564
+ } else if (s.mode === "auto") {
6565
+ s.sessionActive = false;
6566
+ this.fireSessionEnd({ instrument, outcome: "cleared" });
6567
+ } else {
6568
+ s.sessionActive = false;
6569
+ }
6570
+ }
6571
+ serializeInstrument(instrument) {
6572
+ const s = this.state[instrument];
6573
+ return {
6574
+ mode: s.mode,
6575
+ muted: s.muted,
6576
+ pattern: s.pattern ? [...s.pattern] : null,
6577
+ replayLastEventTime: s.replayLastEventTime > 0 ? s.replayLastEventTime : null,
6578
+ replayIndex: s.replayIndex,
6579
+ tapIOIs: [...s.tapIOIs],
6580
+ lastTapTime: s.lastTapTime > 0 ? s.lastTapTime : null,
6581
+ mutedAt: s.mutedAt > 0 ? s.mutedAt : null,
6582
+ refinementIndex: s.refinementIndex,
6583
+ refinementCounts: [...s.refinementCounts]
6584
+ };
6585
+ }
6586
+ applyInstrumentPayload(instrument, payload, clockOffset, now) {
6587
+ const s = this.state[instrument];
6588
+ this.cancelSessionTimers(s);
6589
+ s.sessionActive = false;
6590
+ const translatedReplayLast = payload.replayLastEventTime !== null ? payload.replayLastEventTime + clockOffset : 0;
6591
+ const translatedLastTap = payload.lastTapTime !== null ? payload.lastTapTime + clockOffset : 0;
6592
+ const translatedMutedAt = payload.mutedAt !== null ? payload.mutedAt + clockOffset : 0;
6593
+ let rebasedReplayLast = translatedReplayLast;
6594
+ if (payload.pattern && payload.pattern.length > 0 && translatedReplayLast > 0) {
6595
+ const patternSum = payload.pattern.reduce((a, b) => a + b, 0);
6596
+ if (patternSum > 0) {
6597
+ const elapsed = now - translatedReplayLast;
6598
+ if (elapsed > patternSum) {
6599
+ const cycles = Math.floor(elapsed / patternSum);
6600
+ rebasedReplayLast = translatedReplayLast + cycles * patternSum;
6601
+ }
6602
+ }
6603
+ }
6604
+ s.mode;
6605
+ s.mode = payload.mode;
6606
+ s.muted = payload.muted;
6607
+ s.mutedAt = translatedMutedAt;
6608
+ s.tapIOIs = [...payload.tapIOIs];
6609
+ s.lastTapTime = translatedLastTap;
6610
+ s.pattern = payload.pattern ? [...payload.pattern] : null;
6611
+ s.refinementIndex = payload.refinementIndex;
6612
+ s.refinementCounts = [...payload.refinementCounts];
6613
+ s.replayLastEventTime = rebasedReplayLast;
6614
+ s.replayIndex = payload.replayIndex;
6615
+ s.pendingTapEvents = [];
6616
+ s.envelope.reset();
6617
+ s.envelopeSmoothed.reset();
6618
+ }
6026
6619
  /**
6027
6620
  * Handle a tap that arrives while already in pattern mode.
6028
6621
  * Matching taps refine the pattern via EMA and re-anchor phase.
@@ -6131,7 +6724,7 @@ class OnsetTapManager {
6131
6724
  applyPattern(instrument, pattern) {
6132
6725
  const s = this.state[instrument];
6133
6726
  s.pattern = pattern;
6134
- s.mode = "pattern";
6727
+ this.setMode(instrument, "pattern");
6135
6728
  s.refinementIndex = 0;
6136
6729
  s.refinementCounts = new Array(pattern.length).fill(MIN_REPETITIONS);
6137
6730
  if (s.replayLastEventTime === 0 || s.replayLastEventTime < s.lastTapTime) {
@@ -6840,6 +7433,13 @@ class AudioSystem {
6840
7433
  beatDetectionEnabled = true;
6841
7434
  onsetDetectionEnabled = true;
6842
7435
  autoGainEnabled = true;
7436
+ // Idle ticker — drives `OnsetTapManager.processFrame` via rAF when no audio
7437
+ // is connected, so taps still produce envelope/event output through the
7438
+ // artist API. Paused when audio connects (worklet/analyser path takes over);
7439
+ // resumed on disconnect. See lifecycle methods below for the strict
7440
+ // start-stop ordering invariant.
7441
+ idleTickerHandle = null;
7442
+ idleTickerLastTime = 0;
6843
7443
  /**
6844
7444
  * Enable or disable comprehensive debug logging for all layers
6845
7445
  * Enables enhanced logging in: MultiOnsetDetection, BeatStateManager
@@ -7304,7 +7904,10 @@ class AudioSystem {
7304
7904
  }
7305
7905
  // Analysis configuration
7306
7906
  fftSize = 2048;
7307
- // Beat state for main channel (not on AudioChannel because it's main-only)
7907
+ // Beat state for main channel (not on AudioChannel because it's main-only).
7908
+ // `bpm: 0` is the documented "no signal" sentinel; `runBeatPipeline`'s
7909
+ // `lastNonZeroBpm` carry-over fills in a meaningful value as soon as audio
7910
+ // is connected and the first BPM hypothesis is formed.
7308
7911
  audioStateBeat = {
7309
7912
  kick: 0,
7310
7913
  snare: 0,
@@ -7315,7 +7918,7 @@ class AudioSystem {
7315
7918
  hatSmoothed: 0,
7316
7919
  anySmoothed: 0,
7317
7920
  events: [],
7318
- bpm: 120,
7921
+ bpm: 0,
7319
7922
  confidence: 0,
7320
7923
  isLocked: false
7321
7924
  };
@@ -7345,6 +7948,8 @@ class AudioSystem {
7345
7948
  anySmoothed: new EnvelopeFollower(5, 500, sampleRate)
7346
7949
  };
7347
7950
  this.resetEssentiaBandHistories();
7951
+ this.tickIdle = this.tickIdle.bind(this);
7952
+ this.startIdleTicker();
7348
7953
  }
7349
7954
  /**
7350
7955
  * Get the current audio analysis state (for host-side usage)
@@ -7410,8 +8015,17 @@ class AudioSystem {
7410
8015
  /**
7411
8016
  * Connect a channel to Web Audio nodes (source, worklet/analyser).
7412
8017
  * Used for both main and additional channels.
8018
+ *
8019
+ * Lifecycle ordering invariant: when wiring the main channel, the idle
8020
+ * ticker MUST be stopped before the first `await` so that an in-flight
8021
+ * tick cannot race the audio path coming online. The ticker callback
8022
+ * additionally guards on `mainChannel.audioState.isConnected` for
8023
+ * belt-and-braces.
7413
8024
  */
7414
8025
  async connectChannel(ch, audioStream, isMain) {
8026
+ if (isMain) {
8027
+ this.stopIdleTicker();
8028
+ }
7415
8029
  ch.disconnectNodes();
7416
8030
  ch.refreshFFTResources();
7417
8031
  ch.workletFrameCount = 0;
@@ -7420,6 +8034,7 @@ class AudioSystem {
7420
8034
  console.warn(`No audio tracks in stream for channel ${ch.streamIndex}`);
7421
8035
  ch.audioState.isConnected = false;
7422
8036
  this.sendChannelResults(ch, isMain);
8037
+ if (isMain) this.startIdleTicker();
7423
8038
  return;
7424
8039
  }
7425
8040
  try {
@@ -7482,6 +8097,7 @@ class AudioSystem {
7482
8097
  console.error(`Failed to set up audio for channel ${ch.streamIndex}:`, error);
7483
8098
  ch.audioState.isConnected = false;
7484
8099
  ch.disconnectNodes();
8100
+ if (isMain) this.startIdleTicker();
7485
8101
  }
7486
8102
  this.sendChannelResults(ch, isMain);
7487
8103
  }
@@ -7495,17 +8111,30 @@ class AudioSystem {
7495
8111
  }
7496
8112
  /**
7497
8113
  * Disconnect the main audio stream (does NOT close AudioContext -- additional channels may still be active).
8114
+ *
8115
+ * Lifecycle ordering invariant: tear down the audio nodes first, clear any
8116
+ * stale frequency / waveform buffers, then restart the idle ticker. The
8117
+ * ticker callback's defensive `isConnected` guard prevents any stale tick
8118
+ * from observing a half-disconnected state.
7498
8119
  */
7499
8120
  disconnectMainStream() {
7500
8121
  this.mainChannel.disconnectNodes();
7501
8122
  this.mainChannel.audioState.isConnected = false;
7502
8123
  this.mainChannel.isAnalysisRunning = false;
7503
8124
  this.mainChannel.currentStream = null;
8125
+ if (this.mainChannel.frequencyData) {
8126
+ this.mainChannel.frequencyData.fill(0);
8127
+ }
8128
+ if (this.mainChannel.timeDomainData) {
8129
+ this.mainChannel.timeDomainData.fill(0);
8130
+ }
8131
+ this.mainChannel.lastWaveformFrame = null;
7504
8132
  this.resetAudioValues();
7505
8133
  if (this.additionalChannels.size === 0) {
7506
8134
  this.stopAnalysisLoop();
7507
8135
  this.stopStalenessTimer();
7508
8136
  }
8137
+ this.startIdleTicker();
7509
8138
  this.sendChannelResults(this.mainChannel, true);
7510
8139
  this.debugLog("Main audio stream disconnected (host-side)");
7511
8140
  }
@@ -7739,7 +8368,7 @@ class AudioSystem {
7739
8368
  hatSmoothed: 0,
7740
8369
  anySmoothed: 0,
7741
8370
  events: [],
7742
- bpm: 120,
8371
+ bpm: 0,
7743
8372
  confidence: 0,
7744
8373
  isLocked: false
7745
8374
  };
@@ -7751,6 +8380,7 @@ class AudioSystem {
7751
8380
  resetAudioState() {
7752
8381
  this.stopAnalysisLoop();
7753
8382
  this.stopStalenessTimer();
8383
+ this.stopIdleTicker();
7754
8384
  this.resetEssentiaBandHistories();
7755
8385
  for (const ch of this.additionalChannels.values()) {
7756
8386
  ch.destroy();
@@ -7875,6 +8505,94 @@ class AudioSystem {
7875
8505
  isOnsetMuted(instrument) {
7876
8506
  return this.onsetTapManager.isMuted(instrument);
7877
8507
  }
8508
+ // ─────────────────────────────────────────────────────────────────────────
8509
+ // Onset event subscriptions (forward to OnsetTapManager).
8510
+ // Returned `Unsubscribe` removes the listener.
8511
+ // ─────────────────────────────────────────────────────────────────────────
8512
+ onOnsetModeChange(listener) {
8513
+ return this.onsetTapManager.onModeChange(listener);
8514
+ }
8515
+ onOnsetSessionEnd(listener) {
8516
+ return this.onsetTapManager.onSessionEnd(listener);
8517
+ }
8518
+ // ─────────────────────────────────────────────────────────────────────────
8519
+ // State serialization. Onset-only export is cross-device-safe; the full
8520
+ // audio block is for same-process scene-switch transfer (sender's audio
8521
+ // analysis state would corrupt receiver's tracking on a different source).
8522
+ // ─────────────────────────────────────────────────────────────────────────
8523
+ exportOnsetSessionState() {
8524
+ return this.onsetTapManager.exportSessionState();
8525
+ }
8526
+ importOnsetSessionState(state, clockOffset) {
8527
+ this.onsetTapManager.importSessionState(state, clockOffset);
8528
+ }
8529
+ exportAudioAnalysisState() {
8530
+ return {
8531
+ onset: this.onsetTapManager.exportSessionState().instruments,
8532
+ bpmTracker: this.tempoInduction.exportSessionState(),
8533
+ pll: this.pll.exportSessionState(),
8534
+ beatState: this.stateManager.exportSessionState()
8535
+ };
8536
+ }
8537
+ importAudioAnalysisState(state, clockOffset) {
8538
+ this.onsetTapManager.importSessionState(
8539
+ { version: 1, senderTime: performance.now(), instruments: state.onset },
8540
+ clockOffset
8541
+ );
8542
+ this.tempoInduction.importSessionState(state.bpmTracker, clockOffset);
8543
+ this.pll.importSessionState(state.pll, clockOffset);
8544
+ this.stateManager.importSessionState(state.beatState, clockOffset);
8545
+ }
8546
+ // ─────────────────────────────────────────────────────────────────────────
8547
+ // Idle ticker — drives `OnsetTapManager.processFrame` via rAF when no audio
8548
+ // is connected. Constructs an empty `beatState` and reuses the existing
8549
+ // `processFrame` (verified correctness-preserving on empty input), so taps
8550
+ // produce envelope/event output through the artist API the same way they
8551
+ // do when audio is active.
8552
+ // ─────────────────────────────────────────────────────────────────────────
8553
+ startIdleTicker() {
8554
+ if (this.idleTickerHandle !== null) return;
8555
+ if (typeof requestAnimationFrame === "undefined") return;
8556
+ this.idleTickerLastTime = performance.now();
8557
+ this.idleTickerHandle = requestAnimationFrame(this.tickIdle);
8558
+ }
8559
+ stopIdleTicker() {
8560
+ if (this.idleTickerHandle === null) return;
8561
+ if (typeof cancelAnimationFrame !== "undefined") {
8562
+ cancelAnimationFrame(this.idleTickerHandle);
8563
+ }
8564
+ this.idleTickerHandle = null;
8565
+ }
8566
+ tickIdle(now) {
8567
+ this.idleTickerHandle = null;
8568
+ if (this.mainChannel.audioState.isConnected) return;
8569
+ const dtMs = Math.min(100, now - this.idleTickerLastTime);
8570
+ this.idleTickerLastTime = now;
8571
+ if (this.onsetDetectionEnabled && this.beatDetectionEnabled) {
8572
+ const empty = this.makeEmptyBeatState();
8573
+ this.audioStateBeat = this.onsetTapManager.processFrame(empty, now, dtMs);
8574
+ this.sendChannelResults(this.mainChannel, true);
8575
+ }
8576
+ if (typeof requestAnimationFrame !== "undefined") {
8577
+ this.idleTickerHandle = requestAnimationFrame(this.tickIdle);
8578
+ }
8579
+ }
8580
+ makeEmptyBeatState() {
8581
+ return {
8582
+ kick: 0,
8583
+ snare: 0,
8584
+ hat: 0,
8585
+ any: 0,
8586
+ kickSmoothed: 0,
8587
+ snareSmoothed: 0,
8588
+ hatSmoothed: 0,
8589
+ anySmoothed: 0,
8590
+ events: [],
8591
+ bpm: 0,
8592
+ confidence: 0,
8593
+ isLocked: false
8594
+ };
8595
+ }
7878
8596
  /**
7879
8597
  * Get current BPM (manual or auto-detected)
7880
8598
  */
@@ -8913,6 +9631,10 @@ class VijiCore {
8913
9631
  parameterDefinedListeners = /* @__PURE__ */ new Set();
8914
9632
  parameterErrorListeners = /* @__PURE__ */ new Set();
8915
9633
  capabilitiesChangeListeners = /* @__PURE__ */ new Set();
9634
+ // State-import error listeners. Fire on `importFullState` payloads that fail
9635
+ // version or shape validation; consumers surface meaningful UI feedback
9636
+ // rather than relying on console.warn.
9637
+ stateImportErrorListeners = /* @__PURE__ */ new Set();
8916
9638
  // Performance tracking (basic for Phase 1)
8917
9639
  stats = {
8918
9640
  frameTime: 0,
@@ -9885,6 +10607,86 @@ class VijiCore {
9885
10607
  offParameterError(listener) {
9886
10608
  this.parameterErrorListeners.delete(listener);
9887
10609
  }
10610
+ // ─────────────────────────────────────────────────────────────────────────
10611
+ // State serialization (cross-instance transfer)
10612
+ // ─────────────────────────────────────────────────────────────────────────
10613
+ /**
10614
+ * Snapshot the full audio analysis + onset state for cross-instance
10615
+ * transfer. Use for **same-process scene-switch** continuity (a fresh
10616
+ * `VijiCore` can resume detection where the previous one left off).
10617
+ *
10618
+ * For **cross-device** transfer, prefer `audio.onset.exportSessionState`
10619
+ * — the controller's audio analysis state doesn't apply to the host's
10620
+ * audio source and would corrupt detection.
10621
+ *
10622
+ * Wall-clock fields are in this instance's `performance.now()` clock space.
10623
+ * Receiver applies `clockOffset` on import (use `0` for same-process).
10624
+ *
10625
+ * **Staleness caveat**: the audio block is robust to scene-load gaps under
10626
+ * ~1 second. Longer gaps (>5s, e.g. cold cache + slow network) may cause
10627
+ * stale-timestamp drift in `gridLockMemoryTime` / `warmupStartTimeMs`;
10628
+ * callers should treat that as a degraded path and skip the audio block.
10629
+ */
10630
+ exportFullState() {
10631
+ this.validateReady();
10632
+ const senderTime = performance.now();
10633
+ const out = { version: 1, senderTime };
10634
+ if (this.audioSystem) {
10635
+ const audio = this.audioSystem.exportAudioAnalysisState();
10636
+ out.onset = audio.onset;
10637
+ out.audio = audio;
10638
+ }
10639
+ return out;
10640
+ }
10641
+ /**
10642
+ * Replace the audio analysis + onset state from a serialized payload.
10643
+ * `clockOffset` is added to all sender-clocked fields (`0` for same-process,
10644
+ * NTP-derived for cross-device). Mutation is synchronous; no `onModeChange`
10645
+ * or `onSessionEnd` events are emitted (state replacement is not a transition).
10646
+ *
10647
+ * Validates `version`; on mismatch, fires `onStateImportError` and leaves
10648
+ * existing state intact.
10649
+ */
10650
+ importFullState(state, clockOffset) {
10651
+ this.validateReady();
10652
+ const validation = validateCoreStatePayload(state);
10653
+ if (validation) {
10654
+ this.fireStateImportError(validation);
10655
+ return;
10656
+ }
10657
+ if (!this.audioSystem) return;
10658
+ if (state.audio) {
10659
+ this.audioSystem.importAudioAnalysisState(state.audio, clockOffset);
10660
+ } else if (state.onset) {
10661
+ this.audioSystem.importOnsetSessionState(
10662
+ { version: 1, senderTime: state.senderTime, instruments: state.onset },
10663
+ clockOffset
10664
+ );
10665
+ }
10666
+ }
10667
+ /**
10668
+ * Listen for state-import validation errors. Fires when `importFullState`
10669
+ * or `audio.onset.importSessionState` rejects a payload (version mismatch,
10670
+ * malformed shape, invalid field). Existing state is left intact when
10671
+ * an error fires.
10672
+ *
10673
+ * @returns Unsubscribe function. Call to remove the listener.
10674
+ */
10675
+ onStateImportError(listener) {
10676
+ this.stateImportErrorListeners.add(listener);
10677
+ return () => {
10678
+ this.stateImportErrorListeners.delete(listener);
10679
+ };
10680
+ }
10681
+ fireStateImportError(error) {
10682
+ for (const listener of this.stateImportErrorListeners) {
10683
+ try {
10684
+ listener(error);
10685
+ } catch (err) {
10686
+ console.error("Error in onStateImportError listener:", err);
10687
+ }
10688
+ }
10689
+ }
9888
10690
  /**
9889
10691
  * Notify parameter change listeners
9890
10692
  */
@@ -10313,6 +11115,73 @@ class VijiCore {
10313
11115
  isMuted: (instrument) => {
10314
11116
  this.validateReady();
10315
11117
  return this.audioSystem?.isOnsetMuted(instrument) ?? false;
11118
+ },
11119
+ /**
11120
+ * Listen for instrument mode transitions (`'auto' | 'tapping' | 'pattern'`).
11121
+ * Fires on every transition including the first tap (`'auto' → 'tapping'`)
11122
+ * and pattern recognition (`'tapping' → 'pattern'`). Imported state does
11123
+ * NOT fire mode-change events — state replacement is not a transition.
11124
+ *
11125
+ * @returns Unsubscribe function. Call to remove the listener.
11126
+ */
11127
+ onModeChange: (listener) => {
11128
+ this.validateReady();
11129
+ return this.audioSystem?.onOnsetModeChange(listener) ?? (() => {
11130
+ });
11131
+ },
11132
+ /**
11133
+ * Listen for natural session-end events. Fires when:
11134
+ * - 500ms elapse since last tap with instrument in `'pattern'` mode
11135
+ * (outcome `'pattern'`), or
11136
+ * - 5s elapse in `'tapping'` mode without a recognized pattern
11137
+ * (outcome `'cleared'`; instrument transitions to `'auto'`).
11138
+ *
11139
+ * Explicit `clear()` calls do NOT fire this event (caller-initiated;
11140
+ * caller already knows). Imported state does NOT fire either.
11141
+ *
11142
+ * @returns Unsubscribe function. Call to remove the listener.
11143
+ */
11144
+ onSessionEnd: (listener) => {
11145
+ this.validateReady();
11146
+ return this.audioSystem?.onOnsetSessionEnd(listener) ?? (() => {
11147
+ });
11148
+ },
11149
+ /**
11150
+ * Snapshot per-instrument onset state for cross-instance transfer.
11151
+ * Cross-device-safe (no audio analysis state included). Pair with
11152
+ * `importSessionState` on a receiver. Wall-clock fields are in this
11153
+ * instance's `performance.now()` clock space.
11154
+ */
11155
+ exportSessionState: () => {
11156
+ this.validateReady();
11157
+ return this.audioSystem?.exportOnsetSessionState() ?? {
11158
+ version: 1,
11159
+ senderTime: performance.now(),
11160
+ instruments: {}
11161
+ };
11162
+ },
11163
+ /**
11164
+ * Replace per-instrument onset state from a serialized payload.
11165
+ * `clockOffset` is added to all sender-clocked fields during import
11166
+ * (use `0` for same-process transfer; NTP-derived offset for cross-device).
11167
+ * Mutation is synchronous; no events are emitted.
11168
+ *
11169
+ * Patterns are rebased forward by whole pattern cycles to eliminate
11170
+ * the catch-up burst that would otherwise occur on stale payloads
11171
+ * (phase-preserving — events still land on the original beat positions
11172
+ * modulo pattern length).
11173
+ *
11174
+ * Validates `version`; on mismatch, fires `onStateImportError` and
11175
+ * leaves existing state intact.
11176
+ */
11177
+ importSessionState: (state, clockOffset) => {
11178
+ this.validateReady();
11179
+ const validation = OnsetTapManager.validateOnsetPayload(state);
11180
+ if (validation) {
11181
+ this.fireStateImportError(validation);
11182
+ return;
11183
+ }
11184
+ this.audioSystem?.importOnsetSessionState(state, clockOffset);
10316
11185
  }
10317
11186
  },
10318
11187
  /**
@@ -10602,6 +11471,7 @@ class VijiCore {
10602
11471
  this.parameterDefinedListeners.clear();
10603
11472
  this.parameterErrorListeners.clear();
10604
11473
  this.capabilitiesChangeListeners.clear();
11474
+ this.stateImportErrorListeners.clear();
10605
11475
  this.unlinkEventSource();
10606
11476
  this.unlinkFrameSources();
10607
11477
  for (const [deviceId] of this.deviceVideoCoordinators) {
@@ -10693,7 +11563,25 @@ class VijiCore {
10693
11563
  }
10694
11564
  }
10695
11565
  }
10696
- const VERSION = "0.5.0";
11566
+ function validateCoreStatePayload(state) {
11567
+ if (state === null || typeof state !== "object") {
11568
+ return { code: "malformed", details: "payload is not an object" };
11569
+ }
11570
+ const s = state;
11571
+ if (s.version !== 1) {
11572
+ return {
11573
+ code: "version-mismatch",
11574
+ details: `expected version 1, got ${String(s.version)}`,
11575
+ ...typeof s.version === "number" ? { payloadVersion: s.version } : {},
11576
+ expectedVersion: 1
11577
+ };
11578
+ }
11579
+ if (typeof s.senderTime !== "number") {
11580
+ return { code: "invalid-field", details: "senderTime must be a number", field: "senderTime" };
11581
+ }
11582
+ return null;
11583
+ }
11584
+ const VERSION = "0.5.1";
10697
11585
  export {
10698
11586
  AudioSystem as A,
10699
11587
  VERSION as V,
@@ -10701,4 +11589,4 @@ export {
10701
11589
  VijiCoreError as b,
10702
11590
  getDefaultExportFromCjs as g
10703
11591
  };
10704
- //# sourceMappingURL=index-Cp9G0z4E.js.map
11592
+ //# sourceMappingURL=index-Cqh1k_49.js.map