Create a gist now

Instantly share code, notes, and snippets.

What would you like to do?
Watch PS/2 keyboard signals to add extra keyboard noise with relays (AVR / Arduino)
* Read output from a PS/2 keyboard and make a relay click per key-press.
* On an Arduino board, the connections are:
* PS/2 clock -> pin 2 (PD2)
* PS/2 data -> pin 3 (PD3)
* Relays are on PORTB starting from PB0 to PB[NUM_RELAYS - 1]. Look for
* a pin out diagram of your Arduino to see where PORTB is connected. On
* a Pro Mini, this is pins 8 to 13 for example.
#include <avr/io.h>
#include <util/delay.h>
#include <avr/interrupt.h>
#include <stdbool.h>
// Number of relays connected to PORTB
#define NUM_RELAYS 4
// Timing configuration for relays
// The timer rate should be the average desired delay between a keypress and sound
// Relays always trigger on the _next_ timer event to keep the activation period consistent
#define TIMER_RATE_MS 2
#define RELAY_PULSE_MS 35
#define CLK_IN PD2
#define DATA_IN PD3
inline void setup()
// Set up PS/2 clock line as an input
DDRD &= ~(_BV(CLK_IN) | _BV(DATA_IN));
PORTD = _BV(CLK_IN) | _BV(DATA_IN); // Pull high (prevents floating when disconnected)
// Set up all of PORTB as relay outputs
DDRB = 0xFF;
PORTB = 0xFF; // Pull high (relay inputs are active low)
// Enable INT0 interrupt for listening to PS/2 clock line
EIMSK |= (1<<INT0);
// Trigger INT0 on rising clock edge
EICRA |= _BV(ISC11) | _BV(ISC10);
// Setup timer 1 to handle relay trigger without blocking
OCR1A = (F_CPU/1024/1000) * TIMER_RATE_MS; // Value to count to (compare register A)
TCCR1B |= (1 << WGM12); // Reset timer on compare A match
TIMSK1 |= (1 << OCIE1A); // Trigger interrupt on overflow
// Clicker
// Start TIMER1
inline void startRelayTimer()
TCCR1B |= (1 << CS12) | (1 << CS10); // Run timer with a prescaler of 1024
// Stop and clear TIMER1
inline void stopRelayTimer()
TCCR1B &= ~( (1 << CS12) | (1 << CS10) ); // Stop timer
TCNT1 = 0; // Reset timer value
// Relay should be turned on next time the timer fires
// These values are a count of TIMER_RATE_MS periods
// Relay should turned off once this value is reached, but not marked available
const static uint8_t kRelayCooldown = (RELAY_COOLDOWN_MS/TIMER_RATE_MS);
// Relay is off and ready for use
const static uint8_t kRelayOff = 0;
// List of relay states
volatile uint8_t relayState[NUM_RELAYS] = {};
/// Make a sound on an available relay
void triggerClick(uint8_t keycode)
uint8_t startIndex = 0;
// Give a different sound for some keys
switch (keycode) {
// This position makes a clicky sound
case 0x0D: // Tab
case 0x29: // Esc
case 0x59: // Shift
case 0x66: // Backspace
case 0x76: // Esc
startIndex = 2;
// This position makes a "thunk" sound
case 0x5A: // Enter
case 0x11: // Alt
case 0x14: // Control
case 0x12: // Shift
startIndex = 1;
// This position makes a typewriter-esque sound
// Disable interrupts around this as we're changing volatile data
// Trigger the first free relay next time the timer fires
// The relay is always operated on the next timer event to prevent keystrokes
// from being "lost" if they start too close to the timer firing and don't
// make an audible click.
for (uint8_t i = startIndex; i < NUM_RELAYS; ++i) {
if (relayState[i] == kRelayOff) {
relayState[i] = kRelayFire;
// Enable interrupts again
// Start the timer to turn relays off (if it's not already running)
bool updated = false;
for (uint8_t i = 0; i < NUM_RELAYS; ++i) {
if (relayState[i] > kRelayCooldown && relayState[i] <= kRelayFire) {
// Turn on the relay
PORTB &= ~_BV(i);
// Move state towards off and ready
updated = true;
else if (relayState[i] > kRelayOff && relayState[i] <= kRelayCooldown) {
// Turn off the relay when it's about to become free again
// The relay won't be available again until the state is zero again
// This allows a cooldown so the relay is not immediately activated
// again before is has time to phsyically move to its off state.
PORTB |= _BV(i);
updated = true;
// If nothing was updated, stop the timer to avoid wasting cycles
if (!updated) {
// PS/2 scancode reading
// Ah yes, a one byte buffer
volatile uint8_t lastKeystroke = 0x0;
volatile bool keystrokeWaiting = false;
/// Read data from PS/2 interface when the clock line goes low
ISR (INT0_vect)
// What number bit in the current sequence are we receiving
static int8_t bitCount = -1;
// The byte currently being received
static uint8_t data = 0x0;
// Handle current bit in the 11 bit keyboard -> host transmission
// Bits start at -1 to make collecting these bits easier
switch (bitCount) {
case -1:
// The first bit is always zero (start bit), so nothing to do
case 8:
// Parity bit
case 9:
// Store complete keystroke data
lastKeystroke = data;
keystrokeWaiting = true;
// Stop bit - reset
data = 0x0;
bitCount = -1;
// Wait for the extended low clock period at the end of the signal
// This is again some crappy software debouncing
// Push the DATA_IN bit from PIND to the byte of data we're receiving
// The bit in PIND is moved to bit zero, then shifted to the current bit
data |= ( (PIND >> DATA_IN) & 0x1 ) << bitCount;
// Move to the next bit
// Wait for the ~10-16KHz clock to go high again
// This is a shitty software fix instead of a hardware one for debouncing
// Flag to trigger no feedback for the following keypress
// This is currently only used for ignoring key releases
bool isRelease = false;
// Flag to indicate the next keystroke is an extended scancode
bool isExtended = false;
// Map of keys that are currently pressed
// This is requried to ignore repeats from the keyboard controller, as repeats have
// no markings to identify them as an automatic repeat. This is a huge waste of
// memory, since boolean values are being stored as bytes.
// There are two arrays declared so we don't have to use a 16-bit index
uint8_t pressedKeys[256] = {};
uint8_t pressedKeysExtended[256] = {};
inline void handlePendingKeypress()
uint8_t keycodeForTrigger = 0x0;
// Decide quickly what to do with a pending keypress, while interrupts are disabled
if (keystrokeWaiting) {
cli(); // Disable interrupts while reading volatiles
// Parse any modifiers in the scancode
switch (lastKeystroke) {
case 0xE0: // Extended key code
// This indicates the scancode more than one byte long
// We cheat a bit by letting the extra bytes trigger key presses,
// but relying on the sound trigger timeout to debounce
isExtended = true;
case 0xF0: // Break code (key released)
isRelease = true;
default: // Other keystroke
keycodeForTrigger = lastKeystroke;
// Reset state
keystrokeWaiting = false;
sei(); // Enable interrupts again
// Handle detected state after interrupts are enabled again
if (keycodeForTrigger != 0x0) {
uint8_t* _pressRegister;
if (isExtended) {
// If we're dealing with an extended code, switch press state stores
isExtended = false;
_pressRegister = &pressedKeysExtended;
} else {
_pressRegister = &pressedKeys;
if (isRelease) {
// Mark key as no longer pressed, but make no sound
_pressRegister[lastKeystroke] = false;
isRelease = false;
} else if (!_pressRegister[lastKeystroke]) {
// New key being pressed - trigger sound
_pressRegister[lastKeystroke] = true;
int main(void)
// select minimal prescaler (max system speed)
CLKPR = 0x80;
CLKPR = 0x00;
// Set up inputs, outputs and timers
// Ignore any pending interrupt that occurred during setup
EIFR |= (1<<INTF0);
// Enable interrupts
for (;;) {
// Watch for any processing we need to do
// Everything else is handled by interrupts
return 0;
# This makefile is set up to work with the Arduino bootloader
# Depending on your device, you may need to adjust the PROGRAMMER
# line to point to the right serial port.
# To use, just run "make flash" with an arduino connected
DEVICE = atmega328p
CLOCK = 8000000
PROGRAMMER = -c arduino -P /dev/ttyUSB0 -b57600
SOURCES = $(shell find -name '*.c' -or -name '*.cpp' -or -name '*.S')
COMPILE = avr-gcc -Wall -Os -DF_CPU=$(CLOCK) -mmcu=$(DEVICE)
COMPILE += -I -I. -I./lib/
COMPILE += -funsigned-char -funsigned-bitfields -fpack-struct -fshort-enums
COMPILE += -ffunction-sections -fdata-sections -Wl,--gc-sections
COMPILE += -Wl,--relax -mcall-prologues
COMPILE += -std=gnu99
# symbolic targets:
all: $(SOURCES) main.hex
$(COMPILE) -c $< -o $@
$(COMPILE) -x assembler-with-cpp -c $< -o $@
# "-x assembler-with-cpp" should not be necessary since this is the default
# file type for the .S (with capital S) extension. However, upper case
# characters are not always preserved on Windows. To ensure WinAVR
# compatibility define the file type manually.
$(COMPILE) -S $< -o $@
flash: all
$(AVRDUDE) -U flash:w:main.hex:i
echo For computing fuse byte values see the fuse bit calculator at
# Xcode uses the Makefile targets "", "clean" and "install"
install: flash fuse
find -name '*.d' -exec rm {} +
find -name '*.o' -exec rm {} +
rm -f main.hex main.elf
# file targets:
main.elf: $(OBJECTS)
$(COMPILE) -o main.elf $(OBJECTS)
main.hex: main.elf
rm -f main.hex
avr-objcopy -j .text -j .data -O ihex main.elf main.hex
avr-size --format=avr --mcu=$(DEVICE) main.elf
# Targets for code debugging and analysis:
disasm: main.elf
avr-objdump -d main.elf
$(COMPILE) -E main.c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment