@clankagent/puck 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/AGENTS.md +9 -0
- package/CHANGELOG.md +38 -0
- package/CONTRIBUTING.md +20 -0
- package/DESIGN.md +42 -0
- package/README.md +33 -70
- package/VERIFICATION.md +78 -0
- package/dist/calibration.d.ts +49 -0
- package/dist/calibration.js +135 -0
- package/dist/gestures.d.ts +49 -0
- package/dist/gestures.js +125 -0
- package/dist/graph.d.ts +39 -0
- package/dist/graph.js +59 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +4 -0
- package/dist/recording.d.ts +40 -0
- package/dist/recording.js +48 -0
- package/dist/tune.d.ts +37 -0
- package/dist/tune.js +52 -0
- package/docs/agents.md +26 -0
- package/docs/api.md +86 -0
- package/docs/lab.md +27 -0
- package/docs/motion.md +20 -0
- package/docs/quickstart.md +78 -0
- package/docs/troubleshooting.md +27 -0
- package/docs/tuning.md +146 -0
- package/docs/upgrading.md +44 -0
- package/examples/gesture-session.mjs +55 -0
- package/llms.txt +27 -0
- package/package.json +17 -3
package/AGENTS.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Package development
|
|
2
|
+
|
|
3
|
+
Puck is a public library hosted at https://github.com/clankagent/puck, published as @clankagent/puck. Maintain the focused library scope: source, tests, documentation and small code examples. Do not add a website, test page, local server or router setup unless explicitly requested.
|
|
4
|
+
|
|
5
|
+
Keep the package self-contained. Do not introduce references to private applications, internal servers, local recordings, machine paths, or user metadata. No dependencies on surrounding workspaces. Preserve the established feel when extracting or optimizing; validate timing and neutral behavior with tests.
|
|
6
|
+
|
|
7
|
+
Input transport, motion processing, and application rendering are separate responsibilities. The core owns no DOM, global listeners or render loop. Browser lifecycle belongs to the optional adapter; camera limits and zoom anchors belong to the consuming application. Avoid framework adapters and additional device abstractions without a concrete use case.
|
|
8
|
+
|
|
9
|
+
Use pnpm. This library uses the TypeScript compiler and Node's test runner; no Vite application is being created. Run `pnpm check` before delivery. Additional device profiles require evidence; do not claim untested compatibility. Synthetic fixtures are generated from documented report layouts and physical observations, without original capture metadata.
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
User-facing changes, compatibility impact, and links for adopting them. **Unreleased** describes source on main, not an available npm version. Package versions and tune/recording JSON format versions are independent.
|
|
4
|
+
|
|
5
|
+
## 0.2.0 — 2026-09-19
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
| Feature | What it enables | Integration docs |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| Four-direction single/double cap gestures | Clockwise, counterclockwise, push down and pull up actions | [Browser quickstart](docs/quickstart.md), [event and timing API](docs/api.md#gestures) |
|
|
12
|
+
| Immutable gesture tunes | Shared rotation thresholds, independent push/pull thresholds, default/soft/hard presets and chainable edits | [Tune guide](docs/tuning.md#gesture-tunes), [tune API](docs/api.md#tunes) |
|
|
13
|
+
| Bounded recording SDK | Capture reports, clock ticks, resets and events using the app's storage | [Session example](examples/gesture-session.mjs), [recording API](docs/api.md#recording) |
|
|
14
|
+
| Automatic custom calibration | Infer a tune from freeform captures with three examples of each of eight actions, in any order | [Calibration guide](docs/tuning.md#recording-and-custom-calibration), [result handling](docs/api.md#calibration) |
|
|
15
|
+
| Optional graph entry point | Four direction lanes, inferred action spans and an accessible SVG renderer | [Graph guide](docs/tuning.md#graphs-for-consuming-applications), [graph API](docs/api.md#graphs) |
|
|
16
|
+
| Source-only gesture lab | Record, combine captures, inspect actions, edit tunes and export JSON | [Run the lab](docs/lab.md) |
|
|
17
|
+
| User and agent documentation | API reference, troubleshooting, complete integration and a plain-text index | [Documentation index](README.md#start-here), [agent guide](docs/agents.md), [llms.txt](llms.txt) |
|
|
18
|
+
|
|
19
|
+
### Compatibility and adoption
|
|
20
|
+
|
|
21
|
+
- Existing `createPanZoom`, decoding and WebHID APIs/defaults are unchanged from the original 0.1.0 source baseline. Existing motion integrations need no migration for these additions.
|
|
22
|
+
- Gesture APIs are experimental and opt-in. The app still owns its clock, render loop, actions and storage. Adding gestures does not automatically bind actions or suppress pan/zoom.
|
|
23
|
+
- Exclusive singles wait for the double window; immediate mode emits a single followed by an additive double. Choose deliberately.
|
|
24
|
+
- Tune and recording JSON formats start at `version: 1`. Restore tunes through `createGestureTune`; handle a null calibration tune before applying results.
|
|
25
|
+
- Hardware scope is unchanged. Cap gestures do not add physical button decoding or support for unverified devices.
|
|
26
|
+
|
|
27
|
+
**Adopt the update:** [Upgrade and integration guide](docs/upgrading.md). [Source comparison with the original baseline](https://github.com/clankagent/puck/compare/f065d4a...v0.2.0).
|
|
28
|
+
|
|
29
|
+
## Original 0.1.0 source baseline
|
|
30
|
+
|
|
31
|
+
This is a source-history reference, not a claim that an npm release was published on a particular date.
|
|
32
|
+
|
|
33
|
+
- Normalized six-axis decoding for the verified combined report profile.
|
|
34
|
+
- Frame-independent pan/zoom with held input, neutral stopping and application-owned camera/rendering.
|
|
35
|
+
- Optional WebHID connection, foreground lifecycle resets and cleanup.
|
|
36
|
+
- ESM and TypeScript declarations, no runtime dependencies.
|
|
37
|
+
|
|
38
|
+
[Baseline source](https://github.com/clankagent/puck/tree/f065d4a) · [Motion behavior and hardware](docs/motion.md)
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Use Node 24 and the pnpm version in package.json. Run pnpm install and pnpm check.
|
|
4
|
+
Small fixes can go straight to a pull request. For a new API or device profile,
|
|
5
|
+
open an issue describing the use case first so we can agree on the scope.
|
|
6
|
+
|
|
7
|
+
Keep transport, processing and rendering separate. Preserve neutral stopping,
|
|
8
|
+
held input between reports and frame-rate independence. Include a regression
|
|
9
|
+
test for behavioral changes. Avoid adding dependencies or per-frame work without
|
|
10
|
+
a measured need.
|
|
11
|
+
|
|
12
|
+
For device support, include vendor/product IDs, connection type, browser/OS,
|
|
13
|
+
report descriptor and a short sequence of report IDs and hexadecimal payloads
|
|
14
|
+
for neutral, each single-axis gesture, hold and release. Relative timestamps
|
|
15
|
+
are sufficient. Remove serial numbers, device names, account information and
|
|
16
|
+
other personal metadata. Do not infer support from a shared vendor ID.
|
|
17
|
+
|
|
18
|
+
Only describe physical behavior as tested when someone has actually tested it.
|
|
19
|
+
Synthetic report tests establish decoding and lifecycle behavior, not hardware
|
|
20
|
+
compatibility or subjective feel.
|
package/DESIGN.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Architecture and release preparation
|
|
2
|
+
|
|
3
|
+
The product goal is to make responsive six-axis interaction easy to implement correctly. Correct defaults and clear ownership matter more than exposing many switches.
|
|
4
|
+
|
|
5
|
+
## Boundaries
|
|
6
|
+
|
|
7
|
+
The core consumes normalized deflection and frame timestamps. It produces screen-space pan deltas and a multiplicative zoom factor. It does not own a view transform, renderer, event loop, browser or device. Incoming reports update a held sample; they never directly advance a camera.
|
|
8
|
+
|
|
9
|
+
The optional WebHID adapter decodes one verified device profile, handles explicit device permission and connection lifetime, and forwards samples. It clears input on interruption. A consuming application can pause input when a modal/tool takes ownership, or use the core with another transport entirely.
|
|
10
|
+
|
|
11
|
+
The example camera demonstrates the application boundary: apply the zoom multiplier within app limits, preserve the chosen screen-space anchor using the actual clamped zoom ratio, then apply pan deltas in pixels. This avoids accidentally scaling pan speed with zoom or moving the zoom anchor when a limit is reached.
|
|
12
|
+
|
|
13
|
+
## Defaults backed by the working prototype
|
|
14
|
+
|
|
15
|
+
Six-axis combined report: six signed little-endian words, logical range ±350. Device profile 256f:c63a. Clockwise twist and downward pressure zoom in. Independent pan/zoom deadzones .05/.1; acceleration response 25 ms; full pan speed 1320 px/s; full log-zoom speed 1.5/s. Integrate the response analytically over elapsed frame time. Neutral stops without coasting; reversal discards old-direction response. Cap unusually long frame integration at 50 ms; do not expire held input based on report silence.
|
|
16
|
+
|
|
17
|
+
These are verified defaults for the tested hardware and interaction. They are not a claim that every device uses the same report layout or that everyone prefers the same axis directions.
|
|
18
|
+
|
|
19
|
+
## Package shape
|
|
20
|
+
|
|
21
|
+
One ESM TypeScript package, core and `/webhid` exports, no runtime dependencies. Emitted declarations use explicit `.js` relative specifiers and a Node-compatible module resolution mode. Tests import built output, keeping package behavior visible outside the compiler. A renderer can use the core without importing browser glue. [TypeScript library compilation guidance](https://www.typescriptlang.org/docs/handbook/modules/guides/choosing-compiler-options.html#im-writing-a-library), [WebHID lifecycle and reports](https://developer.chrome.com/docs/capabilities/hid).
|
|
22
|
+
|
|
23
|
+
## Release scope
|
|
24
|
+
|
|
25
|
+
Puck ships a core and an optional WebHID adapter. No website, renderer or server
|
|
26
|
+
is part of the library. The first release documents the measured hardware scope
|
|
27
|
+
and distinguishes prototype physical testing from extracted-adapter tests.
|
|
28
|
+
|
|
29
|
+
## Tune and calibration API
|
|
30
|
+
|
|
31
|
+
Tune data is immutable, versioned JSON with shared rotation and independent
|
|
32
|
+
push/pull force bands. Method edits copy rather than mutate and do not affect
|
|
33
|
+
timing. Gesture recording is a bounded transport-independent utility; apps
|
|
34
|
+
retain responsibility for storage and permissions. Calibration consumes raw
|
|
35
|
+
samples independently of the old recognizer event log or the order of action
|
|
36
|
+
groups, and requires three examples per action. Uncertain trains and missing
|
|
37
|
+
coverage cannot produce a ready tune.
|
|
38
|
+
|
|
39
|
+
The optional /graph export provides renderer-neutral data and a standalone SVG
|
|
40
|
+
renderer. It draws four unsigned direction lanes instead of overlaying two
|
|
41
|
+
signed axes and four gates. It owns no DOM, camera, subscriptions or frame loop.
|
|
42
|
+
The lab adds selection/zoom and tune editing as application responsibilities.
|
package/README.md
CHANGED
|
@@ -1,88 +1,51 @@
|
|
|
1
1
|
# Puck
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Six-axis input, responsive pan/zoom, and tunable single/double gestures for SpaceMouse applications. TypeScript, ESM, zero runtime dependencies. Your app owns its camera, rendering and storage.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**Version note:** these docs target 0.2.0. Gesture recognition, tunes, recording, calibration and graphs are included, with experimental APIs. See the [changelog](CHANGELOG.md) and [upgrade guide](docs/upgrading.md) when moving from 0.1.0.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
pnpm add @clankagent/puck
|
|
9
|
-
```
|
|
7
|
+
## Start here
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
| You want to… | Read |
|
|
10
|
+
|---|---|
|
|
11
|
+
| See what changed and adopt an update | [Changelog](CHANGELOG.md), [upgrade guide](docs/upgrading.md) |
|
|
12
|
+
| Connect a device and recognize gestures | [Complete browser integration](docs/quickstart.md) |
|
|
13
|
+
| Add smooth pan and zoom | [Motion and hardware](docs/motion.md), [camera arithmetic](examples/camera.mjs) |
|
|
14
|
+
| Set sensitivity or learn a personal tune | [Tunes, recording and calibration](docs/tuning.md) |
|
|
15
|
+
| Look up imports, methods, defaults or units | [API reference](docs/api.md) |
|
|
16
|
+
| Fix input or calibration problems | [Troubleshooting](docs/troubleshooting.md) |
|
|
17
|
+
| Integrate using an agent | [Agent integration guide](docs/agents.md), [plain-text index](llms.txt) |
|
|
18
|
+
| Try and record real input | [Gesture lab](docs/lab.md) |
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
For a registry release, install with `pnpm add @clankagent/puck`. For this checkout:
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
onInput: motion.setInput,
|
|
22
|
-
onReset: motion.reset,
|
|
23
|
-
onDisconnect() { connection = null; connectButton.disabled = false; },
|
|
24
|
-
});
|
|
25
|
-
} finally {
|
|
26
|
-
connectButton.disabled = Boolean(connection);
|
|
27
|
-
}
|
|
28
|
-
};
|
|
22
|
+
```sh
|
|
23
|
+
pnpm install
|
|
24
|
+
pnpm check
|
|
25
|
+
```
|
|
29
26
|
|
|
30
|
-
|
|
31
|
-
const delta = motion.step(timestamp);
|
|
32
|
-
if (delta.moving) {
|
|
33
|
-
// Apply zoom about your chosen anchor, then pan in screen pixels.
|
|
34
|
-
camera.zoomAround(viewCenter, delta.zoomFactor);
|
|
35
|
-
camera.panBy(delta.panX, delta.panY);
|
|
36
|
-
render(camera);
|
|
37
|
-
}
|
|
38
|
-
requestAnimationFrame(frame);
|
|
39
|
-
}
|
|
40
|
-
let connection = null;
|
|
41
|
-
requestAnimationFrame(frame);
|
|
27
|
+
The build emits `dist/*.js` and matching TypeScript declarations. Examples use package imports; a bundler or browser import map must resolve them. No CommonJS build is provided.
|
|
42
28
|
|
|
43
|
-
|
|
44
|
-
|
|
29
|
+
```js
|
|
30
|
+
import { createGestures, gesturePresets } from '@clankagent/puck';
|
|
45
31
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
//
|
|
32
|
+
const tune = gesturePresets.default.soften(0.1).widen(0.15);
|
|
33
|
+
const gestures = createGestures(tune);
|
|
34
|
+
// Feed every report to update(input, time), and tick advance(time).
|
|
35
|
+
// See the complete integration for clocks, lifecycle and cleanup.
|
|
49
36
|
```
|
|
50
37
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
## Responsibilities
|
|
38
|
+
## What Puck owns
|
|
54
39
|
|
|
55
|
-
|
|
|
40
|
+
| Layer | Responsibility |
|
|
56
41
|
|---|---|
|
|
57
|
-
|
|
|
58
|
-
|
|
|
59
|
-
|
|
|
60
|
-
| Your
|
|
42
|
+
| Core `@clankagent/puck` | Decode reports; process motion and gestures; record samples; derive tunes. No browser globals, timers or storage. |
|
|
43
|
+
| Optional `/webhid` | Device chooser, reports and connection lifecycle. |
|
|
44
|
+
| Optional `/graph` | Four-lane graph data and a standalone SVG renderer. |
|
|
45
|
+
| Your app | Render loop, action handling, camera, capture controls, persistence and UI. |
|
|
61
46
|
|
|
62
|
-
The
|
|
63
|
-
|
|
64
|
-
## Motion behavior
|
|
65
|
-
|
|
66
|
-
- `step(timestampMs)` uses the timestamp supplied by your render loop, once per frame. The first step produces no movement. It never assumes a display refresh rate.
|
|
67
|
-
- A held cap requests velocity. Input report count does not determine movement distance. The latest deflection remains active between reports, including bursty delivery.
|
|
68
|
-
- Pan has a 0.05 normalized deadzone and full-deflection speed of 1320 screen pixels/second. Zoom has a 0.1 deadzone and log-speed of 1.5/second. Both use a 25 ms acceleration response.
|
|
69
|
-
- Neutral input stops on the next step, without software coasting. Reversal discards response in the old direction.
|
|
70
|
-
- `twist`: clockwise zooms in. `press`: downward pressure zooms in, lifting zooms out. These directions refer to the verified profile.
|
|
71
|
-
- The default 50 ms frame cap limits jumps after rendering stalls. It is not a timeout for input reports.
|
|
72
|
-
- The adapter clears input on blur, hidden state and disconnect. Connect `onReset` to `motion.reset` as above so old response and frame timing are also cleared. Returning to the foreground waits for a fresh report. Use connection `pause()` / `resume()` when another tool owns the input; use controller `reset()` if you provide your own transport.
|
|
73
|
-
- Use one controller per independent input stream. Do not drive both the report callback and frame loop with movement updates.
|
|
74
|
-
|
|
75
|
-
## Hardware scope
|
|
76
|
-
|
|
77
|
-
The report layout and motion defaults were measured with vendor `0x256f`, product `0xc63a`, over Bluetooth on Windows: report 1, twelve bytes, six signed little-endian 16-bit axes, logical range ±350. Buttons and other layouts are not implemented yet. Other devices require a matching `DeviceProfile`; profile support should be backed by descriptors and captures, not guessed from the vendor alone.
|
|
78
|
-
|
|
79
|
-
WebHID requires browser support and device permission. The adapter inherits that availability; the motion core does not. [Official WebHID guide](https://developer.chrome.com/docs/capabilities/hid).
|
|
47
|
+
The verified device profile is vendor `0x256f`, product `0xc63a`, Bluetooth on Windows. Other layouts and physical buttons are not implemented. Gestures are **cap pulses**, not button clicks. See [hardware scope](docs/motion.md#hardware-scope) before adding a profile.
|
|
80
48
|
|
|
81
49
|
## Development
|
|
82
50
|
|
|
83
|
-
|
|
84
|
-
pnpm install
|
|
85
|
-
pnpm check
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
The build emits ESM and declarations. Tests run against those emitted modules. Runtime dependencies: none. Licensed under MIT. See [CONTRIBUTING.md](CONTRIBUTING.md) for development and device support.
|
|
51
|
+
Use Node 24 and the pnpm version pinned in `package.json`. `pnpm check` builds and runs tests. Read [CONTRIBUTING.md](CONTRIBUTING.md) before proposing an API or device profile; [AGENTS.md](AGENTS.md) describes repository development constraints. [DESIGN.md](DESIGN.md) explains architecture and [VERIFICATION.md](VERIFICATION.md) records validation. MIT licensed.
|
package/VERIFICATION.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Local preparation checks
|
|
2
|
+
|
|
3
|
+
- TypeScript build and 13 tests pass. Tests cover report bounds/signs, zero and non-motion handling, frame-rate independence, held input through report gaps, reversal, zoom-axis switching, snapshots, render-owned zoom anchors/limits, adapter pause/resume/disconnect, foreground interruption and cleanup.
|
|
4
|
+
- A separate local comparison against the working prototype evaluated 3,558 frames at 60, 144 and 240 Hz, both zoom mappings, neutral/reversal and mixed-axis input. Pan and camera zoom matched exactly (maximum difference zero). The comparison harness remains outside this package.
|
|
5
|
+
- Packed artifact installs into a separate ESM TypeScript consumer. Both public entry points resolve, declarations type-check, and importing the adapter in Node does not access browser globals. The packed artifact contains emitted code/declarations, package metadata and README; original captures and workspace documentation are excluded.
|
|
6
|
+
- The extracted adapter has not yet been exercised with physical hardware. Physical testing established the report layout and motion defaults in a prototype; adapter lifecycle coverage is currently synthetic.
|
|
7
|
+
- Package name: @clankagent/puck. License: MIT. Publication status is available from GitHub releases and the npm registry.
|
|
8
|
+
|
|
9
|
+
## Gesture experiment (2026-09-18)
|
|
10
|
+
|
|
11
|
+
- `pnpm check`: 26 tests pass. Added four-direction single/double coverage,
|
|
12
|
+
additive behavior, completion-window boundaries, pulse limits, hysteresis,
|
|
13
|
+
neutral dwell, reversal rejection, dominance, cancellation, timestamp validation,
|
|
14
|
+
report bursts, frame-rate independence, and WebHID lifecycle cancellation.
|
|
15
|
+
- Browser QA over HTTPS: pointer double rotation, keyboard single pull, preset
|
|
16
|
+
changes, save/restore comparison, and clear history verified. Zero minimum
|
|
17
|
+
pulse/dwell were used for instantaneous automation; balanced defaults restored.
|
|
18
|
+
- Desktop and 390px phone layouts visually inspected. Input trace and threshold
|
|
19
|
+
lines render; event table fits the phone layout.
|
|
20
|
+
- Fixed mixed report/rAF clock timestamps by using performance.now() in both
|
|
21
|
+
callbacks. Lifecycle neutral sentinels cancel rather than complete gestures.
|
|
22
|
+
- At this stage the gesture work was a local experiment. Later SDK and delivery
|
|
23
|
+
verification is recorded below; inferred replay does not establish subjective feel.
|
|
24
|
+
|
|
25
|
+
## Freeform recording (2026-09-18)
|
|
26
|
+
|
|
27
|
+
- Recording is the first section. No guided capture or prescribed gesture order.
|
|
28
|
+
- Stores input reports, frame timing, resets and detected events; settings locked
|
|
29
|
+
during capture, two-minute cap, retry/download fallback, and saved session list.
|
|
30
|
+
- 29 tests pass, including schema validation, session-token enforcement,
|
|
31
|
+
save/list/readback, and deterministic replay.
|
|
32
|
+
- Browser simulator recording was saved and then read directly by the analysis
|
|
33
|
+
CLI. Replayed double matched the recorded event; a pending final single was
|
|
34
|
+
reported correctly. Simulator evidence is not physical gesture calibration.
|
|
35
|
+
- User feedback: vertical push/pull activation should be lower than rotation.
|
|
36
|
+
Capture raw data first, then infer suitable separate thresholds from natural use.
|
|
37
|
+
|
|
38
|
+
## Direction threshold refinement
|
|
39
|
+
|
|
40
|
+
- Added validated axis/direction overrides without changing existing defaults.
|
|
41
|
+
- Lab has one symmetric rotation gate and independent push/pull gates.
|
|
42
|
+
- Recording tuned preset uses rotation activation/release .25/.15, push .20/.08,
|
|
43
|
+
pull .12/.06, neutral dwell 25 ms, and double window 400 ms.
|
|
44
|
+
- 33 tests pass. Synthetic regressions cover gentle vertical input, independent
|
|
45
|
+
signs, cross-axis pressure during twist, partial release and invalid thresholds.
|
|
46
|
+
- Physical freeform replay yields 18 doubles and 17 singles under the candidate
|
|
47
|
+
profile. These are inferred gesture groups, not ground-truth accuracy labels.
|
|
48
|
+
|
|
49
|
+
## Reusable tune / calibration SDK
|
|
50
|
+
|
|
51
|
+
- Added immutable versioned tune objects, default/soft/hard presets, fractional
|
|
52
|
+
soften/harden/narrow/widen transformations and JSON restoration. Missing
|
|
53
|
+
recognizer configuration now uses the complete default tune consistently.
|
|
54
|
+
- Added bounded recording independent of transport, browser, timers and storage.
|
|
55
|
+
- Added raw-shape calibration, independent of recorded event labels and action
|
|
56
|
+
ordering. At least three of each of eight actions are required; incomplete or
|
|
57
|
+
ambiguous evidence produces no tune. Multiple recordings can be combined;
|
|
58
|
+
reset/session boundaries prevent accidental pairing.
|
|
59
|
+
- 42 tests pass, including shuffled action groups, ambiguity, missing evidence,
|
|
60
|
+
immutable transforms, JSON round trips, recording bounds, default parity and
|
|
61
|
+
graph output. Existing pan/zoom, decoding and lifecycle tests continue to pass.
|
|
62
|
+
- The physical freeform capture passes all eight quotas; the automatically
|
|
63
|
+
generated tune reproduces 18 double and 17 single actions on replay. This is
|
|
64
|
+
consistency with inferred intent, not a claim of labeled accuracy.
|
|
65
|
+
- Browser QA: analyze saved capture, zoom a double, soften/reset/apply tune,
|
|
66
|
+
deduplicate combined captures, desktop/phone graph inspection. Mobile graph
|
|
67
|
+
labels and width were adjusted after visual inspection.
|
|
68
|
+
|
|
69
|
+
## Documentation and repository delivery (2026-09-18)
|
|
70
|
+
|
|
71
|
+
- 43 tests pass, including the public integration example's recording lifecycle,
|
|
72
|
+
pending-action cancellation and frame cleanup.
|
|
73
|
+
- README navigation, API reference, browser integration, calibration guide,
|
|
74
|
+
troubleshooting and agent guidance are included in the packed artifact.
|
|
75
|
+
- Checked 48 relative documentation links. Package inspection confirms docs and
|
|
76
|
+
reusable examples ship, while the lab server and raw recordings stay excluded.
|
|
77
|
+
- Gesture APIs remain experimental and are not yet an npm release. Source delivery
|
|
78
|
+
includes the gesture lab; captured device recordings remain outside the repository.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { GestureDirection } from './gestures.js';
|
|
2
|
+
import type { GestureTune } from './tune.js';
|
|
3
|
+
import type { GestureRecording } from './recording.js';
|
|
4
|
+
export declare const gestureDirections: readonly ['clockwise', 'counterclockwise', 'push', 'pull'];
|
|
5
|
+
export type GestureActionName = `${GestureDirection}.${'single' | 'double'}`;
|
|
6
|
+
export interface CalibrationPulse {
|
|
7
|
+
direction: GestureDirection;
|
|
8
|
+
start: number;
|
|
9
|
+
end: number;
|
|
10
|
+
peak: number;
|
|
11
|
+
peakTime: number;
|
|
12
|
+
valleyBefore: number;
|
|
13
|
+
recording: number;
|
|
14
|
+
segment: number;
|
|
15
|
+
}
|
|
16
|
+
export interface CalibrationAction {
|
|
17
|
+
direction: GestureDirection;
|
|
18
|
+
kind: 'single' | 'double';
|
|
19
|
+
start: number;
|
|
20
|
+
end: number;
|
|
21
|
+
pulses: CalibrationPulse[];
|
|
22
|
+
recording: number;
|
|
23
|
+
}
|
|
24
|
+
export interface CalibrationStats {
|
|
25
|
+
count: number;
|
|
26
|
+
center: number;
|
|
27
|
+
low: number;
|
|
28
|
+
high: number;
|
|
29
|
+
min: number;
|
|
30
|
+
max: number;
|
|
31
|
+
}
|
|
32
|
+
export interface GestureCalibration {
|
|
33
|
+
status: 'ready' | 'incomplete' | 'ambiguous';
|
|
34
|
+
tune: GestureTune | null;
|
|
35
|
+
counts: Record<GestureActionName, number>;
|
|
36
|
+
missing: GestureActionName[];
|
|
37
|
+
pulses: CalibrationPulse[];
|
|
38
|
+
actions: CalibrationAction[];
|
|
39
|
+
stats: Partial<Record<GestureDirection, CalibrationStats>>;
|
|
40
|
+
issues: string[];
|
|
41
|
+
}
|
|
42
|
+
/** Infer actions from raw shape and timing, independent of event labels and action order.
|
|
43
|
+
* Closely spaced singles can be indistinguishable from doubles: inferred counts are not ground truth.
|
|
44
|
+
*/
|
|
45
|
+
export declare function calibrateGestures(input: GestureRecording | readonly GestureRecording[], settings?: {
|
|
46
|
+
minimumPerAction?: number;
|
|
47
|
+
detectionFloor?: number;
|
|
48
|
+
pairGapMs?: number;
|
|
49
|
+
}): GestureCalibration;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createGestureTune } from './tune.js';
|
|
2
|
+
import { validateGestureRecording } from './recording.js';
|
|
3
|
+
export const gestureDirections = ['clockwise', 'counterclockwise', 'push', 'pull'];
|
|
4
|
+
const quantile = (values, p) => { const a = [...values].sort((x, y) => x - y); const at = (a.length - 1) * p, lo = Math.floor(at), hi = Math.ceil(at); return a[lo] + (a[hi] - a[lo]) * (at - lo); };
|
|
5
|
+
const mean = (values) => values.reduce((a, b) => a + b, 0) / values.length;
|
|
6
|
+
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
|
7
|
+
/** Infer actions from raw shape and timing, independent of event labels and action order.
|
|
8
|
+
* Closely spaced singles can be indistinguishable from doubles: inferred counts are not ground truth.
|
|
9
|
+
*/
|
|
10
|
+
export function calibrateGestures(input, settings = {}) {
|
|
11
|
+
const recordings = Array.isArray(input) ? input : [input];
|
|
12
|
+
const minimum = settings.minimumPerAction ?? 3, floor = settings.detectionFloor ?? .025, pairGap = settings.pairGapMs ?? 250;
|
|
13
|
+
if (!Number.isInteger(minimum) || minimum < 3 || minimum > 100 || !Number.isFinite(floor) || floor <= 0 || floor > .1 || !Number.isFinite(pairGap) || pairGap < 50 || pairGap > 500)
|
|
14
|
+
throw new RangeError('Invalid calibration limits; at least three examples per action are required.');
|
|
15
|
+
if (recordings.length < 1 || recordings.length > 20)
|
|
16
|
+
throw new RangeError('Use one to twenty recordings.');
|
|
17
|
+
const pulses = [], issues = [];
|
|
18
|
+
function episode(points, direction, recording, segment) {
|
|
19
|
+
if (points.length < 3 || points[0].v > floor || points[points.length - 1].v > floor)
|
|
20
|
+
return;
|
|
21
|
+
const peaks = [];
|
|
22
|
+
for (let i = 1; i < points.length - 1; i++)
|
|
23
|
+
if (points[i].v >= Math.max(.06, floor * 2.5) && points[i].v >= points[i - 1].v && points[i].v > points[i + 1].v)
|
|
24
|
+
peaks.push(i);
|
|
25
|
+
const selected = [];
|
|
26
|
+
for (const peak of peaks) {
|
|
27
|
+
if (!selected.length) {
|
|
28
|
+
selected.push(peak);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const prev = selected[selected.length - 1];
|
|
32
|
+
let valley = Infinity;
|
|
33
|
+
for (let j = prev; j <= peak; j++)
|
|
34
|
+
valley = Math.min(valley, points[j].v);
|
|
35
|
+
// Ignore tiny ripples on one excursion. A clear relaxation separates pulses.
|
|
36
|
+
if (valley > Math.min(points[prev].v, points[peak].v) * .45 || points[peak].t - points[prev].t < 70) {
|
|
37
|
+
if (points[peak].v > points[prev].v)
|
|
38
|
+
selected[selected.length - 1] = peak;
|
|
39
|
+
}
|
|
40
|
+
else
|
|
41
|
+
selected.push(peak);
|
|
42
|
+
}
|
|
43
|
+
const bounds = [0];
|
|
44
|
+
for (let i = 1; i < selected.length; i++) {
|
|
45
|
+
let index = selected[i - 1];
|
|
46
|
+
for (let j = index; j <= selected[i]; j++)
|
|
47
|
+
if (points[j].v < points[index].v)
|
|
48
|
+
index = j;
|
|
49
|
+
bounds.push(index);
|
|
50
|
+
}
|
|
51
|
+
bounds.push(points.length - 1);
|
|
52
|
+
selected.forEach((peak, i) => {
|
|
53
|
+
const from = points[bounds[i]], to = points[bounds[i + 1]], top = points[peak], duration = to.t - from.t;
|
|
54
|
+
if (top.v < top.other * 1.4)
|
|
55
|
+
return; // Incidental pressure while twisting, or vice versa.
|
|
56
|
+
if (duration < 45 || duration > 1000) {
|
|
57
|
+
issues.push(`Excluded ${direction} excursion outside 45–1000 ms.`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
pulses.push({ direction, start: from.t, end: to.t, peak: top.v, peakTime: top.t, valleyBefore: from.v, recording, segment });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
recordings.forEach((recording, index) => {
|
|
64
|
+
validateGestureRecording(recording);
|
|
65
|
+
for (const direction of gestureDirections) {
|
|
66
|
+
const vertical = direction === 'push' || direction === 'pull', sign = direction === 'push' || direction === 'clockwise' ? 1 : -1;
|
|
67
|
+
let run = [], previous, segment = 0;
|
|
68
|
+
for (const row of recording.timeline) {
|
|
69
|
+
if (row.type === 'reset') {
|
|
70
|
+
run = [];
|
|
71
|
+
previous = undefined;
|
|
72
|
+
segment++;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (row.type !== 'input')
|
|
76
|
+
continue;
|
|
77
|
+
const point = { t: row.t, v: Math.max(0, sign * (vertical ? row.input.z : row.input.rz)), other: Math.abs(vertical ? row.input.rz : row.input.z) };
|
|
78
|
+
if (point.v > floor) {
|
|
79
|
+
if (!run.length)
|
|
80
|
+
run = previous ? [{ ...previous, t: point.t }] : [];
|
|
81
|
+
run.push(point);
|
|
82
|
+
}
|
|
83
|
+
else if (run.length) {
|
|
84
|
+
run.push(point);
|
|
85
|
+
episode(run, direction, index, segment);
|
|
86
|
+
run = [];
|
|
87
|
+
}
|
|
88
|
+
previous = point;
|
|
89
|
+
}
|
|
90
|
+
if (run.length)
|
|
91
|
+
issues.push(`Unfinished ${direction} excursion at recording end.`);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
pulses.sort((a, b) => a.recording - b.recording || a.start - b.start);
|
|
95
|
+
const actions = [], counts = {};
|
|
96
|
+
for (const d of gestureDirections)
|
|
97
|
+
for (const kind of ['single', 'double'])
|
|
98
|
+
counts[`${d}.${kind}`] = 0;
|
|
99
|
+
const paired = (a, b) => a.recording === b.recording && a.segment === b.segment && a.direction === b.direction && b.start - a.end <= pairGap && b.end - a.end <= 650;
|
|
100
|
+
let ambiguous = false;
|
|
101
|
+
for (let i = 0; i < pulses.length;) {
|
|
102
|
+
let end = i + 1;
|
|
103
|
+
while (end < pulses.length && paired(pulses[end - 1], pulses[end]))
|
|
104
|
+
end++;
|
|
105
|
+
if (end - i > 2) {
|
|
106
|
+
ambiguous = true;
|
|
107
|
+
issues.push(`Ambiguous run of ${end - i} ${pulses[i].direction} pulses near ${(pulses[i].start / 1000).toFixed(1)} s; leave a longer pause between actions.`);
|
|
108
|
+
i = end;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const group = pulses.slice(i, end), kind = group.length === 2 ? 'double' : 'single';
|
|
112
|
+
const action = { direction: group[0].direction, kind, start: group[0].start, end: group[group.length - 1].end, pulses: group, recording: group[0].recording };
|
|
113
|
+
actions.push(action);
|
|
114
|
+
counts[`${action.direction}.${kind}`]++;
|
|
115
|
+
i = end;
|
|
116
|
+
}
|
|
117
|
+
const stats = {};
|
|
118
|
+
for (const d of gestureDirections) {
|
|
119
|
+
const values = pulses.filter(p => p.direction === d).map(p => p.peak);
|
|
120
|
+
if (values.length)
|
|
121
|
+
stats[d] = { count: values.length, center: mean(values), low: quantile(values, .1), high: quantile(values, .9), min: Math.min(...values), max: Math.max(...values) };
|
|
122
|
+
}
|
|
123
|
+
const missing = Object.keys(counts).filter(key => counts[key] < minimum);
|
|
124
|
+
let tune = null;
|
|
125
|
+
if (!missing.length && !ambiguous) {
|
|
126
|
+
function band(d) { const s = stats[d]; const activation = clamp(s.low * .7, .04, s.min * .85); const returns = pulses.filter(p => p.direction === d).map(p => p.valleyBefore); const release = clamp(Math.max(activation * .5, ...returns.map(v => v * 1.2)), .01, activation * .8); return { center: s.center, low: s.low, high: s.high, activation, release }; }
|
|
127
|
+
const cw = band('clockwise'), ccw = band('counterclockwise');
|
|
128
|
+
const rotation = { center: (cw.center + ccw.center) / 2, low: (cw.low + ccw.low) / 2, high: (cw.high + ccw.high) / 2, activation: (cw.activation + ccw.activation) / 2, release: Math.max(cw.release, ccw.release) };
|
|
129
|
+
// Equal rotation gates, while still accommodating the shallower inter-pulse return.
|
|
130
|
+
rotation.release = Math.min(rotation.release, rotation.activation * .85);
|
|
131
|
+
const lengths = pulses.map(p => p.end - p.start), doubles = actions.filter(a => a.kind === 'double').map(a => a.pulses[1].end - a.pulses[0].end);
|
|
132
|
+
tune = createGestureTune({ version: 1, rotation, push: band('push'), pull: band('pull'), dominance: 1.4, timing: { minPulseMs: Math.round(clamp(quantile(lengths, .1) * .2, 15, 60)), maxPulseMs: Math.round(clamp(quantile(lengths, .9) * 2, 350, 1000)), neutralMs: 20, doubleMs: Math.round(clamp(Math.max(...doubles) + 40, 200, 650)) } });
|
|
133
|
+
}
|
|
134
|
+
return { status: ambiguous ? 'ambiguous' : missing.length ? 'incomplete' : 'ready', tune, counts, missing, pulses, actions, stats, issues: [...new Set(issues)] };
|
|
135
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { InputState } from './input.js';
|
|
2
|
+
import type { GestureTune } from './tune.js';
|
|
3
|
+
export type GestureDirection = 'clockwise' | 'counterclockwise' | 'push' | 'pull';
|
|
4
|
+
export interface GestureOptions {
|
|
5
|
+
activation?: number;
|
|
6
|
+
release?: number;
|
|
7
|
+
/** Optional axis-specific thresholds; fall back to activation/release. */
|
|
8
|
+
pressActivation?: number;
|
|
9
|
+
pressRelease?: number;
|
|
10
|
+
twistActivation?: number;
|
|
11
|
+
twistRelease?: number;
|
|
12
|
+
clockwiseActivation?: number;
|
|
13
|
+
clockwiseRelease?: number;
|
|
14
|
+
counterclockwiseActivation?: number;
|
|
15
|
+
counterclockwiseRelease?: number;
|
|
16
|
+
pushActivation?: number;
|
|
17
|
+
pushRelease?: number;
|
|
18
|
+
pullActivation?: number;
|
|
19
|
+
pullRelease?: number;
|
|
20
|
+
minPulseMs?: number;
|
|
21
|
+
maxPulseMs?: number;
|
|
22
|
+
neutralMs?: number;
|
|
23
|
+
doubleMs?: number;
|
|
24
|
+
/** Exclusive waits for a double; immediate emits single, then double (additive). */
|
|
25
|
+
singleMode?: 'exclusive' | 'immediate';
|
|
26
|
+
/** Strongest axis must exceed the other by this ratio. */
|
|
27
|
+
dominance?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface GestureEvent {
|
|
30
|
+
direction: GestureDirection;
|
|
31
|
+
kind: 'single' | 'double';
|
|
32
|
+
timestamp: number;
|
|
33
|
+
durationMs: number;
|
|
34
|
+
}
|
|
35
|
+
export interface GestureRecognizer {
|
|
36
|
+
/** Process every report with a monotonic timestamp; do not discard reports between frames. */
|
|
37
|
+
update(input: Readonly<InputState>, timestampMs: number): GestureEvent[];
|
|
38
|
+
/** Advance pending single/release deadlines, even when no reports arrive. */
|
|
39
|
+
advance(timestampMs: number): GestureEvent[];
|
|
40
|
+
/** Cancel everything. Fresh neutral input is required before rearming. */
|
|
41
|
+
reset(): void;
|
|
42
|
+
readonly state: {
|
|
43
|
+
phase: 'neutral' | 'active' | 'releasing' | 'blocked';
|
|
44
|
+
direction: GestureDirection | null;
|
|
45
|
+
pending: GestureDirection | null;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Experimental pulse recognizer. No timers, DOM, transport, or motion side effects. */
|
|
49
|
+
export declare function createGestures(configuration?: GestureOptions | GestureTune): GestureRecognizer;
|