tracky-mouse 2.6.0 → 2.7.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.
Files changed (71) hide show
  1. package/README.md +2 -1
  2. package/audio/click-press.wav +0 -0
  3. package/audio/click-release.wav +0 -0
  4. package/audio/middle-click-press.wav +0 -0
  5. package/audio/middle-click-release.wav +0 -0
  6. package/audio/pause.wav +0 -0
  7. package/audio/unpause.wav +0 -0
  8. package/audio.js +145 -0
  9. package/locales/ar/translation.json +3 -1
  10. package/locales/ar-EG/translation.json +3 -1
  11. package/locales/bg/translation.json +3 -1
  12. package/locales/bn/translation.json +3 -1
  13. package/locales/ca/translation.json +3 -1
  14. package/locales/ce/translation.json +3 -1
  15. package/locales/ceb/translation.json +3 -1
  16. package/locales/cs/translation.json +3 -1
  17. package/locales/da/translation.json +3 -1
  18. package/locales/de/translation.json +3 -1
  19. package/locales/el/translation.json +3 -1
  20. package/locales/emoji/translation.json +3 -1
  21. package/locales/en/translation.json +3 -1
  22. package/locales/eo/translation.json +3 -1
  23. package/locales/es/translation.json +3 -1
  24. package/locales/eu/translation.json +3 -1
  25. package/locales/fa/translation.json +3 -1
  26. package/locales/fi/translation.json +3 -1
  27. package/locales/fr/translation.json +3 -1
  28. package/locales/gu/translation.json +3 -1
  29. package/locales/ha/translation.json +3 -1
  30. package/locales/he/translation.json +3 -1
  31. package/locales/hi/translation.json +3 -1
  32. package/locales/hr/translation.json +3 -1
  33. package/locales/hu/translation.json +3 -1
  34. package/locales/hy/translation.json +3 -1
  35. package/locales/id/translation.json +3 -1
  36. package/locales/it/translation.json +3 -1
  37. package/locales/ja/translation.json +3 -1
  38. package/locales/jv/translation.json +3 -1
  39. package/locales/ko/translation.json +3 -1
  40. package/locales/mr/translation.json +3 -1
  41. package/locales/ms/translation.json +3 -1
  42. package/locales/nan/translation.json +3 -1
  43. package/locales/nb/translation.json +3 -1
  44. package/locales/nl/translation.json +3 -1
  45. package/locales/pa/translation.json +3 -1
  46. package/locales/pl/translation.json +3 -1
  47. package/locales/pt/translation.json +3 -1
  48. package/locales/pt-BR/translation.json +3 -1
  49. package/locales/ro/translation.json +3 -1
  50. package/locales/ru/translation.json +3 -1
  51. package/locales/sk/translation.json +3 -1
  52. package/locales/sl/translation.json +3 -1
  53. package/locales/sr/translation.json +3 -1
  54. package/locales/sv/translation.json +3 -1
  55. package/locales/sw/translation.json +3 -1
  56. package/locales/ta/translation.json +3 -1
  57. package/locales/te/translation.json +3 -1
  58. package/locales/th/translation.json +3 -1
  59. package/locales/tl/translation.json +3 -1
  60. package/locales/tr/translation.json +3 -1
  61. package/locales/tt/translation.json +3 -1
  62. package/locales/uk/translation.json +3 -1
  63. package/locales/ur/translation.json +3 -1
  64. package/locales/uz/translation.json +3 -1
  65. package/locales/vi/translation.json +3 -1
  66. package/locales/war/translation.json +3 -1
  67. package/locales/zh/translation.json +3 -1
  68. package/locales/zh-simplified/translation.json +3 -1
  69. package/package.json +3 -1
  70. package/tracky-mouse.css +1 -1
  71. package/tracky-mouse.js +292 -128
package/tracky-mouse.js CHANGED
@@ -52,24 +52,30 @@ const isSelectorValid = ((dummyElement) =>
52
52
 
53
53
  const dwellClickers = [];
54
54
 
55
+ let playSound = () => { console.log("audio module not loaded yet; can't play sound effect"); };
56
+ let initialAudioEnabled = false;
57
+ let setAudioEnabled = (enabled) => { initialAudioEnabled = enabled; };
58
+
59
+ /**
60
+ * @param {Object} config
61
+ * @param {string} config.targets - a CSS selector for the elements to click. Anything else will be ignored (except as an occluder).
62
+ * @param {(el: Element) => boolean} [config.shouldDrag] - a function that returns true if the element should be dragged rather than simply clicked.
63
+ * @param {(el: Element) => boolean} [config.noCenter] - a function that returns true if the element should be clicked anywhere on the element, rather than always at the center.
64
+ * @param {Array<{
65
+ * from: string | Element | ((el: Element) => boolean), // - an array of `{ from, to, withinMargin }` objects, which define rules for dynamically changing what is hovered/clicked when the mouse is over a different element.
66
+ * to: string | Element | ((el: Element) => Element | null), // - the element to retarget from. Can be a CSS selector, an element, or a function taking the element under the mouse and returning whether it should be retargeted.
67
+ * withinMargin?: number // - the element to retarget to. Can be a CSS selector for an element which is an ancestor or descendant of the `from` element, or an element, or a function taking the element under the mouse and returning an element to retarget to, or null to ignore the element.
68
+ * }>} [config.retarget] - a number of pixels within which to consider the mouse over the `to` element. Default to infinity.
69
+ * @param {(el1: Element, el2: Element) => boolean} [config.isEquivalentTarget] - a function that returns true if two elements should be considered part of the same control, i.e. if clicking either should do the same thing. Elements that are equal are always considered equivalent even if you return false. This option is used for preventing the system from detecting occluding elements as separate controls, and rejecting the click. (When an occlusion is detected, it flashes a red box.)
70
+ * @param {(el: Element) => boolean} [config.dwellClickEvenIfPaused] - a function that returns true if the element should be clicked even while dwell clicking is otherwise paused. Use this for a dwell clicking toggle button, so it's possible to resume dwell clicking. With dwell clicking it's important to let users take a break, since otherwise you have to constantly move the cursor in order to not click on things!
71
+ * @param {(el: Element) => boolean} [config.shouldClickThrough] - a function that returns true if the element should be totally ignored, allowing clicking on content behind it. Prefer `pointer-events: none` when possible, which will work for all input methods. Use this only if you need to differentiate input methods. Default: `(el) => el.matches(".tracky-mouse-click-through, .tracky-mouse-click-through *")`
72
+ * @param {(args: {x: number, y: number, target: Element}) => void} config.click - a function to trigger a click on the given target element.
73
+ * @param {() => void} [config.beforeDispatch] - a function to call before a pointer event is dispatched. For detecting un-trusted user gestures, outside of an event handler.
74
+ * @param {() => void} [config.afterDispatch] - a function to call after a pointer event is dispatched. For detecting un-trusted user gestures, outside of an event handler.
75
+ * @param {() => void} [config.beforePointerDownDispatch] - a function to call before a `pointerdown` event is dispatched. Likely to be merged with `config.beforeDispatch()` in the future.
76
+ * @param {() => boolean} [config.isHeld] - a function that returns true if the next dwell should be a release (triggering `pointerup`).
77
+ */
55
78
  const initDwellClicking = (config) => {
56
- /*
57
- Arguments:
58
- - `config.targets` (required): a CSS selector for the elements to click. Anything else will be ignored.
59
- - `config.shouldDrag(el)` (optional): a function that returns true if the element should be dragged rather than simply clicked.
60
- - `config.noCenter(el)` (optional): a function that returns true if the element should be clicked anywhere on the element, rather than always at the center.
61
- - `config.retarget` (optional): an array of `{ from, to, withinMargin }` objects, which define rules for dynamically changing what is hovered/clicked when the mouse is over a different element.
62
- - `from` (required): the element to retarget from. Can be a CSS selector, an element, or a function taking the element under the mouse and returning whether it should be retargeted.
63
- - `to` (required): the element to retarget to. Can be a CSS selector for an element which is an ancestor or descendant of the `from` element, or an element, or a function taking the element under the mouse and returning an element to retarget to, or null to ignore the element.
64
- - `withinMargin` (optional): a number of pixels within which to consider the mouse over the `to` element. Default to infinity.
65
- - `config.isEquivalentTarget(el1, el2)` (optional): a function that returns true if two elements should be considered part of the same control, i.e. if clicking either should do the same thing. Elements that are equal are always considered equivalent even if you return false. This option is used for preventing the system from detecting occluding elements as separate controls, and rejecting the click. (When an occlusion is detected, it flashes a red box.)
66
- - `config.dwellClickEvenIfPaused(el)` (optional): a function that returns true if the element should be clicked even while dwell clicking is otherwise paused. Use this for a dwell clicking toggle button, so it's possible to resume dwell clicking. With dwell clicking it's important to let users take a break, since otherwise you have to constantly move the cursor in order to not click on things!
67
- - `config.click({x, y, target})` (required): a function to trigger a click on the given target element.
68
- - `config.beforeDispatch()` (optional): a function to call before a pointer event is dispatched. For detecting un-trusted user gestures, outside of an event handler.
69
- - `config.afterDispatch()` (optional): a function to call after a pointer event is dispatched. For detecting un-trusted user gestures, outside of an event handler.
70
- - `config.beforePointerDownDispatch()` (optional): a function to call before a `pointerdown` event is dispatched. Likely to be merged with `config.beforeDispatch()` in the future.
71
- - `config.isHeld()` (optional): a function that returns true if the next dwell should be a release (triggering `pointerup`).
72
- */
73
79
 
74
80
  /** translation placeholder */
75
81
  const t = (key, options = {}) => options.defaultValue ?? key;
@@ -104,6 +110,9 @@ const initDwellClicking = (config) => {
104
110
  if (config.dwellClickEvenIfPaused !== undefined && typeof config.dwellClickEvenIfPaused !== "function") {
105
111
  throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.dwellClickEvenIfPaused"));
106
112
  }
113
+ if (config.shouldClickThrough !== undefined && typeof config.shouldClickThrough !== "function") {
114
+ throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.shouldClickThrough"));
115
+ }
107
116
  if (config.beforeDispatch !== undefined && typeof config.beforeDispatch !== "function") {
108
117
  throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.beforeDispatch"));
109
118
  }
@@ -149,6 +158,8 @@ const initDwellClicking = (config) => {
149
158
  }
150
159
  }
151
160
 
161
+ const shouldClickThrough = config.shouldClickThrough ?? ((el) => el.matches(".tracky-mouse-click-through, .tracky-mouse-click-through *"));
162
+
152
163
  // trackyMouseContainer.querySelector(".tracky-mouse-canvas").classList.add("inset-deep");
153
164
 
154
165
  const circleRadiusMax = 50; // dwell indicator size in pixels
@@ -222,6 +233,14 @@ const initDwellClicking = (config) => {
222
233
  return null;
223
234
  }
224
235
 
236
+ if (shouldClickThrough(target)) {
237
+ const elements = document.elementsFromPoint(clientX, clientY);
238
+ target = elements.find(el => !shouldClickThrough(el));
239
+ if (!target) {
240
+ return null;
241
+ }
242
+ }
243
+
225
244
  let hoverCandidate = {
226
245
  x: clientX,
227
246
  y: clientY,
@@ -365,6 +384,9 @@ const initDwellClicking = (config) => {
365
384
  showOccluderIndicator(apparentHoverCandidate.target);
366
385
  }
367
386
  } else {
387
+ // TODO: ignore .tracky-mouse-click-through elements here as well
388
+ // TODO: distinguish occlusion vs moved element (i.e. element is no longer in the elementsFromPoint list)
389
+ // for example for the archery targets in the demo on the website, which animate
368
390
  let occluder = document.elementFromPoint(hoverCandidate.x, hoverCandidate.y);
369
391
  hoverCandidate = null;
370
392
  deactivateForAtLeast(inactiveAfterInvalidTimespan);
@@ -391,6 +413,7 @@ const initDwellClicking = (config) => {
391
413
  })
392
414
  ));
