Skip to content

feat: measure key hold duration in getKeyboardResponse and record rt_key_duration in keyboard response plugins - #3707

Merged
jodeleeuw merged 9 commits into
mainfrom
feature/rt-key-duration
Jul 26, 2026
Merged

feat: measure key hold duration in getKeyboardResponse and record rt_key_duration in keyboard response plugins#3707
jodeleeuw merged 9 commits into
mainfrom
feature/rt-key-duration

Conversation

@jodeleeuw

@jodeleeuw jodeleeuw commented Jul 21, 2026

Copy link
Copy Markdown
Member

Summary

Adds the ability to measure how long a response key is held down, recorded as a new rt_key_duration data field in the five *-keyboard-response plugins (html, image, audio, video, canvas).

Core API (jspsych)

jsPsych.pluginAPI.getKeyboardResponse() gains an optional boolean option wait_for_key_release (default false). When enabled, callback_function does not fire at keydown — it fires when the response key is released, with an extended payload:

{ key: string, rt: number, rt_key_duration: number }
  • rt is still measured at the keydown (same clock rules as before, including rt_method: "audio"); only the firing of the callback moves to keyup.
  • rt_key_duration = keyup − initial keydown, always measured on the performance.now() clock. Key-repeat events do not reset the press timestamp.
  • Pending (waiting-for-release) responses are cancelled by cancelKeyboardResponse / cancelAllKeyboardResponses, so a deferred callback can never fire after a trial has ended.
  • With wait_for_key_release: false (the default) behavior and the { key, rt } payload are completely unchanged.

Plugins

Each of the five keyboard-response plugins gains a wait_for_key_release trial parameter (default false) and a rt_key_duration data field:

  • When enabled, the response registers at key release: with response_ends_trial: true the trial ends at the release, and rt_key_duration is part of the trial data at finishTrial (so on_finish always sees the final value — no post-hoc data updates anywhere in this design).
  • Because a response is only complete at release, if the trial ends for another reason (e.g. trial_duration) while the key is still held, no response is recorded. This is documented in the parameter description.
  • With the parameter disabled (default), plugin behavior is unchanged and rt_key_duration is null.
  • Simulation mode: data-only simulation samples a plausible duration when wait_for_key_release is enabled (null otherwise); visual simulation works naturally since simulated key presses release immediately.

