@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 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 totalDuration = steps.reduce((sum, s) => sum + s.duration, 0);
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(totalDuration, maxIntensity);
326
+ await this._visualFallback(totalDuration2, maxIntensity);
203
327
  }
204
328
  if (this.config.type === "audio" || this.config.type === "both") {
205
- await this._audioFallback(totalDuration, maxIntensity);
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: 10, intensity }]);
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;