@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/dist/index.d.ts CHANGED
@@ -304,6 +304,36 @@ declare class WebVibrationAdapter implements HapticAdapter {
304
304
  private _pwmVibrate;
305
305
  }
306
306
 
307
+ /**
308
+ * iOS Audio workaround adapter.
309
+ * Uses AudioContext to generate short, low-frequency oscillator tones (20-60 Hz)
310
+ * that create a subtle physical sensation through device speakers.
311
+ * This is a fallback for iOS Safari, which does not support the Vibration API.
312
+ */
313
+ declare class IoSAudioAdapter implements HapticAdapter {
314
+ readonly name = "ios-audio";
315
+ readonly supported: boolean;
316
+ private _audioCtx;
317
+ private _activeOscillator;
318
+ private _activeGain;
319
+ private _cancelled;
320
+ constructor();
321
+ capabilities(): AdapterCapabilities;
322
+ pulse(intensity: number, duration: number): Promise<void>;
323
+ playSequence(steps: HapticStep[]): Promise<void>;
324
+ cancel(): void;
325
+ dispose(): void;
326
+ private _detectSupport;
327
+ /** Get or create the AudioContext, resuming if suspended */
328
+ private _getAudioContext;
329
+ /** Map intensity (0-1) to frequency in the 20-60 Hz sub-bass range */
330
+ private _intensityToFrequency;
331
+ /** Play a single oscillator tone for the given duration */
332
+ private _playTone;
333
+ /** Immediately stop the active oscillator if any */
334
+ private _stopOscillator;
335
+ }
336
+
307
337
  declare class HPLParserError extends Error {
308
338
  constructor(message: string);
309
339
  }
@@ -357,6 +387,61 @@ interface ValidationResult {
357
387
  }
358
388
  declare function validateHPL(input: string): ValidationResult;
359
389
 
390
+ /** Portable JSON-serializable format for sharing haptic patterns */
391
+ interface HapticPatternExport {
392
+ version: 1;
393
+ name: string;
394
+ description?: string;
395
+ /** Original HPL string if pattern was created from HPL */
396
+ hpl?: string;
397
+ /** Compiled haptic steps */
398
+ steps: HapticStep[];
399
+ metadata?: {
400
+ /** Total duration in milliseconds */
401
+ duration: number;
402
+ author?: string;
403
+ tags?: string[];
404
+ /** ISO date string */
405
+ createdAt?: string;
406
+ };
407
+ }
408
+ /** Options for exporting a pattern */
409
+ interface ExportOptions {
410
+ name?: string;
411
+ description?: string;
412
+ author?: string;
413
+ tags?: string[];
414
+ }
415
+ /** Input types accepted by exportPattern */
416
+ type ExportInput = HapticPattern | HapticStep[] | string;
417
+ /**
418
+ * Export a haptic pattern as a portable JSON-serializable object.
419
+ *
420
+ * Accepts a HapticPattern, HapticStep[], or HPL string.
421
+ */
422
+ declare function exportPattern(input: ExportInput, options?: ExportOptions): HapticPatternExport;
423
+ /**
424
+ * Import a pattern from a HapticPatternExport object or JSON string.
425
+ * Validates the data and returns a HapticPattern.
426
+ */
427
+ declare function importPattern(data: HapticPatternExport | string): HapticPattern;
428
+ /**
429
+ * Serialize a pattern to a pretty-printed JSON string.
430
+ */
431
+ declare function patternToJSON(input: ExportInput, options?: ExportOptions): string;
432
+ /**
433
+ * Parse a JSON string into a HapticPattern.
434
+ */
435
+ declare function patternFromJSON(json: string): HapticPattern;
436
+ /**
437
+ * Encode a pattern as a data: URL for easy sharing via links.
438
+ */
439
+ declare function patternToDataURL(input: ExportInput, options?: ExportOptions): string;
440
+ /**
441
+ * Decode a data: URL back to a HapticPattern.
442
+ */
443
+ declare function patternFromDataURL(url: string): HapticPattern;
444
+
360
445
  /** Platform detection utilities */
