@hapticjs/core 0.1.0 → 0.2.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/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/index.cjs +250 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +86 -1
- package/dist/index.d.ts +86 -1
- package/dist/index.js +244 -5
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thirumalesh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# @hapticjs/core
|
|
2
|
+
|
|
3
|
+
Universal Haptics Engine for JavaScript & TypeScript.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @hapticjs/core
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Quick Start
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
import { haptic } from '@hapticjs/core';
|
|
13
|
+
|
|
14
|
+
// Semantic effects
|
|
15
|
+
haptic.tap();
|
|
16
|
+
haptic.success();
|
|
17
|
+
haptic.error();
|
|
18
|
+
|
|
19
|
+
// Haptic Pattern Language
|
|
20
|
+
haptic.play('~~..##..@@');
|
|
21
|
+
|
|
22
|
+
// Fluent composer
|
|
23
|
+
haptic.compose()
|
|
24
|
+
.tap(0.5)
|
|
25
|
+
.pause(100)
|
|
26
|
+
.buzz(200)
|
|
27
|
+
.play();
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Haptic Pattern Language (HPL)
|
|
31
|
+
|
|
32
|
+
| Char | Effect | Duration | Intensity |
|
|
33
|
+
|------|--------|----------|-----------|
|
|
34
|
+
| `~` | Light vibration | 50ms | 0.3 |
|
|
35
|
+
| `#` | Medium vibration | 50ms | 0.6 |
|
|
36
|
+
| `@` | Heavy vibration | 50ms | 1.0 |
|
|
37
|
+
| `.` | Pause | 50ms | - |
|
|
38
|
+
| `\|` | Sharp tap | 10ms | 1.0 |
|
|
39
|
+
| `-` | Sustain | 50ms | - |
|
|
40
|
+
|
|
41
|
+
Groups & repeats: `[~.]x3` repeats 3 times.
|
|
42
|
+
|
|
43
|
+
## 43 Built-in Presets
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
import { presets } from '@hapticjs/core';
|
|
47
|
+
|
|
48
|
+
haptic.play(presets.gaming.explosion);
|
|
49
|
+
haptic.play(presets.ui.pullToRefresh);
|
|
50
|
+
haptic.play(presets.notifications.success);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Categories: UI (12), Notifications (7), Gaming (10), Accessibility (7), System (7).
|
|
54
|
+
|
|
55
|
+
## Pattern Sharing
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
import { exportPattern, importPattern, patternToDataURL } from '@hapticjs/core';
|
|
59
|
+
|
|
60
|
+
const exported = exportPattern('~~..##', { name: 'My Pattern' });
|
|
61
|
+
const url = patternToDataURL('~~..##', { name: 'My Pattern' });
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT
|
package/dist/index.cjs
CHANGED
|
@@ -133,6 +133,124 @@ var WebVibrationAdapter = class {
|
|
|
133
133
|
}
|
|
134
134
|
};
|
|
135
135
|
|
|
136
|
+
// src/adapters/ios-audio.adapter.ts
|
|
137
|
+
var IoSAudioAdapter = class {
|
|
138
|
+
constructor() {
|
|
139
|
+
this.name = "ios-audio";
|
|
140
|
+
this._audioCtx = null;
|
|
141
|
+
this._activeOscillator = null;
|
|
142
|
+
this._activeGain = null;
|
|
143
|
+
this._cancelled = false;
|
|
144
|
+
this.supported = this._detectSupport();
|
|
145
|
+
}
|
|
146
|
+
capabilities() {
|
|
147
|
+
return {
|
|
148
|
+
maxIntensityLevels: 100,
|
|
149
|
+
minDuration: 5,
|
|
150
|
+
maxDuration: 5e3,
|
|
151
|
+
supportsPattern: true,
|
|
152
|
+
supportsIntensity: true,
|
|
153
|
+
dualMotor: false
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
async pulse(intensity, duration) {
|
|
157
|
+
if (!this.supported) return;
|
|
158
|
+
await this._playTone(intensity, duration);
|
|
159
|
+
}
|
|
160
|
+
async playSequence(steps) {
|
|
161
|
+
if (!this.supported || steps.length === 0) return;
|
|
162
|
+
this._cancelled = false;
|
|
163
|
+
for (const step of steps) {
|
|
164
|
+
if (this._cancelled) break;
|
|
165
|
+
if (step.type === "vibrate" && step.intensity > 0) {
|
|
166
|
+
await this._playTone(step.intensity, step.duration);
|
|
167
|
+
} else {
|
|
168
|
+
await delay(step.duration);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
cancel() {
|
|
173
|
+
this._cancelled = true;
|
|
174
|
+
this._stopOscillator();
|
|
175
|
+
}
|
|
176
|
+
dispose() {
|
|
177
|
+
this.cancel();
|
|
178
|
+
if (this._audioCtx) {
|
|
179
|
+
void this._audioCtx.close();
|
|
180
|
+
this._audioCtx = null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// ─── Internal ──────────────────────────────────────────────
|
|
184
|
+
_detectSupport() {
|
|
185
|
+
if (typeof window === "undefined") return false;
|
|
186
|
+
const hasAudioContext = typeof AudioContext !== "undefined" || typeof window.webkitAudioContext !== "undefined";
|
|
187
|
+
if (!hasAudioContext) return false;
|
|
188
|
+
const ua = navigator.userAgent;
|
|
189
|
+
const isIOS = /iPad|iPhone|iPod/.test(ua) || navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
|
|
190
|
+
return isIOS;
|
|
191
|
+
}
|
|
192
|
+
/** Get or create the AudioContext, resuming if suspended */
|
|
193
|
+
async _getAudioContext() {
|
|
194
|
+
if (!this._audioCtx) {
|
|
195
|
+
const Ctor = typeof AudioContext !== "undefined" ? AudioContext : window.webkitAudioContext;
|
|
196
|
+
this._audioCtx = new Ctor();
|
|
197
|
+
}
|
|
198
|
+
if (this._audioCtx.state === "suspended") {
|
|
199
|
+
await this._audioCtx.resume();
|
|
200
|
+
}
|
|
201
|
+
return this._audioCtx;
|
|
202
|
+
}
|
|
203
|
+
/** Map intensity (0-1) to frequency in the 20-60 Hz sub-bass range */
|
|
204
|
+
_intensityToFrequency(intensity) {
|
|
205
|
+
return 20 + intensity * 40;
|
|
206
|
+
}
|
|
207
|
+
/** Play a single oscillator tone for the given duration */
|
|
208
|
+
async _playTone(intensity, duration) {
|
|
209
|
+
const ctx = await this._getAudioContext();
|
|
210
|
+
const oscillator = ctx.createOscillator();
|
|
211
|
+
const gainNode = ctx.createGain();
|
|
212
|
+
oscillator.type = "sine";
|
|
213
|
+
oscillator.frequency.setValueAtTime(
|
|
214
|
+
this._intensityToFrequency(intensity),
|
|
215
|
+
ctx.currentTime
|
|
216
|
+
);
|
|
217
|
+
gainNode.gain.setValueAtTime(intensity, ctx.currentTime);
|
|
218
|
+
oscillator.connect(gainNode);
|
|
219
|
+
gainNode.connect(ctx.destination);
|
|
220
|
+
this._activeOscillator = oscillator;
|
|
221
|
+
this._activeGain = gainNode;
|
|
222
|
+
oscillator.start(ctx.currentTime);
|
|
223
|
+
oscillator.stop(ctx.currentTime + duration / 1e3);
|
|
224
|
+
await new Promise((resolve) => {
|
|
225
|
+
oscillator.onended = () => {
|
|
226
|
+
oscillator.disconnect();
|
|
227
|
+
gainNode.disconnect();
|
|
228
|
+
this._activeOscillator = null;
|
|
229
|
+
this._activeGain = null;
|
|
230
|
+
resolve();
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
/** Immediately stop the active oscillator if any */
|
|
235
|
+
_stopOscillator() {
|
|
236
|
+
if (this._activeOscillator) {
|
|
237
|
+
try {
|
|
238
|
+
this._activeOscillator.stop();
|
|
239
|
+
this._activeOscillator.disconnect();
|
|
240
|
+
} catch {
|
|
241
|
+
}
|
|
242
|
+
this._activeOscillator = null;
|
|
243
|
+
}
|
|
244
|
+
if (this._activeGain) {
|
|
245
|
+
try {
|
|
246
|
+
this._activeGain.disconnect();
|
|
247
|
+
} catch {
|
|
248
|
+
}
|
|
249
|
+
this._activeGain = null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
136
254
|
// src/utils/platform.ts
|
|
137
255
|
function detectPlatform() {
|
|
138
256
|
const isWeb = typeof window !== "undefined" && typeof document !== "undefined";
|
|
@@ -162,6 +280,12 @@ function detectAdapter() {
|
|
|
162
280
|
if (platform.hasVibrationAPI) {
|
|
163
281
|
return new WebVibrationAdapter();
|
|
164
282
|
}
|
|
283
|
+
if (platform.isIOS && platform.isWeb) {
|
|
284
|
+
const iosAdapter = new IoSAudioAdapter();
|
|
285
|
+
if (iosAdapter.supported) {
|
|
286
|
+
return iosAdapter;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
165
289
|
return new NoopAdapter();
|
|
166
290
|
}
|
|
167
291
|
|
|
@@ -196,13 +320,13 @@ var FallbackManager = class {
|
|
|
196
320
|
/** Execute fallback feedback for the given steps */
|
|
197
321
|
async execute(steps) {
|
|
198
322
|
if (this.config.type === "none") return;
|
|
199
|
-
const
|
|
323
|
+
const totalDuration2 = steps.reduce((sum, s) => sum + s.duration, 0);
|
|
200
324
|
const maxIntensity = Math.max(...steps.filter((s) => s.type === "vibrate").map((s) => s.intensity), 0);
|
|
201
325
|
if (this.config.type === "visual" || this.config.type === "both") {
|
|
202
|
-
await this._visualFallback(
|
|
326
|
+
await this._visualFallback(totalDuration2, maxIntensity);
|
|
203
327
|
}
|
|
204
328
|
if (this.config.type === "audio" || this.config.type === "both") {
|
|
205
|
-
await this._audioFallback(
|
|
329
|
+
await this._audioFallback(totalDuration2, maxIntensity);
|
|
206
330
|
}
|
|
207
331
|
}
|
|
208
332
|
async _visualFallback(duration, intensity) {
|
|
@@ -607,7 +731,7 @@ var HapticEngine = class _HapticEngine {
|
|
|
607
731
|
// ─── Semantic API ──────────────────────────────────────────
|
|
608
732
|
/** Light tap feedback */
|
|
609
733
|
async tap(intensity = 0.6) {
|
|
610
|
-
await this._playSteps([{ type: "vibrate", duration:
|
|
734
|
+
await this._playSteps([{ type: "vibrate", duration: 30, intensity }]);
|
|
611
735
|
}
|
|
612
736
|
/** Double tap */
|
|
613
737
|
async doubleTap(intensity = 0.6) {
|
|
@@ -787,6 +911,121 @@ function validateHPL(input) {
|
|
|
787
911
|
return { valid: errors.length === 0, errors };
|
|
788
912
|
}
|
|
789
913
|
|
|
914
|
+
// src/patterns/sharing.ts
|
|
915
|
+
function totalDuration(steps) {
|
|
916
|
+
return steps.reduce((sum, s) => sum + s.duration, 0);
|
|
917
|
+
}
|
|
918
|
+
function resolveInput(input) {
|
|
919
|
+
if (typeof input === "string") {
|
|
920
|
+
const ast = parseHPL(input);
|
|
921
|
+
return { steps: compile(ast), hpl: input };
|
|
922
|
+
}
|
|
923
|
+
if (Array.isArray(input)) {
|
|
924
|
+
return { steps: input };
|
|
925
|
+
}
|
|
926
|
+
return { steps: input.steps, name: input.name };
|
|
927
|
+
}
|
|
928
|
+
function exportPattern(input, options = {}) {
|
|
929
|
+
const resolved = resolveInput(input);
|
|
930
|
+
const steps = resolved.steps;
|
|
931
|
+
const name = options.name ?? resolved.name ?? "Untitled Pattern";
|
|
932
|
+
const result = {
|
|
933
|
+
version: 1,
|
|
934
|
+
name,
|
|
935
|
+
steps
|
|
936
|
+
};
|
|
937
|
+
if (options.description) {
|
|
938
|
+
result.description = options.description;
|
|
939
|
+
}
|
|
940
|
+
if (resolved.hpl) {
|
|
941
|
+
result.hpl = resolved.hpl;
|
|
942
|
+
}
|
|
943
|
+
const hasMetadataFields = options.author || options.tags;
|
|
944
|
+
if (hasMetadataFields || steps.length > 0) {
|
|
945
|
+
result.metadata = {
|
|
946
|
+
duration: totalDuration(steps),
|
|
947
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
948
|
+
};
|
|
949
|
+
if (options.author) {
|
|
950
|
+
result.metadata.author = options.author;
|
|
951
|
+
}
|
|
952
|
+
if (options.tags) {
|
|
953
|
+
result.metadata.tags = options.tags;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
return result;
|
|
957
|
+
}
|
|
958
|
+
function validateExport(data) {
|
|
959
|
+
if (data === null || typeof data !== "object") {
|
|
960
|
+
throw new Error("Invalid pattern data: expected an object");
|
|
961
|
+
}
|
|
962
|
+
const obj = data;
|
|
963
|
+
if (obj.version !== 1) {
|
|
964
|
+
throw new Error(
|
|
965
|
+
`Unsupported pattern version: ${String(obj.version)}. Expected version 1`
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
if (typeof obj.name !== "string" || obj.name.length === 0) {
|
|
969
|
+
throw new Error('Invalid pattern data: "name" must be a non-empty string');
|
|
970
|
+
}
|
|
971
|
+
if (!Array.isArray(obj.steps)) {
|
|
972
|
+
throw new Error('Invalid pattern data: "steps" must be an array');
|
|
973
|
+
}
|
|
974
|
+
for (let i = 0; i < obj.steps.length; i++) {
|
|
975
|
+
const step = obj.steps[i];
|
|
976
|
+
if (step.type !== "vibrate" && step.type !== "pause") {
|
|
977
|
+
throw new Error(
|
|
978
|
+
`Invalid step at index ${i}: "type" must be "vibrate" or "pause"`
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
if (typeof step.duration !== "number" || step.duration < 0) {
|
|
982
|
+
throw new Error(
|
|
983
|
+
`Invalid step at index ${i}: "duration" must be a non-negative number`
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
if (typeof step.intensity !== "number" || step.intensity < 0 || step.intensity > 1) {
|
|
987
|
+
throw new Error(
|
|
988
|
+
`Invalid step at index ${i}: "intensity" must be a number between 0 and 1`
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
function importPattern(data) {
|
|
994
|
+
const parsed = typeof data === "string" ? JSON.parse(data) : data;
|
|
995
|
+
validateExport(parsed);
|
|
996
|
+
const pattern = {
|
|
997
|
+
name: parsed.name,
|
|
998
|
+
steps: parsed.steps.map((s) => ({ ...s }))
|
|
999
|
+
};
|
|
1000
|
+
if (parsed.metadata) {
|
|
1001
|
+
pattern.metadata = { ...parsed.metadata };
|
|
1002
|
+
}
|
|
1003
|
+
return pattern;
|
|
1004
|
+
}
|
|
1005
|
+
function patternToJSON(input, options = {}) {
|
|
1006
|
+
const exported = exportPattern(input, options);
|
|
1007
|
+
return JSON.stringify(exported, null, 2);
|
|
1008
|
+
}
|
|
1009
|
+
function patternFromJSON(json) {
|
|
1010
|
+
return importPattern(json);
|
|
1011
|
+
}
|
|
1012
|
+
function patternToDataURL(input, options = {}) {
|
|
1013
|
+
const json = patternToJSON(input, options);
|
|
1014
|
+
const encoded = btoa(json);
|
|
1015
|
+
return `data:application/haptic+json;base64,${encoded}`;
|
|
1016
|
+
}
|
|
1017
|
+
function patternFromDataURL(url) {
|
|
1018
|
+
const prefix = "data:application/haptic+json;base64,";
|
|
1019
|
+
if (!url.startsWith(prefix)) {
|
|
1020
|
+
throw new Error(
|
|
1021
|
+
'Invalid haptic data URL: expected "data:application/haptic+json;base64," prefix'
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
const encoded = url.slice(prefix.length);
|
|
1025
|
+
const json = atob(encoded);
|
|
1026
|
+
return patternFromJSON(json);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
790
1029
|
// src/presets/ui.ts
|
|
791
1030
|
var ui = {
|
|
792
1031
|
/** Light button tap */
|
|
@@ -1184,6 +1423,7 @@ exports.HPLParser = HPLParser;
|
|
|
1184
1423
|
exports.HPLParserError = HPLParserError;
|
|
1185
1424
|
exports.HPLTokenizerError = HPLTokenizerError;
|
|
1186
1425
|
exports.HapticEngine = HapticEngine;
|
|
1426
|
+
exports.IoSAudioAdapter = IoSAudioAdapter;
|
|
1187
1427
|
exports.NoopAdapter = NoopAdapter;
|
|
1188
1428
|
exports.PatternComposer = PatternComposer;
|
|
1189
1429
|
exports.WebVibrationAdapter = WebVibrationAdapter;
|
|
@@ -1191,11 +1431,17 @@ exports.accessibility = accessibility;
|
|
|
1191
1431
|
exports.compile = compile;
|
|
1192
1432
|
exports.detectAdapter = detectAdapter;
|
|
1193
1433
|
exports.detectPlatform = detectPlatform;
|
|
1434
|
+
exports.exportPattern = exportPattern;
|
|
1194
1435
|
exports.gaming = gaming;
|
|
1195
1436
|
exports.haptic = haptic;
|
|
1437
|
+
exports.importPattern = importPattern;
|
|
1196
1438
|
exports.notifications = notifications;
|
|
1197
1439
|
exports.optimizeSteps = optimizeSteps;
|
|
1198
1440
|
exports.parseHPL = parseHPL;
|
|
1441
|
+
exports.patternFromDataURL = patternFromDataURL;
|
|
1442
|
+
exports.patternFromJSON = patternFromJSON;
|
|
1443
|
+
exports.patternToDataURL = patternToDataURL;
|
|
1444
|
+
exports.patternToJSON = patternToJSON;
|
|
1199
1445
|
exports.presets = presets;
|
|
1200
1446
|
exports.system = system;
|
|
1201
1447
|
exports.tokenize = tokenize;
|