Can EEG Signals Be Used to Control Technology? Building a Muse 2-Based Blink Detection System for LED Control
What if I told you that a blink could turn on a light? When I first thought about this, it sounded almost impossible. Then I learned about brain-computer interfaces (BCIs), technologies that allow people to control external devices using signals recorded from the brain. Suddenly, something that seemed like science fiction started to feel possible. I found it fascinating that signals produced by something as ordinary as blinking could be picked up by sensors and used to control technology. It made me wonder: could I build a system that detects electrical signals associated with blinking using an EEG device and uses them to control an external device?I wanted to find out for myself.
To do this, I developed a prototype using a Muse 2 EEG headband, Python, and an Arduino Uno. The idea was to record EEG signals, process them in Python to identify possible blink-related activity, and send commands to an Arduino to control an LED. The project involved building the physical circuit, programming the Arduino, connecting the Muse 2 to a computer, and developing a Python program to process the incoming signals. The final prototype brought these components together, allowing blink-related activity recorded by the Muse 2 to trigger an LED. But making a light turn on was only the beginning. What really fascinated me was everything happening behind that single blink. Let’s take a closer look.
Step 1: Building the Hardware
Before working on the software, I first assembled the physical circuit that would allow the Arduino to control an LED. I gathered the following materials:
- Arduino Uno R3
- Solderless breadboard
- Standard 5 mm LED
- 220 Ω resistor
- Male-to-male jumper wires
- USB cable
- USB-C adapter
Assembling the Circuit:
I started by placing the LED into the solderless breadboard. I positioned its longer leg, which is the positive (+) side, in position A10 and its shorter leg, which is the negative (−) side, in position A11. Next, I placed the 220 Ω resistor between positions B10 and B15 on the breadboard. The resistor limits the electrical current flowing through the LED, helping prevent it from getting damaged. Next, I connected the breadboard to the Arduino Uno R3 using male-to-male jumper wires. I connected the Arduino’s GND pin to position B11 and digital pin 8 to position C15. These connections would let the Arduino send electrical signals to the LED to turn it on and off. Finally, I connected the Arduino to my computer using a USB cable. Since my Mac only had USB-C ports, I used an adapter to connect the cable. This connection would allow me to upload the program to the Arduino and later communicate with it through Python.
Step 2: Programming the Arduino
After assembling the circuit, I moved on to programming the Arduino Uno. The goal was to make the LED respond to commands sent from my computer. I used the Arduino IDE to write the code and upload it to the board. The program was designed to receive commands through the USB serial connection. When it received the command ON, the Arduino would turn the LED on. When it received OFF, it would turn the LED off.
The Arduino code was:
void setup() {
pinMode(8, OUTPUT);
Serial.begin(9600);
}
void loop() {
if (Serial.available()) {
String command = Serial.readStringUntil('\n');
command.trim();
if (command == "ON") {
digitalWrite(8, HIGH);
}
else if (command == "OFF") {
digitalWrite(8, LOW);
}
}
}The first part of the code, setup(), runs once when the Arduino starts. Inside it, I used pinMode(8, OUTPUT) to set digital pin 8 as an output. This means the Arduino can use pin 8 to control the LED. I also added Serial.begin(9600) to start communication between the Arduino and my computer at a speed of 9600 baud.
The second part, loop(), keeps running while the Arduino is turned on. It checks if the computer has sent a message using Serial.available(). When a message arrives, Serial.readStringUntil(‘\n’) reads it until it reaches a new line. Then, command.trim() removes any extra spaces or line-ending characters from the message.
Next, the program checks which command the Arduino received. If the message is ON, digitalWrite(8, HIGH) sends a signal through pin 8, turning the LED on. If the message is OFF, digitalWrite(8, LOW) sets pin 8 to LOW, turning the LED off.
Once I finished writing the program, I connected the Arduino to my computer using the USB cable. I selected the correct Arduino board and port in the Arduino IDE and uploaded the code. After uploading, the Arduino was ready to receive commands from my computer.
Step 3: Connecting the Muse 2 and Accessing EEG Data
Before I could start recording EEG signals, I needed to connect my Muse 2 headband to my computer and set up the software required to receive its data. First, I installed the Python libraries needed for the project. I used the following command in the Mac Terminal:
python3 -m pip install numpy scipy pylsl pyserialEach library had a different purpose. NumPy would help me organize and work with the recorded EEG data, while SciPy would later allow me to filter and analyze the signals. pylsl would allow my Python programs to receive the EEG data being streamed from the Muse 2. Finally, pyserial would allow Python to communicate with the Arduino through a USB connection.
Next, I installed Bleak, a Python library that supports communication with Bluetooth Low Energy devices. I used it to scan for nearby Bluetooth devices and check whether my computer could detect the Muse 2.
python3 -m pip install bleakAfter scanning for devices, my computer detected the headband under the name Muse-1B9E. This confirmed that my computer could find the device. I then installed muselsl, a Python library designed to connect to Muse headbands and stream their EEG data.
python3 -m pip install muselslOnce the installation was complete, I used MuseLSL to connect to my Muse 2.
python3 -m muselsl stream --name Muse-1B9EThe command searched for the headband, connected to it through Bluetooth Low Energy (BLE), and started an EEG data stream. To view these signals in real time, I opened another Terminal window and ran:
python3 -m muselsl viewMuseLSL automatically displayed the incoming EEG data from the Muse 2’s four channels: TP9, AF7, AF8, and TP10. This allowed me to see the electrical signals being recorded by the headband in real time.
Step 4: Recording the EEG Data
After confirming that I could view the Muse 2’s EEG signals in real time, I needed to record them so I could study how the signals changed when I blinked. First, I created a Python program called muse_blink_test.py to record a 30-second experiment. I divided the recording into three conditions:
- 0–10 seconds: Eyes open and looking forward.
- 10–20 seconds: Natural blinking.
- 20–30 seconds: Eyes open and looking forward again.
The program first searched for the Muse 2 EEG stream and established a connection using StreamInlet. It then waited for me to press Enter before starting the recording. Using local_clock(), it measured the elapsed time and determined which of the three conditions was taking place.
Every time the program received a group of EEG measurements, it processed the individual samples and assigned each one a label: eyes_open_before, blinking, or eyes_open_after.
Each sample contained the measurements from the four Muse 2 channels: TP9, AF7, AF8, and TP10. These channels correspond to four electrode positions on the head. TP9 and TP10 are located just behind the left and right ears, respectively, while AF7 and AF8 are positioned on the forehead, just above the left and right eyebrows, respectively [1]. The program stored these measurements together with their timestamps and condition labels.
At the end of the 30 seconds, all the collected information was saved into a CSV file called muse_blink_test.csv. This gave me a structured dataset that I could open again without having to repeat the experiment.
import csv
import time
from pylsl import StreamInlet, resolve_streams, local_clock
print("Looking for Muse EEG...")
streams = [s for s in resolve_streams(wait_time=10) if s.type() == "EEG"]
if not streams:
raise RuntimeError("No EEG stream found. Check that Muse is streaming.")
inlet = StreamInlet(streams[0])
print("\nGet ready!")
input("Press ENTER to begin recording...")
print("0-10 seconds: Eyes Open.")
print("10-20 seconds: Blink naturally.")
print("20-30 seconds: Eyes open again.")
inlet.flush()
start = local_clock()
rows = []
while local_clock() - start < 30:
samples, timestamps = inlet.pull_chunk(timeout=0.2)
for sample, timestamp in zip(samples, timestamps):
elapsed = local_clock() - start
if elapsed < 10:
phase = "eyes_open_before"
elif elapsed < 20:
phase = "blinking"
else:
phase = "eyes_open_after"
rows.append([timestamp, phase] + sample)
with open("muse_blink_test.csv", "w", newline="") as file:
writer = csv.writer(file)
writer.writerow(["timestamp", "phase", "TP9", "AF7", "AF8", "TP10"])
writer.writerows(rows)
print(f"\nRecording complete! Saved {len(rows)} samples.")
print("File: muse_blink_test.csv")The recording collected 7,716 samples. I checked how many samples belonged to each condition:
- eyes_open_before : 2544
- blinking : 2556
- eyes_open_after : 2616
Once I had saved the first recording, I created a second program called plot_muse_data.py. I used Matplotlib to turn the numerical measurements stored in the CSV file into graphs.
The program opened muse_blink_test.csv, skipped the column headings, and extracted the timestamps, condition labels, and measurements from the four EEG channels. It then converted the timestamps into elapsed seconds, starting at zero.
I created four vertically arranged graphs, one for each channel: TP9, AF7, AF8, and TP10. In each graph, the horizontal axis represented time in seconds, while the vertical axis represented the recorded electrical signal.I also added shaded sections to distinguish the three conditions: green for the first and last sections, when my eyes were open, and orange for the middle section, when I was blinking naturally.
import csv
import matplotlib.pyplot as plt
times = []
phases = []
channels = [[], [], [], []]
with open("muse_blink_test.csv", newline="") as file:
reader = csv.reader(file)
next(reader)
for row in reader:
if len(row) < 6:
continue
times.append(float(row[0]))
phases.append(row[1])
for i in range(4):
channels[i].append(float(row[i + 2]))
if not times:
raise ValueError("No EEG data found in the CSV file.")
start = times[0]
times = [t - start for t in times]
fig, axes = plt.subplots(4, 1, figsize=(13, 9), sharex=True)
names = ["TP9", "AF7", "AF8", "TP10"]
for i, ax in enumerate(axes):
ax.plot(times, channels[i], linewidth=0.6)
ax.set_ylabel(names[i])
ax.grid(True, alpha=0.3)
ax.axvspan(0, 10, alpha=0.12, color="green")
ax.axvspan(10, 20, alpha=0.12, color="orange")
ax.axvspan(20, 30, alpha=0.12, color="green")
axes[0].set_title("Muse 2 EEG Recording — Blink Experiment")
axes[-1].set_xlabel("Time (seconds)")
plt.tight_layout()
plt.show()
This allowed me to see the four EEG signals throughout the entire recording and compare how they changed during the different conditions.
After the first recording, I wanted to collect more data using different blinking patterns. I therefore created additional recording programs to investigate the signals under more controlled conditions. For the next experiment, I created muse_blink_test_3.py. This was another 30-second recording, but instead of only blinking naturally, I deliberately blinked at specific times. The recording was divided into three sections:
- 0–10 seconds: Relax and blink naturally.
- 10–20 seconds: Deliberately blink at 12, 14, 16, and 18 seconds.
- 20–30 seconds: Relax and blink naturally again.
The program recorded the same four EEG channels and saved each measurement with its timestamp and corresponding condition label in muse_blink_test_3.csv.
import csv
import time
from pylsl import StreamInlet, resolve_streams, local_clock
print("Looking for Muse EEG...")
streams = [s for s in resolve_streams(wait_time=10) if s.type() == "EEG"]
if not streams:
raise RuntimeError("No EEG stream found. Check that Muse is streaming.")
inlet = StreamInlet(streams[0])
print("\nGet ready!")
input("Press ENTER to begin recording...")
print("0-10 seconds: Relax and blink naturally.")
print("10-20 seconds: Blink deliberately at 12, 14, 16, and 18 seconds.")
print("20-30 seconds: Relax and blink naturally.")
inlet.flush()
start = local_clock()
rows = []
while local_clock() - start < 30:
samples, timestamps = inlet.pull_chunk(timeout=0.2)
for sample, timestamp in zip(samples, timestamps):
elapsed = local_clock() - start
if elapsed < 10:
phase = "eyes_open_before"
elif elapsed < 20:
phase = "blinking"
else:
phase = "eyes_open_after"
rows.append([timestamp, phase] + sample)
with open("muse_blink_test_3.csv", "w", newline="") as file:
writer = csv.writer(file)
writer.writerow(["timestamp", "phase", "TP9", "AF7", "AF8", "TP10"])
writer.writerows(rows)
print(f"\nRecording complete! Saved {len(rows)} samples.")
print("File: muse_blink_test_3.csv")After completing the experiment, I used Python and Matplotlib to plot the filtered AF7 and AF8 signals.
I then created muse_blink_test_4.py to carry out a longer, 55-second experiment with five different sections:
- 0–10 seconds: Relax and look forward.
- 10–20 seconds: Blink naturally.
- 20–30 seconds: Remain still.
- 30–45 seconds: Deliberately blink at 32, 35, 38, 41, and 44 seconds.
- 45–55 seconds: Relax and look forward again.
This program also recorded the elapsed time and the condition associated with each sample. The additional sections allowed me to compare natural blinking, deliberate blinking, and periods when I was trying to remain still.
import csv
import time
from pylsl import StreamInlet, resolve_streams, local_clock
print("Looking for Muse EEG stream...")
streams = [s for s in resolve_streams(wait_time=10) if s.type() == "EEG"]
if not streams:
raise RuntimeError("No Muse EEG stream found. Check Terminal 1.")
inlet = StreamInlet(streams[0])
print("\n55-second recording")
print("Keep your head still and the Muse comfortably in place.")
print("Natural blinking is okay. Do not strain to keep your eyes open.")
print("\nRecording sections:")
print("0-10s: Relax, look forward.")
print("10-20s: Blink naturally.")
print("20-30s: Look forward and stay still.")
print("30-45s: Blink once at each BLINK NOW cue.")
print("45-55s: Relax and look forward.")
input("\nPress ENTER when you are ready...")
inlet.flush()
start = local_clock()
rows = []
cues = [32, 35, 38, 41, 44]
cue_index = 0
last_phase = None
print("\nGet ready...")
while local_clock() - start < 55:
elapsed = local_clock() - start
if elapsed < 10:
phase = "baseline_1"
elif elapsed < 20:
phase = "natural_blinking"
elif elapsed < 30:
phase = "stillness"
elif elapsed < 45:
phase = "deliberate_blinks"
else:
phase = "baseline_2"
if phase != last_phase:
print(f"\n{elapsed:.1f}s — {phase.replace('_', ' ').upper()}")
last_phase = phase
cue = ""
if cue_index < len(cues) and elapsed >= cues[cue_index]:
cue = "BLINK_CUE"
print(f"{elapsed:.1f}s — BLINK NOW")
cue_index += 1
samples, timestamps = inlet.pull_chunk(timeout=0.05)
for sample, timestamp in zip(samples, timestamps):
rows.append([
timestamp,
elapsed,
phase,
cue,
sample[0],
sample[1],
sample[2],
sample[3]
])
with open("muse_blink_test_4.csv", "w", newline="") as file:
writer = csv.writer(file)
writer.writerow([
"timestamp", "elapsed", "phase", "cue",
"TP9", "AF7", "AF8", "TP10"
])
writer.writerows(rows)
print(f"\nRecording complete! Saved {len(rows)} samples.")
print("File: muse_blink_test_4.csv")Step 5: Analyzing the EEG Signals
After recording the EEG data, I wanted to find out whether blinking produced noticeable changes in the signals. I focused on the AF7 and AF8 channels because they are located near the forehead and can capture activity associated with eye movements and blinks.
Get Xavierabueno’s stories in your inbox
Join Medium for free to get updates from this writer.
The raw EEG recording contained different kinds of electrical activity and noise. I used a Butterworth bandpass filter to keep signal changes between 0.5 and 8 cycles per second (Hz). This range helped make the slower, larger changes associated with blinks easier to examine, while reducing some slower and faster activity. I applied the filter forward and backward using sosfiltfilt, which filters the signal without shifting the timing of its features.
First, I imported the libraries I needed for the analysis. I used csv to read the recording, numpy to work with the signal data, and butter and sosfiltfilt from SciPy to create and apply the filter. Next, I opened muse_blink_test_3.csv and collected the timestamps and signal measurements from AF7 and AF8. I also changed the timestamps so that the recording started at 0 seconds instead of using its original starting time. Finally, I created the Butterworth filter using the Muse’s sampling rate of 256 samples per second. I applied it separately to AF7 and AF8 and saved the filtered signals so I could analyze them in the next part.
After filtering the signals, , I also used find_peaks() to find the biggest changes in the filtered AF7 and AF8 signals. The function looks for local peaks. These are points where the signal is higher than the values around them. I used two settings to control which peaks were found:
- distance=64: This means there must be at least 64 samples between detected peaks. Since the recording collected 256 samples per second, this equals about 0.25 seconds.
- prominence=20: This means a peak must stand out by at least 20 µV from the values around it.
I applied np.abs() to the filtered signal before searching for peaks. This allowed the function to identify large changes in either direction, whether the original signal went strongly positive or strongly negative. The code then sorted the detected peaks by their absolute amplitude and selected the 12 strongest ones. Finally, it printed their timestamps and signal amplitudes.
import csv
import numpy as np
from scipy.signal import butter, sosfiltfilt, find_peaks
times = []
channels = {"AF7": [], "AF8": []}
with open("muse_blink_test_3.csv", newline="") as file:
reader = csv.DictReader(file)
for row in reader:
times.append(float(row["timestamp"]))
for name in channels:
channels[name].append(float(row[name]))
times = np.array(times)
times = times - times[0]
sos = butter(3, [0.5, 8], btype="bandpass", fs=256, output="sos")
for name, values in channels.items():
filtered = sosfiltfilt(sos, values)
peaks, properties = find_peaks(
np.abs(filtered),
distance=64,
prominence=20
)
strongest = sorted(
peaks,
key=lambda p: abs(filtered[p]),
reverse=True
)[:12]
print("\n" + name + " — strongest peaks")
for p in sorted(strongest):
print(f"Time: {times[p]:.2f}s | Amplitude: {filtered[p]:.2f}")After running the code, I found several large peaks close to the times when I deliberately blinked:
- AF7: The code found peaks at 12.52 seconds (−908.23 µV), 14.27 seconds (−868.48 µV), and 16.08 seconds (933.96 µV).
- AF8: It found peaks at 12.52 seconds (−495.16 µV), 14.27 seconds (−454.89 µV), 15.85 seconds (−392.67 µV), and 17.79 seconds (−467.75 µV).
These results showed that large changes in both channels occurred close to several of my deliberate blinks. However, the code also detected large peaks at other times when I was not deliberately blinking. This meant that find_peaks() could identify strong changes in the EEG signals, but it could not determine whether those changes were actually caused by blinks.
After finding the strongest peaks, I wanted to compare what happened during my four deliberate blinks with what happened at other times in the recording when I was not deliberately blinking. I looked at a one-second section around each planned blink. To measure the changes, I calculated three things: the positive peak (the highest signal value), the negative peak (the lowest signal value), and the peak-to-peak range (the difference between the highest and lowest values). This helped me measure how much the signal changed during each blink. I also measured the signal during five other periods: 10–11, 13–13.5, 15–15.5, 17–17.5, and 19–20 seconds. I used these periods to compare the signal changes when I was not deliberately blinking with the
import csv
import numpy as np
from scipy.signal import butter, sosfiltfilt
times = []
channels = {"AF7": [], "AF8": []}
with open("muse_blink_test_3.csv", newline="") as file:
reader = csv.DictReader(file)
for row in reader:
times.append(float(row["timestamp"]))
for name in channels:
channels[name].append(float(row[name]))
times = np.array(times)
times -= times[0]
sos = butter(3, [0.5, 8], btype="bandpass", fs=256, output="sos")
filtered = {}
for name in channels:
filtered[name] = sosfiltfilt(sos, np.array(channels[name]))
print("\nBLINK WINDOW ANALYSIS")
for name in ["AF7", "AF8"]:
print(f"\n===== {name} =====")
for blink in [12, 14, 16, 18]:
mask = (times >= blink - 0.5) & (times <= blink + 0.5)
signal = filtered[name][mask]
positive = np.max(signal)
negative = np.min(signal)
print(f"\nAround {blink}s:")
print(f"Positive peak: {positive:.2f} uV")
print(f"Negative peak: {negative:.2f} uV")
print(f"Peak-to-peak: {positive - negative:.2f} uV")
print("\nBackground comparison:")
for start, end in [(10, 11), (13, 13.5), (15, 15.5), (17, 17.5), (19, 20)]:
mask = (times >= start) & (times <= end)
signal = filtered[name][mask]
print(
f"{start}-{end}s: "
f"peak-to-peak = {np.ptp(signal):.2f} uV"
)The results showed that the signals changed more around the planned blink times than during most of the other periods I measured. The AF7 channel generally showed larger changes than AF8. For each blink, I compared the peak-to-peak range in both channels.
- 12 seconds: AF7: 948.34 µV | AF8: 520.52 µV
- 14 seconds: AF7: 1,673.43 µV | AF8: 663.83 µV
- 16 seconds: AF7: 1,790.05 µV | AF8: 644.86 µV
- 18 seconds: AF7: 1,348.53 µV | AF8: 597.79 µV
However, the background measurements showed that large changes could also happen when I was not deliberately blinking. For example, between 19 and 20 seconds, the peak-to-peak range reached 825.57 µV in AF7 and 315.90 µV in AF8. This was much higher than the other background measurements, even though I was not deliberately blinking. Eye movements, muscle activity, and other noise can also affect EEG signals. Overall, this comparison showed that deliberate blinking produced noticeable signal changes in both AF7 and AF8, especially AF7. However, measuring how much the signal changed was not enough to identify every blink correctly.
Finally, I wanted to create a system that could automatically detect possible blink-related events in the EEG signals. I started by using only the AF7 channel. The program checked the EEG signal in 0.25-second windows and used an initial amplitude threshold of 700 µV. When the signal passed this threshold, the program saved the event’s time and amplitude. In the first test, the program found 27 possible events during the 30-second recording. Four of these happened close to the times when I had deliberately blinked:
- Planned blink at 12 seconds: Detected at 12.50 seconds, with an AF7 amplitude of 1638.47 µV.
- Planned blink at 14 seconds: Detected at 14.26 seconds, with an AF7 amplitude of 1673.43 µV.
- Planned blink at 16 seconds: Detected at 15.96 seconds, with an AF7 amplitude of 1790.05 µV.
- Planned blink at 18 seconds: Detected at 17.79 seconds, with an AF7 amplitude of 1348.53 µV.
However, the detector also identified many events outside these periods. For example, it recorded a signal of 2323.78 µV at 1.24 seconds, 1233.20 µV at 9.29 seconds, and 1877.48 µV at 22.99 seconds. These events occurred when I was not deliberately blinking. This showed that the detector was also picking up other large changes in the EEG signal.
I then changed the program to use both AF7 and AF8. Instead of checking only one channel, it looked for events using information from both sides. In this second test, the program found 20 possible events.
- Planned blink at 12 seconds: Detected at 12.50 seconds, with an AF7 amplitude of 1638 µV and an AF8 amplitude of 648 µV.
- Planned blink at 14 seconds: Detected at 14.26 seconds, with an AF7 amplitude of 1673 µV and an AF8 amplitude of 664 µV.
- Planned blink at 16 seconds: Detected at 15.96 seconds, with an AF7 amplitude of 1790 µV and an AF8 amplitude of 645 µV.
- Planned blink at 18 seconds: Detected at 17.79 seconds, with an AF7 amplitude of 1349 µV and an AF8 amplitude of 497 µV.
However, the two-channel detector continued to identify events outside the planned blink periods. For example, it detected events at 1.24 seconds (AF7: 2324 µV; AF8: 1775 µV) and 22.99 seconds (AF7: 1877 µV; AF8: 927 µV).
Adding AF8 helped reduce the number of possible events, but it did not completely solve the problem. The program was still detecting signal changes that were not part of my planned blinks. I therefore needed to improve the detection method so it could recognize blink patterns more accurately instead of relying mainly on signal amplitude.
Step 6: Calibrating the Detection System
After testing my first blink detectors, I wanted to try a different approach. Instead of only looking for large signal changes, I wanted to compare the EEG signals recorded while I was resting with the signals recorded while I was blinking. To do this, I wrote a new Python script that connected directly to the Muse 2 and collected EEG data in three separate periods: resting, blinking, and resting again.
import time
import numpy as np
from scipy.signal import butter, sosfiltfilt
from pylsl import StreamInlet, resolve_streams
print("Looking for Muse...")
streams = resolve_streams(wait_time=10)
eeg_streams = [s for s in streams if s.type() == "EEG"]
if not eeg_streams:
print("No Muse EEG stream found. Check Terminal 1.")
raise SystemExit
inlet = StreamInlet(eeg_streams[0])
print("Muse connected!")
def record_phase(name, duration=10):
input(f"\n{name}: Press ENTER, then stay still...")
print(f"Recording {name} for 10 seconds...")
values = []
start = time.time()
while time.time() - start < duration:
samples, timestamps = inlet.pull_chunk(timeout=1)
if samples:
values.extend(samples)
data = np.asarray(values)
return data[:, 1], data[:, 2]
rest1_7, rest1_8 = record_phase("RESTING")
input("\nPrepare for five separate blinks.")
blink7, blink8 = record_phase("FIVE SEPARATE BLINKS")
rest2_7, rest2_8 = record_phase("RESTING AGAIN")
sos = butter(3, [0.5, 8], btype="bandpass",
fs=256, output="sos")
def report(name, a, b):
print(f"\n{name}")
for label, channel in [("AF7", a), ("AF8", b)]:
channel = channel[np.abs(channel) < 999]
if len(channel) < 256:
print(label, "Not enough clean data")
continue
filtered = sosfiltfilt(sos, channel)
windows = [
np.ptp(filtered[i:i+256])
for i in range(0, len(filtered)-255, 256)
]
print(label, "1-second signal ranges:",
[round(x, 1) for x in windows])
report("RESTING", rest1_7, rest1_8)
report("BLINKING", blink7, blink8)
report("RESTING AGAIN", rest2_7, rest2_8)
print("\nCalibration finished!")At the beginning, I imported the libraries needed for the experiment. time allowed the program to measure recording durations, NumPy helped organize and process the EEG data, and SciPy provided the filtering functions. I also used pylsl to find and connect to the Muse’s EEG stream. The program searched for an available EEG stream for up to 10 seconds. If it could not find one, it stopped. Otherwise, it connected to the stream and prepared to receive live EEG data.
Next, I wrote the code to find and connect to the Muse 2. The resolve_streams() function searched for available Lab Streaming Layer (LSL) streams for up to 10 seconds. Since the computer could have other types of streams available, I used a list comprehension to select only those whose type was EEG. The if not eeg_streams conditionchecked whether an EEG stream had been found. If no stream was available, the program displayed an error message and stopped running. If an EEG stream was found, StreamInlet() created a connection to it. This allowed the program to receive the measurements being transmitted by the Muse 2.
I then created a function called record_phase() to record the EEG measurements during each experimental condition. The function received two parameters: name, which identified the recording condition, and duration, which specified how long the recording would last. The default duration was 10 seconds. Before each recording, the input() function waited for me to press Enter. The program then created an empty list called values to store the incoming EEG measurements. The time.time() function recorded the starting time. The while loop continued collecting data until the specified recording duration had passed. Inside the loop, inlet.pull_chunk(timeout=1) requested a group of measurements from the Muse 2. If measurements were received, values.extend(samples) added them to the list. After the recording finished, np.asarray(values) converted the collected measurements into a NumPy array. This made it easier to access and process the individual channels. Finally, data[:, 1] selected the AF7 measurements, while data[:, 2] selected the AF8 measurements. I then called the function three times to collect the different conditions:
- RESTING: The first 10-second recording while resting.
- FIVE SEPARATE BLINKS: A 10-second recording while performing five separate blinks.
- RESTING AGAIN: A final 10-second recording while resting again.
The program stored the AF7 and AF8 measurements from each section separately so that I could compare them later.
After collecting the measurements, I created a Butterworth bandpass filter using SciPy’s butter() function. I chose a third-order filter with a frequency range of 0.5 to 8 Hz. This meant that it kept signals within this range and reduced signals outside it. The fs=256 parameter told the filter that the EEG recorded 256 samples per second. I also used output=”sos” to store the filter in a format that made it more stable when processing the signals.
Next, I created another function called report() to process and compare the recordings. The function received the name of the condition and the measurements from the AF7 and AF8 channels. First, np.abs(channel) < 999 selected measurements with absolute values below 999 µV. I used this to remove values that were very close to the Muse 2’s recording limits. The program then checked if at least 256 measurements remained. Since the Muse recorded 256 samples per second, this was the minimum needed to create a complete one-second window. Next, sosfiltfilt() applied the Butterworth filter to the measurements. It filtered the signal in both directions to avoid shifting the signal in time. After filtering, I divided the signal into one-second sections. The expression filtered[i:i+256] selected 256 measurements for each section. For each section, np.ptp() calculated the difference between the highest and lowest values. This showed how much the EEG signal changed during that second. Finally, round(x, 1) rounded each result to one decimal place, and the program displayed the results in the Terminal.
At the end of the program, I called the report() function three times, once for each condition. This displayed the signal ranges for the first resting period, the blinking period, and the final resting period. This allowed me to compare the results and see whether blinking caused larger changes in the EEG signals.
Results of the Calibration
After the program finished processing the recordings, it displayed the peak-to-peak ranges for AF7 and AF8 during each condition. During the first resting period, AF7 produced ranges between 194.2 and 808.3 µV, while AF8 produced ranges between 74.5 and 481.4 µV. During the blinking period, AF7 showed several larger ranges, including 739.8, 1064.2, and 1007.7 µV. AF8 also showed larger changes, reaching 379.8, 509.1, and 506.9 µV. During the final resting period, most of the ranges became lower again. AF7 was often between 67 and 218 µV, while AF8 was often between 23 and 131 µV.
Step 7: Building the Real-Time Blink Detection System
After assembling the circuit, programming the Arduino, collecting EEG recordings, and analyzing the signals, I was ready to bring everything together into one system. During the previous steps, I had learned how to collect EEG measurements from the Muse 2 and how blinking affected the AF7 and AF8 signals. I had also experimented with filtering the signals, identifying peaks, and comparing their amplitudes. These experiments helped me understand which signal changes might be useful for detecting blinks.
I used this information to develop a Python program called blink_detector.py. This program processed EEG measurements as they arrived from the Muse 2. It continuously checked the AF7 and AF8 signals for changes that exceeded the detection thresholds. When both channels passed their thresholds, Python sent an ON command to the Arduino. The Arduino then turned on the LED for a short period before receiving an OFF command.
import time
import serial
import numpy as np
from scipy.signal import butter, sosfilt, sosfilt_zi
from pylsl import StreamInlet, resolve_streams
# Connect to Arduino
arduino = serial.Serial("/dev/cu.usbmodem101", 9600, timeout=1)
time.sleep(2)
arduino.write(b"OFF\n")
# Connect to Muse
print("Looking for Muse...")
streams = resolve_streams(wait_time=10)
eeg_streams = [s for s in streams if s.type() == "EEG"]
if not eeg_streams:
print("No Muse EEG stream found. Check Terminal 1.")
arduino.close()
raise SystemExit
inlet = StreamInlet(eeg_streams[0])
print("Muse connected!")
print("Blink detector running. Press Ctrl+C to stop.")
# Filter the AF7 and AF8 signals
sos = butter(3, [0.5, 8], btype="bandpass",
fs=256, output="sos")
zi7 = sosfilt_zi(sos) * 0
zi8 = sosfilt_zi(sos) * 0
buffer7 = []
buffer8 = []
# Initial experimental thresholds (microvolts)
threshold7 = 500
threshold8 = 300
# Require a new event after the signal settles
rearm7 = threshold7 * 0.6
rearm8 = threshold8 * 0.6
armed = True
led_duration = 0.15
led_on_until = 0
try:
while True:
samples, timestamps = inlet.pull_chunk(timeout=1)
if not samples:
continue
for sample in samples:
af7 = sample[1]
af8 = sample[2]
# Ignore saturated readings
if abs(af7) >= 999 or abs(af8) >= 999:
continue
filtered7, zi7 = sosfilt(sos, [af7], zi=zi7)
filtered8, zi8 = sosfilt(sos, [af8], zi=zi8)
buffer7.append(filtered7[0])
buffer8.append(filtered8[0])
if len(buffer7) > 64:
buffer7.pop(0)
buffer8.pop(0)
if len(buffer7) < 64:
continue
amp7 = np.ptp(buffer7)
amp8 = np.ptp(buffer8)
now = time.time()
# Turn LED off after its short pulse
if led_on_until and now >= led_on_until:
arduino.write(b"OFF\n")
led_on_until = 0
# Rearm only after both signals settle
if amp7 < rearm7 and amp8 < rearm8:
armed = True
# Detect a new possible blink
if armed and amp7 > threshold7 and amp8 > threshold8:
print(
"Possible blink!",
"| AF7:", round(amp7),
"| AF8:", round(amp8)
)
arduino.write(b"ON\n")
led_on_until = now + led_duration
armed = False
except KeyboardInterrupt:
print("\nStopping detector.")
finally:
arduino.write(b"OFF\n")
time.sleep(0.1)
arduino.close()
print("Arduino connection closed.")At the beginning of the program, I imported the libraries needed to receive EEG data and communicate with the Arduino. I used time to measure time and control how long the LED stayed on, serial to send commands to the Arduino through the USB cable, and numpy to work with the EEG measurements. I also used SciPy to filter the signals and PyLSL to find and receive the live EEG data from the Muse 2.
First, the program connected to the Arduino through its USB port. I used the same communication speed of 9600 baud that I had set in the Arduino program in Step 2. I then added a two-second pause to give the Arduino time to get ready. After that, Python sent an OFF command to make sure the LED started turned off. Next, the program searched for the Muse 2 EEG stream. It looked for available EEG streams for up to 10 seconds. If it could not find one, it displayed an error message, closed the Arduino connection, and stopped. If it found the stream, Python connected to it so it could receive the EEG measurements continuously. Once these connections were established, Python could receive live EEG data from the Muse 2 and send commands to the Arduino to control the LED.
In my earlier analysis, I had already used SciPy to filter the EEG recordings and make the signals easier to study. I used a similar method here, but this time the filter had to work continuously as new measurements arrived from the Muse 2. I created a Butterworth bandpass filter that kept frequencies between 0.5 and 8 Hz and reduced signals outside that range. I set the sampling frequency to 256 Hz because the Muse 2 records 256 measurements per second. I also used a format called second-order sections, which helps the filter work more reliably. Finally, I created separate starting values for the AF7 and AF8 channels. These values allowed the filter to remember its previous calculations while processing each new measurement. I started both at zero so the filter was ready to process the live EEG signals.
I then created two empty lists to store the most recent filtered measurements from AF7 and AF8. These lists acted as short-term buffers, allowing the program to continuously examine a small section of recent EEG activity instead of analyzing the entire recording. Based on the calibration results, I chose an initial threshold of 500 µV for AF7 and 300 µV for AF8. I chose these values because, during the blinking period, AF7 reached values above 1000 µV and AF8 reached values above 500 µV. I used these thresholds to identify signal changes that were large enough to be considered possible blink-related events. I also created two lower thresholds, called rearm7 and rearm8, which were set to 60% of the original detection thresholds. These values determined when the detector could become ready to identify another event. The armed variable indicated whether the program was ready to detect a new event, so I initially set it to True. Finally, I defined how long the LED would remain on. I set the LED duration to 0.15 seconds and created another variable to store the time when the LED should turn off.
The main part of the program was a continuous loop that allowed it to keep receiving and analyzing EEG measurements while it was running. Using pull_chunk(), the program received groups of new measurements from the Muse 2 and waited up to one second for new data. If no measurements arrived, it skipped that iteration and continued waiting. When measurements became available, the program processed each sample individually. Each sample contained measurements from the four Muse 2 channels, but I selected only AF7 and AF8. Before filtering the signals, I added a check to ignore measurements with an absolute value of 999 µV or higher. I had previously noticed that some measurements reached values close to the Muse 2’s recording limits, so I wanted to avoid including those readings in the detector. Next, I applied the Butterworth filter to each incoming AF7 and AF8 measurement using sosfilt(). The filter also updated its previous values as it worked, allowing it to continue filtering the signals as new data arrived. I then stored the filtered measurements in the two buffers I had created earlier. Each buffer kept only the latest 64 measurements. Whenever a new measurement was added beyond that limit, the oldest one was removed. Since the Muse 2 recorded 256 measurements per second, 64 measurements represented about a quarter of a second. This created a small, constantly updating section of recent EEG activity for the program to analyze. The program waited until both buffers contained all 64 measurements before moving on to the next part of the detection process.
Once both buffers contained 64 measurements, I used NumPy’s ptp() function to calculate the peak-to-peak range of each channel. This function subtracted the lowest value from the highest value in each buffer, giving me a measurement of how much each EEG signal had changed during the most recent 0.25 seconds. I then recorded the current time using time.time(). This was necessary to control how long the LED remained on. The program first checked whether the LED’s scheduled on-time had passed. If the current time had reached the time stored in led_on_until, Python sent an OFF command to the Arduino and reset the timer. This allowed the LED to turn off automatically after its short pulse. Next, the program checked whether both EEG signals had fallen below their lower thresholds. . If both conditions were met, the detector became ready to identify another possible event. This helped prevent the same large signal change from being detected repeatedly. The program then checked whether the detector was ready and whether both AF7 and AF8 had gone above their original detection thresholds at the same time. If both conditions were met, it displayed a message in the Terminal showing the signal ranges for each channel. I used the message “Possible blink!” because the program could identify large signal changes, but it could not guarantee that every detected event was an actual blink. After identifying a possible event, the program sent an ON command to the Arduino through the USB serial connection. As I had programmed in Step 2, the Arduino responded to this command by setting digital pin 8 to HIGH, which turned on the LED. Python also calculated when the LED should turn off by adding the 0.15-second duration to the current time. I then set the armed variable to False. This prevented the detector from repeatedly registering the same event while the signals remained above the thresholds. The program had to wait until both signals fell below their rearming values before it could identify another possible event.
Finally, I added a way to stop the program safely by pressing Ctrl+C. When I stopped it, Python displayed a message in the Terminal. The program then sent an OFF command to the Arduino, waited briefly, and closed the USB serial connection. This helped make sure the LED was switched off and the connection was properly closed. After finishing the code, I opened the Terminal, activated my Python environment, and ran blink_detector.py. The program successfully connected to the Muse 2 and started processing the live EEG measurements. The Terminal displayed messages for possible blink-related events, along with the signal ranges from AF7 and AF8 and the LED commands being sent to the Arduino.
Testing the Final Prototype
Once the program was running, I put on the Muse 2 headband and made sure it was properly connected to my computer through Bluetooth and that the EEG data was streaming. With the Arduino connected to my computer and the LED ready, I began testing the complete system by blinking several times and observing how the LED responded.
Each time I blinked, the signal measurements exceeded the required thresholds of 500 µV for AF7 and 300 µV for AF8, allowing the detector to identify the event. The Terminal displayed the corresponding measurements from both channels, and Python sent an ON command to the Arduino. The Arduino then turned on the LED briefly before receiving the OFF command and turning it off again. When I stopped blinking, the LED stayed off. I repeated the test several times, and every blink was detected correctly. There were no errors, and the system responded exactly as I had intended. This confirmed that the Muse 2, Python detection program, Arduino, and LED were all communicating and working together as intended.
What Went Wrong and How I Fixed It
The biggest challenge I faced was getting the blink detector to recognize actual blinks without responding to other changes in the EEG signals. During my first tests, the Terminal repeatedly displayed “Possible blink!” even when I wasn’t blinking, causing the LED to turn on and off without me blinking. I realized that my first detector was too sensitive and was interpreting signal changes that were not necessarily caused by blinking as blink events.
To improve it, I decided to create a calibration program( which I explain in Step 6: Calibrating the Detection System) to record and compare EEG signals under different conditions. By analyzing these recordings, I was able to establish more suitable detection thresholds: 500 µV for AF7 and 300 µV for AF8. I also changed the detection logic so that both channels had to exceed their respective thresholds at the same time for the program to identify a possible blink. This made the detector more selective and helped it distinguish blink-related signals from other changes in the EEG measurements. As a result, the system began responding more accurately to my blinks instead of activating the LED when I wasn’t blinking.
From One Blink to Something Bigger
During this project, I learned how brain-computer interfaces (BCIs) can translate electrical signals recorded from the body into commands that control external devices. I explored how the Muse 2 records EEG signals through its four channels: TP9, AF7, AF8, and TP10, and how to stream this data to my computer using Lab Streaming Layer (LSL). I used Python to record the signals, create graphs, apply a 0.5–8 Hz bandpass filter, and compare the recordings during resting and blinking. I also learned how to calculate signal ranges, identify possible blink-related peaks, and use calibration recordings to establish detection thresholds of 500 µV for AF7 and 300 µV for AF8. Finally, I connected Python to an Arduino Uno through serial communication, allowing the program to send commands that turned an LED on and off when blink-related activity was detected.
But this prototype is only my starting point. For my next upgrade, I would like to expand the system to include multiple colored LEDs that respond to different patterns in EEG activity. For example, I want to explore whether the system could light up a green LED when EEG patterns associated with relaxation are detected and a red LED when patterns associated with focused attention are identified. This would allow me to move beyond detecting blink-related activity and investigate how EEG signals could be used to recognize different patterns of brain activity.
Beyond this upgrade, I want to take on a bigger challenge: building my own EEG system using electrodes to record and analyze electrical activity from the brain. In a way, I want to create my own version of the Muse 2, which would give me the opportunity to understand the science and engineering behind the technology rather than relying on a ready-made device. My goal is to build a complete system where I collect the EEG signals directly through my own electrodes, process and analyze them using Python, and connect the system to my Arduino circuit to control the LEDs. This would allow me to investigate whether I can identify different patterns of EEG activity, distinguish between patterns associated with different states, and translate those patterns into real-time commands. It would also test whether I can bring together the hardware, programming, and signal-processing skills needed to build a neural interface from the ground up.
Ultimately, what started as one blink turning on a single LED could become the beginning of something much bigger: building my own technology to understand, interpret, and interact with the human brain.
Works Cited
“Muse Health Frequently Asked Questions.” Muse Health, https://musehealth.ai/pages/faq?utm_
About Me
I am Xaviera Bueno, a 15-year-old student from Quito, Ecuador. I am passionate about exploring technology, science, entrepreneurship, and innovation, especially when they can be used to solve real-world problems. I love learning about emerging technologies and discovering ideas that could shape the future. I am particularly interested in neuroscience, environmental health, space technology, artificial intelligence, and the intersection between science and technology. I enjoy researching, building new ideas, and pushing myself to explore questions that do not yet have clear answers. I am currently part of The Knowledge Society (TKS) 2026–2027 cohort.







