Reaction Timer

circuitpython_cpx-reaction-timer-with-mu-editor-videograb-crop43-1.jpg
Reaction Timer running on a Circuit Playground Express with Mu editor plotter.

Only one board is needed for the reaction timer. A Circuit Playground Bluefruit (CPB) or a Circuit Playground Express (CPX) can be used.

Download the code below by clicking on Download: Project Zip in the code window below. Plug your CPB or CPX board into your computer via a known-good USB data cable. A flash drive named CIRCUITPY should appear in your file explorer/finder program. Copy the code below to the CIRCUITPY drive ensuring it's called code.py.

Code

# cpx-reaction-timer v1.0.1
# A human reaction timer using light and sound

# Measures the time it takes for user to press the right button
# in response to alternate first NeoPixel and beeps from onboard speaker,
# prints times and statistics in Mu friendly format.

import os
import time
import math
import random
import array
import gc
import board
import digitalio
import analogio

# This code works on both CPB and CPX boards by bringing
# in classes with same name
try:
    from audiocore import RawSample
except ImportError:
    from audioio import RawSample
try:
    from audioio import AudioOut
except ImportError:
    from audiopwmio import PWMAudioOut as AudioOut

import neopixel

def seed_with_noise():
    """Set random seed based on four reads from analogue pads.
       Disconnected pads on CPX produce slightly noisy 12bit ADC values.
       Shuffling bits around a little to distribute that noise."""
    a2 = analogio.AnalogIn(board.A2)
    a3 = analogio.AnalogIn(board.A3)
    a4 = analogio.AnalogIn(board.A4)
    a5 = analogio.AnalogIn(board.A5)
    random_value = ((a2.value >> 4) + (a3.value << 1) +
                    (a4.value << 6) + (a5.value << 11))
    for pin in (a2, a3, a4, a5):
        pin.deinit()
    random.seed(random_value)

# Without os.urandom() the random library does not set a useful seed
try:
    os.urandom(4)
except NotImplementedError:
    seed_with_noise()

# Turn the speaker on
speaker_enable = digitalio.DigitalInOut(board.SPEAKER_ENABLE)
speaker_enable.direction = digitalio.Direction.OUTPUT
speaker_enable.value = True

audio = AudioOut(board.SPEAKER)

# Number of seconds
SHORTEST_DELAY = 3.0
LONGEST_DELAY = 7.0

red = (40, 0, 0)
black = (0, 0, 0)

A4refhz = 440
midpoint = 32768
twopi = 2 * math.pi

def sawtooth(angle):
    """A sawtooth function like math.sin(angle).
    Input of 0 returns 1.0, pi returns 0.0, 2*pi returns -1.0."""

    return 1.0 - angle % twopi / twopi * 2

# make a sawtooth wave between +/- each value in volumes
# phase shifted so it starts and ends near midpoint
vol = 32767
sample_len = 10
waveraw = array.array("H",
                      [midpoint +
                       round(vol * sawtooth((idx + 0.5) / sample_len
                                            * twopi
                                            + math.pi))
                       for idx in range(sample_len)])

beep = RawSample(waveraw, sample_rate=sample_len * A4refhz)

# play something to get things inside audio libraries initialised
audio.play(beep, loop=True)
time.sleep(0.1)
audio.stop()
audio.play(beep)

# brightness 1.0 saves memory by removing need for a second buffer
# 10 is number of NeoPixels on CPX/CPB
numpixels = 10
pixels = neopixel.NeoPixel(board.NEOPIXEL, numpixels, brightness=1.0)

# B is right (usb at top)
button_right = digitalio.DigitalInOut(board.BUTTON_B)
button_right.switch_to_input(pull=digitalio.Pull.DOWN)

def wait_finger_off_and_random_delay():
    """Ensure finger is not touching the button then execute random delay."""
    while button_right.value:
        pass
    duration = (SHORTEST_DELAY +
                random.random() * (LONGEST_DELAY - SHORTEST_DELAY))
    time.sleep(duration)


def update_stats(stats, test_type, test_num, duration):
    """Update stats dict and return data in tuple for printing."""
    stats[test_type]["values"].append(duration)
    stats[test_type]["sum"] += duration
    stats[test_type]["mean"] = stats[test_type]["sum"] / test_num

    if test_num > 1:
        # Calculate (sample) variance
        var_s = (sum([(x - stats[test_type]["mean"])**2
                      for x in stats[test_type]["values"]])
                 / (test_num - 1))
    else:
        var_s = 0.0

    stats[test_type]["sd_sample"] = var_s ** 0.5

    return ("Trial " + str(test_num), test_type, duration,
            stats[test_type]["mean"], stats[test_type]["sd_sample"])

