@hapticjs/core 0.2.1 → 0.4.0
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/index.cjs +2014 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +710 -2
- package/dist/index.d.ts +710 -2
- package/dist/index.js +1986 -21
- package/dist/index.js.map +1 -1
- package/dist/presets/index.cjs +181 -1
- package/dist/presets/index.cjs.map +1 -1
- package/dist/presets/index.d.cts +307 -1
- package/dist/presets/index.d.ts +307 -1
- package/dist/presets/index.js +181 -2
- package/dist/presets/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -58,9 +58,9 @@ var WebVibrationAdapter = class {
|
|
|
58
58
|
if (!this.supported || steps.length === 0) return;
|
|
59
59
|
const pattern = [];
|
|
60
60
|
let lastType = null;
|
|
61
|
-
for (const
|
|
62
|
-
if (
|
|
63
|
-
const dur = Math.max(
|
|
61
|
+
for (const step2 of steps) {
|
|
62
|
+
if (step2.type === "vibrate" && step2.intensity > 0.05) {
|
|
63
|
+
const dur = Math.max(step2.duration, 20);
|
|
64
64
|
if (lastType === "vibrate") {
|
|
65
65
|
pattern[pattern.length - 1] += dur;
|
|
66
66
|
} else {
|
|
@@ -68,7 +68,7 @@ var WebVibrationAdapter = class {
|
|
|
68
68
|
}
|
|
69
69
|
lastType = "vibrate";
|
|
70
70
|
} else {
|
|
71
|
-
const dur = Math.max(
|
|
71
|
+
const dur = Math.max(step2.duration, 10);
|
|
72
72
|
if (lastType === "pause") {
|
|
73
73
|
pattern[pattern.length - 1] += dur;
|
|
74
74
|
} else {
|
|
@@ -132,12 +132,12 @@ var IoSAudioAdapter = class {
|
|
|
132
132
|
async playSequence(steps) {
|
|
133
133
|
if (!this.supported || steps.length === 0) return;
|
|
134
134
|
this._cancelled = false;
|
|
135
|
-
for (const
|
|
135
|
+
for (const step2 of steps) {
|
|
136
136
|
if (this._cancelled) break;
|
|
137
|
-
if (
|
|
138
|
-
await this._playTone(
|
|
137
|
+
if (step2.type === "vibrate" && step2.intensity > 0) {
|
|
138
|
+
await this._playTone(step2.intensity, step2.duration);
|
|
139
139
|
} else {
|
|
140
|
-
await delay(
|
|
140
|
+
await delay(step2.duration);
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
}
|
|
@@ -264,10 +264,10 @@ function detectAdapter() {
|
|
|
264
264
|
// src/engine/adaptive-engine.ts
|
|
265
265
|
var AdaptiveEngine = class {
|
|
266
266
|
adapt(steps, capabilities) {
|
|
267
|
-
return steps.map((
|
|
267
|
+
return steps.map((step2) => this._adaptStep(step2, capabilities));
|
|
268
268
|
}
|
|
269
|
-
_adaptStep(
|
|
270
|
-
const adapted = { ...
|
|
269
|
+
_adaptStep(step2, caps) {
|
|
270
|
+
const adapted = { ...step2 };
|
|
271
271
|
if (adapted.type === "vibrate") {
|
|
272
272
|
adapted.duration = clamp(adapted.duration, caps.minDuration, caps.maxDuration);
|
|
273
273
|
}
|
|
@@ -659,12 +659,12 @@ function compileNode(node) {
|
|
|
659
659
|
}
|
|
660
660
|
function mergeSustains(steps) {
|
|
661
661
|
const result = [];
|
|
662
|
-
for (const
|
|
663
|
-
if (
|
|
662
|
+
for (const step2 of steps) {
|
|
663
|
+
if (step2.intensity === -1 && result.length > 0) {
|
|
664
664
|
const prev = result[result.length - 1];
|
|
665
|
-
prev.duration +=
|
|
665
|
+
prev.duration += step2.duration;
|
|
666
666
|
} else {
|
|
667
|
-
result.push({ ...
|
|
667
|
+
result.push({ ...step2 });
|
|
668
668
|
}
|
|
669
669
|
}
|
|
670
670
|
return result;
|
|
@@ -845,6 +845,777 @@ var HapticEngine = class _HapticEngine {
|
|
|
845
845
|
}
|
|
846
846
|
};
|
|
847
847
|
|
|
848
|
+
// src/sound/sound-engine.ts
|
|
849
|
+
var NOTE_FREQUENCIES = {
|
|
850
|
+
C4: 261.63,
|
|
851
|
+
E4: 329.63,
|
|
852
|
+
G4: 392,
|
|
853
|
+
C5: 523.25
|
|
854
|
+
};
|
|
855
|
+
var SoundEngine = class {
|
|
856
|
+
constructor(options) {
|
|
857
|
+
this.ctx = null;
|
|
858
|
+
this._enabled = options?.enabled ?? true;
|
|
859
|
+
this.masterVolume = options?.volume ?? 0.5;
|
|
860
|
+
this._muted = options?.muted ?? false;
|
|
861
|
+
}
|
|
862
|
+
// ─── Public API ──────────────────────────────────────────
|
|
863
|
+
/** Short click sound */
|
|
864
|
+
async click(options) {
|
|
865
|
+
const pitchMap = { low: 800, mid: 1200, high: 2e3 };
|
|
866
|
+
const freq = pitchMap[options?.pitch ?? "mid"];
|
|
867
|
+
const vol = options?.volume ?? 0.3;
|
|
868
|
+
await this.playTone(freq, 4, { waveform: "sine", volume: vol, decay: true });
|
|
869
|
+
}
|
|
870
|
+
/** Ultra-short tick sound */
|
|
871
|
+
async tick() {
|
|
872
|
+
await this.playTone(4e3, 2, { waveform: "sine", volume: 0.15, decay: true });
|
|
873
|
+
}
|
|
874
|
+
/** Bubbly pop sound — quick frequency sweep high to low */
|
|
875
|
+
async pop() {
|
|
876
|
+
const ctx = this._getContext();
|
|
877
|
+
if (!ctx) return;
|
|
878
|
+
const osc = ctx.createOscillator();
|
|
879
|
+
const gain = ctx.createGain();
|
|
880
|
+
osc.connect(gain);
|
|
881
|
+
gain.connect(ctx.destination);
|
|
882
|
+
const now = ctx.currentTime;
|
|
883
|
+
const vol = this._effectiveVolume(0.25);
|
|
884
|
+
osc.type = "sine";
|
|
885
|
+
osc.frequency.setValueAtTime(1600, now);
|
|
886
|
+
osc.frequency.exponentialRampToValueAtTime(300, now + 0.04);
|
|
887
|
+
gain.gain.setValueAtTime(vol, now);
|
|
888
|
+
gain.gain.exponentialRampToValueAtTime(1e-3, now + 0.06);
|
|
889
|
+
osc.start(now);
|
|
890
|
+
osc.stop(now + 0.06);
|
|
891
|
+
}
|
|
892
|
+
/** Swipe/swoosh sound — noise with quick fade */
|
|
893
|
+
async whoosh() {
|
|
894
|
+
const ctx = this._getContext();
|
|
895
|
+
if (!ctx) return;
|
|
896
|
+
const bufferSize = ctx.sampleRate * 0.08;
|
|
897
|
+
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
|
898
|
+
const data = buffer.getChannelData(0);
|
|
899
|
+
for (let i = 0; i < bufferSize; i++) {
|
|
900
|
+
data[i] = (Math.random() * 2 - 1) * (1 - i / bufferSize);
|
|
901
|
+
}
|
|
902
|
+
const source = ctx.createBufferSource();
|
|
903
|
+
const gain = ctx.createGain();
|
|
904
|
+
const filter = ctx.createBiquadFilter();
|
|
905
|
+
source.buffer = buffer;
|
|
906
|
+
filter.type = "bandpass";
|
|
907
|
+
filter.frequency.value = 2e3;
|
|
908
|
+
filter.Q.value = 0.5;
|
|
909
|
+
source.connect(filter);
|
|
910
|
+
filter.connect(gain);
|
|
911
|
+
gain.connect(ctx.destination);
|
|
912
|
+
const now = ctx.currentTime;
|
|
913
|
+
const vol = this._effectiveVolume(0.2);
|
|
914
|
+
gain.gain.setValueAtTime(vol, now);
|
|
915
|
+
gain.gain.exponentialRampToValueAtTime(1e-3, now + 0.08);
|
|
916
|
+
source.start(now);
|
|
917
|
+
}
|
|
918
|
+
/** Musical chime */
|
|
919
|
+
async chime(note = "C5") {
|
|
920
|
+
const freq = NOTE_FREQUENCIES[note] ?? 523;
|
|
921
|
+
await this.playTone(freq, 100, { waveform: "sine", volume: 0.2, decay: true });
|
|
922
|
+
}
|
|
923
|
+
/** Low buzzer/error tone — two descending tones */
|
|
924
|
+
async error() {
|
|
925
|
+
const ctx = this._getContext();
|
|
926
|
+
if (!ctx) return;
|
|
927
|
+
const now = ctx.currentTime;
|
|
928
|
+
this._scheduleTone(ctx, 400, now, 0.08, "square", 0.15);
|
|
929
|
+
this._scheduleTone(ctx, 280, now + 0.1, 0.1, "square", 0.15);
|
|
930
|
+
}
|
|
931
|
+
/** Ascending two-tone success sound */
|
|
932
|
+
async success() {
|
|
933
|
+
const ctx = this._getContext();
|
|
934
|
+
if (!ctx) return;
|
|
935
|
+
const now = ctx.currentTime;
|
|
936
|
+
this._scheduleTone(ctx, 880, now, 0.06, "sine", 0.15);
|
|
937
|
+
this._scheduleTone(ctx, 1320, now + 0.08, 0.08, "sine", 0.15);
|
|
938
|
+
}
|
|
939
|
+
/** Subtle tap sound */
|
|
940
|
+
async tap() {
|
|
941
|
+
await this.playTone(1e3, 3, { waveform: "sine", volume: 0.2, decay: true });
|
|
942
|
+
}
|
|
943
|
+
/** Toggle sound — ascending for on, descending for off */
|
|
944
|
+
async toggle(on) {
|
|
945
|
+
const ctx = this._getContext();
|
|
946
|
+
if (!ctx) return;
|
|
947
|
+
const now = ctx.currentTime;
|
|
948
|
+
if (on) {
|
|
949
|
+
this._scheduleTone(ctx, 600, now, 0.04, "sine", 0.15);
|
|
950
|
+
this._scheduleTone(ctx, 900, now + 0.06, 0.04, "sine", 0.15);
|
|
951
|
+
} else {
|
|
952
|
+
this._scheduleTone(ctx, 900, now, 0.04, "sine", 0.15);
|
|
953
|
+
this._scheduleTone(ctx, 600, now + 0.06, 0.04, "sine", 0.15);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
/** Generic tone player */
|
|
957
|
+
async playTone(frequency, duration, options) {
|
|
958
|
+
const ctx = this._getContext();
|
|
959
|
+
if (!ctx) return;
|
|
960
|
+
const waveform = options?.waveform ?? "sine";
|
|
961
|
+
const vol = this._effectiveVolume(options?.volume ?? 0.3);
|
|
962
|
+
const decay = options?.decay ?? false;
|
|
963
|
+
const osc = ctx.createOscillator();
|
|
964
|
+
const gain = ctx.createGain();
|
|
965
|
+
osc.connect(gain);
|
|
966
|
+
gain.connect(ctx.destination);
|
|
967
|
+
osc.type = waveform;
|
|
968
|
+
osc.frequency.value = frequency;
|
|
969
|
+
const now = ctx.currentTime;
|
|
970
|
+
const durSec = duration / 1e3;
|
|
971
|
+
gain.gain.setValueAtTime(vol, now);
|
|
972
|
+
if (decay) {
|
|
973
|
+
gain.gain.exponentialRampToValueAtTime(1e-3, now + durSec);
|
|
974
|
+
}
|
|
975
|
+
osc.start(now);
|
|
976
|
+
osc.stop(now + durSec);
|
|
977
|
+
}
|
|
978
|
+
/** Set master volume (0-1) */
|
|
979
|
+
setVolume(volume) {
|
|
980
|
+
this.masterVolume = Math.max(0, Math.min(1, volume));
|
|
981
|
+
}
|
|
982
|
+
/** Mute all sounds */
|
|
983
|
+
mute() {
|
|
984
|
+
this._muted = true;
|
|
985
|
+
}
|
|
986
|
+
/** Unmute sounds */
|
|
987
|
+
unmute() {
|
|
988
|
+
this._muted = false;
|
|
989
|
+
}
|
|
990
|
+
/** Whether sounds are currently muted */
|
|
991
|
+
get muted() {
|
|
992
|
+
return this._muted;
|
|
993
|
+
}
|
|
994
|
+
/** Current master volume */
|
|
995
|
+
get volume() {
|
|
996
|
+
return this.masterVolume;
|
|
997
|
+
}
|
|
998
|
+
/** Whether the engine is enabled */
|
|
999
|
+
get enabled() {
|
|
1000
|
+
return this._enabled;
|
|
1001
|
+
}
|
|
1002
|
+
/** Close AudioContext and release resources */
|
|
1003
|
+
dispose() {
|
|
1004
|
+
if (this.ctx) {
|
|
1005
|
+
this.ctx.close().catch(() => {
|
|
1006
|
+
});
|
|
1007
|
+
this.ctx = null;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
// ─── Internal ──────────────────────────────────────────────
|
|
1011
|
+
/** Lazily create and return AudioContext, handling autoplay policy */
|
|
1012
|
+
_getContext() {
|
|
1013
|
+
if (!this._enabled || this._muted) return null;
|
|
1014
|
+
if (typeof AudioContext === "undefined") return null;
|
|
1015
|
+
if (!this.ctx) {
|
|
1016
|
+
this.ctx = new AudioContext();
|
|
1017
|
+
}
|
|
1018
|
+
if (this.ctx.state === "suspended") {
|
|
1019
|
+
this.ctx.resume().catch(() => {
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
return this.ctx;
|
|
1023
|
+
}
|
|
1024
|
+
/** Compute effective volume from master + per-sound volume */
|
|
1025
|
+
_effectiveVolume(soundVolume) {
|
|
1026
|
+
return this.masterVolume * soundVolume;
|
|
1027
|
+
}
|
|
1028
|
+
/** Schedule a tone at a specific AudioContext time */
|
|
1029
|
+
_scheduleTone(ctx, frequency, startTime, durationSec, waveform, volume) {
|
|
1030
|
+
const osc = ctx.createOscillator();
|
|
1031
|
+
const gain = ctx.createGain();
|
|
1032
|
+
osc.connect(gain);
|
|
1033
|
+
gain.connect(ctx.destination);
|
|
1034
|
+
osc.type = waveform;
|
|
1035
|
+
osc.frequency.value = frequency;
|
|
1036
|
+
const vol = this._effectiveVolume(volume);
|
|
1037
|
+
gain.gain.setValueAtTime(vol, startTime);
|
|
1038
|
+
gain.gain.exponentialRampToValueAtTime(1e-3, startTime + durationSec);
|
|
1039
|
+
osc.start(startTime);
|
|
1040
|
+
osc.stop(startTime + durationSec);
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
// src/visual/visual-engine.ts
|
|
1045
|
+
var STYLE_ID = "__hapticjs_visual_keyframes__";
|
|
1046
|
+
var VisualEngine = class {
|
|
1047
|
+
constructor(options) {
|
|
1048
|
+
this._styleInjected = false;
|
|
1049
|
+
this._cleanups = [];
|
|
1050
|
+
this._enabled = options?.enabled ?? true;
|
|
1051
|
+
this._target = options?.target ?? null;
|
|
1052
|
+
this._intensity = options?.intensity ?? 1;
|
|
1053
|
+
}
|
|
1054
|
+
// ─── Public API ──────────────────────────────────────────
|
|
1055
|
+
/** Quick screen flash overlay */
|
|
1056
|
+
flash(options) {
|
|
1057
|
+
if (!this._canRun()) return;
|
|
1058
|
+
const color = options?.color ?? "white";
|
|
1059
|
+
const duration = options?.duration ?? 100;
|
|
1060
|
+
const opacity = options?.opacity ?? 0.15;
|
|
1061
|
+
const overlay = document.createElement("div");
|
|
1062
|
+
Object.assign(overlay.style, {
|
|
1063
|
+
position: "fixed",
|
|
1064
|
+
inset: "0",
|
|
1065
|
+
backgroundColor: color,
|
|
1066
|
+
opacity: String(opacity * this._intensity),
|
|
1067
|
+
pointerEvents: "none",
|
|
1068
|
+
zIndex: "99999",
|
|
1069
|
+
transition: `opacity ${duration}ms ease-out`
|
|
1070
|
+
});
|
|
1071
|
+
document.body.appendChild(overlay);
|
|
1072
|
+
requestAnimationFrame(() => {
|
|
1073
|
+
overlay.style.opacity = "0";
|
|
1074
|
+
});
|
|
1075
|
+
const timer = setTimeout(() => {
|
|
1076
|
+
overlay.remove();
|
|
1077
|
+
this._removeCleanup(cleanup);
|
|
1078
|
+
}, duration + 50);
|
|
1079
|
+
const cleanup = () => {
|
|
1080
|
+
clearTimeout(timer);
|
|
1081
|
+
overlay.remove();
|
|
1082
|
+
};
|
|
1083
|
+
this._cleanups.push(cleanup);
|
|
1084
|
+
}
|
|
1085
|
+
/** CSS shake animation on target */
|
|
1086
|
+
shake(options) {
|
|
1087
|
+
if (!this._canRun()) return;
|
|
1088
|
+
this._injectStyles();
|
|
1089
|
+
const target = this._getTarget();
|
|
1090
|
+
const intensity = (options?.intensity ?? 3) * this._intensity;
|
|
1091
|
+
const duration = options?.duration ?? 200;
|
|
1092
|
+
const magnitude = Math.round(intensity);
|
|
1093
|
+
const name = `hapticjs-shake-${magnitude}`;
|
|
1094
|
+
this._ensureKeyframes(
|
|
1095
|
+
name,
|
|
1096
|
+
`0%,100%{transform:translateX(0)}10%,30%,50%,70%,90%{transform:translateX(-${magnitude}px)}20%,40%,60%,80%{transform:translateX(${magnitude}px)}`
|
|
1097
|
+
);
|
|
1098
|
+
this._applyAnimation(target, name, duration);
|
|
1099
|
+
}
|
|
1100
|
+
/** Scale pulse animation */
|
|
1101
|
+
pulse(options) {
|
|
1102
|
+
if (!this._canRun()) return;
|
|
1103
|
+
this._injectStyles();
|
|
1104
|
+
const target = this._getTarget();
|
|
1105
|
+
const scale = options?.scale ?? 1.02;
|
|
1106
|
+
const duration = options?.duration ?? 150;
|
|
1107
|
+
const adjusted = 1 + (scale - 1) * this._intensity;
|
|
1108
|
+
const name = `hapticjs-pulse-${Math.round(adjusted * 1e3)}`;
|
|
1109
|
+
this._ensureKeyframes(
|
|
1110
|
+
name,
|
|
1111
|
+
`0%,100%{transform:scale(1)}50%{transform:scale(${adjusted})}`
|
|
1112
|
+
);
|
|
1113
|
+
this._applyAnimation(target, name, duration);
|
|
1114
|
+
}
|
|
1115
|
+
/** Material Design style ripple at coordinates */
|
|
1116
|
+
ripple(x, y, options) {
|
|
1117
|
+
if (!this._canRun()) return;
|
|
1118
|
+
this._injectStyles();
|
|
1119
|
+
const color = options?.color ?? "rgba(255,255,255,0.4)";
|
|
1120
|
+
const size = options?.size ?? 100;
|
|
1121
|
+
const duration = options?.duration ?? 400;
|
|
1122
|
+
this._ensureKeyframes(
|
|
1123
|
+
"hapticjs-ripple",
|
|
1124
|
+
`0%{transform:scale(0);opacity:1}100%{transform:scale(4);opacity:0}`
|
|
1125
|
+
);
|
|
1126
|
+
const el = document.createElement("div");
|
|
1127
|
+
const half = size / 2;
|
|
1128
|
+
Object.assign(el.style, {
|
|
1129
|
+
position: "fixed",
|
|
1130
|
+
left: `${x - half}px`,
|
|
1131
|
+
top: `${y - half}px`,
|
|
1132
|
+
width: `${size}px`,
|
|
1133
|
+
height: `${size}px`,
|
|
1134
|
+
borderRadius: "50%",
|
|
1135
|
+
backgroundColor: color,
|
|
1136
|
+
pointerEvents: "none",
|
|
1137
|
+
zIndex: "99999",
|
|
1138
|
+
animation: `hapticjs-ripple ${duration}ms ease-out forwards`
|
|
1139
|
+
});
|
|
1140
|
+
document.body.appendChild(el);
|
|
1141
|
+
const timer = setTimeout(() => {
|
|
1142
|
+
el.remove();
|
|
1143
|
+
this._removeCleanup(cleanup);
|
|
1144
|
+
}, duration + 50);
|
|
1145
|
+
const cleanup = () => {
|
|
1146
|
+
clearTimeout(timer);
|
|
1147
|
+
el.remove();
|
|
1148
|
+
};
|
|
1149
|
+
this._cleanups.push(cleanup);
|
|
1150
|
+
}
|
|
1151
|
+
/** Box shadow glow effect */
|
|
1152
|
+
glow(options) {
|
|
1153
|
+
if (!this._canRun()) return;
|
|
1154
|
+
const target = this._getTarget();
|
|
1155
|
+
const color = options?.color ?? "rgba(59,130,246,0.5)";
|
|
1156
|
+
const duration = options?.duration ?? 300;
|
|
1157
|
+
const size = (options?.size ?? 15) * this._intensity;
|
|
1158
|
+
const prev = target.style.boxShadow;
|
|
1159
|
+
const prevTransition = target.style.transition;
|
|
1160
|
+
target.style.transition = `box-shadow ${duration / 2}ms ease-in-out`;
|
|
1161
|
+
target.style.boxShadow = `0 0 ${size}px ${color}`;
|
|
1162
|
+
const timer = setTimeout(() => {
|
|
1163
|
+
target.style.boxShadow = prev;
|
|
1164
|
+
setTimeout(() => {
|
|
1165
|
+
target.style.transition = prevTransition;
|
|
1166
|
+
this._removeCleanup(cleanup);
|
|
1167
|
+
}, duration / 2);
|
|
1168
|
+
}, duration / 2);
|
|
1169
|
+
const cleanup = () => {
|
|
1170
|
+
clearTimeout(timer);
|
|
1171
|
+
target.style.boxShadow = prev;
|
|
1172
|
+
target.style.transition = prevTransition;
|
|
1173
|
+
};
|
|
1174
|
+
this._cleanups.push(cleanup);
|
|
1175
|
+
}
|
|
1176
|
+
/** Bounce animation on target */
|
|
1177
|
+
bounce(options) {
|
|
1178
|
+
if (!this._canRun()) return;
|
|
1179
|
+
this._injectStyles();
|
|
1180
|
+
const target = this._getTarget();
|
|
1181
|
+
const height = (options?.height ?? 8) * this._intensity;
|
|
1182
|
+
const duration = options?.duration ?? 300;
|
|
1183
|
+
const name = `hapticjs-bounce-${Math.round(height)}`;
|
|
1184
|
+
this._ensureKeyframes(
|
|
1185
|
+
name,
|
|
1186
|
+
`0%,100%{transform:translateY(0)}40%{transform:translateY(-${height}px)}60%{transform:translateY(-${Math.round(height * 0.4)}px)}`
|
|
1187
|
+
);
|
|
1188
|
+
this._applyAnimation(target, name, duration);
|
|
1189
|
+
}
|
|
1190
|
+
/** Jello/wobble animation */
|
|
1191
|
+
jello(options) {
|
|
1192
|
+
if (!this._canRun()) return;
|
|
1193
|
+
this._injectStyles();
|
|
1194
|
+
const target = this._getTarget();
|
|
1195
|
+
const intensity = (options?.intensity ?? 5) * this._intensity;
|
|
1196
|
+
const duration = options?.duration ?? 400;
|
|
1197
|
+
const skew = intensity;
|
|
1198
|
+
const name = `hapticjs-jello-${Math.round(skew)}`;
|
|
1199
|
+
this._ensureKeyframes(
|
|
1200
|
+
name,
|
|
1201
|
+
`0%,100%{transform:skew(0)}20%{transform:skew(-${skew}deg)}40%{transform:skew(${skew * 0.6}deg)}60%{transform:skew(-${skew * 0.3}deg)}80%{transform:skew(${skew * 0.1}deg)}`
|
|
1202
|
+
);
|
|
1203
|
+
this._applyAnimation(target, name, duration);
|
|
1204
|
+
}
|
|
1205
|
+
/** Rubber band scale effect */
|
|
1206
|
+
rubber(options) {
|
|
1207
|
+
if (!this._canRun()) return;
|
|
1208
|
+
this._injectStyles();
|
|
1209
|
+
const target = this._getTarget();
|
|
1210
|
+
const scaleX = options?.scaleX ?? 1.15;
|
|
1211
|
+
const scaleY = options?.scaleY ?? 0.85;
|
|
1212
|
+
const duration = options?.duration ?? 300;
|
|
1213
|
+
const name = `hapticjs-rubber-${Math.round(scaleX * 100)}-${Math.round(scaleY * 100)}`;
|
|
1214
|
+
this._ensureKeyframes(
|
|
1215
|
+
name,
|
|
1216
|
+
`0%,100%{transform:scale(1,1)}30%{transform:scale(${scaleX},${scaleY})}60%{transform:scale(${2 - scaleX},${2 - scaleY})}`
|
|
1217
|
+
);
|
|
1218
|
+
this._applyAnimation(target, name, duration);
|
|
1219
|
+
}
|
|
1220
|
+
/** Brief background color highlight */
|
|
1221
|
+
highlight(options) {
|
|
1222
|
+
if (!this._canRun()) return;
|
|
1223
|
+
const target = this._getTarget();
|
|
1224
|
+
const color = options?.color ?? "rgba(255,255,0,0.2)";
|
|
1225
|
+
const duration = options?.duration ?? 300;
|
|
1226
|
+
const prev = target.style.backgroundColor;
|
|
1227
|
+
const prevTransition = target.style.transition;
|
|
1228
|
+
target.style.transition = `background-color ${duration / 2}ms ease-in-out`;
|
|
1229
|
+
target.style.backgroundColor = color;
|
|
1230
|
+
const timer = setTimeout(() => {
|
|
1231
|
+
target.style.backgroundColor = prev;
|
|
1232
|
+
setTimeout(() => {
|
|
1233
|
+
target.style.transition = prevTransition;
|
|
1234
|
+
this._removeCleanup(cleanup);
|
|
1235
|
+
}, duration / 2);
|
|
1236
|
+
}, duration / 2);
|
|
1237
|
+
const cleanup = () => {
|
|
1238
|
+
clearTimeout(timer);
|
|
1239
|
+
target.style.backgroundColor = prev;
|
|
1240
|
+
target.style.transition = prevTransition;
|
|
1241
|
+
};
|
|
1242
|
+
this._cleanups.push(cleanup);
|
|
1243
|
+
}
|
|
1244
|
+
/** Change the target element for animations */
|
|
1245
|
+
setTarget(element) {
|
|
1246
|
+
this._target = element;
|
|
1247
|
+
}
|
|
1248
|
+
/** Whether the engine is enabled */
|
|
1249
|
+
get enabled() {
|
|
1250
|
+
return this._enabled;
|
|
1251
|
+
}
|
|
1252
|
+
/** Current intensity multiplier */
|
|
1253
|
+
get intensity() {
|
|
1254
|
+
return this._intensity;
|
|
1255
|
+
}
|
|
1256
|
+
/** Remove all active animations and clean up */
|
|
1257
|
+
dispose() {
|
|
1258
|
+
for (const cleanup of this._cleanups) {
|
|
1259
|
+
cleanup();
|
|
1260
|
+
}
|
|
1261
|
+
this._cleanups = [];
|
|
1262
|
+
}
|
|
1263
|
+
// ─── Internal ──────────────────────────────────────────────
|
|
1264
|
+
_canRun() {
|
|
1265
|
+
return this._enabled && typeof document !== "undefined";
|
|
1266
|
+
}
|
|
1267
|
+
_getTarget() {
|
|
1268
|
+
return this._target ?? document.body;
|
|
1269
|
+
}
|
|
1270
|
+
/** Inject a <style> tag for keyframes on first use */
|
|
1271
|
+
_injectStyles() {
|
|
1272
|
+
if (this._styleInjected) return;
|
|
1273
|
+
if (typeof document === "undefined") return;
|
|
1274
|
+
if (!document.getElementById(STYLE_ID)) {
|
|
1275
|
+
const style = document.createElement("style");
|
|
1276
|
+
style.id = STYLE_ID;
|
|
1277
|
+
style.textContent = "";
|
|
1278
|
+
document.head.appendChild(style);
|
|
1279
|
+
}
|
|
1280
|
+
this._styleInjected = true;
|
|
1281
|
+
}
|
|
1282
|
+
/** Ensure a @keyframes rule exists in our style tag */
|
|
1283
|
+
_ensureKeyframes(name, frames) {
|
|
1284
|
+
const style = document.getElementById(STYLE_ID);
|
|
1285
|
+
if (!style) return;
|
|
1286
|
+
if (style.textContent?.includes(`@keyframes ${name}`)) return;
|
|
1287
|
+
style.textContent += `@keyframes ${name}{${frames}}`;
|
|
1288
|
+
}
|
|
1289
|
+
/** Apply a CSS animation to an element and clean up after */
|
|
1290
|
+
_applyAnimation(target, animationName, duration) {
|
|
1291
|
+
const prev = target.style.animation;
|
|
1292
|
+
target.style.animation = `${animationName} ${duration}ms ease-in-out`;
|
|
1293
|
+
const timer = setTimeout(() => {
|
|
1294
|
+
target.style.animation = prev;
|
|
1295
|
+
this._removeCleanup(cleanup);
|
|
1296
|
+
}, duration + 50);
|
|
1297
|
+
const cleanup = () => {
|
|
1298
|
+
clearTimeout(timer);
|
|
1299
|
+
target.style.animation = prev;
|
|
1300
|
+
};
|
|
1301
|
+
this._cleanups.push(cleanup);
|
|
1302
|
+
}
|
|
1303
|
+
_removeCleanup(fn) {
|
|
1304
|
+
const idx = this._cleanups.indexOf(fn);
|
|
1305
|
+
if (idx !== -1) this._cleanups.splice(idx, 1);
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
// src/themes/theme-manager.ts
|
|
1310
|
+
var themes = {
|
|
1311
|
+
default: {
|
|
1312
|
+
name: "default",
|
|
1313
|
+
hapticIntensity: 0.7,
|
|
1314
|
+
soundEnabled: true,
|
|
1315
|
+
soundVolume: 0.3,
|
|
1316
|
+
visualEnabled: true,
|
|
1317
|
+
visualStyle: "flash",
|
|
1318
|
+
colors: {
|
|
1319
|
+
primary: "#3b82f6",
|
|
1320
|
+
success: "#22c55e",
|
|
1321
|
+
error: "#ef4444",
|
|
1322
|
+
warning: "#eab308"
|
|
1323
|
+
}
|
|
1324
|
+
},
|
|
1325
|
+
gaming: {
|
|
1326
|
+
name: "gaming",
|
|
1327
|
+
hapticIntensity: 1,
|
|
1328
|
+
soundEnabled: true,
|
|
1329
|
+
soundVolume: 0.8,
|
|
1330
|
+
visualEnabled: true,
|
|
1331
|
+
visualStyle: "shake",
|
|
1332
|
+
colors: {
|
|
1333
|
+
primary: "#a855f7",
|
|
1334
|
+
success: "#00ff88",
|
|
1335
|
+
error: "#ff2222",
|
|
1336
|
+
warning: "#ff8800"
|
|
1337
|
+
}
|
|
1338
|
+
},
|
|
1339
|
+
minimal: {
|
|
1340
|
+
name: "minimal",
|
|
1341
|
+
hapticIntensity: 0.4,
|
|
1342
|
+
soundEnabled: false,
|
|
1343
|
+
soundVolume: 0,
|
|
1344
|
+
visualEnabled: true,
|
|
1345
|
+
visualStyle: "pulse",
|
|
1346
|
+
colors: {
|
|
1347
|
+
primary: "#6b7280",
|
|
1348
|
+
success: "#9ca3af",
|
|
1349
|
+
error: "#4b5563",
|
|
1350
|
+
warning: "#d1d5db"
|
|
1351
|
+
}
|
|
1352
|
+
},
|
|
1353
|
+
luxury: {
|
|
1354
|
+
name: "luxury",
|
|
1355
|
+
hapticIntensity: 0.6,
|
|
1356
|
+
soundEnabled: true,
|
|
1357
|
+
soundVolume: 0.25,
|
|
1358
|
+
visualEnabled: true,
|
|
1359
|
+
visualStyle: "glow",
|
|
1360
|
+
colors: {
|
|
1361
|
+
primary: "#d4af37",
|
|
1362
|
+
success: "#50c878",
|
|
1363
|
+
error: "#8b0000",
|
|
1364
|
+
warning: "#cd853f"
|
|
1365
|
+
}
|
|
1366
|
+
},
|
|
1367
|
+
retro: {
|
|
1368
|
+
name: "retro",
|
|
1369
|
+
hapticIntensity: 0.9,
|
|
1370
|
+
soundEnabled: true,
|
|
1371
|
+
soundVolume: 0.5,
|
|
1372
|
+
visualEnabled: true,
|
|
1373
|
+
visualStyle: "flash",
|
|
1374
|
+
colors: {
|
|
1375
|
+
primary: "#00ff00",
|
|
1376
|
+
success: "#00ffff",
|
|
1377
|
+
error: "#ff0000",
|
|
1378
|
+
warning: "#ffff00"
|
|
1379
|
+
}
|
|
1380
|
+
},
|
|
1381
|
+
nature: {
|
|
1382
|
+
name: "nature",
|
|
1383
|
+
hapticIntensity: 0.5,
|
|
1384
|
+
soundEnabled: true,
|
|
1385
|
+
soundVolume: 0.2,
|
|
1386
|
+
visualEnabled: true,
|
|
1387
|
+
visualStyle: "pulse",
|
|
1388
|
+
colors: {
|
|
1389
|
+
primary: "#2d6a4f",
|
|
1390
|
+
success: "#40916c",
|
|
1391
|
+
error: "#9b2226",
|
|
1392
|
+
warning: "#ee9b00"
|
|
1393
|
+
}
|
|
1394
|
+
},
|
|
1395
|
+
silent: {
|
|
1396
|
+
name: "silent",
|
|
1397
|
+
hapticIntensity: 0.7,
|
|
1398
|
+
soundEnabled: false,
|
|
1399
|
+
soundVolume: 0,
|
|
1400
|
+
visualEnabled: false,
|
|
1401
|
+
visualStyle: "flash",
|
|
1402
|
+
colors: {
|
|
1403
|
+
primary: "#3b82f6",
|
|
1404
|
+
success: "#22c55e",
|
|
1405
|
+
error: "#ef4444",
|
|
1406
|
+
warning: "#eab308"
|
|
1407
|
+
}
|
|
1408
|
+
},
|
|
1409
|
+
accessible: {
|
|
1410
|
+
name: "accessible",
|
|
1411
|
+
hapticIntensity: 1,
|
|
1412
|
+
soundEnabled: true,
|
|
1413
|
+
soundVolume: 0.6,
|
|
1414
|
+
visualEnabled: true,
|
|
1415
|
+
visualStyle: "flash",
|
|
1416
|
+
colors: {
|
|
1417
|
+
primary: "#0000ff",
|
|
1418
|
+
success: "#008000",
|
|
1419
|
+
error: "#ff0000",
|
|
1420
|
+
warning: "#ff8c00"
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
var ThemeManager = class {
|
|
1425
|
+
constructor() {
|
|
1426
|
+
this._registry = new Map(Object.entries(themes));
|
|
1427
|
+
this._current = themes.default;
|
|
1428
|
+
}
|
|
1429
|
+
/** Apply a theme by name or provide a custom preset */
|
|
1430
|
+
setTheme(name) {
|
|
1431
|
+
if (typeof name === "string") {
|
|
1432
|
+
const preset = this._registry.get(name);
|
|
1433
|
+
if (!preset) {
|
|
1434
|
+
throw new Error(`Unknown theme: "${name}". Available: ${this.listThemes().join(", ")}`);
|
|
1435
|
+
}
|
|
1436
|
+
this._current = preset;
|
|
1437
|
+
} else {
|
|
1438
|
+
this._registry.set(name.name, name);
|
|
1439
|
+
this._current = name;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
/** Get the current theme preset */
|
|
1443
|
+
getTheme() {
|
|
1444
|
+
return this._current;
|
|
1445
|
+
}
|
|
1446
|
+
/** Current theme name */
|
|
1447
|
+
get current() {
|
|
1448
|
+
return this._current.name;
|
|
1449
|
+
}
|
|
1450
|
+
/** List all available theme names */
|
|
1451
|
+
listThemes() {
|
|
1452
|
+
return Array.from(this._registry.keys());
|
|
1453
|
+
}
|
|
1454
|
+
/** Register a custom theme preset */
|
|
1455
|
+
registerTheme(preset) {
|
|
1456
|
+
this._registry.set(preset.name, preset);
|
|
1457
|
+
}
|
|
1458
|
+
};
|
|
1459
|
+
|
|
1460
|
+
// src/engine/sensory-engine.ts
|
|
1461
|
+
var SensoryEngine = class _SensoryEngine {
|
|
1462
|
+
constructor(options) {
|
|
1463
|
+
this._haptic = new HapticEngine(options?.haptic);
|
|
1464
|
+
this._sound = new SoundEngine(options?.sound);
|
|
1465
|
+
this._visual = new VisualEngine(options?.visual);
|
|
1466
|
+
this._themes = new ThemeManager();
|
|
1467
|
+
if (options?.theme) {
|
|
1468
|
+
this.setTheme(options.theme);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
/** Factory method */
|
|
1472
|
+
static create(options) {
|
|
1473
|
+
return new _SensoryEngine(options);
|
|
1474
|
+
}
|
|
1475
|
+
// ─── Multi-sensory API ─────────────────────────────────────
|
|
1476
|
+
/** Tap: vibrate + click sound + pulse visual */
|
|
1477
|
+
async tap() {
|
|
1478
|
+
const theme = this._themes.getTheme();
|
|
1479
|
+
await Promise.all([
|
|
1480
|
+
this._haptic.tap(),
|
|
1481
|
+
theme.soundEnabled ? this._sound.click({ pitch: "mid" }) : Promise.resolve(),
|
|
1482
|
+
theme.visualEnabled ? this._runVisual(theme, "tap") : Promise.resolve()
|
|
1483
|
+
]);
|
|
1484
|
+
}
|
|
1485
|
+
/** Success: haptic success + success sound + green glow */
|
|
1486
|
+
async success() {
|
|
1487
|
+
const theme = this._themes.getTheme();
|
|
1488
|
+
await Promise.all([
|
|
1489
|
+
this._haptic.success(),
|
|
1490
|
+
theme.soundEnabled ? this._sound.success() : Promise.resolve(),
|
|
1491
|
+
theme.visualEnabled ? this._visual.glow({ color: theme.colors.success, duration: 300 }) : Promise.resolve()
|
|
1492
|
+
]);
|
|
1493
|
+
}
|
|
1494
|
+
/** Error: haptic error + error sound + red flash */
|
|
1495
|
+
async error() {
|
|
1496
|
+
const theme = this._themes.getTheme();
|
|
1497
|
+
await Promise.all([
|
|
1498
|
+
this._haptic.error(),
|
|
1499
|
+
theme.soundEnabled ? this._sound.error() : Promise.resolve(),
|
|
1500
|
+
theme.visualEnabled ? this._visual.flash({ color: theme.colors.error, duration: 150, opacity: 0.2 }) : Promise.resolve()
|
|
1501
|
+
]);
|
|
1502
|
+
}
|
|
1503
|
+
/** Warning: haptic warning + warning sound + yellow flash */
|
|
1504
|
+
async warning() {
|
|
1505
|
+
const theme = this._themes.getTheme();
|
|
1506
|
+
await Promise.all([
|
|
1507
|
+
this._haptic.warning(),
|
|
1508
|
+
theme.soundEnabled ? this._sound.chime("E4") : Promise.resolve(),
|
|
1509
|
+
theme.visualEnabled ? this._visual.flash({ color: theme.colors.warning, duration: 150, opacity: 0.2 }) : Promise.resolve()
|
|
1510
|
+
]);
|
|
1511
|
+
}
|
|
1512
|
+
/** Selection: haptic selection + tick sound + subtle pulse */
|
|
1513
|
+
async selection() {
|
|
1514
|
+
const theme = this._themes.getTheme();
|
|
1515
|
+
await Promise.all([
|
|
1516
|
+
this._haptic.selection(),
|
|
1517
|
+
theme.soundEnabled ? this._sound.tick() : Promise.resolve(),
|
|
1518
|
+
theme.visualEnabled ? this._visual.pulse({ scale: 1.01, duration: 100 }) : Promise.resolve()
|
|
1519
|
+
]);
|
|
1520
|
+
}
|
|
1521
|
+
/** Toggle: haptic toggle + toggle sound + pulse */
|
|
1522
|
+
async toggle(on) {
|
|
1523
|
+
const theme = this._themes.getTheme();
|
|
1524
|
+
await Promise.all([
|
|
1525
|
+
this._haptic.toggle(on),
|
|
1526
|
+
theme.soundEnabled ? this._sound.toggle(on) : Promise.resolve(),
|
|
1527
|
+
theme.visualEnabled ? this._visual.pulse({ scale: on ? 1.03 : 0.98, duration: 150 }) : Promise.resolve()
|
|
1528
|
+
]);
|
|
1529
|
+
}
|
|
1530
|
+
/** Play a haptic pattern (sound/visual auto-mapped from theme) */
|
|
1531
|
+
async play(pattern) {
|
|
1532
|
+
const theme = this._themes.getTheme();
|
|
1533
|
+
await Promise.all([
|
|
1534
|
+
this._haptic.play(pattern),
|
|
1535
|
+
theme.soundEnabled ? this._sound.tap() : Promise.resolve(),
|
|
1536
|
+
theme.visualEnabled ? this._runVisual(theme, "play") : Promise.resolve()
|
|
1537
|
+
]);
|
|
1538
|
+
}
|
|
1539
|
+
// ─── Theme ─────────────────────────────────────────────────
|
|
1540
|
+
/** Apply a theme by name or preset */
|
|
1541
|
+
setTheme(name) {
|
|
1542
|
+
this._themes.setTheme(name);
|
|
1543
|
+
const theme = this._themes.getTheme();
|
|
1544
|
+
this._haptic.configure({ intensity: theme.hapticIntensity });
|
|
1545
|
+
this._sound.setVolume(theme.soundVolume);
|
|
1546
|
+
if (!theme.soundEnabled) {
|
|
1547
|
+
this._sound.mute();
|
|
1548
|
+
} else {
|
|
1549
|
+
this._sound.unmute();
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
// ─── Accessors ─────────────────────────────────────────────
|
|
1553
|
+
/** Access the underlying HapticEngine */
|
|
1554
|
+
get haptic() {
|
|
1555
|
+
return this._haptic;
|
|
1556
|
+
}
|
|
1557
|
+
/** Access the SoundEngine */
|
|
1558
|
+
get sound() {
|
|
1559
|
+
return this._sound;
|
|
1560
|
+
}
|
|
1561
|
+
/** Access the VisualEngine */
|
|
1562
|
+
get visual() {
|
|
1563
|
+
return this._visual;
|
|
1564
|
+
}
|
|
1565
|
+
/** Access the ThemeManager */
|
|
1566
|
+
get themes() {
|
|
1567
|
+
return this._themes;
|
|
1568
|
+
}
|
|
1569
|
+
// ─── Configuration ─────────────────────────────────────────
|
|
1570
|
+
/** Configure all engines */
|
|
1571
|
+
configure(options) {
|
|
1572
|
+
if (options.haptic) {
|
|
1573
|
+
this._haptic.configure(options.haptic);
|
|
1574
|
+
}
|
|
1575
|
+
if (options.sound) {
|
|
1576
|
+
this._sound = new SoundEngine(options.sound);
|
|
1577
|
+
}
|
|
1578
|
+
if (options.visual) {
|
|
1579
|
+
this._visual = new VisualEngine(options.visual);
|
|
1580
|
+
}
|
|
1581
|
+
if (options.theme) {
|
|
1582
|
+
this.setTheme(options.theme);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
// ─── Lifecycle ─────────────────────────────────────────────
|
|
1586
|
+
/** Clean up all engines */
|
|
1587
|
+
dispose() {
|
|
1588
|
+
this._haptic.dispose();
|
|
1589
|
+
this._sound.dispose();
|
|
1590
|
+
this._visual.dispose();
|
|
1591
|
+
}
|
|
1592
|
+
// ─── Internal ──────────────────────────────────────────────
|
|
1593
|
+
/** Run the appropriate visual effect based on theme style */
|
|
1594
|
+
_runVisual(theme, context) {
|
|
1595
|
+
switch (theme.visualStyle) {
|
|
1596
|
+
case "flash":
|
|
1597
|
+
this._visual.flash({ color: theme.colors.primary });
|
|
1598
|
+
break;
|
|
1599
|
+
case "ripple":
|
|
1600
|
+
if (typeof window !== "undefined") {
|
|
1601
|
+
this._visual.ripple(window.innerWidth / 2, window.innerHeight / 2, {
|
|
1602
|
+
color: theme.colors.primary
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
break;
|
|
1606
|
+
case "shake":
|
|
1607
|
+
this._visual.shake({ intensity: context === "play" ? 5 : 3 });
|
|
1608
|
+
break;
|
|
1609
|
+
case "glow":
|
|
1610
|
+
this._visual.glow({ color: theme.colors.primary });
|
|
1611
|
+
break;
|
|
1612
|
+
case "pulse":
|
|
1613
|
+
this._visual.pulse();
|
|
1614
|
+
break;
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
};
|
|
1618
|
+
|
|
848
1619
|
// src/patterns/validator.ts
|
|
849
1620
|
var VALID_CHARS = new Set("~#@.|\\-[] \nx0123456789");
|
|
850
1621
|
function validateHPL(input) {
|
|
@@ -944,18 +1715,18 @@ function validateExport(data) {
|
|
|
944
1715
|
throw new Error('Invalid pattern data: "steps" must be an array');
|
|
945
1716
|
}
|
|
946
1717
|
for (let i = 0; i < obj.steps.length; i++) {
|
|
947
|
-
const
|
|
948
|
-
if (
|
|
1718
|
+
const step2 = obj.steps[i];
|
|
1719
|
+
if (step2.type !== "vibrate" && step2.type !== "pause") {
|
|
949
1720
|
throw new Error(
|
|
950
1721
|
`Invalid step at index ${i}: "type" must be "vibrate" or "pause"`
|
|
951
1722
|
);
|
|
952
1723
|
}
|
|
953
|
-
if (typeof
|
|
1724
|
+
if (typeof step2.duration !== "number" || step2.duration < 0) {
|
|
954
1725
|
throw new Error(
|
|
955
1726
|
`Invalid step at index ${i}: "duration" must be a non-negative number`
|
|
956
1727
|
);
|
|
957
1728
|
}
|
|
958
|
-
if (typeof
|
|
1729
|
+
if (typeof step2.intensity !== "number" || step2.intensity < 0 || step2.intensity > 1) {
|
|
959
1730
|
throw new Error(
|
|
960
1731
|
`Invalid step at index ${i}: "intensity" must be a number between 0 and 1`
|
|
961
1732
|
);
|
|
@@ -998,6 +1769,138 @@ function patternFromDataURL(url) {
|
|
|
998
1769
|
return patternFromJSON(json);
|
|
999
1770
|
}
|
|
1000
1771
|
|
|
1772
|
+
// src/recorder/pattern-recorder.ts
|
|
1773
|
+
var DEFAULT_TAP_DURATION = 10;
|
|
1774
|
+
var DEFAULT_GRID_MS = 50;
|
|
1775
|
+
var DEFAULT_INTENSITY = 0.6;
|
|
1776
|
+
var PatternRecorder = class {
|
|
1777
|
+
constructor(options) {
|
|
1778
|
+
this.taps = [];
|
|
1779
|
+
this.recording = false;
|
|
1780
|
+
this.startTime = 0;
|
|
1781
|
+
this.stopTime = 0;
|
|
1782
|
+
this.tapCallbacks = [];
|
|
1783
|
+
this.nowFn = options?.now ?? (() => Date.now());
|
|
1784
|
+
}
|
|
1785
|
+
/** Whether the recorder is currently recording */
|
|
1786
|
+
get isRecording() {
|
|
1787
|
+
return this.recording;
|
|
1788
|
+
}
|
|
1789
|
+
/** Total duration of the recording in ms */
|
|
1790
|
+
get duration() {
|
|
1791
|
+
if (this.taps.length === 0) return 0;
|
|
1792
|
+
if (this.recording) {
|
|
1793
|
+
return this.nowFn() - this.startTime;
|
|
1794
|
+
}
|
|
1795
|
+
return this.stopTime - this.startTime;
|
|
1796
|
+
}
|
|
1797
|
+
/** Number of taps recorded */
|
|
1798
|
+
get tapCount() {
|
|
1799
|
+
return this.taps.length;
|
|
1800
|
+
}
|
|
1801
|
+
/** Begin recording. Records timestamps of tap events. */
|
|
1802
|
+
start() {
|
|
1803
|
+
this.taps = [];
|
|
1804
|
+
this.recording = true;
|
|
1805
|
+
this.startTime = this.nowFn();
|
|
1806
|
+
this.stopTime = 0;
|
|
1807
|
+
}
|
|
1808
|
+
/** Register a tap at current time. */
|
|
1809
|
+
tap(intensity = DEFAULT_INTENSITY) {
|
|
1810
|
+
if (!this.recording) return;
|
|
1811
|
+
const now = this.nowFn();
|
|
1812
|
+
const time = now - this.startTime;
|
|
1813
|
+
const clamped = Math.max(0, Math.min(1, intensity));
|
|
1814
|
+
const recorded = { time, intensity: clamped };
|
|
1815
|
+
this.taps.push(recorded);
|
|
1816
|
+
for (const cb of this.tapCallbacks) {
|
|
1817
|
+
cb(recorded, this.taps.length - 1);
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
/** Stop recording, return the recorded taps. */
|
|
1821
|
+
stop() {
|
|
1822
|
+
this.recording = false;
|
|
1823
|
+
this.stopTime = this.nowFn();
|
|
1824
|
+
return [...this.taps];
|
|
1825
|
+
}
|
|
1826
|
+
/** Register a callback for each tap (for visual feedback during recording). */
|
|
1827
|
+
onTap(callback) {
|
|
1828
|
+
this.tapCallbacks.push(callback);
|
|
1829
|
+
}
|
|
1830
|
+
/** Reset the recorder. */
|
|
1831
|
+
clear() {
|
|
1832
|
+
this.taps = [];
|
|
1833
|
+
this.recording = false;
|
|
1834
|
+
this.startTime = 0;
|
|
1835
|
+
this.stopTime = 0;
|
|
1836
|
+
}
|
|
1837
|
+
/**
|
|
1838
|
+
* Snap taps to a grid for cleaner patterns.
|
|
1839
|
+
* @param gridMs Grid size in ms (default 50ms)
|
|
1840
|
+
*/
|
|
1841
|
+
quantize(gridMs = DEFAULT_GRID_MS) {
|
|
1842
|
+
this.taps = this.taps.map((tap) => ({
|
|
1843
|
+
...tap,
|
|
1844
|
+
time: Math.round(tap.time / gridMs) * gridMs
|
|
1845
|
+
}));
|
|
1846
|
+
}
|
|
1847
|
+
/**
|
|
1848
|
+
* Convert recorded taps to an HPL string.
|
|
1849
|
+
*
|
|
1850
|
+
* Maps gaps between taps to `.` characters (each 50ms),
|
|
1851
|
+
* and tap intensities to `~` (light), `#` (medium), `@` (heavy).
|
|
1852
|
+
*/
|
|
1853
|
+
toHPL() {
|
|
1854
|
+
if (this.taps.length === 0) return "";
|
|
1855
|
+
const sorted = [...this.taps].sort((a, b) => a.time - b.time);
|
|
1856
|
+
let hpl = "";
|
|
1857
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
1858
|
+
if (i > 0) {
|
|
1859
|
+
const gap = sorted[i].time - sorted[i - 1].time;
|
|
1860
|
+
if (gap >= 25) {
|
|
1861
|
+
const pauses = Math.round(gap / DEFAULT_GRID_MS);
|
|
1862
|
+
hpl += ".".repeat(pauses);
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
hpl += intensityToChar(sorted[i].intensity);
|
|
1866
|
+
}
|
|
1867
|
+
return hpl;
|
|
1868
|
+
}
|
|
1869
|
+
/** Convert recorded taps to HapticStep[]. */
|
|
1870
|
+
toSteps() {
|
|
1871
|
+
if (this.taps.length === 0) return [];
|
|
1872
|
+
const sorted = [...this.taps].sort((a, b) => a.time - b.time);
|
|
1873
|
+
const steps = [];
|
|
1874
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
1875
|
+
if (i > 0) {
|
|
1876
|
+
const gap = sorted[i].time - sorted[i - 1].time;
|
|
1877
|
+
if (gap >= 25) {
|
|
1878
|
+
const quantized = Math.round(gap / DEFAULT_GRID_MS) * DEFAULT_GRID_MS;
|
|
1879
|
+
steps.push({ type: "pause", duration: quantized, intensity: 0 });
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
steps.push({
|
|
1883
|
+
type: "vibrate",
|
|
1884
|
+
duration: DEFAULT_TAP_DURATION,
|
|
1885
|
+
intensity: sorted[i].intensity
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
return steps;
|
|
1889
|
+
}
|
|
1890
|
+
/** Convert recorded taps to a HapticPattern. */
|
|
1891
|
+
toPattern(name) {
|
|
1892
|
+
return {
|
|
1893
|
+
name: name ?? "recorded-pattern",
|
|
1894
|
+
steps: this.toSteps()
|
|
1895
|
+
};
|
|
1896
|
+
}
|
|
1897
|
+
};
|
|
1898
|
+
function intensityToChar(intensity) {
|
|
1899
|
+
if (intensity < 0.35) return "~";
|
|
1900
|
+
if (intensity <= 0.7) return "#";
|
|
1901
|
+
return "@";
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1001
1904
|
// src/presets/ui.ts
|
|
1002
1905
|
var ui = {
|
|
1003
1906
|
/** Light button tap */
|
|
@@ -1376,13 +2279,1075 @@ var system = {
|
|
|
1376
2279
|
}
|
|
1377
2280
|
};
|
|
1378
2281
|
|
|
2282
|
+
// src/presets/emotions.ts
|
|
2283
|
+
var emotions = {
|
|
2284
|
+
/** Excited — fast, energetic pulses building up */
|
|
2285
|
+
excited: {
|
|
2286
|
+
name: "emotions.excited",
|
|
2287
|
+
steps: [
|
|
2288
|
+
{ type: "vibrate", duration: 30, intensity: 0.5 },
|
|
2289
|
+
{ type: "pause", duration: 30, intensity: 0 },
|
|
2290
|
+
{ type: "vibrate", duration: 30, intensity: 0.6 },
|
|
2291
|
+
{ type: "pause", duration: 25, intensity: 0 },
|
|
2292
|
+
{ type: "vibrate", duration: 35, intensity: 0.7 },
|
|
2293
|
+
{ type: "pause", duration: 25, intensity: 0 },
|
|
2294
|
+
{ type: "vibrate", duration: 35, intensity: 0.8 },
|
|
2295
|
+
{ type: "pause", duration: 25, intensity: 0 },
|
|
2296
|
+
{ type: "vibrate", duration: 40, intensity: 0.9 },
|
|
2297
|
+
{ type: "vibrate", duration: 50, intensity: 1 }
|
|
2298
|
+
]
|
|
2299
|
+
},
|
|
2300
|
+
/** Calm — slow, gentle wave with soft sustained vibrations */
|
|
2301
|
+
calm: {
|
|
2302
|
+
name: "emotions.calm",
|
|
2303
|
+
steps: [
|
|
2304
|
+
{ type: "vibrate", duration: 80, intensity: 0.2 },
|
|
2305
|
+
{ type: "pause", duration: 200, intensity: 0 },
|
|
2306
|
+
{ type: "vibrate", duration: 100, intensity: 0.25 },
|
|
2307
|
+
{ type: "pause", duration: 250, intensity: 0 },
|
|
2308
|
+
{ type: "vibrate", duration: 80, intensity: 0.2 },
|
|
2309
|
+
{ type: "pause", duration: 200, intensity: 0 },
|
|
2310
|
+
{ type: "vibrate", duration: 100, intensity: 0.15 }
|
|
2311
|
+
]
|
|
2312
|
+
},
|
|
2313
|
+
/** Tense — tight, irregular short heavy bursts */
|
|
2314
|
+
tense: {
|
|
2315
|
+
name: "emotions.tense",
|
|
2316
|
+
steps: [
|
|
2317
|
+
{ type: "vibrate", duration: 35, intensity: 0.8 },
|
|
2318
|
+
{ type: "pause", duration: 40, intensity: 0 },
|
|
2319
|
+
{ type: "vibrate", duration: 30, intensity: 0.9 },
|
|
2320
|
+
{ type: "pause", duration: 30, intensity: 0 },
|
|
2321
|
+
{ type: "vibrate", duration: 40, intensity: 0.85 },
|
|
2322
|
+
{ type: "pause", duration: 35, intensity: 0 },
|
|
2323
|
+
{ type: "vibrate", duration: 30, intensity: 0.9 },
|
|
2324
|
+
{ type: "pause", duration: 45, intensity: 0 },
|
|
2325
|
+
{ type: "vibrate", duration: 35, intensity: 0.8 }
|
|
2326
|
+
]
|
|
2327
|
+
},
|
|
2328
|
+
/** Happy — bouncy, playful ascending rhythm */
|
|
2329
|
+
happy: {
|
|
2330
|
+
name: "emotions.happy",
|
|
2331
|
+
steps: [
|
|
2332
|
+
{ type: "vibrate", duration: 30, intensity: 0.4 },
|
|
2333
|
+
{ type: "pause", duration: 50, intensity: 0 },
|
|
2334
|
+
{ type: "vibrate", duration: 35, intensity: 0.5 },
|
|
2335
|
+
{ type: "pause", duration: 50, intensity: 0 },
|
|
2336
|
+
{ type: "vibrate", duration: 35, intensity: 0.6 },
|
|
2337
|
+
{ type: "pause", duration: 40, intensity: 0 },
|
|
2338
|
+
{ type: "vibrate", duration: 40, intensity: 0.7 },
|
|
2339
|
+
{ type: "pause", duration: 40, intensity: 0 },
|
|
2340
|
+
{ type: "vibrate", duration: 45, intensity: 0.8 }
|
|
2341
|
+
]
|
|
2342
|
+
},
|
|
2343
|
+
/** Sad — slow, heavy, descending vibrations that fade */
|
|
2344
|
+
sad: {
|
|
2345
|
+
name: "emotions.sad",
|
|
2346
|
+
steps: [
|
|
2347
|
+
{ type: "vibrate", duration: 100, intensity: 0.8 },
|
|
2348
|
+
{ type: "pause", duration: 120, intensity: 0 },
|
|
2349
|
+
{ type: "vibrate", duration: 90, intensity: 0.6 },
|
|
2350
|
+
{ type: "pause", duration: 140, intensity: 0 },
|
|
2351
|
+
{ type: "vibrate", duration: 80, intensity: 0.4 },
|
|
2352
|
+
{ type: "pause", duration: 160, intensity: 0 },
|
|
2353
|
+
{ type: "vibrate", duration: 70, intensity: 0.25 }
|
|
2354
|
+
]
|
|
2355
|
+
},
|
|
2356
|
+
/** Angry — aggressive, chaotic heavy rapid hits */
|
|
2357
|
+
angry: {
|
|
2358
|
+
name: "emotions.angry",
|
|
2359
|
+
steps: [
|
|
2360
|
+
{ type: "vibrate", duration: 40, intensity: 1 },
|
|
2361
|
+
{ type: "pause", duration: 25, intensity: 0 },
|
|
2362
|
+
{ type: "vibrate", duration: 35, intensity: 0.9 },
|
|
2363
|
+
{ type: "pause", duration: 25, intensity: 0 },
|
|
2364
|
+
{ type: "vibrate", duration: 45, intensity: 1 },
|
|
2365
|
+
{ type: "pause", duration: 30, intensity: 0 },
|
|
2366
|
+
{ type: "vibrate", duration: 40, intensity: 0.95 },
|
|
2367
|
+
{ type: "vibrate", duration: 50, intensity: 1 },
|
|
2368
|
+
{ type: "pause", duration: 25, intensity: 0 },
|
|
2369
|
+
{ type: "vibrate", duration: 45, intensity: 0.9 }
|
|
2370
|
+
]
|
|
2371
|
+
},
|
|
2372
|
+
/** Surprised — sharp sudden hit, silence, then lighter hit */
|
|
2373
|
+
surprised: {
|
|
2374
|
+
name: "emotions.surprised",
|
|
2375
|
+
steps: [
|
|
2376
|
+
{ type: "vibrate", duration: 40, intensity: 1 },
|
|
2377
|
+
{ type: "pause", duration: 200, intensity: 0 },
|
|
2378
|
+
{ type: "vibrate", duration: 30, intensity: 0.4 }
|
|
2379
|
+
]
|
|
2380
|
+
},
|
|
2381
|
+
/** Anxious — fast irregular heartbeat with inconsistent spacing */
|
|
2382
|
+
anxious: {
|
|
2383
|
+
name: "emotions.anxious",
|
|
2384
|
+
steps: [
|
|
2385
|
+
{ type: "vibrate", duration: 30, intensity: 0.7 },
|
|
2386
|
+
{ type: "pause", duration: 60, intensity: 0 },
|
|
2387
|
+
{ type: "vibrate", duration: 35, intensity: 0.8 },
|
|
2388
|
+
{ type: "pause", duration: 40, intensity: 0 },
|
|
2389
|
+
{ type: "vibrate", duration: 25, intensity: 0.6 },
|
|
2390
|
+
{ type: "pause", duration: 80, intensity: 0 },
|
|
2391
|
+
{ type: "vibrate", duration: 30, intensity: 0.75 },
|
|
2392
|
+
{ type: "pause", duration: 50, intensity: 0 },
|
|
2393
|
+
{ type: "vibrate", duration: 35, intensity: 0.85 },
|
|
2394
|
+
{ type: "pause", duration: 35, intensity: 0 },
|
|
2395
|
+
{ type: "vibrate", duration: 30, intensity: 0.7 }
|
|
2396
|
+
]
|
|
2397
|
+
},
|
|
2398
|
+
/** Confident — strong, steady, measured even pulses */
|
|
2399
|
+
confident: {
|
|
2400
|
+
name: "emotions.confident",
|
|
2401
|
+
steps: [
|
|
2402
|
+
{ type: "vibrate", duration: 50, intensity: 0.8 },
|
|
2403
|
+
{ type: "pause", duration: 80, intensity: 0 },
|
|
2404
|
+
{ type: "vibrate", duration: 50, intensity: 0.8 },
|
|
2405
|
+
{ type: "pause", duration: 80, intensity: 0 },
|
|
2406
|
+
{ type: "vibrate", duration: 50, intensity: 0.8 },
|
|
2407
|
+
{ type: "pause", duration: 80, intensity: 0 },
|
|
2408
|
+
{ type: "vibrate", duration: 50, intensity: 0.8 }
|
|
2409
|
+
]
|
|
2410
|
+
},
|
|
2411
|
+
/** Playful — alternating light-heavy in bouncy rhythm */
|
|
2412
|
+
playful: {
|
|
2413
|
+
name: "emotions.playful",
|
|
2414
|
+
steps: [
|
|
2415
|
+
{ type: "vibrate", duration: 25, intensity: 0.3 },
|
|
2416
|
+
{ type: "pause", duration: 40, intensity: 0 },
|
|
2417
|
+
{ type: "vibrate", duration: 40, intensity: 0.7 },
|
|
2418
|
+
{ type: "pause", duration: 50, intensity: 0 },
|
|
2419
|
+
{ type: "vibrate", duration: 25, intensity: 0.35 },
|
|
2420
|
+
{ type: "pause", duration: 40, intensity: 0 },
|
|
2421
|
+
{ type: "vibrate", duration: 40, intensity: 0.75 },
|
|
2422
|
+
{ type: "pause", duration: 50, intensity: 0 },
|
|
2423
|
+
{ type: "vibrate", duration: 25, intensity: 0.3 },
|
|
2424
|
+
{ type: "pause", duration: 40, intensity: 0 },
|
|
2425
|
+
{ type: "vibrate", duration: 45, intensity: 0.8 }
|
|
2426
|
+
]
|
|
2427
|
+
},
|
|
2428
|
+
/** Romantic — gentle heartbeat rhythm, two soft pulses, long pause, repeat */
|
|
2429
|
+
romantic: {
|
|
2430
|
+
name: "emotions.romantic",
|
|
2431
|
+
steps: [
|
|
2432
|
+
{ type: "vibrate", duration: 35, intensity: 0.4 },
|
|
2433
|
+
{ type: "pause", duration: 60, intensity: 0 },
|
|
2434
|
+
{ type: "vibrate", duration: 45, intensity: 0.5 },
|
|
2435
|
+
{ type: "pause", duration: 300, intensity: 0 },
|
|
2436
|
+
{ type: "vibrate", duration: 35, intensity: 0.4 },
|
|
2437
|
+
{ type: "pause", duration: 60, intensity: 0 },
|
|
2438
|
+
{ type: "vibrate", duration: 45, intensity: 0.5 },
|
|
2439
|
+
{ type: "pause", duration: 300, intensity: 0 },
|
|
2440
|
+
{ type: "vibrate", duration: 35, intensity: 0.4 },
|
|
2441
|
+
{ type: "pause", duration: 60, intensity: 0 },
|
|
2442
|
+
{ type: "vibrate", duration: 45, intensity: 0.5 }
|
|
2443
|
+
]
|
|
2444
|
+
},
|
|
2445
|
+
/** Peaceful — very subtle, barely-there ultra-light slow pulses */
|
|
2446
|
+
peaceful: {
|
|
2447
|
+
name: "emotions.peaceful",
|
|
2448
|
+
steps: [
|
|
2449
|
+
{ type: "vibrate", duration: 60, intensity: 0.1 },
|
|
2450
|
+
{ type: "pause", duration: 300, intensity: 0 },
|
|
2451
|
+
{ type: "vibrate", duration: 70, intensity: 0.12 },
|
|
2452
|
+
{ type: "pause", duration: 350, intensity: 0 },
|
|
2453
|
+
{ type: "vibrate", duration: 60, intensity: 0.1 },
|
|
2454
|
+
{ type: "pause", duration: 300, intensity: 0 },
|
|
2455
|
+
{ type: "vibrate", duration: 70, intensity: 0.08 }
|
|
2456
|
+
]
|
|
2457
|
+
}
|
|
2458
|
+
};
|
|
2459
|
+
|
|
1379
2460
|
// src/presets/index.ts
|
|
1380
2461
|
var presets = {
|
|
1381
2462
|
ui,
|
|
1382
2463
|
notifications,
|
|
1383
2464
|
gaming,
|
|
1384
2465
|
accessibility,
|
|
1385
|
-
system
|
|
2466
|
+
system,
|
|
2467
|
+
emotions
|
|
2468
|
+
};
|
|
2469
|
+
|
|
2470
|
+
// src/physics/physics-patterns.ts
|
|
2471
|
+
var clamp2 = (v, min, max) => Math.min(max, Math.max(min, v));
|
|
2472
|
+
var step = (type, duration, intensity) => ({
|
|
2473
|
+
type,
|
|
2474
|
+
duration: Math.max(25, Math.round(duration)),
|
|
2475
|
+
intensity: clamp2(Math.round(intensity * 100) / 100, 0, 1)
|
|
2476
|
+
});
|
|
2477
|
+
function spring(options) {
|
|
2478
|
+
const stiffness = clamp2(options?.stiffness ?? 0.7, 0.5, 1);
|
|
2479
|
+
const damping = clamp2(options?.damping ?? 0.3, 0.1, 0.9);
|
|
2480
|
+
const duration = options?.duration ?? 500;
|
|
2481
|
+
const numSteps = Math.round(10 + stiffness * 5);
|
|
2482
|
+
const stepDuration = duration / numSteps;
|
|
2483
|
+
const steps = [];
|
|
2484
|
+
for (let i = 0; i < numSteps; i++) {
|
|
2485
|
+
const t = i / (numSteps - 1);
|
|
2486
|
+
const decay = Math.exp(-damping * t * 5);
|
|
2487
|
+
const oscillation = Math.abs(Math.cos(t * Math.PI * stiffness * 4));
|
|
2488
|
+
const intensity = decay * oscillation * stiffness;
|
|
2489
|
+
if (intensity > 0.05) {
|
|
2490
|
+
steps.push(step("vibrate", stepDuration, intensity));
|
|
2491
|
+
} else {
|
|
2492
|
+
steps.push(step("pause", stepDuration, 0));
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
return { name: "physics.spring", steps };
|
|
2496
|
+
}
|
|
2497
|
+
function bounce(options) {
|
|
2498
|
+
const height = clamp2(options?.height ?? 1, 0.5, 1);
|
|
2499
|
+
const bounciness = clamp2(options?.bounciness ?? 0.6, 0.3, 0.9);
|
|
2500
|
+
const bounces = options?.bounces ?? 5;
|
|
2501
|
+
const steps = [];
|
|
2502
|
+
let currentHeight = height;
|
|
2503
|
+
for (let i = 0; i < bounces; i++) {
|
|
2504
|
+
const vibDuration = 40 + currentHeight * 60;
|
|
2505
|
+
const pauseDuration = 30 + currentHeight * 70;
|
|
2506
|
+
steps.push(step("vibrate", vibDuration, currentHeight));
|
|
2507
|
+
if (i < bounces - 1) {
|
|
2508
|
+
steps.push(step("pause", pauseDuration, 0));
|
|
2509
|
+
}
|
|
2510
|
+
currentHeight *= bounciness;
|
|
2511
|
+
}
|
|
2512
|
+
return { name: "physics.bounce", steps };
|
|
2513
|
+
}
|
|
2514
|
+
function friction(options) {
|
|
2515
|
+
const roughness = clamp2(options?.roughness ?? 0.5, 0.1, 1);
|
|
2516
|
+
const speed = clamp2(options?.speed ?? 0.5, 0.1, 1);
|
|
2517
|
+
const duration = options?.duration ?? 300;
|
|
2518
|
+
const pulseCount = Math.round(4 + speed * 8);
|
|
2519
|
+
const pulseDuration = duration / pulseCount;
|
|
2520
|
+
const steps = [];
|
|
2521
|
+
for (let i = 0; i < pulseCount; i++) {
|
|
2522
|
+
const variation = (Math.sin(i * 7.3) + 1) / 2 * roughness * 0.4;
|
|
2523
|
+
const baseIntensity = 0.3 + roughness * 0.4;
|
|
2524
|
+
const intensity = baseIntensity + variation;
|
|
2525
|
+
steps.push(step("vibrate", pulseDuration * 0.7, intensity));
|
|
2526
|
+
steps.push(step("pause", pulseDuration * 0.3, 0));
|
|
2527
|
+
}
|
|
2528
|
+
return { name: "physics.friction", steps };
|
|
2529
|
+
}
|
|
2530
|
+
function impact(options) {
|
|
2531
|
+
const mass = clamp2(options?.mass ?? 0.5, 0.1, 1);
|
|
2532
|
+
const hardness = clamp2(options?.hardness ?? 0.7, 0.1, 1);
|
|
2533
|
+
const steps = [];
|
|
2534
|
+
const hitDuration = 30 + mass * 50;
|
|
2535
|
+
const hitIntensity = 0.5 + mass * 0.5;
|
|
2536
|
+
steps.push(step("vibrate", hitDuration, hitIntensity));
|
|
2537
|
+
const resonanceCount = Math.round(2 + hardness * 4);
|
|
2538
|
+
let decayIntensity = hitIntensity * 0.6;
|
|
2539
|
+
for (let i = 0; i < resonanceCount; i++) {
|
|
2540
|
+
const pauseDur = 25 + (1 - hardness) * 30;
|
|
2541
|
+
steps.push(step("pause", pauseDur, 0));
|
|
2542
|
+
steps.push(step("vibrate", 25 + mass * 20, decayIntensity));
|
|
2543
|
+
decayIntensity *= 0.5;
|
|
2544
|
+
}
|
|
2545
|
+
return { name: "physics.impact", steps };
|
|
2546
|
+
}
|
|
2547
|
+
function gravity(options) {
|
|
2548
|
+
const distance = clamp2(options?.distance ?? 1, 0.3, 1);
|
|
2549
|
+
const duration = options?.duration ?? 400;
|
|
2550
|
+
const numSteps = Math.round(6 + distance * 4);
|
|
2551
|
+
const stepDuration = duration / numSteps;
|
|
2552
|
+
const steps = [];
|
|
2553
|
+
for (let i = 0; i < numSteps; i++) {
|
|
2554
|
+
const t = i / (numSteps - 1);
|
|
2555
|
+
const intensity = t * t * distance;
|
|
2556
|
+
steps.push(step("vibrate", stepDuration, Math.max(0.1, intensity)));
|
|
2557
|
+
}
|
|
2558
|
+
return { name: "physics.gravity", steps };
|
|
2559
|
+
}
|
|
2560
|
+
function elastic(options) {
|
|
2561
|
+
const stretch = clamp2(options?.stretch ?? 0.7, 0.3, 1);
|
|
2562
|
+
const snapSpeed = clamp2(options?.snapSpeed ?? 0.8, 0.3, 1);
|
|
2563
|
+
const steps = [];
|
|
2564
|
+
const tensionSteps = Math.round(3 + stretch * 3);
|
|
2565
|
+
for (let i = 0; i < tensionSteps; i++) {
|
|
2566
|
+
const t = (i + 1) / tensionSteps;
|
|
2567
|
+
const intensity = t * stretch * 0.6;
|
|
2568
|
+
steps.push(step("vibrate", 40 + (1 - snapSpeed) * 30, intensity));
|
|
2569
|
+
}
|
|
2570
|
+
steps.push(step("vibrate", 25 + (1 - snapSpeed) * 20, 0.8 + stretch * 0.2));
|
|
2571
|
+
const recoilIntensity = 0.4 * snapSpeed;
|
|
2572
|
+
steps.push(step("vibrate", 30, recoilIntensity));
|
|
2573
|
+
steps.push(step("vibrate", 25, recoilIntensity * 0.4));
|
|
2574
|
+
return { name: "physics.elastic", steps };
|
|
2575
|
+
}
|
|
2576
|
+
function wave(options) {
|
|
2577
|
+
const amplitude = clamp2(options?.amplitude ?? 0.7, 0.3, 1);
|
|
2578
|
+
const frequency = clamp2(options?.frequency ?? 1, 0.5, 2);
|
|
2579
|
+
const cycles = options?.cycles ?? 2;
|
|
2580
|
+
const stepsPerCycle = Math.round(8 / frequency);
|
|
2581
|
+
const totalSteps = stepsPerCycle * cycles;
|
|
2582
|
+
const stepDuration = 400 / frequency / stepsPerCycle;
|
|
2583
|
+
const steps = [];
|
|
2584
|
+
for (let i = 0; i < totalSteps; i++) {
|
|
2585
|
+
const t = i / stepsPerCycle;
|
|
2586
|
+
const sineValue = (Math.sin(t * 2 * Math.PI) + 1) / 2;
|
|
2587
|
+
const intensity = 0.1 + sineValue * amplitude * 0.9;
|
|
2588
|
+
steps.push(step("vibrate", stepDuration, intensity));
|
|
2589
|
+
}
|
|
2590
|
+
return { name: "physics.wave", steps };
|
|
2591
|
+
}
|
|
2592
|
+
function pendulum(options) {
|
|
2593
|
+
const energy = clamp2(options?.energy ?? 0.8, 0.3, 1);
|
|
2594
|
+
const swings = options?.swings ?? 3;
|
|
2595
|
+
const stepsPerSwing = 6;
|
|
2596
|
+
const steps = [];
|
|
2597
|
+
for (let s = 0; s < swings; s++) {
|
|
2598
|
+
const swingEnergy = energy * Math.pow(0.8, s);
|
|
2599
|
+
for (let i = 0; i < stepsPerSwing; i++) {
|
|
2600
|
+
const t = i / (stepsPerSwing - 1);
|
|
2601
|
+
const swing = Math.abs(Math.cos(t * Math.PI));
|
|
2602
|
+
const intensity = swing * swingEnergy;
|
|
2603
|
+
if (intensity > 0.05) {
|
|
2604
|
+
steps.push(step("vibrate", 35, intensity));
|
|
2605
|
+
} else {
|
|
2606
|
+
steps.push(step("pause", 35, 0));
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
return { name: "physics.pendulum", steps };
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
// src/physics/index.ts
|
|
2614
|
+
var physics = {
|
|
2615
|
+
spring,
|
|
2616
|
+
bounce,
|
|
2617
|
+
friction,
|
|
2618
|
+
impact,
|
|
2619
|
+
gravity,
|
|
2620
|
+
elastic,
|
|
2621
|
+
wave,
|
|
2622
|
+
pendulum
|
|
2623
|
+
};
|
|
2624
|
+
|
|
2625
|
+
// src/middleware/middleware.ts
|
|
2626
|
+
var MiddlewareManager = class {
|
|
2627
|
+
constructor() {
|
|
2628
|
+
this.middleware = [];
|
|
2629
|
+
}
|
|
2630
|
+
/** Register a middleware */
|
|
2631
|
+
use(middleware) {
|
|
2632
|
+
this.middleware.push(middleware);
|
|
2633
|
+
}
|
|
2634
|
+
/** Remove a middleware by name */
|
|
2635
|
+
remove(name) {
|
|
2636
|
+
this.middleware = this.middleware.filter((m) => m.name !== name);
|
|
2637
|
+
}
|
|
2638
|
+
/** Run all middleware in order */
|
|
2639
|
+
process(steps) {
|
|
2640
|
+
let result = steps;
|
|
2641
|
+
for (const m of this.middleware) {
|
|
2642
|
+
result = m.process(result);
|
|
2643
|
+
}
|
|
2644
|
+
return result;
|
|
2645
|
+
}
|
|
2646
|
+
/** Remove all middleware */
|
|
2647
|
+
clear() {
|
|
2648
|
+
this.middleware = [];
|
|
2649
|
+
}
|
|
2650
|
+
/** List registered middleware names */
|
|
2651
|
+
list() {
|
|
2652
|
+
return this.middleware.map((m) => m.name);
|
|
2653
|
+
}
|
|
2654
|
+
};
|
|
2655
|
+
function intensityScaler(scale) {
|
|
2656
|
+
return {
|
|
2657
|
+
name: "intensityScaler",
|
|
2658
|
+
process: (steps) => steps.map((s) => ({
|
|
2659
|
+
...s,
|
|
2660
|
+
intensity: Math.min(1, Math.max(0, s.intensity * scale))
|
|
2661
|
+
}))
|
|
2662
|
+
};
|
|
2663
|
+
}
|
|
2664
|
+
function durationScaler(scale) {
|
|
2665
|
+
return {
|
|
2666
|
+
name: "durationScaler",
|
|
2667
|
+
process: (steps) => steps.map((s) => ({
|
|
2668
|
+
...s,
|
|
2669
|
+
duration: Math.max(20, Math.round(s.duration * scale))
|
|
2670
|
+
}))
|
|
2671
|
+
};
|
|
2672
|
+
}
|
|
2673
|
+
function intensityClamper(min, max) {
|
|
2674
|
+
return {
|
|
2675
|
+
name: "intensityClamper",
|
|
2676
|
+
process: (steps) => steps.map((s) => ({
|
|
2677
|
+
...s,
|
|
2678
|
+
intensity: Math.min(max, Math.max(min, s.intensity))
|
|
2679
|
+
}))
|
|
2680
|
+
};
|
|
2681
|
+
}
|
|
2682
|
+
function patternRepeater(times) {
|
|
2683
|
+
return {
|
|
2684
|
+
name: "patternRepeater",
|
|
2685
|
+
process: (steps) => {
|
|
2686
|
+
const result = [];
|
|
2687
|
+
for (let i = 0; i < times; i++) {
|
|
2688
|
+
result.push(...steps.map((s) => ({ ...s })));
|
|
2689
|
+
}
|
|
2690
|
+
return result;
|
|
2691
|
+
}
|
|
2692
|
+
};
|
|
2693
|
+
}
|
|
2694
|
+
function reverser() {
|
|
2695
|
+
return {
|
|
2696
|
+
name: "reverser",
|
|
2697
|
+
process: (steps) => [...steps].reverse()
|
|
2698
|
+
};
|
|
2699
|
+
}
|
|
2700
|
+
function accessibilityBooster() {
|
|
2701
|
+
return {
|
|
2702
|
+
name: "accessibilityBooster",
|
|
2703
|
+
process: (steps) => steps.map((s) => ({
|
|
2704
|
+
...s,
|
|
2705
|
+
intensity: Math.min(1, s.intensity * 1.3),
|
|
2706
|
+
duration: Math.max(20, Math.round(s.duration * 1.2))
|
|
2707
|
+
}))
|
|
2708
|
+
};
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
// src/profiles/intensity-profiles.ts
|
|
2712
|
+
var profiles = {
|
|
2713
|
+
off: {
|
|
2714
|
+
name: "off",
|
|
2715
|
+
hapticScale: 0,
|
|
2716
|
+
durationScale: 0,
|
|
2717
|
+
soundEnabled: false,
|
|
2718
|
+
soundVolume: 0,
|
|
2719
|
+
visualEnabled: false
|
|
2720
|
+
},
|
|
2721
|
+
subtle: {
|
|
2722
|
+
name: "subtle",
|
|
2723
|
+
hapticScale: 0.5,
|
|
2724
|
+
durationScale: 0.7,
|
|
2725
|
+
soundEnabled: true,
|
|
2726
|
+
soundVolume: 0.1,
|
|
2727
|
+
visualEnabled: true
|
|
2728
|
+
},
|
|
2729
|
+
normal: {
|
|
2730
|
+
name: "normal",
|
|
2731
|
+
hapticScale: 1,
|
|
2732
|
+
durationScale: 1,
|
|
2733
|
+
soundEnabled: true,
|
|
2734
|
+
soundVolume: 0.3,
|
|
2735
|
+
visualEnabled: true
|
|
2736
|
+
},
|
|
2737
|
+
strong: {
|
|
2738
|
+
name: "strong",
|
|
2739
|
+
hapticScale: 1.3,
|
|
2740
|
+
durationScale: 1.2,
|
|
2741
|
+
soundEnabled: true,
|
|
2742
|
+
soundVolume: 0.5,
|
|
2743
|
+
visualEnabled: true
|
|
2744
|
+
},
|
|
2745
|
+
intense: {
|
|
2746
|
+
name: "intense",
|
|
2747
|
+
hapticScale: 1.8,
|
|
2748
|
+
durationScale: 1.5,
|
|
2749
|
+
soundEnabled: true,
|
|
2750
|
+
soundVolume: 0.7,
|
|
2751
|
+
visualEnabled: true
|
|
2752
|
+
},
|
|
2753
|
+
accessible: {
|
|
2754
|
+
name: "accessible",
|
|
2755
|
+
hapticScale: 1.5,
|
|
2756
|
+
durationScale: 1.3,
|
|
2757
|
+
soundEnabled: true,
|
|
2758
|
+
soundVolume: 0.6,
|
|
2759
|
+
visualEnabled: true
|
|
2760
|
+
}
|
|
2761
|
+
};
|
|
2762
|
+
var ProfileManager = class {
|
|
2763
|
+
constructor() {
|
|
2764
|
+
this.registry = /* @__PURE__ */ new Map();
|
|
2765
|
+
for (const [name, profile] of Object.entries(profiles)) {
|
|
2766
|
+
this.registry.set(name, profile);
|
|
2767
|
+
}
|
|
2768
|
+
this.currentProfile = profiles.normal;
|
|
2769
|
+
}
|
|
2770
|
+
/** Apply a profile by name or custom profile object */
|
|
2771
|
+
setProfile(name) {
|
|
2772
|
+
if (typeof name === "string") {
|
|
2773
|
+
const profile = this.registry.get(name);
|
|
2774
|
+
if (!profile) {
|
|
2775
|
+
throw new Error(`Unknown profile: "${name}"`);
|
|
2776
|
+
}
|
|
2777
|
+
this.currentProfile = profile;
|
|
2778
|
+
} else {
|
|
2779
|
+
this.registry.set(name.name, name);
|
|
2780
|
+
this.currentProfile = name;
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
/** Get the current profile */
|
|
2784
|
+
getProfile() {
|
|
2785
|
+
return this.currentProfile;
|
|
2786
|
+
}
|
|
2787
|
+
/** List available profile names */
|
|
2788
|
+
listProfiles() {
|
|
2789
|
+
return Array.from(this.registry.keys());
|
|
2790
|
+
}
|
|
2791
|
+
/** Register a custom profile */
|
|
2792
|
+
registerProfile(profile) {
|
|
2793
|
+
this.registry.set(profile.name, profile);
|
|
2794
|
+
}
|
|
2795
|
+
/** Convert current profile to a HapticMiddleware (intensity + duration scaling) */
|
|
2796
|
+
toMiddleware() {
|
|
2797
|
+
const profile = this.currentProfile;
|
|
2798
|
+
const iScaler = intensityScaler(profile.hapticScale);
|
|
2799
|
+
const dScaler = durationScaler(profile.durationScale);
|
|
2800
|
+
return {
|
|
2801
|
+
name: `profile:${profile.name}`,
|
|
2802
|
+
process: (steps) => dScaler.process(iScaler.process(steps))
|
|
2803
|
+
};
|
|
2804
|
+
}
|
|
2805
|
+
/** Current profile name */
|
|
2806
|
+
get current() {
|
|
2807
|
+
return this.currentProfile.name;
|
|
2808
|
+
}
|
|
2809
|
+
};
|
|
2810
|
+
|
|
2811
|
+
// src/experiment/ab-testing.ts
|
|
2812
|
+
var HapticExperiment = class {
|
|
2813
|
+
constructor(name, variants) {
|
|
2814
|
+
this.assignments = /* @__PURE__ */ new Map();
|
|
2815
|
+
this.tracking = [];
|
|
2816
|
+
this._name = name;
|
|
2817
|
+
this.variants = variants;
|
|
2818
|
+
this.variantNames = Object.keys(variants);
|
|
2819
|
+
if (this.variantNames.length === 0) {
|
|
2820
|
+
throw new Error("Experiment must have at least one variant");
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
/** Experiment name */
|
|
2824
|
+
get name() {
|
|
2825
|
+
return this._name;
|
|
2826
|
+
}
|
|
2827
|
+
/**
|
|
2828
|
+
* Randomly assign a variant (consistent for same userId via simple hash).
|
|
2829
|
+
* If no userId is provided, generates a random assignment.
|
|
2830
|
+
*/
|
|
2831
|
+
assign(userId) {
|
|
2832
|
+
const id = userId ?? `anon-${Math.random().toString(36).slice(2)}`;
|
|
2833
|
+
const existing = this.assignments.get(id);
|
|
2834
|
+
if (existing) return existing;
|
|
2835
|
+
const hash = this._hash(id);
|
|
2836
|
+
const index = hash % this.variantNames.length;
|
|
2837
|
+
const variant = this.variantNames[index];
|
|
2838
|
+
this.assignments.set(id, variant);
|
|
2839
|
+
return variant;
|
|
2840
|
+
}
|
|
2841
|
+
/** Get the assigned variant pattern for a user */
|
|
2842
|
+
getVariant(userId) {
|
|
2843
|
+
const id = userId ?? void 0;
|
|
2844
|
+
if (!id) return void 0;
|
|
2845
|
+
const variant = this.assignments.get(id);
|
|
2846
|
+
if (!variant) return void 0;
|
|
2847
|
+
return this.variants[variant];
|
|
2848
|
+
}
|
|
2849
|
+
/** Track an event for a user */
|
|
2850
|
+
track(userId, event, value) {
|
|
2851
|
+
const variant = this.assignments.get(userId);
|
|
2852
|
+
if (!variant) return;
|
|
2853
|
+
this.tracking.push({
|
|
2854
|
+
userId,
|
|
2855
|
+
variant,
|
|
2856
|
+
event,
|
|
2857
|
+
value,
|
|
2858
|
+
timestamp: Date.now()
|
|
2859
|
+
});
|
|
2860
|
+
}
|
|
2861
|
+
/** Get aggregated results per variant */
|
|
2862
|
+
getResults() {
|
|
2863
|
+
const results = {};
|
|
2864
|
+
for (const name of this.variantNames) {
|
|
2865
|
+
results[name] = { assignments: 0, events: {} };
|
|
2866
|
+
}
|
|
2867
|
+
for (const variant of this.assignments.values()) {
|
|
2868
|
+
if (results[variant]) {
|
|
2869
|
+
results[variant].assignments++;
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
for (const entry of this.tracking) {
|
|
2873
|
+
if (results[entry.variant]) {
|
|
2874
|
+
const events = results[entry.variant].events;
|
|
2875
|
+
events[entry.event] = (events[entry.event] ?? 0) + 1;
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
return results;
|
|
2879
|
+
}
|
|
2880
|
+
/** Clear all tracking data and assignments */
|
|
2881
|
+
reset() {
|
|
2882
|
+
this.assignments.clear();
|
|
2883
|
+
this.tracking = [];
|
|
2884
|
+
}
|
|
2885
|
+
/** Simple string hash for deterministic variant assignment */
|
|
2886
|
+
_hash(str) {
|
|
2887
|
+
let hash = 0;
|
|
2888
|
+
for (let i = 0; i < str.length; i++) {
|
|
2889
|
+
const char = str.charCodeAt(i);
|
|
2890
|
+
hash = (hash << 5) - hash + char;
|
|
2891
|
+
hash = hash & hash;
|
|
2892
|
+
}
|
|
2893
|
+
return Math.abs(hash);
|
|
2894
|
+
}
|
|
2895
|
+
};
|
|
2896
|
+
|
|
2897
|
+
// src/rhythm/rhythm-sync.ts
|
|
2898
|
+
var RhythmSync = class {
|
|
2899
|
+
constructor(options = {}) {
|
|
2900
|
+
// reserved for pattern-based rhythm
|
|
2901
|
+
this._isPlaying = false;
|
|
2902
|
+
this._beatCount = 0;
|
|
2903
|
+
this._intervalId = null;
|
|
2904
|
+
this._callbacks = [];
|
|
2905
|
+
this._tapTimestamps = [];
|
|
2906
|
+
this._audioElement = null;
|
|
2907
|
+
this._syncEngine = null;
|
|
2908
|
+
this._syncEffect = "tap";
|
|
2909
|
+
this._bpm = Math.min(300, Math.max(60, options.bpm ?? 120));
|
|
2910
|
+
this._intensity = Math.min(1, Math.max(0, options.intensity ?? 0.7));
|
|
2911
|
+
this._pattern = options.pattern ?? "";
|
|
2912
|
+
}
|
|
2913
|
+
// ─── BPM Control ─────────────────────────────────────────
|
|
2914
|
+
/** Set beats per minute (clamped to 60-300) */
|
|
2915
|
+
setBPM(bpm) {
|
|
2916
|
+
this._bpm = Math.min(300, Math.max(60, bpm));
|
|
2917
|
+
if (this._isPlaying) {
|
|
2918
|
+
this._stopInterval();
|
|
2919
|
+
this._startInterval();
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
/**
|
|
2923
|
+
* Store an audio element reference for sync.
|
|
2924
|
+
* Since Web Audio API's AnalyserNode isn't reliably available in all
|
|
2925
|
+
* environments, use tapTempo() for BPM detection instead.
|
|
2926
|
+
*/
|
|
2927
|
+
detectBPM(audioElement) {
|
|
2928
|
+
this._audioElement = audioElement;
|
|
2929
|
+
}
|
|
2930
|
+
/**
|
|
2931
|
+
* Tap tempo — call repeatedly to set BPM from tap intervals.
|
|
2932
|
+
* Calculates average BPM from the last 4-8 taps.
|
|
2933
|
+
* Returns the current estimated BPM.
|
|
2934
|
+
*/
|
|
2935
|
+
tapTempo() {
|
|
2936
|
+
const now = Date.now();
|
|
2937
|
+
this._tapTimestamps.push(now);
|
|
2938
|
+
if (this._tapTimestamps.length > 8) {
|
|
2939
|
+
this._tapTimestamps = this._tapTimestamps.slice(-8);
|
|
2940
|
+
}
|
|
2941
|
+
if (this._tapTimestamps.length < 2) {
|
|
2942
|
+
return this._bpm;
|
|
2943
|
+
}
|
|
2944
|
+
const timestamps = this._tapTimestamps.slice(-8);
|
|
2945
|
+
const intervals = [];
|
|
2946
|
+
for (let i = 1; i < timestamps.length; i++) {
|
|
2947
|
+
intervals.push(timestamps[i] - timestamps[i - 1]);
|
|
2948
|
+
}
|
|
2949
|
+
const avgInterval = intervals.reduce((sum, v) => sum + v, 0) / intervals.length;
|
|
2950
|
+
if (avgInterval > 0) {
|
|
2951
|
+
const estimatedBPM = Math.round(6e4 / avgInterval);
|
|
2952
|
+
this._bpm = Math.min(300, Math.max(60, estimatedBPM));
|
|
2953
|
+
}
|
|
2954
|
+
return this._bpm;
|
|
2955
|
+
}
|
|
2956
|
+
// ─── Playback ────────────────────────────────────────────
|
|
2957
|
+
/** Start emitting beats at the current BPM */
|
|
2958
|
+
start(callback) {
|
|
2959
|
+
if (this._isPlaying) return;
|
|
2960
|
+
if (callback) {
|
|
2961
|
+
this._callbacks.push(callback);
|
|
2962
|
+
}
|
|
2963
|
+
this._isPlaying = true;
|
|
2964
|
+
this._beatCount = 0;
|
|
2965
|
+
this._startInterval();
|
|
2966
|
+
}
|
|
2967
|
+
/** Stop the rhythm */
|
|
2968
|
+
stop() {
|
|
2969
|
+
this._isPlaying = false;
|
|
2970
|
+
this._stopInterval();
|
|
2971
|
+
}
|
|
2972
|
+
/** Register a beat callback */
|
|
2973
|
+
onBeat(callback) {
|
|
2974
|
+
this._callbacks.push(callback);
|
|
2975
|
+
}
|
|
2976
|
+
// ─── Haptic Sync ────────────────────────────────────────
|
|
2977
|
+
/**
|
|
2978
|
+
* Auto-trigger a haptic effect on each beat.
|
|
2979
|
+
* Calls engine.tap() by default, or the specified semantic method.
|
|
2980
|
+
*/
|
|
2981
|
+
syncHaptic(engine, effect = "tap") {
|
|
2982
|
+
this._syncEngine = engine;
|
|
2983
|
+
this._syncEffect = effect;
|
|
2984
|
+
}
|
|
2985
|
+
// ─── Getters ─────────────────────────────────────────────
|
|
2986
|
+
/** Current BPM */
|
|
2987
|
+
get bpm() {
|
|
2988
|
+
return this._bpm;
|
|
2989
|
+
}
|
|
2990
|
+
/** Whether rhythm is active */
|
|
2991
|
+
get isPlaying() {
|
|
2992
|
+
return this._isPlaying;
|
|
2993
|
+
}
|
|
2994
|
+
/** Total beats since start */
|
|
2995
|
+
get beatCount() {
|
|
2996
|
+
return this._beatCount;
|
|
2997
|
+
}
|
|
2998
|
+
/** Current pattern string */
|
|
2999
|
+
get pattern() {
|
|
3000
|
+
return this._pattern;
|
|
3001
|
+
}
|
|
3002
|
+
/** The attached audio element, if any */
|
|
3003
|
+
get audioElement() {
|
|
3004
|
+
return this._audioElement;
|
|
3005
|
+
}
|
|
3006
|
+
// ─── Lifecycle ───────────────────────────────────────────
|
|
3007
|
+
/** Clean up intervals and callbacks */
|
|
3008
|
+
dispose() {
|
|
3009
|
+
this.stop();
|
|
3010
|
+
this._callbacks = [];
|
|
3011
|
+
this._tapTimestamps = [];
|
|
3012
|
+
this._syncEngine = null;
|
|
3013
|
+
this._audioElement = null;
|
|
3014
|
+
}
|
|
3015
|
+
// ─── Internal ────────────────────────────────────────────
|
|
3016
|
+
_startInterval() {
|
|
3017
|
+
const intervalMs = 6e4 / this._bpm;
|
|
3018
|
+
this._intervalId = setInterval(() => {
|
|
3019
|
+
this._beatCount++;
|
|
3020
|
+
const beat = this._beatCount;
|
|
3021
|
+
for (const cb of this._callbacks) {
|
|
3022
|
+
cb(beat);
|
|
3023
|
+
}
|
|
3024
|
+
if (this._syncEngine) {
|
|
3025
|
+
const engine = this._syncEngine;
|
|
3026
|
+
if (typeof engine[this._syncEffect] === "function") {
|
|
3027
|
+
engine[this._syncEffect](this._intensity);
|
|
3028
|
+
} else if (typeof engine.tap === "function") {
|
|
3029
|
+
engine.tap(this._intensity);
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
}, intervalMs);
|
|
3033
|
+
}
|
|
3034
|
+
_stopInterval() {
|
|
3035
|
+
if (this._intervalId !== null) {
|
|
3036
|
+
clearInterval(this._intervalId);
|
|
3037
|
+
this._intervalId = null;
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
};
|
|
3041
|
+
|
|
3042
|
+
// src/motion/motion-detector.ts
|
|
3043
|
+
var MotionDetector = class {
|
|
3044
|
+
constructor(options = {}) {
|
|
3045
|
+
this._isListening = false;
|
|
3046
|
+
this._callbacks = {
|
|
3047
|
+
shake: [],
|
|
3048
|
+
tilt: [],
|
|
3049
|
+
rotation: [],
|
|
3050
|
+
flip: []
|
|
3051
|
+
};
|
|
3052
|
+
this._lastOrientation = null;
|
|
3053
|
+
this._lastFlipState = null;
|
|
3054
|
+
this._boundMotionHandler = null;
|
|
3055
|
+
this._boundOrientationHandler = null;
|
|
3056
|
+
this._shakeThreshold = options.shakeThreshold ?? 15;
|
|
3057
|
+
this._tiltThreshold = options.tiltThreshold ?? 10;
|
|
3058
|
+
}
|
|
3059
|
+
// ─── Detection ───────────────────────────────────────────
|
|
3060
|
+
/** Whether DeviceMotion API is available */
|
|
3061
|
+
get isSupported() {
|
|
3062
|
+
if (typeof window === "undefined") return false;
|
|
3063
|
+
return "DeviceMotionEvent" in window;
|
|
3064
|
+
}
|
|
3065
|
+
/** Whether currently listening for motion events */
|
|
3066
|
+
get isListening() {
|
|
3067
|
+
return this._isListening;
|
|
3068
|
+
}
|
|
3069
|
+
// ─── Permission ──────────────────────────────────────────
|
|
3070
|
+
/**
|
|
3071
|
+
* Request permission for motion events (required on iOS 13+).
|
|
3072
|
+
* Returns true if permission was granted.
|
|
3073
|
+
*/
|
|
3074
|
+
async requestPermission() {
|
|
3075
|
+
if (typeof window === "undefined") return false;
|
|
3076
|
+
const DeviceMotionEventRef = window.DeviceMotionEvent;
|
|
3077
|
+
if (DeviceMotionEventRef && typeof DeviceMotionEventRef.requestPermission === "function") {
|
|
3078
|
+
try {
|
|
3079
|
+
const result = await DeviceMotionEventRef.requestPermission();
|
|
3080
|
+
return result === "granted";
|
|
3081
|
+
} catch {
|
|
3082
|
+
return false;
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
return this.isSupported;
|
|
3086
|
+
}
|
|
3087
|
+
// ─── Lifecycle ───────────────────────────────────────────
|
|
3088
|
+
/** Begin listening to device motion and orientation events */
|
|
3089
|
+
start() {
|
|
3090
|
+
if (this._isListening) return;
|
|
3091
|
+
if (typeof window === "undefined") return;
|
|
3092
|
+
this._boundMotionHandler = this._handleMotion.bind(this);
|
|
3093
|
+
this._boundOrientationHandler = this._handleOrientation.bind(this);
|
|
3094
|
+
window.addEventListener("devicemotion", this._boundMotionHandler);
|
|
3095
|
+
window.addEventListener("deviceorientation", this._boundOrientationHandler);
|
|
3096
|
+
this._isListening = true;
|
|
3097
|
+
}
|
|
3098
|
+
/** Stop listening to motion events */
|
|
3099
|
+
stop() {
|
|
3100
|
+
if (!this._isListening) return;
|
|
3101
|
+
if (typeof window === "undefined") return;
|
|
3102
|
+
if (this._boundMotionHandler) {
|
|
3103
|
+
window.removeEventListener("devicemotion", this._boundMotionHandler);
|
|
3104
|
+
}
|
|
3105
|
+
if (this._boundOrientationHandler) {
|
|
3106
|
+
window.removeEventListener("deviceorientation", this._boundOrientationHandler);
|
|
3107
|
+
}
|
|
3108
|
+
this._isListening = false;
|
|
3109
|
+
this._lastOrientation = null;
|
|
3110
|
+
this._lastFlipState = null;
|
|
3111
|
+
}
|
|
3112
|
+
// ─── Callbacks ───────────────────────────────────────────
|
|
3113
|
+
/** Register callback for shake events. Intensity is 0-1 based on acceleration. */
|
|
3114
|
+
onShake(callback) {
|
|
3115
|
+
this._callbacks.shake.push(callback);
|
|
3116
|
+
}
|
|
3117
|
+
/** Register callback for tilt changes. Direction x/y are -1 to 1. */
|
|
3118
|
+
onTilt(callback) {
|
|
3119
|
+
this._callbacks.tilt.push(callback);
|
|
3120
|
+
}
|
|
3121
|
+
/** Register callback for rotation. Angle in degrees. */
|
|
3122
|
+
onRotation(callback) {
|
|
3123
|
+
this._callbacks.rotation.push(callback);
|
|
3124
|
+
}
|
|
3125
|
+
/** Register callback for device flip (face-down/up toggle). */
|
|
3126
|
+
onFlip(callback) {
|
|
3127
|
+
this._callbacks.flip.push(callback);
|
|
3128
|
+
}
|
|
3129
|
+
// ─── Cleanup ─────────────────────────────────────────────
|
|
3130
|
+
/** Remove all listeners and callbacks */
|
|
3131
|
+
dispose() {
|
|
3132
|
+
this.stop();
|
|
3133
|
+
this._callbacks = { shake: [], tilt: [], rotation: [], flip: [] };
|
|
3134
|
+
}
|
|
3135
|
+
// ─── Internal ────────────────────────────────────────────
|
|
3136
|
+
_handleMotion(event) {
|
|
3137
|
+
const accel = event.accelerationIncludingGravity;
|
|
3138
|
+
if (!accel) return;
|
|
3139
|
+
const { x, y, z } = accel;
|
|
3140
|
+
if (x == null || y == null || z == null) return;
|
|
3141
|
+
const magnitude = Math.sqrt(x * x + y * y + z * z) - 9.81;
|
|
3142
|
+
if (magnitude > this._shakeThreshold) {
|
|
3143
|
+
const intensity = Math.min(
|
|
3144
|
+
1,
|
|
3145
|
+
(magnitude - this._shakeThreshold) / this._shakeThreshold
|
|
3146
|
+
);
|
|
3147
|
+
for (const cb of this._callbacks.shake) {
|
|
3148
|
+
cb(intensity);
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
_handleOrientation(event) {
|
|
3153
|
+
const { alpha, beta, gamma } = event;
|
|
3154
|
+
if (beta == null || gamma == null) return;
|
|
3155
|
+
if (this._lastOrientation) {
|
|
3156
|
+
const deltaBeta = Math.abs(beta - this._lastOrientation.beta);
|
|
3157
|
+
const deltaGamma = Math.abs(gamma - this._lastOrientation.gamma);
|
|
3158
|
+
if (deltaBeta > this._tiltThreshold || deltaGamma > this._tiltThreshold) {
|
|
3159
|
+
const tiltX = Math.max(-1, Math.min(1, gamma / 90));
|
|
3160
|
+
const tiltY = Math.max(-1, Math.min(1, beta / 180));
|
|
3161
|
+
for (const cb of this._callbacks.tilt) {
|
|
3162
|
+
cb({ x: tiltX, y: tiltY });
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
this._lastOrientation = { beta, gamma };
|
|
3167
|
+
if (alpha != null) {
|
|
3168
|
+
for (const cb of this._callbacks.rotation) {
|
|
3169
|
+
cb(alpha);
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
const isFaceDown = Math.abs(beta) > 140;
|
|
3173
|
+
if (this._lastFlipState !== null && isFaceDown !== this._lastFlipState) {
|
|
3174
|
+
for (const cb of this._callbacks.flip) {
|
|
3175
|
+
cb();
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
this._lastFlipState = isFaceDown;
|
|
3179
|
+
}
|
|
3180
|
+
};
|
|
3181
|
+
|
|
3182
|
+
// src/accessibility/haptic-a11y.ts
|
|
3183
|
+
var HapticA11y = class {
|
|
3184
|
+
constructor(engine, options = {}) {
|
|
3185
|
+
this._isAttached = false;
|
|
3186
|
+
this._root = null;
|
|
3187
|
+
this._observer = null;
|
|
3188
|
+
this._focusChangeCallback = null;
|
|
3189
|
+
this._formErrorCallback = null;
|
|
3190
|
+
// Bound handlers for cleanup
|
|
3191
|
+
this._handleFocusIn = null;
|
|
3192
|
+
this._handleFocusOut = null;
|
|
3193
|
+
this._handleInvalid = null;
|
|
3194
|
+
this._handleClick = null;
|
|
3195
|
+
this.engine = engine;
|
|
3196
|
+
this.options = {
|
|
3197
|
+
focusChange: options.focusChange ?? true,
|
|
3198
|
+
formErrors: options.formErrors ?? true,
|
|
3199
|
+
navigation: options.navigation ?? true,
|
|
3200
|
+
announcements: options.announcements ?? true
|
|
3201
|
+
};
|
|
3202
|
+
}
|
|
3203
|
+
/** Whether currently attached and listening */
|
|
3204
|
+
get isAttached() {
|
|
3205
|
+
return this._isAttached;
|
|
3206
|
+
}
|
|
3207
|
+
// ─── Attach / Detach ─────────────────────────────────────
|
|
3208
|
+
/**
|
|
3209
|
+
* Attach to a root element and begin listening for interactions.
|
|
3210
|
+
* Defaults to document.body if no root is provided.
|
|
3211
|
+
*/
|
|
3212
|
+
attach(root) {
|
|
3213
|
+
if (this._isAttached) return;
|
|
3214
|
+
if (typeof document === "undefined") return;
|
|
3215
|
+
this._root = root ?? document.body;
|
|
3216
|
+
if (!this._root) return;
|
|
3217
|
+
this._bindHandlers();
|
|
3218
|
+
this._attachListeners();
|
|
3219
|
+
this._startObserver();
|
|
3220
|
+
this._isAttached = true;
|
|
3221
|
+
}
|
|
3222
|
+
/** Remove all listeners and stop observing */
|
|
3223
|
+
detach() {
|
|
3224
|
+
if (!this._isAttached) return;
|
|
3225
|
+
this._removeListeners();
|
|
3226
|
+
this._stopObserver();
|
|
3227
|
+
this._isAttached = false;
|
|
3228
|
+
this._root = null;
|
|
3229
|
+
}
|
|
3230
|
+
// ─── Custom Handlers ─────────────────────────────────────
|
|
3231
|
+
/** Set a custom handler for focus changes */
|
|
3232
|
+
onFocusChange(callback) {
|
|
3233
|
+
this._focusChangeCallback = callback ?? null;
|
|
3234
|
+
}
|
|
3235
|
+
/** Set a custom handler for form errors */
|
|
3236
|
+
onFormError(callback) {
|
|
3237
|
+
this._formErrorCallback = callback ?? null;
|
|
3238
|
+
}
|
|
3239
|
+
// ─── Cleanup ─────────────────────────────────────────────
|
|
3240
|
+
/** Clean up all listeners, observers, and callbacks */
|
|
3241
|
+
dispose() {
|
|
3242
|
+
this.detach();
|
|
3243
|
+
this._focusChangeCallback = null;
|
|
3244
|
+
this._formErrorCallback = null;
|
|
3245
|
+
}
|
|
3246
|
+
// ─── Internal ────────────────────────────────────────────
|
|
3247
|
+
_bindHandlers() {
|
|
3248
|
+
this._handleFocusIn = (e) => {
|
|
3249
|
+
if (!this.options.focusChange) return;
|
|
3250
|
+
if (this._focusChangeCallback) {
|
|
3251
|
+
this._focusChangeCallback(e);
|
|
3252
|
+
return;
|
|
3253
|
+
}
|
|
3254
|
+
this._safeCall(() => this.engine.selection());
|
|
3255
|
+
};
|
|
3256
|
+
this._handleFocusOut = (_e) => {
|
|
3257
|
+
};
|
|
3258
|
+
this._handleInvalid = (e) => {
|
|
3259
|
+
if (!this.options.formErrors) return;
|
|
3260
|
+
if (this._formErrorCallback) {
|
|
3261
|
+
this._formErrorCallback(e);
|
|
3262
|
+
return;
|
|
3263
|
+
}
|
|
3264
|
+
this._safeCall(() => this.engine.error());
|
|
3265
|
+
};
|
|
3266
|
+
this._handleClick = (e) => {
|
|
3267
|
+
const target = e.target;
|
|
3268
|
+
if (!target) return;
|
|
3269
|
+
const tagName = target.tagName?.toLowerCase();
|
|
3270
|
+
if (tagName === "button" || target.getAttribute?.("role") === "button") {
|
|
3271
|
+
this._safeCall(() => this.engine.tap());
|
|
3272
|
+
return;
|
|
3273
|
+
}
|
|
3274
|
+
if (tagName === "a" && this.options.navigation) {
|
|
3275
|
+
this._safeCall(() => this.engine.selection());
|
|
3276
|
+
return;
|
|
3277
|
+
}
|
|
3278
|
+
if (tagName === "input") {
|
|
3279
|
+
const inputType = target.type?.toLowerCase();
|
|
3280
|
+
if (inputType === "checkbox" || inputType === "radio") {
|
|
3281
|
+
const checked = target.checked;
|
|
3282
|
+
this._safeCall(() => this.engine.toggle(checked));
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
};
|
|
3286
|
+
}
|
|
3287
|
+
_attachListeners() {
|
|
3288
|
+
if (!this._root) return;
|
|
3289
|
+
this._root.addEventListener("focusin", this._handleFocusIn, true);
|
|
3290
|
+
this._root.addEventListener("focusout", this._handleFocusOut, true);
|
|
3291
|
+
this._root.addEventListener("invalid", this._handleInvalid, true);
|
|
3292
|
+
this._root.addEventListener("click", this._handleClick, true);
|
|
3293
|
+
}
|
|
3294
|
+
_removeListeners() {
|
|
3295
|
+
if (!this._root) return;
|
|
3296
|
+
this._root.removeEventListener("focusin", this._handleFocusIn, true);
|
|
3297
|
+
this._root.removeEventListener("focusout", this._handleFocusOut, true);
|
|
3298
|
+
this._root.removeEventListener("invalid", this._handleInvalid, true);
|
|
3299
|
+
this._root.removeEventListener("click", this._handleClick, true);
|
|
3300
|
+
}
|
|
3301
|
+
_startObserver() {
|
|
3302
|
+
if (typeof MutationObserver === "undefined") return;
|
|
3303
|
+
if (!this.options.announcements) return;
|
|
3304
|
+
this._observer = new MutationObserver((mutations) => {
|
|
3305
|
+
for (const mutation of mutations) {
|
|
3306
|
+
for (const node of mutation.addedNodes) {
|
|
3307
|
+
if (node.nodeType !== 1) continue;
|
|
3308
|
+
const el = node;
|
|
3309
|
+
const role = el.getAttribute?.("role");
|
|
3310
|
+
const ariaLive = el.getAttribute?.("aria-live");
|
|
3311
|
+
if (role === "alert" || role === "alertdialog") {
|
|
3312
|
+
this._safeCall(() => this.engine.warning());
|
|
3313
|
+
} else if (role === "status" || ariaLive === "polite" || ariaLive === "assertive") {
|
|
3314
|
+
this._safeCall(() => this.engine.selection());
|
|
3315
|
+
}
|
|
3316
|
+
if (role === "dialog" || el.tagName?.toLowerCase() === "dialog") {
|
|
3317
|
+
this._safeCall(() => this.engine.toggle(true));
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
for (const node of mutation.removedNodes) {
|
|
3321
|
+
if (node.nodeType !== 1) continue;
|
|
3322
|
+
const el = node;
|
|
3323
|
+
const role = el.getAttribute?.("role");
|
|
3324
|
+
if (role === "dialog" || el.tagName?.toLowerCase() === "dialog") {
|
|
3325
|
+
this._safeCall(() => this.engine.toggle(false));
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
});
|
|
3330
|
+
this._observer.observe(this._root, {
|
|
3331
|
+
childList: true,
|
|
3332
|
+
subtree: true
|
|
3333
|
+
});
|
|
3334
|
+
}
|
|
3335
|
+
_stopObserver() {
|
|
3336
|
+
if (this._observer) {
|
|
3337
|
+
this._observer.disconnect();
|
|
3338
|
+
this._observer = null;
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
_safeCall(fn) {
|
|
3342
|
+
try {
|
|
3343
|
+
const result = fn();
|
|
3344
|
+
if (result && typeof result.catch === "function") {
|
|
3345
|
+
result.catch(() => {
|
|
3346
|
+
});
|
|
3347
|
+
}
|
|
3348
|
+
} catch {
|
|
3349
|
+
}
|
|
3350
|
+
}
|
|
1386
3351
|
};
|
|
1387
3352
|
|
|
1388
3353
|
// src/index.ts
|
|
@@ -1393,30 +3358,59 @@ exports.FallbackManager = FallbackManager;
|
|
|
1393
3358
|
exports.HPLParser = HPLParser;
|
|
1394
3359
|
exports.HPLParserError = HPLParserError;
|
|
1395
3360
|
exports.HPLTokenizerError = HPLTokenizerError;
|
|
3361
|
+
exports.HapticA11y = HapticA11y;
|
|
1396
3362
|
exports.HapticEngine = HapticEngine;
|
|
3363
|
+
exports.HapticExperiment = HapticExperiment;
|
|
1397
3364
|
exports.IoSAudioAdapter = IoSAudioAdapter;
|
|
3365
|
+
exports.MiddlewareManager = MiddlewareManager;
|
|
3366
|
+
exports.MotionDetector = MotionDetector;
|
|
1398
3367
|
exports.NoopAdapter = NoopAdapter;
|
|
1399
3368
|
exports.PatternComposer = PatternComposer;
|
|
3369
|
+
exports.PatternRecorder = PatternRecorder;
|
|
3370
|
+
exports.ProfileManager = ProfileManager;
|
|
3371
|
+
exports.RhythmSync = RhythmSync;
|
|
3372
|
+
exports.SensoryEngine = SensoryEngine;
|
|
3373
|
+
exports.SoundEngine = SoundEngine;
|
|
3374
|
+
exports.ThemeManager = ThemeManager;
|
|
3375
|
+
exports.VisualEngine = VisualEngine;
|
|
1400
3376
|
exports.WebVibrationAdapter = WebVibrationAdapter;
|
|
1401
3377
|
exports.accessibility = accessibility;
|
|
3378
|
+
exports.accessibilityBooster = accessibilityBooster;
|
|
3379
|
+
exports.bounce = bounce;
|
|
1402
3380
|
exports.compile = compile;
|
|
1403
3381
|
exports.detectAdapter = detectAdapter;
|
|
1404
3382
|
exports.detectPlatform = detectPlatform;
|
|
3383
|
+
exports.durationScaler = durationScaler;
|
|
3384
|
+
exports.elastic = elastic;
|
|
3385
|
+
exports.emotions = emotions;
|
|
1405
3386
|
exports.exportPattern = exportPattern;
|
|
3387
|
+
exports.friction = friction;
|
|
1406
3388
|
exports.gaming = gaming;
|
|
3389
|
+
exports.gravity = gravity;
|
|
1407
3390
|
exports.haptic = haptic;
|
|
3391
|
+
exports.impact = impact;
|
|
1408
3392
|
exports.importPattern = importPattern;
|
|
3393
|
+
exports.intensityClamper = intensityClamper;
|
|
3394
|
+
exports.intensityScaler = intensityScaler;
|
|
1409
3395
|
exports.notifications = notifications;
|
|
1410
3396
|
exports.optimizeSteps = optimizeSteps;
|
|
1411
3397
|
exports.parseHPL = parseHPL;
|
|
1412
3398
|
exports.patternFromDataURL = patternFromDataURL;
|
|
1413
3399
|
exports.patternFromJSON = patternFromJSON;
|
|
3400
|
+
exports.patternRepeater = patternRepeater;
|
|
1414
3401
|
exports.patternToDataURL = patternToDataURL;
|
|
1415
3402
|
exports.patternToJSON = patternToJSON;
|
|
3403
|
+
exports.pendulum = pendulum;
|
|
3404
|
+
exports.physics = physics;
|
|
1416
3405
|
exports.presets = presets;
|
|
3406
|
+
exports.profiles = profiles;
|
|
3407
|
+
exports.reverser = reverser;
|
|
3408
|
+
exports.spring = spring;
|
|
1417
3409
|
exports.system = system;
|
|
3410
|
+
exports.themes = themes;
|
|
1418
3411
|
exports.tokenize = tokenize;
|
|
1419
3412
|
exports.ui = ui;
|
|
1420
3413
|
exports.validateHPL = validateHPL;
|
|
3414
|
+
exports.wave = wave;
|
|
1421
3415
|
//# sourceMappingURL=index.cjs.map
|
|
1422
3416
|
//# sourceMappingURL=index.cjs.map
|