mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] voice: native trackpad haptics addon (NSHapticFeedbackManager, mouseclamp pattern) taps on dictation start/stop
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"}]]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -75,6 +75,13 @@
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "build-staging/haptics/${arch}",
|
||||
"to": "haptics",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "build-staging/python-env/${arch}",
|
||||
"to": "python-env",
|
||||
|
||||
@@ -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();
|
||||
|
||||
Executable
+28
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user