diff --git a/README.md b/README.md index 494f75d..dd8cfa7 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,26 @@ Chords is an application based on Web Serial connection, you can connect boards - [Vendors](src/components/vendors.ts) list for board name and there vendor id are taken from [Stackblitz](https://stackblitz.com/edit/typescript-web-serial?file=vendors.ts) created by [William Grasel](https://github.com/willgm) +## Roadmap for upcoming update + +**Data Filtering** : We will be adding bio-potential signal filtering options which includes 50/60 Hz notch filter to remove AC interference noise and highpass/lowpass remove artefacts from ECG, Emg ,Eog and EEg. Under filters, we will be adding different highpass and lowpass filters for specific bio-potential signals this feature will further enhance the user experience to record even more clear biopotential signals. + + +**Snapshot of data** : We will add the option to show up to 10 snapshots of length 4 seconds each providing you the option to take a peek into past 40 seconds of your data. + +**Multiple file download support** : We’re excited to enhance your options for downloading recorded data! Currently, you can record a file and choose to save or delete it. Soon, you’ll be able to download multiple files at once and have the flexibility to download or delete individual recorded files as needed. + +**Raspberry Pi Pico support** : We will be releasing Raspberry Pi Pico support for chords which by the way works very well with our new Heart BioAmp Candy. Let us know your favorite board in the comments section below and we will make sure to add chords support for your board in the upcoming updates. + + +**CSV compatibility with [Chords Python](https://github.com/upsidedownlabs/Chords-Python)** : we will update the CSV data format and file names for both chords-web and chords-python so that you can use csvplotter.py to easily plot the recorded data. + + ## Contributors Thank you for contributing to our project! Your support is invaluable in creating & enhancing Chords-Web and making it even better. 😊 +
diff --git a/package-lock.json b/package-lock.json index b087b21..e941fe0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "framer-motion": "^11.5.4", "html2canvas": "^1.4.1", "jszip": "^3.10.1", - "lucide-react": "^0.383.0", + "lucide-react": "^0.460.0", "next": "14.2.10", "next-themes": "^0.3.0", "react": "^18", @@ -6256,11 +6256,12 @@ } }, "node_modules/lucide-react": { - "version": "0.383.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.383.0.tgz", - "integrity": "sha512-13xlG0CQCJtzjSQYwwJ3WRqMHtRj3EXmLlorrARt7y+IHnxUCp3XyFNL1DfaGySWxHObDvnu1u1dV+0VMKHUSg==", + "version": "0.460.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.460.0.tgz", + "integrity": "sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==", + "license": "ISC", "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "node_modules/make-dir": { diff --git a/package.json b/package.json index eac1370..75f867a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "start": "next start", "lint": "next lint", "export": "next export", - "distDir":"out" + "distDir": "out" }, "dependencies": { "@radix-ui/react-accordion": "^1.2.0", @@ -38,7 +38,7 @@ "framer-motion": "^11.5.4", "html2canvas": "^1.4.1", "jszip": "^3.10.1", - "lucide-react": "^0.383.0", + "lucide-react": "^0.460.0", "next": "14.2.10", "next-themes": "^0.3.0", "react": "^18", diff --git a/src/components/Canvas.tsx b/src/components/Canvas.tsx index 0ef6418..9e4739c 100644 --- a/src/components/Canvas.tsx +++ b/src/components/Canvas.tsx @@ -238,14 +238,11 @@ const Canvas = forwardRef( }); linesRef.current.forEach((line, i) => { - const bitsPoints = Math.pow(2, getValue(selectedBits)); // Adjust according to your ADC resolution - const yScale = 2 / bitsPoints; - const chData = (data[i] - bitsPoints / 2) * yScale; // Use a separate sweep position for each line currentSweepPos.current[i] = sweepPositions.current[i]; // Plot the new data at the current sweep position - line.setY(currentSweepPos.current[i] % line.numPoints, chData); + line.setY(currentSweepPos.current[i] % line.numPoints, data[i]); // Clear the next point to create a gap (optional, for visual effect) const clearPosition = (currentSweepPos.current[i] + (numX / 100)) % line.numPoints; diff --git a/src/components/Connection.tsx b/src/components/Connection.tsx index 70a15a9..55352b2 100644 --- a/src/components/Connection.tsx +++ b/src/components/Connection.tsx @@ -2,7 +2,7 @@ import React, { useState, useRef, useCallback, useEffect } from "react"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; -import { Notch } from './filters'; +import { EXGFilter, Notch } from './filters'; import { Cable, @@ -20,6 +20,10 @@ import { ZoomOut, // For zoom out functionality CircleOff, ReplaceAll, + Heart, + Brain, + Eye, + BicepsFlexed, } from "lucide-react"; import { BoardsList } from "./boards"; import { toast } from "sonner"; @@ -71,7 +75,7 @@ const Connection: React.FC = ({ const isRecordingRef = useRef(false); // Ref to track if the device is recording const [isEndTimePopoverOpen, setIsEndTimePopoverOpen] = useState(false); const [detectedBits, setDetectedBits] = useState(null); // State to store the detected bits - const detectedBitsRef = React.useRef(null); + const detectedBitsRef = React.useRef("ten"); const [isRecordButtonDisabled, setIsRecordButtonDisabled] = useState(false); // New state variable const [datasets, setDatasets] = useState([]); // State to store the recorded datasets const [hasData, setHasData] = useState(false); @@ -196,7 +200,7 @@ const Connection: React.FC = ({ setifBits(board.bits as BitSelection); setSelectedBits(board.bits as BitSelection); detectedBitsRef.current = board.bits as BitSelection; - return `${board.name} | Product ID: ${info.usbProductId}`; // Return the board name and product ID + return (<>{board.name}
Product ID: {info.usbProductId}); // Return the board name and product ID } setDetectedBits(null); @@ -212,23 +216,140 @@ const Connection: React.FC = ({ connectToDevice(); } }; - let lastConnectedPort: SerialPort | null = null; + + + // const connectToDevice = async () => { + // try { + // // Disconnect any currently open port + // if (portRef.current && portRef.current.readable) { + // await disconnectDevice(); + // } + + // // Retrieve saved port information from localStorage + // const savedPort = localStorage.getItem('lastdevice'); + // let port: SerialPort | null = null; + + // if (savedPort) { + // const savedPorts = JSON.parse(savedPort); + + // // Attempt to get the matching port based on saved info + // const ports = await navigator.serial.getPorts(); + // port = ports.find(p => { + // const info = p.getInfo(); + // return info.usbVendorId === savedPorts.usbVendorId && info.usbProductId === savedPorts.usbProductId; + // })|| null; + + // if (port) { + // await port.open({ baudRate: 230400}); + // } + // } + + // if (!port) { + // // If no saved port or no matching port found, prompt user to select a port + // port = await navigator.serial.requestPort(); + // await port.open({ baudRate: 230400 }); + // } + + // // If port is successfully connected + // Connection(true); + // setIsConnected(true); + // onPauseChange(true); + // setIsDisplay(true); + // isConnectedRef.current = true; + // portRef.current = port; + + // // Save the necessary information (usbVendorId, usbProductId, baudRate) to localStorage + // const portInfo = await port.getInfo(); + // localStorage.setItem('lastdevice', JSON.stringify({ + // usbVendorId: portInfo.usbVendorId, + // usbProductId: portInfo.usbProductId, + // baudRate: 230400 + // })); + + // toast.success("Connection Successful", { + // description: ( + //
+ //