run = 1
statistics = {"visual":    {"values": [], "sum": 0.0, "mean": 0.0,
                            "sd_sample": 0.0},
              "auditory":  {"values": [], "sum": 0.0, "mean": 0.0,
                            "sd_sample": 0.0},
              "tactile":   {"values": [], "sum": 0.0, "mean": 0.0,
                            "sd_sample": 0.0}}

print("# Trialnumber, time, mean, standarddeviation")
# serial console output is printed as tuple to allow Mu to graph it
while True:
    # Visual test using first NeoPixel
    wait_finger_off_and_random_delay()
    # do GC now to reduce likelihood of occurrence during reaction timing
    gc.collect()
    pixels[0] = red
    start_t = time.monotonic()
    while not button_right.value:
        pass
    react_t = time.monotonic()
    reaction_dur = react_t - start_t
    print(update_stats(statistics, "visual", run, reaction_dur))
    pixels[0] = black

    # Auditory test using onboard speaker and 444.4Hz beep
    wait_finger_off_and_random_delay()
    # do GC now to reduce likelihood of occurrence during reaction timing
    gc.collect()
    audio.play(beep, loop=True)
    start_t = time.monotonic()
    while not button_right.value:
        pass
    react_t = time.monotonic()
    reaction_dur = react_t - start_t
    print(update_stats(statistics, "auditory", run, reaction_dur))
    audio.stop()
    audio.play(beep)  # ensure speaker is left near midpoint

    run += 1

Reaction Timer with Mu Editor Video

The video below shows the reaction timer being used on a CPX board with the Mu editor in the background. The serial console output can be seen and the numerical output is plotted. The tests alternate between visual and auditory stimulus. Since these are output on separate lines they unfortunately get plotted together by Mu.

Code Discussion

The libraries are loaded in a way that allows the same code to be used on both the CPX or CPB. The exception handling mechanism is used to load one library and if that fails, presumably due to absence, then the other will be attempted. The example below also caters for later versions of the libraries where RawSample has migrated to audiocore.

Download: file
try:
    from audiocore import RawSample
except ImportError:
    from audioio import RawSample

The other conditional import is more interesting as it reveals the CPB's implementation of audio relies on pulse width modulation (PWM) , i.e. the audio output is digital unlike the CPX's analogue output from its DAC. The as syntax is useful here to rename the library's class at import time, allowing the application code to refer to a single class, AudioOut.

Download: file
try:
    from audioio import AudioOut
except ImportError:
    from audiopwmio import PWMAudioOut as AudioOut

The CPB board uses an nRF52840 processor which includes a hardware random number generator accessible using the function os.urandom(). The random library uses this to seed its pseudo-random number generator (PRNG). The CPX's ATSAMD21 processor does not have this feature which can cause the random library to generate the same sequence each time the board is powered up. The os.urandom() function is always present so it must be executed to look for the exception indicating the absence of the hardware feature.

The code below uses the same seeding technique as the original Quick Draw code for the CPX. The function seed_with_noise() reads some analogue values from pads and uses these to seed the PRNG. These values randomly fluctuate to some degree if the pads are not connected. This will prevent a user from intentionally or unintentionally learning the sequence of pause intervals in the program.

Download: file
try:
    os.urandom(4)
except NotImplementedError:
    seed_with_noise()

The update_stats() procedure has to be cautious when calculating the standard deviation. The first step is to calculate the variance and if there is only one observation then this calculation would divide by zero. Python handles this with a ZeroDivisionError exception which in this case would terminate the program! A simple if condition avoids this disaster.

Download: file
if test_num > 1:
    var_s = (sum([(x - stats[test_type]["mean"])**2
                 for x in stats[test_type]["values"]])
             / (test_num - 1))
else:
    var_s = 0.0

The reaction time only turns on one NeoPixel using pixels[0] = red. This is an attempt to make this operation as fast as possible to make the reaction timing more accurate.

This guide was first published on Jan 20, 2020. It was last updated on Jan 20, 2020. This page (Reaction Timer) was last updated on May 21, 2020.