393
415
  config.afterDispatch?.();
416
+ playSound("clickRelease");
394
417
  } else {
395
418
  config.beforePointerDownDispatch?.();
396
419
  config.beforeDispatch?.();
@@ -403,6 +426,7 @@ const initDwellClicking = (config) => {
403
426
  config.afterDispatch?.();
404
427
  if (config.shouldDrag?.(hoverCandidate.target)) {
405
428
  dwellDragging = hoverCandidate.target;
429
+ playSound("clickPress");
406
430
  } else {
407
431
  config.beforeDispatch?.();
408
432
  hoverCandidate.target.dispatchEvent(new PointerEvent("pointerup",
@@ -413,6 +437,8 @@ const initDwellClicking = (config) => {
413
437
  ));
414
438
  config.click(hoverCandidate);
415
439
  config.afterDispatch?.();
440
+ playSound("clickPress");
441
+ playSound("clickRelease", { delay: 0.03 }); // fully separating the sounds sounded worse
416
442
  }
417
443
  }
418
444
  hoverCandidate = null;
@@ -571,6 +597,28 @@ TrackyMouse.cleanupDwellClicking = function () {
571
597
  }
572
598
  };
573
599
 
600
+ TrackyMouse._initAudio = async function () {
601
+ let module;
602
+ try {
603
+ // console.log("Loading audio support...");
604
+ module = await import("./audio.js");
605
+ } catch (e) {
606
+ console.warn("Failed to load audio module, click sounds will be disabled:", e);
607
+ }
608
+ // console.log("Audio module loaded.");
609
+ try {
610
+ const { initAudio } = module;
611
+ initAudio();
612
+ playSound = module.playSound;
613
+ setAudioEnabled = module.setAudioEnabled;
614
+ setAudioEnabled(initialAudioEnabled);
615
+ // console.log("Audio is initially " + (initialAudioEnabled ? "enabled" : "disabled"));
616
+ } catch (e) {
617
+ console.warn("Failed to initialize audio support, click sounds will be disabled:", e);
618
+ }
619
+ return module;
620
+ };
621
+
574
622
  TrackyMouse._initInner = function (div, initOptions, reinit) {
575
623
 
576
624
  const {
@@ -590,6 +638,17 @@ TrackyMouse._initInner = function (div, initOptions, reinit) {
590
638
  // Could group things under an "unstable" object, or ideally, design nice APIs for everything.
591
639
  } = initOptions;
592
640
 
641
+ /** @type {SleepSweep | null} */
642
+ let sleepSweep = null;
643
+
644
+ TrackyMouse._initAudio().then((module) => {
645
+ // _initAudio warns in the console and resolves to undefined if it fails to load audio support
646
+ if (module) {
647
+ const { SleepSweep } = module;
648
+ sleepSweep = new SleepSweep();
649
+ }
650
+ });
651
+
593
652
  const isDesktopApp = !!window.electronAPI;
594
653
 
595
654
  let translations = {};
@@ -843,7 +902,7 @@ TrackyMouse._initInner = function (div, initOptions, reinit) {
843
902
  };
844
903
 
845
904
 
846
- var languageToDefaultRegion = {
905
+ let languageToDefaultRegion = {
847
906
  aa: "ET",
848
907
  ab: "GE",
849
908
  abr: "GH",
@@ -1588,9 +1647,9 @@ TrackyMouse._initInner = function (div, initOptions, reinit) {
1588
1647
  // </svg>`;
1589
1648
  }
1590
1649
 
1591
- var split = locale.toUpperCase().split(/-|_/);
1592
- var lang = split.shift();
1593
- var code = split.pop();
1650
+ let split = locale.toUpperCase().split(/-|_/);
1651
+ let lang = split.shift();
1652
+ let code = split.pop();
1594
1653
 
1595
1654
  if (!/^[A-Z]{2}$/.test(code)) {
1596
1655
  code = languageToDefaultRegion[lang.toLowerCase()];
@@ -1605,7 +1664,7 @@ TrackyMouse._initInner = function (div, initOptions, reinit) {
1605
1664
  return a + b;
1606
1665
  }
1607
1666
 
1608
- var uiContainer = div || document.createElement("div");
1667
+ let uiContainer = div || document.createElement("div");
1609
1668
  uiContainer.classList.add("tracky-mouse-ui");
1610
1669
  uiContainer.classList.toggle("tracky-mouse-rtl", isRTL);
1611
1670
  uiContainer.dir = isRTL ? "rtl" : "ltr";
@@ -1631,12 +1690,12 @@ TrackyMouse._initInner = function (div, initOptions, reinit) {
1631
1690
  if (!div) {
1632
1691
  document.body.appendChild(uiContainer);
1633
1692
  }
1634
- var startStopButton = uiContainer.querySelector(".tracky-mouse-start-stop-button");
1635
- var useCameraButton = uiContainer.querySelector(".tracky-mouse-use-camera-button");
1636
- var useDemoFootageButton = uiContainer.querySelector(".tracky-mouse-use-demo-footage-button");
1637
- var errorMessage = uiContainer.querySelector(".tracky-mouse-error-message");
1638
- var canvasContainer = uiContainer.querySelector('.tracky-mouse-canvas-container');
1639
- var desktopAppDownloadMessage = uiContainer.querySelector('.tracky-mouse-desktop-app-download-message');
1693
+ let startStopButton = uiContainer.querySelector(".tracky-mouse-start-stop-button");
1694
+ let useCameraButton = uiContainer.querySelector(".tracky-mouse-use-camera-button");
1695
+ let useDemoFootageButton = uiContainer.querySelector(".tracky-mouse-use-demo-footage-button");
1696
+ let errorMessage = uiContainer.querySelector(".tracky-mouse-error-message");
1697
+ let canvasContainer = uiContainer.querySelector('.tracky-mouse-canvas-container');
1698
+ let desktopAppDownloadMessage = uiContainer.querySelector('.tracky-mouse-desktop-app-download-message');
1640
1699
 
1641
1700
  // Settings (initialized later; defaults are defined in settingsCategories)
1642
1701
  const s = {};
@@ -2015,6 +2074,20 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2015
2074
  type: "group",
2016
2075
  label: t("settings.sections.general.label", { defaultValue: "General" }),
2017
2076
  settings: [
2077
+ {
2078
+ label: t("settings.soundEffects.label", { defaultValue: "Sound effects" }),
2079
+ className: "tracky-mouse-sound-effects",
2080
+ key: "soundEffects",
2081
+ type: "checkbox",
2082
+ default: true,
2083
+ afterInitialLoad: () => {
2084
+ setAudioEnabled(s.soundEffects);
2085
+ },
2086
+ handleSettingChange: () => {
2087
+ setAudioEnabled(s.soundEffects);
2088
+ },
2089
+ description: t("settings.soundEffects.description", { defaultValue: "Plays sounds when you click." }),
2090
+ },
2018
2091
  // opposite, "Start paused", might be clearer, especially if I add a "pause" button
2019
2092
  {
2020
2093
  label: t("settings.startEnabled.label", { defaultValue: "Start enabled" }),
@@ -2036,7 +2109,8 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2036
2109
  // - I considered adding "⚠︎" but it feels a little too alarming
2037
2110
  // label: "Close eyes to start/stop (<span style=\"border-bottom: 1px dotted;\" title=\"Planned refinements include: visual and auditory feedback, improved detection accuracy, and separate settings for durations to toggle on and off.\">experimental</span>)",
2038
2111
  // label: "Close eyes to start/stop (<span style=\"border-bottom: 1px dotted;\" title=\"• Missing visual and auditory feedback.\n• Missing settings for duration(s) to toggle on and off.\n• Affected by false positive blink detections, especially when looking downward.\">Experimental</span>)",
2039
- label: t("settings.closeEyesToToggle.label", { defaultValue: "Close eyes to start/stop (<span style=\"border-bottom: 1px dotted;\" title=\"• There is currently no visual or auditory feedback.\n• There are no settings for duration(s) to toggle on and off.\n• It is affected by false positive blink detections, especially when looking downward.\">Experimental</span>)" }),
2112
+ // label: t("settings.closeEyesToToggle.label", { defaultValue: "Close eyes to start/stop (<span style=\"border-bottom: 1px dotted;\" title=\"• There is currently no visual or auditory feedback.\n• There are no settings for duration(s) to toggle on and off.\n• It is affected by false positive blink detections, especially when looking downward.\">Experimental</span>)" }),
2113
+ label: t("settings.closeEyesToToggle.label", { defaultValue: "Close eyes to start/stop" }),
2040
2114
  className: "tracky-mouse-close-eyes-to-toggle",
2041
2115
  key: "closeEyesToToggle",
2042
2116
  type: "checkbox",
@@ -2309,26 +2383,27 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2309
2383
  });
2310
2384
  }
2311
2385
 
2312
- var canvas = uiContainer.querySelector(".tracky-mouse-canvas");
2313
- var ctx = canvas.getContext('2d', { willReadFrequently: true });
2386
+ let canvas = uiContainer.querySelector(".tracky-mouse-canvas");
2387
+ let ctx = canvas.getContext('2d', { willReadFrequently: true });
2314
2388
 
2315
- var debugEyeCanvas = document.createElement("canvas");
2389
+ let debugEyeCanvas = document.createElement("canvas");
2316
2390
  debugEyeCanvas.className = "tracky-mouse-debug-eye-canvas";
2317
2391
  debugEyeCanvas.style.display = "none";
2318
2392
  uiContainer.querySelector(".tracky-mouse-canvas-container-container").appendChild(debugEyeCanvas);
2319
- var debugEyeCtx = debugEyeCanvas.getContext('2d');
2393
+ let debugEyeCtx = debugEyeCanvas.getContext('2d');
2320
2394
 
2321
- var pointerEl = document.createElement('div');
2395
+ let pointerEl = document.createElement('div');
2322
2396
  pointerEl.className = "tracky-mouse-pointer";
2323
2397
  pointerEl.style.display = "none";
2324
2398
  document.body.appendChild(pointerEl);
2325
2399
 
2326
- var cameraVideo = document.createElement('video');
2400
+ let cameraVideo = document.createElement('video');
2327
2401
  // required to work in iOS 11 & up:
2328
2402
  cameraVideo.setAttribute('playsinline', '');
2329
2403
 
2404
+ let stats;
2330
2405
  if (statsJs) {
2331
- var stats = new Stats();
2406
+ stats = new Stats();
2332
2407
  stats.domElement.style.position = 'fixed';
2333
2408
  stats.domElement.style.top = '0px';
2334
2409
  stats.domElement.style.right = '0px';
@@ -2337,72 +2412,74 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2337
2412
  }
2338
2413
 
2339
2414
  // Debug flags (not shown in the UI; could become Advanced Settings in the future)
2340
- var debugAcceleration = false;
2341
- var showDebugText = false;
2342
- var showDebugEyeZoom = false;
2343
- var showDebugHeadTilt = false;
2415
+ let debugAcceleration = false;
2416
+ let showDebugText = false;
2417
+ let showDebugEyeZoom = false;
2418
+ let showDebugHeadTilt = false;
2419
+ let showDebugRegionFilter = false;
2344
2420
 
2345
2421
  // Constants (could become Advanced Settings in the future)
2346
- var defaultWidth = 640;
2347
- var defaultHeight = 480;
2348
- var maxPoints = 1000;
2349
- var faceScoreThreshold = 0.5;
2350
- var facemeshOptions = {
2422
+ let defaultWidth = 640;
2423
+ let defaultHeight = 480;
2424
+ let maxPoints = 1000;
2425
+ let faceScoreThreshold = 0.5;
2426
+ let facemeshOptions = {
2351
2427
  maxContinuousChecks: 5,
2352
2428
  detectionConfidence: 0.9,
2353
2429
  maxFaces: 1,
2354
2430
  iouThreshold: 0.3,
2355
2431
  scoreThreshold: 0.75
2356
2432
  };
2357
- var useFacemesh = true;
2433
+ let useFacemesh = true;
2434
+ let sleepGestureEyesClosedDuration = 2000;
2358
2435
  // maybe should be based on size of head in view?
2359
2436
  const pruningGridSize = 5;
2360
2437
  const minDistanceToAddPoint = pruningGridSize * 1.5;
2361
2438
 
2362
2439
  // Head tracking and facial gesture state
2363
2440
  // ## Clmtrackr state
2364
- var face;
2365
- var faceScore = 0;
2366
- var faceConvergence = 0;
2367
- // var faceConvergenceThreshold = 50;
2368
- var pointsBasedOnFaceScore = 0;
2441
+ let face;
2442
+ let faceScore = 0;
2443
+ let faceConvergence = 0;
2444
+ // let faceConvergenceThreshold = 50;
2445
+ let pointsBasedOnFaceScore = 0;
2369
2446
  // ## Facemesh state
2370
2447
  let detector;
2371
2448
  let currentCameraImageData;
2372
- var facemeshLoaded = false;
2373
- var facemeshFirstEstimation = true;
2374
- var facemeshEstimating = false;
2375
- var facemeshRejectNext = 0;
2376
- var facemeshPrediction;
2377
- var facemeshEstimateFaces;
2378
- var faceInViewConfidenceThreshold = 0.7;
2379
- var pointsBasedOnFaceInViewConfidence = 0;
2380
- var cameraFramesSinceFacemeshUpdate = [];
2381
- var blinkInfo;
2382
- var mouthInfo;
2383
- var headTilt = { pitch: 0, yaw: 0, roll: 0 };
2384
- var headTiltFilters = { pitch: null, yaw: null, roll: null };
2385
- var lastTimeWhenAnEyeWasOpen = Infinity; // far future rather than far past so that sleep gesture doesn't trigger initially, skipping the delay
2449
+ let facemeshLoaded = false;
2450
+ let facemeshFirstEstimation = true;
2451
+ let facemeshEstimating = false;
2452
+ let facemeshRejectNext = 0;
2453
+ let facemeshPrediction;
2454
+ let facemeshEstimateFaces;
2455
+ let faceInViewConfidenceThreshold = 0.7;
2456
+ let pointsBasedOnFaceInViewConfidence = 0;
2457
+ let cameraFramesSinceFacemeshUpdate = [];
2458
+ let blinkInfo;
2459
+ let mouthInfo;
2460
+ let headTilt = { pitch: 0, yaw: 0, roll: 0 };
2461
+ let headTiltFilters = { pitch: null, yaw: null, roll: null };
2462
+ let sleepGestureProgress = 0;
2386
2463
  // ## State related to switching between head trackers
2387
- var useClmTracking = true;
2388
- var showClmTracking = useClmTracking;
2389
- var fallbackTimeoutID;
2464
+ let useClmTracking = true;
2465
+ let showClmTracking = useClmTracking;
2466
+ let fallbackTimeoutID;
2390
2467
 
2391
2468
  // Mouse state
2392
- var mouseX = 0;
2393
- var mouseY = 0;
2394
- var buttonStates = {
2469
+ let mouseX = 0;
2470
+ let mouseY = 0;
2471
+ let buttonStates = {
2395
2472
  left: false,
2396
2473
  right: false,
2397
2474
  middle: false,
2398
2475
  };
2399
- var mouseButtonUntilMouthCloses = -1;
2400
- var lastMouseDownTime = -Infinity;
2401
- var mouseNeedsInitPos = true;
2476
+ let mouseButtonUntilMouthCloses = -1;
2477
+ let lastMouseDownTime = -Infinity;
2478
+ let mouseNeedsInitPos = true;
2402
2479
 
2403
2480
  // Other state
2404
- var paused = true;
2405
- var pointTracker;
2481
+ let paused = true;
2482
+ let pointTracker;
2406
2483
 
2407
2484
  // Named lists of facemesh landmark indices
2408
2485
  const MESH_ANNOTATIONS = {
@@ -2514,6 +2591,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2514
2591
  setting._load?.(settings, initialLoad);
2515
2592
  });
2516
2593
  }
2594
+ setAudioEnabled(s.soundEffects);
2517
2595
 
2518
2596
  // Now that all settings are loaded, update disabled states
2519
2597
  for (const func of functionsToUpdateDisabledStates) {
@@ -2579,6 +2657,12 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2579
2657
  }
2580
2658
  if (stored) {
2581
2659
  deserializeSettings(stored, initialLoad);
2660
+ } else {
2661
+ // HACK: ensure handleInitialLoad is called even for first run
2662
+ // Combined with the below, this feels very redundant, and I'd like to
2663
+ // move to a subscription-based pattern, more of a formal "settings store", something like that.
2664
+ // This is currently necessary for sound effects to work on the first run of the web demo.
2665
+ deserializeSettings(serializeSettings(), initialLoad);
2582
2666
  }
2583
2667
  if (initialLoad && (!stored || !stored.globalSettings || Object.keys(stored.globalSettings).length === 0)) {
2584
2668
  // We could just call setOptions in both cases,
@@ -2692,9 +2776,9 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2692
2776
  const settingsLoadedPromise = loadOptions(true);
2693
2777
 
2694
2778
  // Don't use WebGL because clmTracker is our fallback! It's also not much slower than with WebGL.
2695
- var clmTracker = new clm.tracker({ useWebGL: false });
2779
+ let clmTracker = new clm.tracker({ useWebGL: false });
2696
2780
  clmTracker.init();
2697
- var clmTrackingStarted = false;
2781
+ let clmTrackingStarted = false;
2698
2782
 
2699
2783
  const stopCameraStream = () => {
2700
2784
  if (cameraVideo.srcObject) {
@@ -2720,7 +2804,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2720
2804
  pointsBasedOnFaceScore = 0;
2721
2805
  faceScore = 0;
2722
2806
  faceConvergence = 0;
2723
- lastTimeWhenAnEyeWasOpen = Infinity; // far future rather than far past so that sleep gesture doesn't trigger initially, skipping the delay
2807
+ sleepGestureProgress = 0;
2724
2808
  updateStartStopButton();
2725
2809
  };
2726
2810
 
@@ -3008,7 +3092,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3008
3092
  }
3009
3093
  addPoint(x, y) {
3010
3094
  if (this.pointCount < maxPoints) {
3011
- var pointIndex = this.pointCount * 2;
3095
+ let pointIndex = this.pointCount * 2;
3012
3096
  this.curXY[pointIndex] = x;
3013
3097
  this.curXY[pointIndex + 1] = y;
3014
3098
  this.prevXY[pointIndex] = x;
@@ -3017,8 +3101,8 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3017
3101
  }
3018
3102
  }
3019
3103
  filterPoints(condition) {
3020
- var outputPointIndex = 0;
3021
- for (var inputPointIndex = 0; inputPointIndex < this.pointCount; inputPointIndex++) {
3104
+ let outputPointIndex = 0;
3105
+ for (let inputPointIndex = 0; inputPointIndex < this.pointCount; inputPointIndex++) {
3022
3106
  if (condition(inputPointIndex)) {
3023
3107
  if (outputPointIndex < inputPointIndex) {
3024
3108
  const inputOffset = inputPointIndex * 2;
@@ -3066,10 +3150,10 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3066
3150
  [this.prevPyramid, this.curPyramid] = [this.curPyramid, this.prevPyramid];
3067
3151
 
3068
3152
  // these are options worth breaking out and exploring
3069
- var winSize = 20;
3070
- var maxIterations = 30;
3071
- var epsilon = 0.01;
3072
- var minEigen = 0.001;
3153
+ let winSize = 20;
3154
+ let maxIterations = 30;
3155
+ let epsilon = 0.01;
3156
+ let minEigen = 0.001;
3073
3157
 
3074
3158
  jsfeat.imgproc.grayscale(imageData.data, imageData.width, imageData.height, this.curPyramid.data[0]);
3075
3159
  this.curPyramid.build(this.curPyramid.data[0], true);
@@ -3083,9 +3167,9 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3083
3167
  this.prunePoints();
3084
3168
  }
3085
3169
  draw(ctx) {
3086
- for (var i = 0; i < this.pointCount; i++) {
3087
- var pointOffset = i * 2;
3088
- // var distMoved = Math.hypot(
3170
+ for (let i = 0; i < this.pointCount; i++) {
3171
+ let pointOffset = i * 2;
3172
+ // let distMoved = Math.hypot(
3089
3173
  // this.prevXY[pointOffset] - this.curXY[pointOffset],
3090
3174
  // this.prevXY[pointOffset + 1] - this.curXY[pointOffset + 1]
3091
3175
  // );
@@ -3103,11 +3187,11 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3103
3187
  }
3104
3188
  }
3105
3189
  getMovement() {
3106
- var movementX = 0;
3107
- var movementY = 0;
3108
- var numMovements = 0;
3109
- for (var i = 0; i < this.pointCount; i++) {
3110
- var pointOffset = i * 2;
3190
+ let movementX = 0;
3191
+ let movementY = 0;
3192
+ let numMovements = 0;
3193
+ for (let i = 0; i < this.pointCount; i++) {
3194
+ let pointOffset = i * 2;
3111
3195
  movementX += this.curXY[pointOffset] - this.prevXY[pointOffset];
3112
3196
  movementY += this.curXY[pointOffset + 1] - this.prevXY[pointOffset + 1];
3113
3197
  numMovements += 1;
@@ -3144,9 +3228,9 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3144
3228
  // in order to keep a smooth overall tracking calculation,
3145
3229
  // don't add points if they're close to an existing point.
3146
3230
  // Otherwise, it would not just be redundant, but often remove the older points, in the pruning.
3147
- for (var pointIndex = 0; pointIndex < oops.pointCount; pointIndex++) {
3148
- var pointOffset = pointIndex * 2;
3149
- // var distance = Math.hypot(
3231
+ for (let pointIndex = 0; pointIndex < oops.pointCount; pointIndex++) {
3232
+ let pointOffset = pointIndex * 2;
3233
+ // let distance = Math.hypot(
3150
3234
  // x - oops.curXY[pointOffset],
3151
3235
  // y - oops.curXY[pointOffset + 1]
3152
3236
  // );
@@ -3183,6 +3267,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3183
3267
  return ((px - x1) * nx + (py - y1) * ny) / Math.hypot(nx, ny);
3184
3268
  }
3185
3269
 
3270
+ let lastTimestamp = -Infinity;
3186
3271
  function draw(update = true) {
3187
3272
  ctx.resetTransform(); // in case there is an error, don't flip constantly back and forth due to mirroring
3188
3273
  ctx.clearRect(0, 0, canvas.width, canvas.height); // in case there's no footage
@@ -3197,6 +3282,13 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3197
3282
  ctx.drawImage(cameraVideo, 0, 0, canvas.width, canvas.height);
3198
3283
  }
3199
3284
 
3285
+ const timestamp = performance.now();
3286
+ const deltaTime = Math.min(timestamp - lastTimestamp, 100);
3287
+ lastTimestamp = timestamp;
3288
+
3289
+ sleepSweep?.setEnabled(s.closeEyesToToggle);
3290
+ sleepSweep?.update(sleepGestureProgress);
3291
+
3200
3292
  if (!pointTracker) {
3201
3293
  return;
3202
3294
  }
@@ -3332,39 +3424,77 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3332
3424
 
3333
3425
  // TODO: separate confidence threshold for removing vs adding points?
3334
3426
 
3427
+
3335
3428
  // cull points to those within useful facial region
3336
- pointTracker.filterPoints((pointIndex) => {
3337
- var pointOffset = pointIndex * 2;
3429
+ function regionFilter([x, y]) {
3430
+
3338
3431
  // distance from tip of nose (stretched so make an ellipse taller than wide)
3339
- var distance = Math.hypot(
3340
- (annotations.noseTip[0][0] - pointTracker.curXY[pointOffset]) * 1.4,
3341
- annotations.noseTip[0][1] - pointTracker.curXY[pointOffset + 1]
3432
+ let distance = Math.hypot(
3433
+ (annotations.noseTip[0][0] - x) * 1.4,
3434
+ annotations.noseTip[0][1] - y
3342
3435
  );
3343
- var headSize = Math.hypot(
3436
+ let headSize = Math.hypot(
3344
3437
  annotations.leftCheek[0][0] - annotations.rightCheek[0][0],
3345
3438
  annotations.leftCheek[0][1] - annotations.rightCheek[0][1]
3346
3439
  );
3347
3440
  if (distance > headSize) {
3348
3441
  return false;
3349
3442
  }
3443
+ // Avoid mouth affecting pointer position.
3444
+ distance = annotations.lipsLowerInner.map((lipPoint) =>
3445
+ Math.min(
3446
+ Math.hypot(lipPoint[0] - x, lipPoint[1] - y),
3447
+ Math.hypot(lipPoint[0] - x, lipPoint[1] + headSize * 0.1 - y), // a bit below too
3448
+ Math.hypot(lipPoint[0] - x, lipPoint[1] + headSize * 0.2 - y), // a bit below too
3449
+ Math.hypot(lipPoint[0] - x, lipPoint[1] + headSize * 0.3 - y), // a bit below too
3450
+ Math.hypot(lipPoint[0] - x, lipPoint[1] + headSize * 0.4 - y), // a bit below too (yeah I'm being a little lazy here)
3451
+ )
3452
+ ).reduce((a, b) => Math.min(a, b), Infinity);
3453
+ if (distance < headSize * 0.1) {
3454
+ return false;
3455
+ }
3350
3456
  // Avoid blinking eyes affecting pointer position.
3351
3457
  // distance to outer corners of eyes
3352
3458
  distance = Math.min(
3353
3459
  Math.hypot(
3354
- annotations.leftEyeLower0[0][0] - pointTracker.curXY[pointOffset],
3355
- annotations.leftEyeLower0[0][1] - pointTracker.curXY[pointOffset + 1]
3460
+ annotations.leftEyeLower0[0][0] - x,
3461
+ annotations.leftEyeLower0[0][1] - y
3356
3462
  ),
3357
3463
  Math.hypot(
3358
- annotations.rightEyeLower0[0][0] - pointTracker.curXY[pointOffset],
3359
- annotations.rightEyeLower0[0][1] - pointTracker.curXY[pointOffset + 1]
3464
+ annotations.rightEyeLower0[0][0] - x,
3465
+ annotations.rightEyeLower0[0][1] - y
3360
3466
  ),
3361
3467
  );
3362
3468
  if (distance < headSize * 0.42) {
3363
3469
  return false;
3364
3470
  }
3365
3471
  return true;
3472
+ }
3473
+ pointTracker.filterPoints((pointIndex) => {
3474
+ let pointOffset = pointIndex * 2;
3475
+ const point = [pointTracker.curXY[pointOffset], pointTracker.curXY[pointOffset + 1]];
3476
+ return regionFilter(point);
3366
3477
  });
3367
3478
 
3479
+ // Debug visualization for region filter (a sort of heatmap of where points will be culled)
3480
+ if (showDebugRegionFilter) {
3481
+ ctx.save();
3482
+ if (s.mirror) {
3483
+ ctx.translate(canvas.width, 0);
3484
+ ctx.scale(-1, 1);
3485
+ }
3486
+ ctx.fillStyle = "rgba(255, 0, 0, 0.5)";
3487
+ const vizStep = 4;
3488
+ for (let x = 0; x < canvas.width; x += vizStep) {
3489
+ for (let y = 0; y < canvas.height; y += vizStep) {
3490
+ if (!regionFilter([x, y])) {
3491
+ ctx.fillRect(x - 5, y - 5, vizStep, vizStep);
3492
+ }
3493
+ }
3494
+ }
3495
+ ctx.restore();
3496
+ }
3497
+
3368
3498
  const keypoints = facemeshPrediction.keypoints;
3369
3499
  if (keypoints) {
3370
3500
  const top = keypoints[10]; // Top of forehead
@@ -3546,16 +3676,19 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3546
3676
 
3547
3677
  blinkInfo = detectBlinks();
3548
3678
  mouthInfo = detectMouthOpen();
3549
- if (blinkInfo.rightEye.open || blinkInfo.leftEye.open) {
3550
- lastTimeWhenAnEyeWasOpen = performance.now();
3679
+ if (!blinkInfo.rightEye.open && !blinkInfo.leftEye.open) {
3680
+ sleepGestureProgress += deltaTime / sleepGestureEyesClosedDuration;
3681
+ sleepGestureProgress = Math.min(sleepGestureProgress, 1);
3682
+ } else {
3683
+ sleepGestureProgress -= deltaTime / sleepGestureEyesClosedDuration;
3684
+ sleepGestureProgress = Math.max(sleepGestureProgress, 0);
3551
3685
  }
3552
- if (performance.now() - lastTimeWhenAnEyeWasOpen > 2000) {
3686
+ if (sleepGestureProgress >= 1) {
3687
+ sleepGestureProgress = 0;
3553
3688
  if (s.closeEyesToToggle) {
3554
3689
  paused = !paused;
3555
3690
  updatePaused();
3556
- // TODO: handle edge cases
3557
- // TODO: try to keep variable names meaningful
3558
- lastTimeWhenAnEyeWasOpen = Infinity;
3691
+ sleepSweep?.sleepModeWasToggled(paused);
3559
3692
  }
3560
3693
  }
3561
3694
 
@@ -3617,10 +3750,41 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3617
3750
 
3618
3751
  const buttonNames = ["left", "middle", "right"];
3619
3752
  for (let buttonIndex = 0; buttonIndex < 3; buttonIndex++) {
3620
- if ((clickButton === buttonIndex) !== buttonStates[buttonNames[buttonIndex]]) {
3621
- setMouseButtonState(buttonIndex, clickButton === buttonIndex);
3622
- buttonStates[buttonNames[buttonIndex]] = clickButton === buttonIndex;
3623
- if ((clickButton === buttonIndex)) {
3753
+ const buttonIsActive = clickButton === buttonIndex;
3754
+ if (buttonIsActive !== buttonStates[buttonNames[buttonIndex]]) {
3755
+ // Wait for confirmation of the button state change before playing SFX
3756
+ // but not before updating buttonStates, since we check that in this loop
3757
+ // to decide whether to call setMouseButtonState.
3758
+ // We don't want to send extraneous mouse button changes to the main process,
3759
+ // even if it does track button states itself. If nothing else it's wasted IPC.
3760
+ // That said, an argument could be made for updating lastMouseDownTime later
3761
+ // if the IPC is slow, to extend the time frame for making a simple click
3762
+ // rather than a drag.
3763
+ if (!setMouseButtonState) {
3764
+ console.warn("setMouseButtonState function not provided");
3765
+ } else {
3766
+ const maybeAPromise = setMouseButtonState(buttonIndex, buttonIsActive);
3767
+ const playSoundForButton = (changedButtonState) => {
3768
+ if (changedButtonState) {
3769
+ if (buttonIndex === 1) {
3770
+ playSound(buttonIsActive ? "middleClickPress" : "middleClickRelease", {
3771
+ volume: 4,
3772
+ });
3773
+ } else {
3774
+ playSound(buttonIsActive ? "clickPress" : "clickRelease", {
3775
+ playbackRate: buttonIndex === 0 ? 1 : buttonIndex === 2 ? 1.2 : 1.5,
3776
+ });
3777
+ }
3778
+ }
3779
+ };
3780
+ if (maybeAPromise instanceof Promise) {
3781
+ maybeAPromise.then(playSoundForButton);
3782
+ } else {
3783
+ playSoundForButton(maybeAPromise);
3784
+ }
3785
+ }
3786
+ buttonStates[buttonNames[buttonIndex]] = buttonIsActive;
3787
+ if (buttonIsActive) {
3624
3788
  lastMouseDownTime = performance.now();
3625
3789
  } else {
3626
3790
  // Limit "Delay Before Dragging" effect to the duration of a click.
@@ -3893,14 +4057,14 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3893
4057
 
3894
4058
  // cull points to those within useful facial region
3895
4059
  pointTracker.filterPoints((pointIndex) => {
3896
- var pointOffset = pointIndex * 2;
4060
+ let pointOffset = pointIndex * 2;
3897
4061
  // distance from tip of nose (stretched so make an ellipse taller than wide)
3898
- var distance = Math.hypot(
4062
+ let distance = Math.hypot(
3899
4063
  (face[62][0] - pointTracker.curXY[pointOffset]) * 1.4,
3900
4064
  face[62][1] - pointTracker.curXY[pointOffset + 1]
3901
4065
  );
3902
4066
  // distance based on outer eye corners
3903
- var headSize = Math.hypot(
4067
+ let headSize = Math.hypot(
3904
4068
  face[23][0] - face[28][0],
3905
4069
  face[23][1] - face[28][1]
3906
4070
  );
@@ -3928,18 +4092,18 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3928
4092
  const screenWidth = window.electronAPI ? screen.width : innerWidth;
3929
4093
  const screenHeight = window.electronAPI ? screen.height : innerHeight;
3930
4094
 
3931
- var [movementX, movementY] = pointTracker.getMovement();
4095
+ let [movementX, movementY] = pointTracker.getMovement();
3932
4096
 
3933
4097
  // Acceleration curves add a lot of stability,
3934
4098
  // letting you focus on a specific point without jitter, but still move quickly.
3935
4099
 
3936
- // var accelerate = (delta, distance) => (delta / 10) * (distance ** 0.8);
3937
- // var accelerate = (delta, distance) => (delta / 1) * (Math.abs(delta) ** 0.8);
3938
- var accelerate = (delta, _distance) => (delta / 1) * (Math.abs(delta * 5) ** s.headTrackingAcceleration);
4100
+ // let accelerate = (delta, distance) => (delta / 10) * (distance ** 0.8);
4101
+ // let accelerate = (delta, distance) => (delta / 1) * (Math.abs(delta) ** 0.8);
4102
+ let accelerate = (delta, _distance) => (delta / 1) * (Math.abs(delta * 5) ** s.headTrackingAcceleration);
3939
4103
 
3940
- var distance = Math.hypot(movementX, movementY);
3941
- var deltaX = accelerate(movementX * s.headTrackingSensitivityX, distance);
3942
- var deltaY = accelerate(movementY * s.headTrackingSensitivityY, distance);
4104
+ let distance = Math.hypot(movementX, movementY);
4105
+ let deltaX = accelerate(movementX * s.headTrackingSensitivityX, distance);
4106
+ let deltaY = accelerate(movementY * s.headTrackingSensitivityY, distance);
3943
4107
 
3944
4108
  if (s.headTrackingTiltInfluence > 0) {
3945
4109
  const yawRange = [
@@ -4112,7 +4276,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
4112
4276
 
4113
4277
  // Can't use requestAnimationFrame, doesn't work with webPreferences.backgroundThrottling: false (at least in some version of Electron (v12 I think, when I tested it), on Ubuntu, with XFCE)
4114
4278
  const iid = setInterval(function animationLoop() {
4115
- draw(!paused || document.visibilityState === "visible");
4279
+ draw(!paused || document.visibilityState === "visible" || isDesktopApp);
4116
4280
  }, 15);
4117
4281
 
4118
4282
  let autoDemo = false;