Bind Frame right X to normal-priority hold-to-talk

This commit is contained in:
baketnk committed 2026-09-24 13:40:27 -04:00
1 parent b5499eccdf
commit 6b1d20858a
10 files changed
+85 -23

No files matched your search

+3 -1
View File
@@ -138,9 +138,11 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)
endif() endif()
endif() endif()
if(Python3_FOUND) if(Python3_FOUND)
add_test(NAME frameyap.action_bindings COMMAND "${Python3_EXECUTABLE}" -m unittest discover -s tests -p test_action_bindings.py)
add_test(NAME frameyap.python_worker COMMAND "${Python3_EXECUTABLE}" -m unittest discover -s tests -p test_worker.py) add_test(NAME frameyap.python_worker COMMAND "${Python3_EXECUTABLE}" -m unittest discover -s tests -p test_worker.py)
add_test(NAME frameyap.installer COMMAND "${Python3_EXECUTABLE}" -m unittest discover -s tests -p test_installer.py) add_test(NAME frameyap.installer COMMAND "${Python3_EXECUTABLE}" -m unittest discover -s tests -p test_installer.py)
set_tests_properties(frameyap.python_worker frameyap.installer PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" TIMEOUT 60) set_tests_properties(frameyap.action_bindings frameyap.python_worker frameyap.installer
PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" TIMEOUT 60)
if(TARGET frameyap_worker) if(TARGET frameyap_worker)
add_executable(frameyap_worker_test tests/worker_test.cpp) add_executable(frameyap_worker_test tests/worker_test.cpp)
target_link_libraries(frameyap_worker_test PRIVATE frameyap_worker) target_link_libraries(frameyap_worker_test PRIVATE frameyap_worker)
+5 -4
View File
@@ -2,7 +2,7 @@
Standalone, on-device voice typing POC for Steam Frame. **MIT licensed.** Standalone, on-device voice typing POC for Steam Frame. **MIT licensed.**
Implemented: native OpenVR overlay, remappable grip controls, bounded SDL3 capture, Implemented: native OpenVR overlay, remappable controller actions, bounded SDL3 capture,
persistent local Parakeet Redux worker, preview/explicit insertion through Gamescope, persistent local Parakeet Redux worker, preview/explicit insertion through Gamescope,
and an idempotent user-local installer. No Steam store AppID, sudo, desktop ASR and an idempotent user-local installer. No Steam store AppID, sudo, desktop ASR
server, cloud fallback or unrelated application dependency. server, cloud fallback or unrelated application dependency.
@@ -19,9 +19,10 @@ See [third-party notes](docs/third-party.md). No GitHub release is published yet
## Controls ## Controls
- **Right grip:** short tap, then hold the second squeeze to record; release to - **Right X (default Frame binding):** hold to record; release to
transcribe. Remappable through SteamVR bindings; separate hold-to-talk action too. transcribe. This PTT action is remappable through SteamVR bindings; delivery
- **Left grip:** double-tap to explicitly send Enter. Never inferred from speech. in the dashboard still requires an on-device test.
- **Left grip (when active):** double-tap to explicitly send Enter. Never inferred from speech.
- **Overlay:** Record/Stop, Cancel, paginated preview, Insert, Enter and Quit. - **Overlay:** Record/Stop, Cancel, paginated preview, Insert, Enter and Quit.
Review and settings share one Inconsolata/neon-framed surface. World-space by Review and settings share one Inconsolata/neon-framed surface. World-space by
default; settings offer left wrist, right wrist and head mounting, plus recenter. default; settings offer left wrist, right wrist and head mounting, plus recenter.
+1 -1
View File
@@ -19,7 +19,7 @@
"/actions/frameyap": "FrameYap controls", "/actions/frameyap": "FrameYap controls",
"/actions/frameyap/in/left_grip": "Double tap left grip: Enter (enabled only)", "/actions/frameyap/in/left_grip": "Double tap left grip: Enter (enabled only)",
"/actions/frameyap/in/right_grip": "Tap right grip briefly, then squeeze and hold to record; release to finish", "/actions/frameyap/in/right_grip": "Tap right grip briefly, then squeeze and hold to record; release to finish",
"/actions/frameyap/in/ptt": "Hold to record", "/actions/frameyap/in/ptt": "Hold right X to record (default; remappable)",
"/actions/frameyap/in/cancel": "Cancel recording or discard", "/actions/frameyap/in/cancel": "Cancel recording or discard",
"/actions/frameyap/in/insert": "Insert reviewed text", "/actions/frameyap/in/insert": "Insert reviewed text",
"/actions/frameyap/in/enter": "Explicit Enter" "/actions/frameyap/in/enter": "Explicit Enter"
+7 -2
View File
@@ -1,7 +1,7 @@
{ {
"controller_type": "frame_controller", "controller_type": "frame_controller",
"description": "Right grip: tap then hold to dictate. Left grip: double tap Enter. Remap other actions in SteamVR.", "description": "Hold right X to dictate; release to review. Grip gestures remain remappable in SteamVR.",
"name": "FrameYap Steam Frame grips", "name": "FrameYap Steam Frame controls",
"bindings": { "bindings": {
"/actions/frameyap": { "/actions/frameyap": {
"sources": [ "sources": [
@@ -14,6 +14,11 @@
"path": "/user/hand/right/input/grip", "path": "/user/hand/right/input/grip",
"mode": "button", "mode": "button",
"inputs": { "click": { "output": "/actions/frameyap/in/right_grip" } } "inputs": { "click": { "output": "/actions/frameyap/in/right_grip" } }
},
{
"path": "/user/hand/right/input/x",
"mode": "button",
"inputs": { "click": { "output": "/actions/frameyap/in/ptt" } }
} }
] ]
} }
+5 -3
View File
@@ -92,9 +92,11 @@ texture replacements; it does not establish that ordinary state-changing UI
updates are flicker-free. updates are flicker-free.
`assets/actions.json` names six actions: left/right grip, PTT, cancel, insert, `assets/actions.json` names six actions: left/right grip, PTT, cancel, insert,
Enter. `bindings_frame_controller.json` uses the observed Frame profile's grip Enter. `bindings_frame_controller.json` maps right X click to hold-to-talk PTT;
click paths; both bound actions were reported tracked/active in the device check. the grip bindings remain for optional remapping/diagnosis. Both grip actions
This does not prove physical gesture delivery. `bindings_knuckles.json` is an were **inactive** during the dashboard controls check despite a loaded binding
file; this does not prove whether right X will deliver until an on-device test.
`bindings_knuckles.json` is an
additional **Index/knuckles example only**. Collisions with scene actions require additional **Index/knuckles example only**. Collisions with scene actions require
separate on-device validation. Left grip double tap separate on-device validation. Left grip double tap
(releases <=250 ms, second press within 350 ms) requests explicit Enter only (releases <=250 ms, second press within 350 ms) requests explicit Enter only
+12 -8
View File
@@ -7,10 +7,13 @@ initialize OpenVR, open a microphone, run ASR, download files or inject input.
- Opt-in OpenVR RGBA overlay with world-space default and selectable wrist/head - Opt-in OpenVR RGBA overlay with world-space default and selectable wrist/head
mounts, status, recording timer, paginated UTF-8 preview and explicit controls. mounts, status, recording timer, paginated UTF-8 preview and explicit controls.
- Remappable SteamVR actions. Default Steam Frame grip bindings use the observed - Remappable SteamVR actions. The default Steam Frame binding maps right X
`frame_controller` profile. Right: short tap, then hold the second squeeze to (hold to record, release to transcribe) to the existing PTT action using the
record; release to transcribe. Left: two short taps request explicit Enter. observed `frame_controller` profile. Grip bindings remain, but both grip
First squeeze <=250 ms; second squeeze begins <=350 ms after first release. actions were inactive in the observed dashboard check; do not rely on them.
If left grip becomes active, two short taps request explicit Enter.
For grip gestures, first squeeze <=250 ms; second squeeze begins <=350 ms
after first release.
Activity/tracking loss cancels a held recording and requires neutral rearm. Activity/tracking loss cancels a held recording and requires neutral rearm.
- SDL3 default recording device, mono float32 conversion at 16 kHz, 200 ms minimum, - SDL3 default recording device, mono float32 conversion at 16 kHz, 200 ms minimum,
20 second maximum. Microphone is closed outside actual capture. Other apps may 20 second maximum. Microphone is closed outside actual capture. Other apps may
@@ -90,10 +93,11 @@ That is a producer workflow, not an end-user compiler requirement.
The first acquires/releases an IME without text/actions. The second displays a The first acquires/releases an IME without text/actions. The second displays a
five-second inert panel. The third displays a 30-second diagnostic panel and five-second inert panel. The third displays a 30-second diagnostic panel and
reports pointer actions, SteamVR action activity, tracking and read-only legacy reports pointer actions, SteamVR grip and PTT action activity, tracking and
grip state, **without microphone or input injection**. Mount clicks in check read-only legacy grip state, **without microphone or input injection**. Mount
modes do not save a preference. A SteamVR error code or inactive grip means the clicks in check modes do not save a preference. A SteamVR error code or inactive
gesture cannot be accepted; raw grip reads do not authorize a fallback binding. action means the gesture cannot be accepted; raw grip reads do not authorize a
fallback binding.
Action handles alone are not proof that gestures were delivered. Checks must be explicitly launched Action handles alone are not proof that gestures were delivered. Checks must be explicitly launched
while the user expects the panel. Normal CLI/help/version remain inert. while the user expects the panel. Normal CLI/help/version remain inert.
+3 -2
View File
@@ -24,8 +24,9 @@ void help() {
"Mount: world (first-launch default), left-wrist, right-wrist, head.\n" "Mount: world (first-launch default), left-wrist, right-wrist, head.\n"
"Settings save the mount; --mount overrides it for this launch. --head is an alias.\n" "Settings save the mount; --mount overrides it for this launch. --head is an alias.\n"
"Font defaults to bundled Inconsolata in the assets directory.\n" "Font defaults to bundled Inconsolata in the assets directory.\n"
"Right grip: double-tap, hold second squeeze to speak, release to review.\n" "Right X: hold to speak, release to review (default Frame binding).\n"
"Left grip: double-tap for explicit Enter. Bindings are remappable.\n" "Grip gestures are remappable but may be unavailable in the dashboard.\n"
"Left grip: double-tap for explicit Enter when active.\n"
"Review first: Insert approves current focus; never automatic Enter.\n" "Review first: Insert approves current focus; never automatic Enter.\n"
"No device access unless an explicit runtime/check/registration mode is used.\n"; "No device access unless an explicit runtime/check/registration mode is used.\n";
#ifndef FRAMEYAP_NATIVE #ifndef FRAMEYAP_NATIVE
+9
View File
@@ -341,6 +341,15 @@ std::string Overlay::controls_status() {
" origin=" + (origin_ok ? "Y" : "N") + " origin=" + (origin_ok ? "Y" : "N") +
" raw=" + (raw_ok ? ((raw.ulButtonPressed & vr::ButtonMaskFromId(vr::k_EButton_Grip)) ? "down" : "up") : "n/a"); " raw=" + (raw_ok ? ((raw.ulButtonPressed & vr::ButtonMaskFromId(vr::k_EButton_Grip)) ? "down" : "up") : "n/a");
} }
// The default Frame binding maps right X to the named hold-to-talk action.
// Report the action's own activity separately from the stricter pose gate;
// remapped bindings may intentionally have a different origin.
vr::InputDigitalActionData_t ptt{};
const auto ptt_error = impl_->input->GetDigitalActionData(impl_->actions[2], &ptt, sizeof(ptt), vr::k_ulInvalidInputValueHandle);
const auto [accepted, down] = impl_->digital(2);
result += "\nPTT (default right X): err=" + std::to_string(int(ptt_error)) +
" active=" + (ptt.bActive ? "Y" : "N") + " down=" + (ptt.bState ? "Y" : "N") +
" accepted=" + (accepted ? "Y" : "N") + " gated-down=" + (down ? "Y" : "N");
return result; return result;
} }
std::string Overlay::pointer_status() const { std::string Overlay::pointer_status() const {
+2 -2
View File
@@ -24,7 +24,7 @@ void signal_stop(int) { interrupted = 1; }
std::string state_label(State state) { std::string state_label(State state) {
switch (state) { switch (state) {
case State::Warming: return "Warming - on-device Redux CPU"; case State::Warming: return "Warming - on-device Redux CPU";
case State::Ready: return "Ready - double-tap + hold right grip"; case State::Ready: return "Ready - hold X or click Record";
case State::Recording: return "RECORDING"; case State::Recording: return "RECORDING";
case State::Transcribing: return "Transcribing - on device"; case State::Transcribing: return "Transcribing - on device";
case State::Review: return "Review - Insert approves CURRENT focus"; case State::Review: return "Review - Insert approves CURRENT focus";
@@ -66,7 +66,7 @@ int run(const Options& options) {
if (session.state() == State::Error) session.cancel(); if (session.state() == State::Error) session.cancel();
if (!session.record()) return; if (!session.record()) return;
audio.start(); audio.start();
detail = "Release grip to finish. Cancel discards. Maximum 20 seconds."; detail = "Release to finish. Cancel discards. Maximum 20 seconds.";
}; };
try { warm(); } try { warm(); }
catch (const std::exception& e) { session.fail(); detail = e.what(); } catch (const std::exception& e) { session.fail(); detail = e.what(); }
+38
View File
@@ -0,0 +1,38 @@
"""Offline contract for the default Frame controller shortcut (no OpenVR)."""
import json
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[1]
class ActionBindingTests(unittest.TestCase):
def test_right_x_is_hold_to_talk(self):
manifest = json.loads((ROOT / "assets/actions.json").read_text())
binding = json.loads((ROOT / "assets/bindings_frame_controller.json").read_text())
self.assertIn(
{"controller_type": "frame_controller", "binding_url": "bindings_frame_controller.json"},
manifest["default_bindings"],
)
self.assertIn(
{"name": "/actions/frameyap/in/ptt", "type": "boolean"},
manifest["actions"],
)
sources = binding["bindings"]["/actions/frameyap"]["sources"]
right_x = [source for source in sources if source["path"] == "/user/hand/right/input/x"]
self.assertEqual(right_x, [{
"path": "/user/hand/right/input/x",
"mode": "button",
"inputs": {"click": {"output": "/actions/frameyap/in/ptt"}},
}])
# Do not map a second PTT source or replace the dashboard's pointer trigger.
self.assertEqual(sum(
source["inputs"].get("click", {}).get("output") == "/actions/frameyap/in/ptt"
for source in sources
), 1)
self.assertFalse(any(source["path"].endswith("/input/trigger") for source in sources))
if __name__ == "__main__":
unittest.main()