Docs & release

  • docs/reference/jspsych-pluginAPI.md: documents wait_for_key_release and the extended payload.
  • docs/plugins/*.md: parameter row and rt_key_duration data row for each of the five plugins.
  • Changeset with minor bumps for jspsych and the five plugins.

Test plan

Tests were written first (TDD) and confirmed failing before implementation.

  • Core (pluginapi.test.ts): callback does not fire at keydown and fires at keyup with { key, rt, rt_key_duration }; rt reflects the keydown time; default payload has no rt_key_duration property; cancellation between keydown and keyup suppresses the callback (both cancelKeyboardResponse and cancelAllKeyboardResponses); invalid keys fire nothing; key-repeat does not reset the press timestamp; persist: true fires once per press-release cycle.
  • Plugins (full-trial tests for html/image/audio; video/canvas via simulation mode, matching their existing jsdom-limited specs): keydown does not end the trial while keyup does, with correct rt and rt_key_duration; press+release during a fixed-duration trial; timeout while the key is still held records a null response; a key held past trial_duration does not contaminate the following trial (no response from the leftover keyup or from key-repeat events, and a fresh press-release cycle in the next trial records correct values); default trials unchanged.
  • Full monorepo suite: 79 suites, 694 tests passing. turbo run build passes for all six packages.

🤖 Generated with Claude Code

…_key_duration in keyboard response plugins

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f14e978

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
jspsych Minor
@jspsych/plugin-html-keyboard-response Minor
@jspsych/plugin-image-keyboard-response Minor
@jspsych/plugin-audio-keyboard-response Minor
@jspsych/plugin-video-keyboard-response Minor
@jspsych/plugin-canvas-keyboard-response Minor
@jspsych/test-utils Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

📦 Preview build ready

Built from PR head f14e978 and published at ebc9965 on branch preview/pr-3707.
URLs below are pinned to an immutable commit SHA, so they are safe to share and are cached permanently by jsDelivr.

Changed packages: jspsych, plugin-audio-keyboard-response, plugin-canvas-keyboard-response, plugin-html-keyboard-response, plugin-image-keyboard-response, plugin-video-keyboard-response, test-utils

Quick-start HTML:

<script src="https://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/jspsych/dist/index.browser.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/jspsych/css/jspsych.css">
<script src="https://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-audio-keyboard-response/dist/index.browser.min.js"></script>
All package URLs
  • @jspsych/extension-mouse-trackinghttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/extension-mouse-tracking/dist/index.browser.min.js
  • @jspsych/extension-record-videohttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/extension-record-video/dist/index.browser.min.js
  • @jspsych/extension-webgazerhttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/extension-webgazer/dist/index.browser.min.js
  • jspsychhttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/jspsych/dist/index.browser.min.js
  • @jspsych/plugin-animationhttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-animation/dist/index.browser.min.js
  • @jspsych/plugin-audio-button-responsehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-audio-button-response/dist/index.browser.min.js
  • @jspsych/plugin-audio-keyboard-responsehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-audio-keyboard-response/dist/index.browser.min.js
  • @jspsych/plugin-audio-slider-responsehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-audio-slider-response/dist/index.browser.min.js
  • @jspsych/plugin-browser-checkhttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-browser-check/dist/index.browser.min.js
  • @jspsych/plugin-call-functionhttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-call-function/dist/index.browser.min.js
  • @jspsych/plugin-canvas-button-responsehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-canvas-button-response/dist/index.browser.min.js
  • @jspsych/plugin-canvas-keyboard-responsehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-canvas-keyboard-response/dist/index.browser.min.js
  • @jspsych/plugin-canvas-slider-responsehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-canvas-slider-response/dist/index.browser.min.js
  • @jspsych/plugin-categorize-animationhttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-categorize-animation/dist/index.browser.min.js
  • @jspsych/plugin-categorize-htmlhttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-categorize-html/dist/index.browser.min.js
  • @jspsych/plugin-categorize-imagehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-categorize-image/dist/index.browser.min.js
  • @jspsych/plugin-clozehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-cloze/dist/index.browser.min.js
  • @jspsych/plugin-external-htmlhttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-external-html/dist/index.browser.min.js
  • @jspsych/plugin-free-sorthttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-free-sort/dist/index.browser.min.js
  • @jspsych/plugin-fullscreenhttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-fullscreen/dist/index.browser.min.js
  • @jspsych/plugin-html-audio-responsehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-html-audio-response/dist/index.browser.min.js
  • @jspsych/plugin-html-button-responsehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-html-button-response/dist/index.browser.min.js
  • @jspsych/plugin-html-keyboard-responsehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-html-keyboard-response/dist/index.browser.min.js
  • @jspsych/plugin-html-slider-responsehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-html-slider-response/dist/index.browser.min.js
  • @jspsych/plugin-html-video-responsehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-html-video-response/dist/index.browser.min.js
  • @jspsych/plugin-iat-htmlhttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-iat-html/dist/index.browser.min.js
  • @jspsych/plugin-iat-imagehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-iat-image/dist/index.browser.min.js
  • @jspsych/plugin-image-button-responsehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-image-button-response/dist/index.browser.min.js
  • @jspsych/plugin-image-keyboard-responsehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-image-keyboard-response/dist/index.browser.min.js
  • @jspsych/plugin-image-slider-responsehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-image-slider-response/dist/index.browser.min.js
  • @jspsych/plugin-initialize-camerahttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-initialize-camera/dist/index.browser.min.js
  • @jspsych/plugin-initialize-microphonehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-initialize-microphone/dist/index.browser.min.js
  • @jspsych/plugin-instructionshttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-instructions/dist/index.browser.min.js
  • @jspsych/plugin-maxdiffhttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-maxdiff/dist/index.browser.min.js
  • @jspsych/plugin-mirror-camerahttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-mirror-camera/dist/index.browser.min.js
  • @jspsych/plugin-preloadhttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-preload/dist/index.browser.min.js
  • @jspsych/plugin-reconstructionhttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-reconstruction/dist/index.browser.min.js
  • @jspsych/plugin-resizehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-resize/dist/index.browser.min.js
  • @jspsych/plugin-same-different-htmlhttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-same-different-html/dist/index.browser.min.js
  • @jspsych/plugin-same-different-imagehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-same-different-image/dist/index.browser.min.js
  • @jspsych/plugin-serial-reaction-time-mousehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-serial-reaction-time-mouse/dist/index.browser.min.js
  • @jspsych/plugin-serial-reaction-timehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-serial-reaction-time/dist/index.browser.min.js
  • @jspsych/plugin-sketchpadhttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-sketchpad/dist/index.browser.min.js
  • @jspsych/plugin-survey-html-formhttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-survey-html-form/dist/index.browser.min.js
  • @jspsych/plugin-survey-likerthttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-survey-likert/dist/index.browser.min.js
  • @jspsych/plugin-survey-multi-choicehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-survey-multi-choice/dist/index.browser.min.js
  • @jspsych/plugin-survey-multi-selecthttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-survey-multi-select/dist/index.browser.min.js
  • @jspsych/plugin-survey-texthttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-survey-text/dist/index.browser.min.js
  • @jspsych/plugin-surveyhttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-survey/dist/index.browser.min.js
  • @jspsych/plugin-video-button-responsehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-video-button-response/dist/index.browser.min.js
  • @jspsych/plugin-video-keyboard-responsehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-video-keyboard-response/dist/index.browser.min.js
  • @jspsych/plugin-video-slider-responsehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-video-slider-response/dist/index.browser.min.js
  • @jspsych/plugin-virtual-chinresthttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-virtual-chinrest/dist/index.browser.min.js
  • @jspsych/plugin-visual-search-circlehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-visual-search-circle/dist/index.browser.min.js
  • @jspsych/plugin-webgazer-calibratehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-webgazer-calibrate/dist/index.browser.min.js
  • @jspsych/plugin-webgazer-init-camerahttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-webgazer-init-camera/dist/index.browser.min.js
  • @jspsych/plugin-webgazer-validatehttps://cdn.jsdelivr.net/gh/jspsych/jsPsych@ebc99650b9337d231d3fdbd5eb6c58aa99c054e9/packages/plugin-webgazer-validate/dist/index.browser.min.js

Last updated 2026-07-25 15:31 UTC for PR head f14e978.

…hat defers the response to keyup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
github-actions Bot pushed a commit that referenced this pull request Jul 21, 2026
…next trial

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
github-actions Bot pushed a commit that referenced this pull request Jul 21, 2026
@jodeleeuw
jodeleeuw marked this pull request as ready for review July 21, 2026 14:27
@jodeleeuw
jodeleeuw requested a review from jadeddelta July 21, 2026 14:27
With persist: true, a listener holds one pending release at a time; a
later valid press supersedes an unreleased earlier one. Document this
intended behavior in the API reference and a code comment, and add a
test that locks it in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
github-actions Bot pushed a commit that referenced this pull request Jul 22, 2026
github-actions Bot pushed a commit that referenced this pull request Jul 23, 2026

@jadeddelta jadeddelta left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it looks great, there's just one thing to address of debatable urgency: if one actuates a keydown, alt/cmd+tabs or has an OS notification that steals attention away from the browser, releases the key after focus is lost, and then comes back to the experiment, tapping the key will measure the duration from the key press before the loss of focus, rather than the most recent one. deferring this to you because it looks like changes to handle require managing blur events or other mechanisms if you find them (and because it might not be that important- if it isn't, re-request the review and i'll approve it)

(here's an html that describes this, put this in your /examples folder to demonstrate after running npm run build: https://gist.github.com/jadeddelta/2906d6e99c4e4894463d2935693a66ff)

…ending release

When the window loses focus, the browser stops delivering keyup, leaving a held
key looking permanently pressed and orphaning any wait_for_key_release callback
(later inflating rt_key_duration by the blur gap). Treat blur as releasing all
keys: resolve pending releases with rt_key_duration: null and clear held-key
state so it cannot leak into the next trial.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jodeleeuw
jodeleeuw requested a review from jadeddelta July 23, 2026 20:49
@jodeleeuw

Copy link
Copy Markdown
Member Author

good catch! I decided to have the release fire on blur, but pass null as the duration.

github-actions Bot pushed a commit that referenced this pull request Jul 23, 2026

@jadeddelta jadeddelta left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just the changeset and we should be good!

}

export async function windowBlur() {
window.dispatchEvent(new Event("blur"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't forget to add a changeset for this now

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jodeleeuw
jodeleeuw requested a review from jadeddelta July 25, 2026 15:28
github-actions Bot pushed a commit that referenced this pull request Jul 25, 2026

@jadeddelta jadeddelta left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good to me!

@jodeleeuw
jodeleeuw merged commit 1707cd7 into main Jul 26, 2026
9 checks passed
@jodeleeuw
jodeleeuw deleted the feature/rt-key-duration branch July 26, 2026 12:56
@github-actions github-actions Bot mentioned this pull request Jul 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants