@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/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
|
|
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(
|
|
324
|
+
await this._visualFallback(totalDuration2, maxIntensity);
|
|
201
325
|
}
|
|
202
326
|
if (this.config.type === "audio" || this.config.type === "both") {
|
|
203
|
-
await this._audioFallback(
|
|
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:
|
|
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
|