A portable, hands-free, zero-effort blink-controlled speech system, inspired by the voice system of Stephen Hawking. Uses mbed, Neurosky Mindwave Mobile headset, BlueSMiRF modem and Emic2 speech board.

Dependencies:   SPI_TFT TFT_fonts mbed

/media/uploads/RorschachUK/_scaled_p6150001.jpg

Ever since I got the Parallax / Grand Idea Studio Emic2 sound board I've been wanting to find some way to mimic the hands-free typing / speech system used by Stephen Hawking. And when I figured out how to get the Neurosky Mindwave Mobile headset to identify blinks, I realised I could use blinking as the single minimal-effort input into the system to control a keyboard menuing system.

/media/uploads/RorschachUK/_scaled_p6150002.jpg

The system sweeps through rows until I blink, then sweeps along the row to select a character or action with another blink. If I let it go past the end, it cancels and carries on sweeping rows. Since we're also getting eSense (attention/meditation) and brainwave data from the headset, I've included small barcharts at the top of the screen to show those - perhaps concentrating on typing will show up in the concentration levels.

See my other projects for more on how to setup the BlueSMiRF for auto-connecting to the NeuroSky headset.

Hardware:

  • Neurosky Mindwave Mobile headset - sends serial data packets over BlueTooth
  • BlueSMiRF Silver Mate - receives data over BlueTooth from headset and relays over serial to mbed
  • Parallax / Grand Idea Studio Emic2 speech synthesis - speaks text sent over serial from the mbed
  • MikroElektronika TFT Proto - 320x240 TFT display with HX8374 controller driven by SPI
  • Sparkfun level shifter - translates Emic2 5V serial to 3.3V serial for mbed

/media/uploads/RorschachUK/blinktalkfritz.jpg

