[eric] voice: native trackpad haptics addon (NSHapticFeedbackManager, mouseclamp pattern) taps on dictation start/stop

This commit is contained in:
ciregenz
2026-07-28 15:01:59 -07:00
parent 7112a8786e
commit fe29551d74
9 changed files with 139 additions and 0 deletions
+30
View File
@@ -1777,6 +1777,35 @@ function installMacMouseClamp() {
}
}
// Trackpad haptic taps (macOS, Force Touch only): dictation start/stop feedback. Fail-open like
// mouseclamp; a missing addon or non-mac just makes 'haptic:perform' return false.
let hapticsAddon = null;
function installHaptics() {
if (process.platform !== 'darwin') return;
try {
const nodePath = isPackaged
? path.join(process.resourcesPath, 'haptics', 'haptics.node')
: path.join(__dirname, 'build-staging', 'haptics', process.arch, 'haptics.node');
if (!fs.existsSync(nodePath)) {
console.log('[haptics] addon not present, skipping:', nodePath);
return;
}
hapticsAddon = require(nodePath);
console.log('[haptics] addon loaded');
} catch (e) {
console.log('[haptics] load failed (continuing):', e && e.message);
}
}
ipcMain.handle('haptic:perform', (event, pattern) => {
try {
if (!hapticsAddon) return false;
const p = pattern === 'alignment' ? 1 : pattern === 'level' ? 2 : 0;
return hapticsAddon.perform(p);
} catch (_) {
return false;
}
});
app.whenReady().then(async () => {
// We made it here, so any prior update swap finished. Drop a stale updating.lock
// (the watchdog never deletes it) so a real crash later isn't silently swallowed.
@@ -1788,6 +1817,7 @@ app.whenReady().then(async () => {
// Off-window mouse-release crash dodge (macOS). Safe to call before windows exist.
installMacMouseClamp();
installHaptics();
// Voice dictation hotkey (F5 / Cmd-Ctrl+Shift+D). Native uiohook key tap = true keyboard
// hold-to-talk on every platform; falls back to the old press-to-toggle when the tap can't run
+14
View File
@@ -0,0 +1,14 @@
{
"targets": [
{
"target_name": "haptics",
"sources": ["haptics.mm"],
"xcode_settings": {
"CLANG_ENABLE_OBJC_ARC": "NO",
"OTHER_CFLAGS": ["-fobjc-exceptions"]
},
"libraries": ["-framework Cocoa"],
"conditions": [["OS!=\"mac\"", {"type": "none"}]]
}
]
}
+48
View File
@@ -0,0 +1,48 @@
// Trackpad haptic taps for dictation start/stop (and any future micro-feedback), the missing half
// of the WhisperFlow feel. NSHapticFeedbackManager only fires on Force Touch trackpads and only
// when macOS deems the app active; both are fine, this is garnish, never load-bearing.
//
// Fail-open everywhere: no trackpad, no permission, wrong thread, we return false and the app
// behaves exactly like today. macOS-only by binding.gyp condition.
#include <node_api.h>
#import <Cocoa/Cocoa.h>
static napi_value Perform(napi_env env, napi_callback_info info) {
bool ok = false;
@try {
size_t argc = 1;
napi_value argv[1];
napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
int32_t pattern = 0;
if (argc >= 1) napi_get_value_int32(env, argv[0], &pattern);
NSHapticFeedbackPattern p = NSHapticFeedbackPatternGeneric;
if (pattern == 1) p = NSHapticFeedbackPatternAlignment;
else if (pattern == 2) p = NSHapticFeedbackPatternLevelChange;
// Main-thread dispatch: AppKit feedback performers are main-thread creatures, and the IPC
// handler that calls us already runs there in Electron's browser process; the async hop is
// belt-and-suspenders for any future caller.
dispatch_async(dispatch_get_main_queue(), ^{
@try {
[[NSHapticFeedbackManager defaultPerformer]
performFeedbackPattern:p
performanceTime:NSHapticFeedbackPerformanceTimeNow];
} @catch (NSException *e) { /* garnish only */ }
});
ok = true;
} @catch (NSException *e) {
ok = false;
}
napi_value result;
napi_get_boolean(env, ok, &result);
return result;
}
static napi_value Init(napi_env env, napi_value exports) {
napi_value fn;
napi_create_function(env, "perform", NAPI_AUTO_LENGTH, Perform, NULL, &fn);
napi_set_named_property(env, exports, "perform", fn);
return exports;
}
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
+7
View File
@@ -0,0 +1,7 @@
{
"name": "haptics",
"version": "1.0.0",
"private": true,
"description": "macOS-only native addon: Force Touch trackpad haptic taps via NSHapticFeedbackManager for dictation start/stop feedback. See haptics.mm.",
"gypfile": true
}
+7
View File
@@ -75,6 +75,13 @@
"**/*"
]
},
{
"from": "build-staging/haptics/${arch}",
"to": "haptics",
"filter": [
"**/*"
]
},
{
"from": "build-staging/python-env/${arch}",
"to": "python-env",
+1
View File
@@ -77,6 +77,7 @@ contextBridge.exposeInMainWorld('openswarm', {
// and request triggers the macOS Accessibility prompt when the tap is blocked on permission.
voiceHoldCapable: () => ipcRenderer.invoke('voice:hold-capable'),
voiceRequestHoldPermission: () => ipcRenderer.invoke('voice:request-hold-permission'),
haptic: (pattern) => ipcRenderer.invoke('haptic:perform', pattern),
// Native-tap hold relay: real global key-down/key-up for the voice combo, focus-independent.
onVoiceHold: (onDown, onUp) => {
const down = () => onDown();
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# Compile the macOS mouse-clamp native addon for one arch and stage it where
# electron-builder's extraResources picks it up (build-staging/haptics/<arch>).
# See electron/native/haptics/haptics.mm for what it fixes.
set -euo pipefail
ARCH="${1:?usage: build-haptics.sh <arm64|x64>}"
HERE="$(cd "$(dirname "$0")/.." && pwd)" # electron/
# Derive the node-gyp header target from the actually-installed electron so a version bump (e.g. 42.0.0 -> 42.3.3) is auto-tracked instead of silently building against stale headers. Strip any +wvcus suffix; node-gyp wants a plain semver.
ELECTRON_TARGET="$(node -p "require('$HERE/node_modules/electron/package.json').version.split('+')[0]" 2>/dev/null || echo '42.3.3')"
SRC="$HERE/native/haptics"
OUT="$HERE/build-staging/haptics/$ARCH"
NODE_GYP="$HERE/node_modules/.bin/node-gyp"
[[ -x "$NODE_GYP" ]] || NODE_GYP="npx --yes node-gyp" # transitive dep usually, npx if not
echo "[haptics] building for arch=$ARCH (electron $ELECTRON_TARGET)"
cd "$SRC"
rm -rf build
$NODE_GYP rebuild \
--target="$ELECTRON_TARGET" \
--arch="$ARCH" \
--dist-url=https://electronjs.org/headers
mkdir -p "$OUT"
cp "build/Release/haptics.node" "$OUT/haptics.node"
echo "[haptics] staged -> $OUT/haptics.node"
file "$OUT/haptics.node"
@@ -119,6 +119,7 @@ export function useVoiceDictation() {
recRef.current = { ctx, stream, node, source, chunks };
setState('recording');
playVoiceCue('start');
void (window.openswarm as { haptic?: (p: string) => Promise<boolean> } | undefined)?.haptic?.('generic');
// Warm the model the moment recording begins so transcription is instant on stop.
void window.openswarm?.voiceWarmup?.();
} catch (err) {
@@ -134,6 +135,7 @@ export function useVoiceDictation() {
if (stateRef.current !== 'recording') return;
const samples = teardown();
playVoiceCue('stop');
void (window.openswarm as { haptic?: (p: string) => Promise<boolean> } | undefined)?.haptic?.('alignment');
setState('transcribing');
try {
if (!samples || samples.length < VOICE_SAMPLE_RATE * 0.2) { setState('idle'); return; } // < 0.2s = a misfire
+2
View File
@@ -497,6 +497,8 @@ if [[ "$(uname)" == "Darwin" ]]; then
echo "Building mouse-clamp native addon (arm64 + x64)..."
bash scripts/build-mouseclamp.sh arm64
bash scripts/build-mouseclamp.sh x64
bash scripts/build-haptics.sh arm64
bash scripts/build-haptics.sh x64
fi
# Node's default ~4 GB heap OOMs while codesign'ing the .app on dual-arch