@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.
- package/dist/artist-dts-p5.js +1 -1
- package/dist/artist-dts.js +1 -1
- package/dist/artist-global-p5.d.ts +22 -14
- package/dist/artist-global.d.ts +22 -14
- package/dist/artist-jsdoc.d.ts +1 -1
- package/dist/assets/{viji.worker-CWKkFyOs.js → viji.worker-DwYMDyfQ.js} +7 -3
- package/dist/assets/viji.worker-DwYMDyfQ.js.map +1 -0
- package/dist/docs-api.js +9794 -9759
- package/dist/{essentia-wasm.web-CmPC-AKu.js → essentia-wasm.web-x6zu4Vib.js} +2 -2
- package/dist/{essentia-wasm.web-CmPC-AKu.js.map → essentia-wasm.web-x6zu4Vib.js.map} +1 -1
- package/dist/{index-Cp9G0z4E.js → index-Cqh1k_49.js} +903 -15
- package/dist/index-Cqh1k_49.js.map +1 -0
- package/dist/index.d.ts +479 -14
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/assets/viji.worker-CWKkFyOs.js.map +0 -1
- package/dist/index-Cp9G0z4E.js.map +0 -1
|
@@ -640,7 +640,7 @@ class IFrameManager {
|
|
|
640
640
|
}
|
|
641
641
|
}
|
|
642
642
|
}
|
|
643
|
-
const workerUrl = "" + new URL("assets/viji.worker-
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
6407
|
+
this.setMode(inst, "pattern");
|
|
5968
6408
|
s.replayLastEventTime = now;
|
|
5969
6409
|
s.replayIndex = 0;
|
|
5970
6410
|
} else {
|
|
5971
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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-
|
|
11592
|
+
//# sourceMappingURL=index-Cqh1k_49.js.map
|