Device: {formatPortInfo(portInfo)}

+ //

Baud Rate: 230400

+ //
+ // ), + // }); + + // // Set up reader and writer for data transfer + // const reader = port.readable?.getReader(); + // readerRef.current = reader; + + // const writer = port.writable?.getWriter(); + // if (writer) { + // setTimeout(function () { + // writerRef.current = writer; + // const message = new TextEncoder().encode("START\n"); + // writerRef.current.write(message); + // }, 2000); + // } else { + // console.error("Writable stream not available"); + // } + + // readData(); + // await navigator.wakeLock.request("screen"); + + // } catch (error) { + // await disconnectDevice(); + // console.error("Error connecting to device:", error); + // toast.error("Failed to connect to device."); + // } + // }; + + interface SavedDevice { + usbVendorId: number; + usbProductId: number; + baudRate: number; + } + const connectToDevice = async () => { try { if (portRef.current && portRef.current.readable) { await disconnectDevice(); } - - const port = lastConnectedPort || await navigator.serial.requestPort(); - await port.open({ baudRate: 230400 }); + + const savedPorts: SavedDevice[] = JSON.parse(localStorage.getItem('savedDevices') || '[]'); + let port: SerialPort | null = null; + + const ports = await navigator.serial.getPorts(); + if (savedPorts.length > 0) { + port = ports.find(p => { + const info = p.getInfo(); + return savedPorts.some((saved: SavedDevice) => + saved.usbVendorId === info.usbVendorId && saved.usbProductId === info.usbProductId + ); + }) || null; + } + + if (!port) { + port = await navigator.serial.requestPort(); + await port.open({ baudRate: 230400 }); + + const newPortInfo = await port.getInfo(); + if (!savedPorts.some(saved => saved.usbVendorId === newPortInfo.usbVendorId && saved.usbProductId === newPortInfo.usbProductId)) { + savedPorts.push({ + usbVendorId: newPortInfo.usbVendorId??0, + usbProductId: newPortInfo.usbProductId??0, + baudRate: 230400 + }); + localStorage.setItem('savedDevices', JSON.stringify(savedPorts)); + } + } else { + await port.open({ baudRate: 230400 }); + } + Connection(true); setIsConnected(true); onPauseChange(true); setIsDisplay(true); isConnectedRef.current = true; portRef.current = port; - lastConnectedPort = port; + toast.success("Connection Successful", { description: (
@@ -237,13 +358,13 @@ const Connection: React.FC = ({
), }); - + const reader = port.readable?.getReader(); readerRef.current = reader; - + const writer = port.writable?.getWriter(); if (writer) { - setTimeout(function () { + setTimeout(() => { writerRef.current = writer; const message = new TextEncoder().encode("START\n"); writerRef.current.write(message); @@ -251,31 +372,18 @@ const Connection: React.FC = ({ } else { console.error("Writable stream not available"); } - + readData(); - await navigator.wakeLock.request("screen"); + } catch (error) { await disconnectDevice(); console.error("Error connecting to device:", error); toast.error("Failed to connect to device."); } }; - const sample = useCallback((bits: BitSelection | null): number => { - if (bits === null) { - return 0; // Default value for null input - } - console.log(bits); - switch (bits) { - case "fourteen": - return 1; - case "ten": - return 2; - default: - return 0; // Fallback value for unexpected cases - } - }, []); - + + const disconnectDevice = async (): Promise => { try { if (portRef.current) { @@ -322,21 +430,49 @@ const Connection: React.FC = ({ } }; const appliedFiltersRef = React.useRef<{ [key: number]: number }>({}); + const appliedEXGFiltersRef = React.useRef<{ [key: number]: number }>({}); const [, forceUpdate] = React.useReducer((x) => x + 1, 0); + const [, forceEXGUpdate] = React.useReducer((x) => x + 1, 0); + + const removeEXGFilter = (channelIndex: number) => { + delete appliedEXGFiltersRef.current[channelIndex]; // Remove the filter for the channel + forceEXGUpdate(); // Trigger re-render + + }; + + // Function to handle frequency selection + const handleFrequencySelectionEXG = (channelIndex: number, frequency: number) => { + appliedEXGFiltersRef.current[channelIndex] = frequency; // Update the filter for the channel + forceEXGUpdate(); //Trigger re-render + + }; + + // Function to set the same filter for all channels + const applyEXGFilterToAllChannels = (channels: number[], frequency: number) => { + channels.forEach((channelIndex) => { + appliedEXGFiltersRef.current[channelIndex] = frequency; // Set the filter for the channel + }); + forceEXGUpdate(); // Trigger re-render + + }; + - const removeFilter = (channelIndex: number) => { + // Function to remove the filter for all channels + const removeEXGFilterFromAllChannels = (channels: number[]) => { + channels.forEach((channelIndex) => { + delete appliedEXGFiltersRef.current[channelIndex]; // Remove the filter for the channel + }); + forceEXGUpdate(); // Trigger re-render + + }; + const removeNotchFilter = (channelIndex: number) => { delete appliedFiltersRef.current[channelIndex]; // Remove the filter for the channel forceUpdate(); // Trigger re-render - console.log(`Filter removed from Channel ${channelIndex}`); }; - // Function to handle frequency selection const handleFrequencySelection = (channelIndex: number, frequency: number) => { appliedFiltersRef.current[channelIndex] = frequency; // Update the filter for the channel forceUpdate(); //Trigger re-render - console.log( - `Channel ${channelIndex} selected with frequency ${frequency}Hz` - ); }; // Function to set the same filter for all channels @@ -345,18 +481,14 @@ const Connection: React.FC = ({ appliedFiltersRef.current[channelIndex] = frequency; // Set the filter for the channel }); forceUpdate(); // Trigger re-render - console.log( - `Filter set to ${frequency}Hz for all channels: ${channels.join(", ")}` - ); }; // Function to remove the filter for all channels - const removeFilterFromAllChannels = (channels: number[]) => { + const removeNotchFromAllChannels = (channels: number[]) => { channels.forEach((channelIndex) => { delete appliedFiltersRef.current[channelIndex]; // Remove the filter for the channel }); forceUpdate(); // Trigger re-render - console.log(`Filters removed from all channels: ${channels.join(", ")}`); }; // Function to read data from a connected device and process it @@ -368,17 +500,16 @@ const Connection: React.FC = ({ const SYNC_BYTE2 = 0x7c; // Second synchronization byte const END_BYTE = 0x01; // End byte to signify the end of a packet let previousCounter: number | null = null; // Variable to store the previous counter value for loss detection - const notchFilters = [ - new Notch(), // Notch_1 - new Notch(), // Notch_2 - new Notch(), // Notch_3 - new Notch(), // Notch_4 - new Notch(), // Notch_5 - new Notch(), // Notch_6 - ]; - + const notchFilters = Array.from({ length: 6 }, () => new Notch()); + const EXGFilters = Array.from({ length: 6 }, () => new EXGFilter()); + notchFilters.forEach((filter) => { + filter.setSample(detectedBitsRef.current); // Set the sample value for all instances + }); + EXGFilters.forEach((filter) => { + filter.setSample(detectedBitsRef.current); // Set the sample value for all instances + }); try { - // Loop while the device is connected + // Loop while the device is connectedconsole.log(`Filters removed from all channels: ${channels.join(", ")}`); while (isConnectedRef.current) { const streamData = await readerRef.current?.read(); // Read data from the device if (streamData?.done) { @@ -421,13 +552,17 @@ const Connection: React.FC = ({ const highByte = packet[channel * 2 + HEADER_LENGTH]; const lowByte = packet[channel * 2 + HEADER_LENGTH + 1]; const value = (highByte << 8) | lowByte; + channelData.push( notchFilters[channel].process( - value, - appliedFiltersRef.current[channel], - sample(detectedBitsRef.current) + EXGFilters[channel].process( + value, + appliedEXGFiltersRef.current[channel] + ), + appliedFiltersRef.current[channel] ) ); + } const counter = packet[2]; // Extract the counter value from the packet @@ -1036,7 +1171,7 @@ const Connection: React.FC = ({ onClick={saveData} disabled={!hasData} > - + @@ -1079,6 +1214,7 @@ const Connection: React.FC = ({ )} + {isConnected && ( = ({
{/* Buttons */}
- - - +
+ + +
+
+ + + + +
@@ -1143,56 +1341,118 @@ const Connection: React.FC = ({
{/* Filter Name */}
{filterName}
- {/* Buttons */}
- - - +
+ + + + + +
+
+ + + +
))}
-
)} - {/* Canvas control buttons with tooltip */} {isConnected && ( diff --git a/src/components/filters.tsx b/src/components/filters.tsx index 6def59c..fbf0e8b 100644 --- a/src/components/filters.tsx +++ b/src/components/filters.tsx @@ -8,8 +8,153 @@ // // Note: // filter_gen.py provides C/C++ type functions which we have converted to TS +// TypeScript filter classes for Chords +// Made with <3 at Upside Down labs +// Author: Aman Maheshwari +// +// Reference: +// https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.butter.html +// https://courses.ideate.cmu.edu/16-223/f2020/Arduino/FilterDemos/filter_gen.py +// +// Note: +// filter_gen.py provides C/C++ type functions which we have converted to TS //Notch Filter 50Hz/60Hz +export class EXGFilter { + // Properties to hold the state of the filter + private z1: number; + private z2: number; + private x1: number; + private x2: number; + private x3: number; + private x4: number; + private sample: string | null; + private bitsPoints: number; + private yScale: number; + + + constructor() { + // Initialize state variables + this.z1 = 0; + this.z2 = 0; + + this.x1 = 0; + this.x2 = 0; + this.x3 = 0; + this.x4 = 0; + this.sample = null; + this.bitsPoints=0; + this.yScale=0; + } + //sample 1.500 2.250 + //TYPE 1.ECG + //2.EOG + //3.EEG + //4.EMG + // function to apply the + setSample(sample: string): void { + this.sample = sample; + this.bitsPoints = Math.pow(2, sample=="fourteen"?14:10); // Adjust according to your ADC resolution + this.yScale = 2 / this.bitsPoints; + } + + process(input: number, type: number): number { + if(!type) return (input - this.bitsPoints / 2) * this.yScale; + let output = input; + let chData=0; + switch (this.sample) { + //samplerate 500Hz + case "fourteen": + switch (type) { + case 1: - this.bitsPoints / 2// ECG Sampling rate: 500.0 Hz, frequency: 30.0 Hz. + // Filter is order 2, implemented as second-order sections (biquads). + this.x1 = output - (-1.47548044 * this.z1) - (0.58691951 * this.z2); + output = 0.02785977 * this.x1 + 0.05571953 * this.z1 + 0.02785977 * this.z2; + this.z2 = this.z1; + this.z1 = this.x1; + chData = (output - this.bitsPoints / 2) * this.yScale; + break; + case 2: // EOG Sampling rate: 500.0 Hz, frequency: 10.0 Hz. + // Filter is order 2, implemented as second-order sections (biquads). + this.x2 = output - (-1.82269493 * this.z1) - (0.83718165 * this.z2); + output = 0.00362168 * this.x2 + 0.00724336 * this.z1 + 0.00362168 * this.z2; + this.z2 = this.z1; + this.z1 = this.x2; + chData = (output - this.bitsPoints / 2) * this.yScale; + break; + case 3: // EEG Sampling rate: 500.0 Hz, frequency: 45.0 Hz. + // Filter is order 2, implemented as second-order sections (biquads). + this.x3 = output - (-0.51930341 * this.z1) - (0.21965398 * this.z2); + output = 0.17508764 * this.x3 + 0.35017529 * this.z1 + 0.17508764 * this.z2; + this.z2 = this.z1; + this.z1 = this.x3; + chData = (output - this.bitsPoints / 2) * this.yScale; + break; + case 4: // EMG Sampling rate: 500.0 Hz, frequency: 70.0 Hz. + // Filter is order 2, implemented as second-order sections (biquads). + this.x4 = output - (-0.82523238 * this.z1) - (0.29463653 * this.z2); + output = 0.52996723 * this.x4 + -1.05993445 * this.z1 + 0.52996723 * this.z2; + this.z2 = this.z1; + this.z1 = this.x4; + chData = output * this.yScale; + break; + default: + break; + } + break; + case "ten": + //samplerate 250Hz + switch (type) { + case 1: // ECG Sampling rate: 250.0 Hz, frequency: 30.0 Hz. + // Filter is order 2, implemented as second-order sections (biquads). + this.x1 = output - -0.98240579 * this.z1 - 0.34766539 * this.z2; + output = 0.09131490 * this.x1 + 0.18262980 * this.z1 + 0.09131490 * this.z2; + this.z2 = this.z1; + this.z1 = this.x1; + chData = (output - this.bitsPoints / 2) * this.yScale; + break; + + case 2: // EOG Sampling rate: 250.0 Hz, frequency: 10.0 Hz. + // Filter is order 2, implemented as second-order sections (biquads). + this.x2 = output - -1.64745998 * this.z1 - 0.70089678 * this.z2; + output = 0.01335920 * this.x2 + 0.02671840 * this.z1 + 0.01335920 * this.z2; + this.z2 = this.z1; + this.z1 = this.x2; + chData = (output - this.bitsPoints / 2) * this.yScale; + break; + + case 3: // EEG Sampling rate: 250.0 Hz, frequency: 45.0 Hz. + // Filter is order 2, implemented as second-order sections (biquads). + this.x3 = output - -0.51930341 * this.z1 - 0.21965398 * this.z2; + output = 0.17508764 * this.x3 + 0.35017529 * this.z1 + 0.17508764 * this.z2; + this.z2 = this.z1; + this.z1 = this.x3; + chData = (output - this.bitsPoints / 2) * this.yScale; + break; + + case 4: // EMG Sampling rate: 250.0 Hz, frequency: 70.0 Hz. + // Filter is order 2, implemented as second-order sections (biquads). + this.x4 = output - 0.22115344 * this.z1 - 0.18023207 * this.z2; + output = 0.23976966 * this.x4 + -0.47953932 * this.z1 + 0.23976966 * this.z2; + this.z2 = this.z1; + this.z1 = this.x4; + chData = output * this.yScale; + break; + + default: + break; + } + break; + default: + break; + + } + return chData; + } +} + + + export class Notch { // Properties to hold the state of the filter sections private z1_1: number; @@ -21,6 +166,8 @@ export class Notch { private x_1: number; private x_2: number; + private sample: string | null; + constructor() { // Initialize state variables for both filter sections this.z1_1 = 0; @@ -31,21 +178,25 @@ export class Notch { this.x_1 = 0; this.x_2 = 0; + this.sample = null; + } + + setSample(sample: string): void { + this.sample = sample; } // Method to apply the filter - process(input: number, type: number, sample: number): number { + process(input: number, type: number): number { if(!type) return input; let output = input; - switch (sample) { - case 1: // 500Hz + switch (this.sample) { + case "fourteen": // 500Hz switch (type) { case 1: // Notch Sampling rate: 500.0 Hz, frequency: [48.0, 52.0] Hz. this.x_1 = output - (-1.56858163 * this.z1_1) - (0.96424138 * this.z2_1); output = 0.96508099 * this.x_1 + -1.56202714 * this.z1_1 + 0.96508099 * this.z2_1; this.z2_1 = this.z1_1; this.z1_1 = this.x_1; - // Second filter section this.x_2 = output - (-1.61100358 * this.z1_2) - (0.96592171 * this.z2_2); output = 1.0 * this.x_2 + -1.61854514 * this.z1_2 + 1.0 * this.z2_2; @@ -57,7 +208,6 @@ export class Notch { output = 0.96508099 * this.x_1 + (-1.40747202 * this.z1_1) + (0.96508099 * this.z2_1); this.z2_1 = this.z1_1; this.z1_1 = this.x_1; - // Second filter section this.x_2 = output - (-1.45687509 * this.z1_2) - (0.96573127 * this.z2_2); output = 1.00000000 * this.x_2 + (-1.45839783 * this.z1_2) + (1.00000000 * this.z2_2); @@ -69,7 +219,7 @@ export class Notch { } break; - case 2: // 250Hz + case "ten": // 250Hz switch (type) { case 1: // Notch Sampling rate: 250.0 Hz, frequency: [48.0, 52.0] Hz. this.x_1 = output - (-0.53127491 * this.z1_1) - (0.93061518 * this.z2_1); @@ -108,4 +258,7 @@ export class Notch { return output; } -} \ No newline at end of file +} + + +