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