Revision:
0:e2daaf858e13
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/main.cpp	Sat Jun 15 19:43:44 2013 +0000
@@ -0,0 +1,552 @@
+/* BlinkTalk - Bob Stone June 2013
+ * Hands-free control of a speech synthesis system by blinking.
+ *
+ * This project implements a proof of concept speech system actuated by a single input
+ * - this could be a button, but for a more fun application I am going to use blinks as
+ * detected by an EEG headset, with a row & column time-sweep keyboard, inspired by
+ * (but not the same as) the speech system used by Prof Stephen Hawking, mainly because the
+ * Emic2 voice board includes the DECTalk 'PerfectPaul' voice familiar to all as Hawking.
+ *
+ * In Hawking's actual system, an infrared sensor mounted on his glasses detects a movement
+ * of his cheek muscle, selecting letters from a keyboard  sweeping rows and columns, with a
+ * predictive text system handling word completion and suggested follow-on words.  In
+ * our version we will detect a blink using a Neurosky Mindwave Mobile headset and use it to
+ * stop and select a cursor sweeping a keyboard grid.
+ *
+ * Hardware:
+ *      Neurosky Mindwave Mobile headset - sends serial data packets over BlueTooth
+ *      BlueSMiRF Silver Mate - receives data over BlueTooth from headset and relays over serial to mbed
+ *      Parallax / Grand Idea Studio Emic2 speech synthesis - speaks text sent over serial from the mbed
+ *      MikroElektronika TFT Proto - 320x240 TFT display with HX8374 controller driven by SPI
+ *      Sparkfun level shifter - translates Emic2 5V serial to 3.3V serial for mbed
+ *
+ * Connections:
+ *  mbed        BlueSMiRF   LevelShift  Emic2   Speaker TFT-PROTO
+ *   GND         GND         GND/GND     GND             GND
+ *   VOUT(3.3V)  VCC         LV                          3.3V
+ *   VU (5V)                 HV          5V
+ *   p9 (TX)     RX-I
+ *   p10(RX)     TX-O
+ *   p11(MOSI)                                           SDI
+ *   p12(MISO)                                           SDO
+ *   p13(SCLK)                                           SCL
+ *   p14                                                 CS
+ *   p15                                                 RST
+ *   p27(RX)                 LV:RXO
+ *   p28(TX)                 LV:TXI
+ *                           HV:RXI      SOUT
+ *                           HV:TXO      SN
+ *                                       SP+     +
+ *                                       SP-     -
+ *      
+ * Borrows from http://developer.neurosky.com/docs/doku.php?id=mindwave_mobile_and_arduino
+ * Display library from Peter Drescher: http://mbed.org/cookbook/SPI-driven-QVGA-TFT
+ */
+#include "mbed.h"
+#include "SPI_TFT.h"
+#include "Arial12x12.h"
+#include "Arial28x28.h"
+#include <string>
+
+//Peripherals
+Serial blueSmirf(p9, p10);                      //bluetooth comms (TX, RX)
+Serial voice(p28,p27);                          //Emic2 text to speech voice synth
+SPI_TFT screen(p11, p12, p13, p14, p15, "TFT"); //display
+
+//control variables
+int quality=0;
+bool connected=false;
+bool started=false;
+int row=-1;
+int col=-1;
+Timer initialDelay;
+
+//Keyboard - rows to display
+#define ROWS 5
+#define COLS 10
+//keys or text to display
+string keys[ROWS][COLS] = {
+    {"1","2","3","4","5","6","7","8","9","0"},
+    {"Q","W","E","R","T","Y","U","I","O","P"},
+    {"A","S","D","F","G","H","J","K","L","!"},
+    {"Z","X","C","V","B","N","M",",",".","?"},
+    {"Space","\"SAY!\"","Delete"}
+};
+//positions to draw a line at the end of a column
+int keysColPos[ROWS][COLS] = {
+    {36,67,98,129,160,191,222,253,284,315},
+    {36,67,98,129,160,191,222,253,284,315},
+    {36,67,98,129,160,191,222,253,284,315},
+    {36,67,98,129,160,191,222,253,284,315},
+    {100,210,315}
+};
+//number of elements in each row
+int keysRowSize[ROWS] = {10, 10, 10, 10, 3};
+
+//text being typed
+string typingBuffer("");
+
+//*****************************
+//User routines to process data
+//*****************************
+
+/** Say some text out loud
+ *
+ * @param text The text to say out loud
+ */
+void say(string text)
+{
+    voice.printf("S%s\n", text);//send command to speak to the Emic2
+    while(!voice.readable())    //wait for Emic2 to return ':'
+        ;                       //do nothing whilst it's still speaking
+    voice.getc();               //pop the ':' response from the stream
+}
+
+/** Maps a value from one scale to another
+ *
+ * @param value Value we're trying to scale
+ * @param min,max The range that value came from
+ * @param newMin,newMax The new range we're scaling value into
+ * @returns value mapped into new scale
+ */
+int map(int value, int min, int max, int newMin, int newMax)
+{
+    if (min==max)
+        return newMax;
+    else
+        return newMin + (newMax-newMin) * (value-min) / (max-min);
+}
+
+/** Returns a 16-bit RGB565 colour from three 8-bit component values.
+ *
+ * @param red,green,blue primary colour channel values expressed as 0-255 each
+ * @returns 16-bit RGB565 colour constructed as RRRRRGGGGGGBBBBB
+ */
+int RGBColour(int red, int green, int blue)
+{
+    //take most-significant parts of red, green and blue and bit-shift into RGB565 positions
+    return ((red & 0xf8) << 8) | ((green & 0xfc) << 3) | ((blue & 0xf8) >> 3);
+}
+
+/** Returns a colour mapped on a gradient from one colour to another.
+ *
+ * @param value Value we're trying to pick a colour for
+ * @param min,max Scale that value belongs in
+ * @param minColour,maxColour start and end colours of the gradient we're choosing from (16-bit RGB565)
+ * @returns colour that's as far along the gradient from minColour to maxColour as value is between min and max (16-bit RGB565)
+ */
+int getMappedColour(int value, int min, int max, int minColour, int maxColour)
+{
+    // TFT screen colours are 16-bit RGB565 i.e. RRRRRGGGGGGBBBBB
+    int minRed = (minColour & 0xf800) >> 11; //bitmask for 5 bits red
+    int maxRed = (maxColour & 0xf800) >> 11;
+    int minGreen = (minColour & 0x7e0) >> 5; //bitmask for 6 bits green
+    int maxGreen = (maxColour & 0x7e0) >> 5;
+    int minBlue = minColour & 0x1f; // bitmask for 5 bits blue
+    int maxBlue = maxColour & 0x1f;
+    int valRed = map(value, min, max, minRed, maxRed);
+    int valGreen = map(value, min, max, minGreen, maxGreen);
+    int valBlue = map(value, min, max, minBlue, maxBlue);
+    int valColour = ((valRed & 0x1F) << 11) | ((valGreen & 0x3F) << 5) | (valBlue & 0x1F);
+    return valColour;
+}
+
+/** Displays a bar graph showing 'value' on a scale 'min' to 'max', where coords (x0,y0) are at 'min' and (x1,y1) are at 'max'.
+ *
+ * @param x0,y0 coordinates of the 'min' end of the bargraph
+ * @param x1,y1 coordinates of the 'max' end of the bargraph
+ * @param isHorizontal If true, bar graph will be drawn with horizontal bars
+ * @param value Value of the bar, with bars drawn from min up to value, remaining 'backColour' from there to max
+ * @param min,max Scale of the bar graph that value should be found within
+ * @param minColour,maxColour colours at the min and max ends of the bar, drawn in a gradient between the two (16-bit RGB565)
+ * @param backColour background colour of the bar graph (16-bit RGB565)
+ */
+void displayBarGraph(int x0, int y0, int x1, int y1, bool isHorizontal, int value, int min, int max, int minColour, int maxColour, int backColour)
+{
+    int valColour;
+    if (isHorizontal) {
+        if (x1>x0) {
+            for (int i = x0; i < x1; i+=5) {
+                if (map(i, x0, x1, min, max) > value)
+                    valColour = backColour;
+                else
+                    valColour = getMappedColour(i, x0, x1, minColour, maxColour);
+                screen.fillrect(i, y0, i+3, y1, valColour);
+            }
+        } else {
+            for (int i = x1; i < x0; i+=5) {
+                if (map(i, x0, x1, min, max) > value)
+                    valColour = backColour;
+                else
+                    valColour = getMappedColour(i, x0, x1, minColour, maxColour);
+                screen.fillrect(i-3, y0, i, y1, valColour);
+            }
+        }
+    } else {
+        if (y1>y0) {
+            for (int i = y0; i < y1; i+=5) {
+                if (map(i, y0, y1, min, max) > value)
+                    valColour = backColour;
+                else
+                    valColour = getMappedColour(i, y0, y1, minColour, maxColour);
+                screen.fillrect(x0, i, x1, i+3, valColour);
+            }
+        } else {
+            for (int i = y1; i < y0; i+=5) {
+                if (map(i, y0, y1, min, max) > value)
+                    valColour = backColour;
+                else
+                    valColour = getMappedColour(i, y0, y1, minColour, maxColour);
+                screen.fillrect(x0, i-3, x1, i, valColour);
+            }
+        }
+    }
+}
+
+/** Draw a keyboard based on the supplied array of cells
+ *
+ * @param cells the cells to draw
+ * @param rowHighlight off if -1, else highlights the numbered row
+ * @param colHighlight off if -1, else highlights the cell in row/col
+ */
+void drawKeyboard(int rowSize[ROWS], string cells[ROWS][COLS], int colPos[ROWS][COLS], int rowHighlight, int colHighlight)
+{
+    int lineColour = RGBColour(0x20,0xFF,0xE0);
+    screen.foreground(RGBColour(0xE0,0xC0,0x10));
+    screen.set_font((unsigned char*) Arial28x28);
+    for (int i=0; i<ROWS; i++) {
+        int yPos=111+i*32;
+        for (int j=0; j< rowSize[i]; j++) {
+            if (j>0)
+                screen.locate(colPos[i][j-1]+5,84+i*32);
+            else
+                screen.locate(10,84+i*32);
+            screen.printf("%s",cells[i][j]);
+            screen.line(colPos[i][j],yPos-31,colPos[i][j],yPos, lineColour);
+        }
+        screen.line(5, yPos, 315, yPos, lineColour);
+    }
+    screen.line(5, 79, 315, 79, lineColour);
+    screen.line(5, 79, 5, 239, lineColour);
+    if (rowHighlight >= 0) {
+        if (colHighlight >= 0) { //highlight a cell
+            if (colHighlight==0)
+                screen.rect(5,79+rowHighlight*32,colPos[rowHighlight][0],111+rowHighlight*32,RGBColour(0xFF,0x40,0x40));
+            else
+                screen.rect(colPos[rowHighlight][colHighlight-1],79+rowHighlight*32,colPos[rowHighlight][colHighlight],111+rowHighlight*32,RGBColour(0xFF,0x40,0x40));
+        } else { // highlight whole row
+            screen.rect(5,79+rowHighlight*32,315,111+rowHighlight*32,RGBColour(0xFF,0x40,0x40));
+        }
+    }
+}
+
+void drawText()
+{
+    screen.rect(5,35,315,74,RGBColour(0x20,0xFF,0xE0));
+    screen.locate(10,40);
+    screen.foreground(RGBColour(0xE0,0xC0,0x10));
+    screen.set_font((unsigned char*) Arial28x28);
+    screen.printf("%s_  ", typingBuffer);
+}
+
+/** This will be called if you blink.
+ */
+void blinked(void)
+{
+    //draw blink indicator
+    if (quality == 0) {
+        screen.fillrect(313, 13, 317, 17, White);
+    }
+    //select row or cell
+    if (col == -1)
+        col=-2;
+    else {
+        string s = keys[row][col];
+        if (s.compare("Space") == 0)
+            s=" ";
+        if (s.compare("Delete") == 0) {
+            s="";
+            if (typingBuffer.length() > 0) {
+                typingBuffer = typingBuffer.substr(0, typingBuffer.length() -1);
+            }
+        } else if (s.compare("\"SAY!\"") == 0) {
+            say(typingBuffer);
+            screen.fillrect(5,35,315,74,Black);
+            typingBuffer="";
+        } else {
+            typingBuffer.append(s);
+        }
+        row = -1;
+        col = -1;
+        drawText();
+    }
+}
+
+/** This will be called when processed eSense data comes in, about once a second.
+ *
+ * @param poorQuality will be 0 if connections are good, 200 if connections are useless, and somewhere in between if connection dodgy.
+ * @param attention processed percentage denoting focus and attention.  0 to 100
+ * @param meditation processed percentage denoting calmness and serenity.  0 to 100
+ * @param timeSinceLastPacket time since last packet processed, in milliseconds.
+ */
+void eSenseData(int poorQuality, int attention, int meditation, int timeSinceLastPacket)
+{
+    //quality indicator
+    quality=poorQuality;
+    if (poorQuality == 200)
+        screen.fillrect(313, 3, 317, 7, Red);
+    else if (poorQuality == 0)
+        screen.fillrect(313, 3, 317, 7, Green);
+    else
+        screen.fillrect(313, 3, 317, 7, Yellow);
+
+    //minimal eSense bars up at the top of the screen
+    screen.set_font((unsigned char*) Arial12x12);
+    if (attention > 0) {
+        displayBarGraph(200, 5, 310, 15, true, attention, 0, 100, RGBColour(0x10,0x00,0x00), RGBColour(0xFF,0x00,0x00), 0x00);
+        screen.locate(135, 6);
+        screen.foreground(Red);
+        screen.printf("Att: %d ",attention);
+    }
+    if (meditation > 0) {
+        displayBarGraph(200, 18, 310, 28, true, meditation, 0, 100, RGBColour(0x00,0x10,0x00), RGBColour(0x00,0xFF,0x00), 0x00);
+        screen.locate(128, 19);
+        screen.foreground(Green);
+        screen.printf("Med: %d ",meditation);
+    }
+    //clear blink indicator
+    screen.fillrect(313, 13, 317, 17, Black);
+    //Safe to start yet?
+    if (initialDelay.read() == 0)
+        initialDelay.start();
+    else if (initialDelay.read() > 5) {
+        started=true;
+        initialDelay.stop();
+    }
+}
+
+/** This will be called when processed meter reading data arrives, about once a second.
+ * This is a breakdown of frequencies in the wave data into 8 named bands, these are:
+ *   0: Delta        (0.5-2.75 Hz)
+ *   1: Theta        (3.5-6.75 Hz)
+ *   2: Low-Alpha    (7.5-9.25 Hz)
+ *   3: High-Alpha   (10-11.75 Hz)
+ *   4: Low-Beta     (13-16.75 Hz)
+ *   5: High-Beta    (18-29.75 Hz)
+ *   6: Low-Gamma    (31-39.75 Hz)
+ *   7: High-Gamma   (41-49.75 Hz)
+ *
+ * @param meter array of meter data for different frequency bands
+ * @param meterMin array of minimum recorded samples of each band
+ * @param meterMax arrat if naximum recorded samples of each band
+ */
+void meterData(int meter[8], int meterMin[8], int meterMax[8])
+{
+    //first good signal?
+    if (!connected) {
+        connected=true;
+        screen.fillrect(0,0,319,30,Black); //clear the Waiting to connect msg
+        initialDelay.reset();
+        initialDelay.start();
+    }
+    //minimal meter bars up at the top of the screen
+    for (int j=0; j<8; j++) {
+        displayBarGraph(5 + j * 13, 30, 16 + j * 13, 3, false, meter[j], meterMin[j], meterMax[j], RGBColour(0, j*2, 0x10), RGBColour(0, j*32, 0xFF), 0x00);
+    }
+    //Hijack this routine for menu movement
+    if (started) {
+        if (col==-1) {
+            row++;
+            if (row>=ROWS)
+                row=0;
+        } else if (col==-2) {
+            col=0;
+        } else {
+            col++;
+            if (col>=keysRowSize[row])
+                col=-1;
+        }
+        drawKeyboard(keysRowSize, keys, keysColPos, row, col);
+    }
+}
+
+/** This will be called when wave data arrives.
+ * There will be a lot of these, 512 a second, so if you're planning to do anything
+ * here, don't let it take long.  Best not to printf this out as it will just choke.
+ *
+ * param wave Raw wave data point
+ */
+void waveData(int wave)
+{
+}
+
+//*****************
+//End User routines
+//*****************
+
+//System routines to obtain and parse data
+
+/** Simplify serial comms
+ */
+unsigned char ReadOneByte()
+{
+    int ByteRead;
+
+    while(!blueSmirf.readable());
+    ByteRead = blueSmirf.getc();
+
+    return ByteRead;
+}
+
+/** Main loop, sets up and keeps listening for serial
+ */
+int main()
+{
+    //Video setup
+    screen.claim(stdout);        // send stdout to the TFT display
+    screen.background(Black);    // set background to black
+    screen.foreground(White);    // set chars to white
+    screen.cls();                // clear the screen
+    screen.set_orientation(1);
+    screen.set_font((unsigned char*) Arial12x12);
+    screen.locate(5,5);
+    screen.printf("Waiting to connect...");
+
+    drawText();
+    drawKeyboard(keysRowSize, keys, keysColPos, -1, -1);
+
+    //Voice setup
+    voice.baud(9600);
+    voice.printf("\n");
+    while (!voice.readable())
+        ;
+    wait(0.01);
+    voice.getc();
+    say("Welcome to Blink talk.");
+
+    Timer t; //packet timer
+    t.start();
+    Timer blinkTimer; //used for detecting blinks
+    int time;
+    int generatedChecksum = 0;
+    int checksum = 0;
+    int payloadLength = 0;
+    int payloadData[64] = {0};
+    int poorQuality = 0;
+    int attention = 0;
+    int meditation = 0;
+    int wave = 0;
+    int meter[8] = {0};
+    int meterMin[8];
+    int meterMax[8];
+    for (int j = 0; j < 8; j++) {
+        meterMin[j]=99999999;
+        meterMax[j]=-99999999;
+    }
+    bool eSensePacket = false;
+    bool meterPacket = false;
+    bool wavePacket = false;
+
+    blueSmirf.baud(57600);
+    blinkTimer.reset();
+
+    while(1) {
+        // Look for sync bytes
+        if(ReadOneByte() == 170) {
+            if(ReadOneByte() == 170) {
+                //Synchronised to start of packet
+                payloadLength = ReadOneByte();
+                if(payloadLength > 169) //Payload length can not be greater than 169
+                    return;
+
+                generatedChecksum = 0;
+                for(int i = 0; i < payloadLength; i++) {
+                    payloadData[i] = ReadOneByte();            //Read payload into memory
+                    generatedChecksum += payloadData[i];
+                }
+
+                checksum = ReadOneByte();                      //Read checksum byte from stream
+                generatedChecksum = 255 - (generatedChecksum & 0xFF);   //Take one's compliment of generated checksum
+
+                if(checksum == generatedChecksum) {
+                    //Packet seems OK
+                    poorQuality = 200;
+                    attention = 0;
+                    meditation = 0;
+                    wave = 0;
+                    for(int i = 0; i < payloadLength; i++) {    // Parse the payload
+                        switch (payloadData[i]) {
+                            case 2: //quality
+                                i++;
+                                poorQuality = payloadData[i];
+                                eSensePacket = true;
+                                break;
+                            case 4: //attention
+                                i++;
+                                attention = payloadData[i];
+                                eSensePacket = true;
+                                break;
+                            case 5: //meditation
+                                i++;
+                                meditation = payloadData[i];
+                                eSensePacket = true;
+                                break;
+                            case 0x80: //wave
+                                wave = payloadData[i+2] * 256 + payloadData[i+3];
+                                //We also want to try to detect blinks via analysing wave data
+                                time = blinkTimer.read_ms();
+                                if (wave > 32767) wave -= 65535; //cope with negatives
+                                if (wave>200 && time == 0) {
+                                    blinkTimer.start();
+                                } else if (wave<-90 && time > 10 && time < 350) {
+                                    blinkTimer.stop();
+                                    blinkTimer.reset();
+                                    blinked();
+                                } else if (time>500) {
+                                    blinkTimer.stop();
+                                    blinkTimer.reset();
+                                }
+                                i = i + 3;
+                                wavePacket = true;
+                                break;
+                            case 0x83: //meter readings for different frequency bands
+                                for (int j=0; j<8; j++) {
+                                    //documentation is inconsistent about whether these values are big-endian or little-endian,
+                                    //and claims both in different places.  But wave data is big-endian so assuming that here.
+                                    meter[j] = payloadData[i+j*3+2]*65536 + payloadData[i+j*3+3]*256 + payloadData[i+j*3+4];
+                                    if (quality==0) {
+                                        if (meter[j]<meterMin[j])
+                                            meterMin[j]=meter[j];
+                                        if (meter[j]>meterMax[j])
+                                            meterMax[j]=meter[j];
+                                    }
+                                }
+                                meterPacket = true;
+                                i = i + 25;
+                                break;
+                            default:
+                                break;
+                        } // switch
+                    } // for loop
+
+                    //Call routines to process data
+                    if(eSensePacket) {
+                        eSenseData(poorQuality, attention, meditation, t.read_ms());
+                        eSensePacket = false;
+                    }
+                    if (meterPacket) {
+                        meterData(meter, meterMin, meterMax);
+                        t.reset();
+                        meterPacket=false;
+                    }
+                    if (wavePacket) {
+                        waveData(wave);
+                        wavePacket=false;
+                    }
+                } else {
+                    // Checksum Error
+                }  // end if else for checksum
+            } // end if read 0xAA byte
+        } // end if read 0xAA byte
+    } //end while
+}
\ No newline at end of file