361
446
  interface PlatformInfo {
362
447
  isWeb: boolean;
@@ -381,4 +466,4 @@ declare function detectPlatform(): PlatformInfo;
381
466
  */
382
467
  declare const haptic: HapticEngine;
383
468
 
384
- export { type AdapterCapabilities, AdaptiveEngine, type EasingFunction, type FallbackConfig, FallbackManager, type HPLNode, type HPLNodeType, HPLParser, HPLParserError, type HPLToken, type HPLTokenType, HPLTokenizerError, type HapticAdapter, type HapticConfig, HapticEngine, type HapticPattern, type HapticStep, type ImpactStyle, NoopAdapter, type NotificationType, PatternComposer, type PlatformInfo, type SemanticEffect, type ValidationResult, type VisualFallbackStyle, WebVibrationAdapter, compile, detectAdapter, detectPlatform, haptic, optimizeSteps, parseHPL, tokenize, validateHPL };
469
+ export { type AdapterCapabilities, AdaptiveEngine, type EasingFunction, type ExportOptions, type FallbackConfig, FallbackManager, type HPLNode, type HPLNodeType, HPLParser, HPLParserError, type HPLToken, type HPLTokenType, HPLTokenizerError, type HapticAdapter, type HapticConfig, HapticEngine, type HapticPattern, type HapticPatternExport, type HapticStep, type ImpactStyle, IoSAudioAdapter, NoopAdapter, type NotificationType, PatternComposer, type PlatformInfo, type SemanticEffect, type ValidationResult, type VisualFallbackStyle, WebVibrationAdapter, compile, detectAdapter, detectPlatform, exportPattern, haptic, importPattern, optimizeSteps, parseHPL, patternFromDataURL, patternFromJSON, patternToDataURL, patternToJSON, tokenize, validateHPL };
package/dist/index.js CHANGED
@@ -131,6 +131,124 @@ var WebVibrationAdapter = class {
131
131
  }
132
132
  };
133
133
 
134
+ // src/adapters/ios-audio.adapter.ts
135
+ var IoSAudioAdapter = class {
136
+ constructor() {
137
+ this.name = "ios-audio";
138
+ this._audioCtx = null;
139
+ this._activeOscillator = null;
140
+ this._activeGain = null;
141
+ this._cancelled = false;
142
+ this.supported = this._detectSupport();
143
+ }
144
+ capabilities() {
145
+ return {
146
+ maxIntensityLevels: 100,
147
+ minDuration: 5,
148
+ maxDuration: 5e3,
149
+ supportsPattern: true,
150
+ supportsIntensity: true,
151
+ dualMotor: false
152
+ };
153
+ }
154
+ async pulse(intensity, duration) {
155
+ if (!this.supported) return;
156
+ await this._playTone(intensity, duration);
157
+ }
158
+ async playSequence(steps) {
159
+ if (!this.supported || steps.length === 0) return;
160
+ this._cancelled = false;
161
+ for (const step of steps) {
162
+ if (this._cancelled) break;
163
+ if (step.type === "vibrate" && step.intensity > 0) {
164
+ await this._playTone(step.intensity, step.duration);
165
+ } else {
166
+ await delay(step.duration);
167
+ }
168
+ }
169
+ }
170
+ cancel() {
171
+ this._cancelled = true;
172
+ this._stopOscillator();
173
+ }
174
+ dispose() {
175
+ this.cancel();
176
+ if (this._audioCtx) {
177
+ void this._audioCtx.close();
178
+ this._audioCtx = null;
179
+ }
180
+ }
181
+ // ─── Internal ──────────────────────────────────────────────
182
+ _detectSupport() {
183
+ if (typeof window === "undefined") return false;
184
+ const hasAudioContext = typeof AudioContext !== "undefined" || typeof window.webkitAudioContext !== "undefined";
185
+ if (!hasAudioContext) return false;
186
+ const ua = navigator.userAgent;
187
+ const isIOS = /iPad|iPhone|iPod/.test(ua) || navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
188
+ return isIOS;
189
+ }
190
+ /** Get or create the AudioContext, resuming if suspended */
191
+ async _getAudioContext() {
192
+ if (!this._audioCtx) {
193
+ const Ctor = typeof AudioContext !== "undefined" ? AudioContext : window.webkitAudioContext;
194
+ this._audioCtx = new Ctor();
195
+ }
196
+ if (this._audioCtx.state === "suspended") {
197
+ await this._audioCtx.resume();
198
+ }
199
+ return this._audioCtx;
200
+ }
201
+ /** Map intensity (0-1) to frequency in the 20-60 Hz sub-bass range */
202
+ _intensityToFrequency(intensity) {
203
+ return 20 + intensity * 40;
204
+ }
205
+ /** Play a single oscillator tone for the given duration */
206
+ async _playTone(intensity, duration) {
207
+ const ctx = await this._getAudioContext();
208
+ const oscillator = ctx.createOscillator();
209
+ const gainNode = ctx.createGain();
210
+ oscillator.type = "sine";
211
+ oscillator.frequency.setValueAtTime(
212
+ this._intensityToFrequency(intensity),
213
+ ctx.currentTime
214
+ );
215
+ gainNode.gain.setValueAtTime(intensity, ctx.currentTime);
216
+ oscillator.connect(gainNode);
217
+ gainNode.connect(ctx.destination);
218
+ this._activeOscillator = oscillator;
219
+ this._activeGain = gainNode;
220
+ oscillator.start(ctx.currentTime);
221
+ oscillator.stop(ctx.currentTime + duration / 1e3);
222
+ await new Promise((resolve) => {
223
+ oscillator.onended = () => {
224
+ oscillator.disconnect();
225
+ gainNode.disconnect();
226
+ this._activeOscillator = null;
227
+ this._activeGain = null;
228
+ resolve();
229
+ };
230
+ });
231
+ }
232
+ /** Immediately stop the active oscillator if any */
233
+ _stopOscillator() {
234
+ if (this._activeOscillator) {
235
+ try {
236
+ this._activeOscillator.stop();
237
+ this._activeOscillator.disconnect();
238
+ } catch {
239
+ }
240
+ this._activeOscillator = null;
241
+ }
242
+ if (this._activeGain) {
243
+ try {
244
+ this._activeGain.disconnect();
245
+ } catch {
246
+ }
247
+ this._activeGain = null;
248
+ }
249
+ }
250
+ };
251
+
134
252
  // src/utils/platform.ts
135
253
  function detectPlatform() {
136
254
  const isWeb = typeof window !== "undefined" && typeof document !== "undefined";
@@ -160,6 +278,12 @@ function detectAdapter() {
160
278
  if (platform.hasVibrationAPI) {
161
279
  return new WebVibrationAdapter();
162
280
  }
281
+ if (platform.isIOS && platform.isWeb) {
282
+ const iosAdapter = new IoSAudioAdapter();
283
+ if (iosAdapter.supported) {
284
+ return iosAdapter;
285
+ }
286
+ }
163
287
  return new NoopAdapter();
164
288
  }
165
289
 
@@ -194,13 +318,13 @@ var FallbackManager = class {
194
318
  /** Execute fallback feedback for the given steps */
195
319
  async execute(steps) {
196
320
  if (this.config.type === "none") return;
197
- const totalDuration = steps.reduce((sum, s) => sum + s.duration, 0);
321
+ const totalDuration2 = steps.reduce((sum, s) => sum + s.duration, 0);
198
322
  const maxIntensity = Math.max(...steps.filter((s) => s.type === "vibrate").map((s) => s.intensity), 0);
199
323
  if (this.config.type === "visual" || this.config.type === "both") {
200
- await this._visualFallback(totalDuration, maxIntensity);
324
+ await this._visualFallback(totalDuration2, maxIntensity);
201
325
  }
202
326
  if (this.config.type === "audio" || this.config.type === "both") {
203
- await this._audioFallback(totalDuration, maxIntensity);
327
+ await this._audioFallback(totalDuration2, maxIntensity);
204
328
  }
205
329
  }
206
330
  async _visualFallback(duration, intensity) {
@@ -605,7 +729,7 @@ var HapticEngine = class _HapticEngine {
605
729
  // ─── Semantic API ──────────────────────────────────────────
606
730
  /** Light tap feedback */
607
731
  async tap(intensity = 0.6) {
608
- await this._playSteps([{ type: "vibrate", duration: 10, intensity }]);
732
+ await this._playSteps([{ type: "vibrate", duration: 30, intensity }]);
609
733
  }
610
734
  /** Double tap */
611
735
  async doubleTap(intensity = 0.6) {
@@ -785,6 +909,121 @@ function validateHPL(input) {
785
909
  return { valid: errors.length === 0, errors };
786
910
  }
787
911
 
912
+ // src/patterns/sharing.ts
913
+ function totalDuration(steps) {
914
+ return steps.reduce((sum, s) => sum + s.duration, 0);
915
+ }
916
+ function resolveInput(input) {
917
+ if (typeof input === "string") {
918
+ const ast = parseHPL(input);
919
+ return { steps: compile(ast), hpl: input };
920
+ }
921
+ if (Array.isArray(input)) {
922
+ return { steps: input };
923
+ }
924
+ return { steps: input.steps, name: input.name };
925
+ }
926
+ function exportPattern(input, options = {}) {
927
+ const resolved = resolveInput(input);
928
+ const steps = resolved.steps;
929
+ const name = options.name ?? resolved.name ?? "Untitled Pattern";
930
+ const result = {
931
+ version: 1,
932
+ name,
933
+ steps
934
+ };
935
+ if (options.description) {
936
+ result.description = options.description;
937
+ }
938
+ if (resolved.hpl) {
939
+ result.hpl = resolved.hpl;
940
+ }
941
+ const hasMetadataFields = options.author || options.tags;
942
+ if (hasMetadataFields || steps.length > 0) {
943
+ result.metadata = {
944
+ duration: totalDuration(steps),
945
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
946
+ };
947
+ if (options.author) {
948
+ result.metadata.author = options.author;
949
+ }
950
+ if (options.tags) {
951
+ result.metadata.tags = options.tags;
952
+ }
953
+ }
954
+ return result;
955
+ }
956
+ function validateExport(data) {
957
+ if (data === null || typeof data !== "object") {
958
+ throw new Error("Invalid pattern data: expected an object");
959
+ }
960
+ const obj = data;
961
+ if (obj.version !== 1) {
962
+ throw new Error(
963
+ `Unsupported pattern version: ${String(obj.version)}. Expected version 1`
964
+ );
965
+ }
966
+ if (typeof obj.name !== "string" || obj.name.length === 0) {
967
+ throw new Error('Invalid pattern data: "name" must be a non-empty string');
968
+ }
969
+ if (!Array.isArray(obj.steps)) {
970
+ throw new Error('Invalid pattern data: "steps" must be an array');
971
+ }
972
+ for (let i = 0; i < obj.steps.length; i++) {
973
+ const step = obj.steps[i];
974
+ if (step.type !== "vibrate" && step.type !== "pause") {
975
+ throw new Error(
976
+ `Invalid step at index ${i}: "type" must be "vibrate" or "pause"`
977
+ );
978
+ }
979
+ if (typeof step.duration !== "number" || step.duration < 0) {
980
+ throw new Error(
981
+ `Invalid step at index ${i}: "duration" must be a non-negative number`
982
+ );
983
+ }
984
+ if (typeof step.intensity !== "number" || step.intensity < 0 || step.intensity > 1) {
985
+ throw new Error(
986
+ `Invalid step at index ${i}: "intensity" must be a number between 0 and 1`
987
+ );
988
+ }
989
+ }
990
+ }
991
+ function importPattern(data) {
992
+ const parsed = typeof data === "string" ? JSON.parse(data) : data;
993
+ validateExport(parsed);
994
+ const pattern = {
995
+ name: parsed.name,
996
+ steps: parsed.steps.map((s) => ({ ...s }))
997
+ };
998
+ if (parsed.metadata) {
999
+ pattern.metadata = { ...parsed.metadata };
1000
+ }
1001
+ return pattern;
1002
+ }
1003
+ function patternToJSON(input, options = {}) {
1004
+ const exported = exportPattern(input, options);
1005
+ return JSON.stringify(exported, null, 2);
1006
+ }
1007
+ function patternFromJSON(json) {
1008
+ return importPattern(json);
1009
+ }
1010
+ function patternToDataURL(input, options = {}) {
1011
+ const json = patternToJSON(input, options);
1012
+ const encoded = btoa(json);
1013
+ return `data:application/haptic+json;base64,${encoded}`;
1014
+ }
1015
+ function patternFromDataURL(url) {
1016
+ const prefix = "data:application/haptic+json;base64,";
1017
+ if (!url.startsWith(prefix)) {
1018
+ throw new Error(
1019
+ 'Invalid haptic data URL: expected "data:application/haptic+json;base64," prefix'
1020
+ );
1021
+ }
1022
+ const encoded = url.slice(prefix.length);
1023
+ const json = atob(encoded);
1024
+ return patternFromJSON(json);
1025
+ }
1026
+
788
1027
  // src/presets/ui.ts
789
1028
  var ui = {
790
1029
  /** Light button tap */
@@ -1176,6 +1415,6 @@ var presets = {
1176
1415
  // src/index.ts
1177
1416
  var haptic = HapticEngine.create();
1178
1417
 
1179
- export { AdaptiveEngine, FallbackManager, HPLParser, HPLParserError, HPLTokenizerError, HapticEngine, NoopAdapter, PatternComposer, WebVibrationAdapter, accessibility, compile, detectAdapter, detectPlatform, gaming, haptic, notifications, optimizeSteps, parseHPL, presets, system, tokenize, ui, validateHPL };
1418
+ export { AdaptiveEngine, FallbackManager, HPLParser, HPLParserError, HPLTokenizerError, HapticEngine, IoSAudioAdapter, NoopAdapter, PatternComposer, WebVibrationAdapter, accessibility, compile, detectAdapter, detectPlatform, exportPattern, gaming, haptic, importPattern, notifications, optimizeSteps, parseHPL, patternFromDataURL, patternFromJSON, patternToDataURL, patternToJSON, presets, system, tokenize, ui, validateHPL };
1180
1419
  //# sourceMappingURL=index.js.map
1181
1420
  //# sourceMappingURL=index.js.map