diff --git a/octave/README.md b/octave/README.md index d1b8b80..b55da92 100644 --- a/octave/README.md +++ b/octave/README.md @@ -13,14 +13,14 @@ The local connection has two parts: ## Folder Structure -- `functions/`: Public functions (`cortexConnect`, `cortexSubscribe`, `cortexGetData`, `cortexDisconnect`) that hide the internal MATLAB classes. +- `functions/`: Public functions (`cortexConnect`, `cortexSession`, `cortexSubscribe`, `cortexGetData`, `cortexRecord`, `cortexMarker`, `cortexDisconnect`) that hide the internal MATLAB classes. - `internal/`: Internal SDK classes. Not a public interface; they may change. - `lib/`: `nv-websocket-client-2.14.jar` and `cortex-ws-client.jar`, the Java WebSocket client used for WSS. - `java/`: Source of the small Java wrapper (`cortex-ws-client.jar`). - `config/`: Local configuration. Copy `config/config.example.json` to `config/config.json` to store your Cortex application credentials. `config/config.json` is ignored by Git and must not be committed or shared. - `examples/`: Beginner-friendly scripts that show complete workflows. -The public workflow uses `cortexConnect` and `cortexDisconnect`. The underlying `CortexManager` and `CortexAuth` classes are kept inside `internal/` so future examples can extend the SDK without making the user-facing scripts more complicated. +The public workflow uses procedural functions such as `cortexConnect`, `cortexSession`, `cortexSubscribe`, `cortexRecord`, `cortexMarker`, and `cortexDisconnect`. The underlying Cortex classes are kept inside `internal/` so examples can extend the SDK without making researchers manage the WebSocket or internal class instances. ## Requirements @@ -136,7 +136,8 @@ The internal manager is stored in the struct only so that `cortexDisconnect(cort ```matlab cortex = cortexConnect(); -cortex = cortexSubscribe(cortex, 'INSIGHT-A1B2C3D4', {'eeg'}); +cortex = cortexSession(cortex, 'INSIGHT-A1B2C3D4'); +cortex = cortexSubscribe(cortex, {'eeg'}); [labels, data, time] = cortexGetData(cortex, 'eeg', 256); % newest 256 samples cortex = cortexDisconnect(cortex); % unsubscribes and closes the socket ``` @@ -144,10 +145,44 @@ cortex = cortexDisconnect(cortex); % unsubscribes and cl - To subscribe to other supported streams, pass their names instead, for example `{'mot','dev','eq','pow','met'}`. Call `cortexGetData(cortex, streamName)` for each stream you want to consume. - `labels{j}` names column `j` of `data`; each row is a sample. Use the returned labels to select columns rather than hard-coding positions. For console output, loop over `labels` and print each `data(end,j)`; for plots, plot the desired numeric columns against `time`. - `met` columns named `.isActive` are 1 when that metric is running. When it is 0 the metric value is `NaN` (Cortex sends `null`). Without a PRO license `met` arrives about every 10 seconds; with a PRO license about 2 samples per second. `pow` does not need a PRO license. -- `cortexSubscribe` scans for the headset, connects it if needed (waiting up to 5 seconds for Cortex warning 104), creates an active session and subscribes. Optional name/value pairs: `'autoRescan'` (default `true`), `'queryInterval'` (default `3` seconds), `'scanTimeout'` (default `15` seconds). +- `cortexSession` scans for the headset, connects it if needed (waiting up to 5 seconds for Cortex warning 104), and creates one active session for that headset. Calling it again for the same active headset reuses that session; a different headset requires disconnecting first. +- `cortexSubscribe(cortex, streams)` subscribes the requested streams on the active session created by `cortexSession`. - `cortexGetData` collects the samples that have arrived and returns them from a buffer that keeps the latest 10000 samples per stream. `data` has one row per sample and one column per entry in `labels`; empty values are `NaN`. - Call `cortexGetData` regularly. Samples are only read from the connection when it runs, and it can take up to about 1 second when no data is arriving. +## Record and Markers + +`examples/recordAndMarker.m` demonstrates creating and stopping a record without subscribing to any data stream, and adding keyboard-controlled markers. + +1. Set `headsetId` and `titlePrefix` near the top of `examples/recordAndMarker.m`. Add the examples folder to the MATLAB or Octave path and run the function without arguments: + +```matlab +addpath('octave/examples'); +recordAndMarker(); +``` + +2. Focus the opened figure. Press `S` to start a record titled `_`. The example adds a description and the tags `octave` and `record-marker-demo`. +3. Press any other letter to inject an instance marker; the pressed letter is used as both its label and string value. Press that letter again to update the marker with the current epoch time in milliseconds, turning it into an interval marker. The next press injects a new instance marker. +4. Press `Esc` or close the figure to stop the active record and disconnect. + +The headset-only connection and record/marker APIs are also available directly: + +```matlab +cortex = cortexConnect(); +cortex = cortexSession(cortex, 'INSIGHT-A1B2C3D4'); +[cortex, started] = cortexRecord(cortex, 'start', 'my-study_1792692000', ... + 'Example record description', {'octave', 'demo'}); +[cortex, injected] = cortexMarker(cortex, 'inject', 'A', 'A'); +endTime = double(javaMethod('currentTimeMillis', 'java.lang.System')); +[cortex, updated] = cortexMarker(cortex, 'update', injected.marker.uuid, endTime); +[cortex, stopped] = cortexRecord(cortex, 'stop'); +cortex = cortexDisconnect(cortex); +``` + +`cortexSession(cortex, headsetId)` connects the headset and creates or reuses its active session without subscribing to streams. From that session, call either `cortexSubscribe(cortex, streams)` or start a record. `cortexRecord(cortex, 'start', title, description, tags)` returns the Cortex result fields `record` and `sessionId`; `cortexRecord(cortex, 'stop')` returns the stopped `record` and its `sessionId`. `cortex.isRecording` and `cortex.recordId` reflect the active record. + +`cortexMarker(cortex, 'inject', label, value, ...)` returns `recordId`, `sessionId`, and `marker`. Optional name/value pairs are `time` (epoch milliseconds; defaults to the current time), `port` (defaults to `Software`), and `extras` (a scalar struct). `cortexMarker(cortex, 'update', markerId, endTime, ...)` returns the same result fields and accepts optional `extras`. `endTime` is required and is measured in milliseconds since 1970-01-01 UTC. Both marker actions require an active record. + ## Troubleshooting ### Could not connect to Cortex at `wss://localhost:6868` @@ -179,7 +214,7 @@ Use a recent MATLAB or GNU Octave release with native JSON support. The SDK does - Switch the headset on and make sure it is in range, or enable a virtual headset in EMOTIV Launcher. - Check that `headsetId` matches the id shown in EMOTIV Launcher exactly. -- Increase `'scanTimeout'` in `cortexSubscribe` if the headset is slow to appear. +- Increase `'scanTimeout'` in `cortexSession` if the headset is slow to appear. - If you see "warning 104 was not received", the headset did not finish connecting in 5 seconds; try again. ## Next Steps diff --git a/octave/examples/recordAndMarker.m b/octave/examples/recordAndMarker.m new file mode 100644 index 0000000..40d1a8a --- /dev/null +++ b/octave/examples/recordAndMarker.m @@ -0,0 +1,167 @@ +function recordAndMarker() +% recordAndMarker Start/stop a record and inject/update markers from keyboard input. +% Set headsetId and recordTitlePrefix below, then call recordAndMarker(). +% Press S to start, any other letter to toggle a marker interval, and Esc to stop and exit. + +% Set your headset id and the prefix used to generate each record title. +headsetId = ''; +recordTitlePrefix = 'my-record'; + +currentDir = fileparts(mfilename('fullpath')); +addpath(fullfile(currentDir, '../functions')); + +% Connect: establish authorization, connect the selected headset, and create its active session. +cortex = cortexConnect(); +fig = []; +connectionCleanup = onCleanup(@cleanupConnection); +try + if ~cortex.isConnected + error('recordAndMarker:connect', 'Cortex connection or authorization failed.'); + end + cortex = cortexSession(cortex, headsetId); + fprintf('[recordAndMarker] Connected to %s. Key help:\n', headsetId); + fprintf(' S: start a record.\n'); + fprintf(' A-Z except S: inject an instance marker; press that key again to end its interval.\n'); + fprintf(' Esc: stop the record and exit.\n'); + + markerIds = struct(); + fig = figure('Name', 'Cortex Record and Marker Example', 'NumberTitle', 'off', ... + 'KeyPressFcn', @onKeyPress, 'Interruptible', 'off', 'BusyAction', 'queue'); + helpLines = {'Keyboard controls', '', ... + 'S: start a new record', ... + 'A-Z (except S): add an instance marker while recording', ... + 'Press the same letter again: end its marker interval', ... + 'Esc: stop the record and exit', '', ... + 'Marker ID, type, start time, and end time are printed', ... + 'in the Command Window after each marker action.'}; + helpText = uicontrol(fig, 'Style', 'text', 'Units', 'normalized', ... + 'Position', [0.08 0.12 0.84 0.78], ... + 'String', helpLines, ... + 'HorizontalAlignment', 'left', 'FontSize', 12, ... + 'BackgroundColor', get(fig, 'Color'), 'Enable', 'on', ... + 'KeyPressFcn', @onKeyPress, 'Interruptible', 'off', 'BusyAction', 'queue'); + statusText = uicontrol(fig, 'Style', 'text', 'Units', 'normalized', ... + 'Position', [0.08 0.02 0.84 0.08], ... + 'String', 'Ready: press S to start a record.', ... + 'HorizontalAlignment', 'left', 'FontSize', 10, ... + 'BackgroundColor', get(fig, 'Color'), 'Enable', 'on', ... + 'KeyPressFcn', @onKeyPress, 'Interruptible', 'off', 'BusyAction', 'queue'); + + % Acquire: wait for keyboard events while Cortex keeps the active session open. + uiwait(fig); +catch failure + fprintf('[recordAndMarker] ERROR %s: %s\n', failure.identifier, failure.message); +end + +% Disconnect: stop any active record and close the Cortex connection on every exit path. +clear connectionCleanup; +fprintf('[recordAndMarker] Finished.\n'); + + function onKeyPress(source, event) + keyName = event.Key; + showStatus(sprintf('Key %s received.', keyName)); + if strcmpi(keyName, 'escape') || strcmpi(keyName, 'esc') + if cortex.isRecording + try + [cortex, ~] = cortexRecord(cortex, 'stop'); + fprintf('[recordAndMarker] Record stopped.\n'); + showStatus('Record stopped. Closing example.'); + catch stopFailure + fprintf('[recordAndMarker] ERROR %s: %s\n', stopFailure.identifier, stopFailure.message); + showStatus(['Could not stop record: ' stopFailure.message]); + end + end + if ishandle(fig), uiresume(fig); end + return; + end + + keyCharacter = event.Character; + if isempty(keyCharacter) && numel(keyName) == 1 + keyCharacter = keyName; + end + if ~ischar(keyCharacter) || numel(keyCharacter) ~= 1 || ... + isempty(regexp(keyCharacter, '^[A-Za-z]$', 'once')) + showStatus(sprintf('Ignored key: %s. Use S, A-Z, or Esc.', keyName)); + return; + end + + % Process: reserve S for record start and use every other letter to toggle marker intervals. + if lower(keyCharacter) == 's' + if cortex.isRecording + fprintf('[recordAndMarker] A record is already active.\n'); + showStatus('A record is already active.'); + return; + end + try + recordTitle = sprintf('%s_%d', recordTitlePrefix, floor(currentTimeMilliseconds() / 1000)); + description = 'Keyboard-controlled Cortex record and marker example'; + tags = {'octave', 'record-marker-demo'}; + [cortex, result] = cortexRecord(cortex, 'start', recordTitle, description, tags); + fprintf('[recordAndMarker] Started record %s.\n', result.record.uuid); + showStatus(['Recording started: ' result.record.uuid]); + catch startFailure + fprintf('[recordAndMarker] ERROR %s: %s\n', startFailure.identifier, startFailure.message); + showStatus(['Record start failed: ' startFailure.message]); + end + return; + end + + if ~cortex.isRecording + fprintf('[recordAndMarker] Start a record with S before adding markers.\n'); + showStatus('No active record. Press S to start one first.'); + return; + end + + markerKey = lower(keyCharacter); + try + if isfield(markerIds, markerKey) + markerId = markerIds.(markerKey); + [cortex, result] = cortexMarker(cortex, 'update', markerId, currentTimeMilliseconds()); + markerIds = rmfield(markerIds, markerKey); + fprintf('[recordAndMarker] Updated marker %s (type: %s).\n', ... + result.marker.uuid, result.marker.type); + fprintf(' Start: %s\n End: %s\n', ... + result.marker.startDatetime, result.marker.endDatetime); + showStatus(['Marker interval updated: ' result.marker.uuid]); + else + [cortex, result] = cortexMarker(cortex, 'inject', keyCharacter, keyCharacter, ... + 'time', currentTimeMilliseconds(), 'port', 'MATLAB/Octave example'); + markerIds.(markerKey) = result.marker.uuid; + fprintf('[recordAndMarker] Injected marker %s (type: %s, port: %s) with value %s.\n', ... + result.marker.uuid, result.marker.type, result.marker.port, keyCharacter); + fprintf(' Start: %s\n End: %s\n', ... + result.marker.startDatetime, result.marker.endDatetime); + showStatus(['Marker injected: ' result.marker.uuid ... + ' (value: ' result.marker.value ')']); + end + catch markerFailure + fprintf('[recordAndMarker] ERROR %s: %s\n', markerFailure.identifier, markerFailure.message); + showStatus(['Marker action failed: ' markerFailure.message]); + end + end + + function showStatus(message) + if ~isempty(statusText) && ishandle(statusText) + set(statusText, 'String', message); + drawnow(); + end + end + + function cleanupConnection() + if cortex.isRecording + try + [cortex, ~] = cortexRecord(cortex, 'stop'); + catch stopFailure + fprintf('[recordAndMarker] Could not stop the active record: %s\n', stopFailure.message); + end + end + if ~isempty(fig) && ishandle(fig) + delete(fig); + end + cortex = cortexDisconnect(cortex); + end +end + +function time = currentTimeMilliseconds() +time = double(javaMethod('currentTimeMillis', 'java.lang.System')); +end diff --git a/octave/examples/subscribeAndPlot.m b/octave/examples/subscribeAndPlot.m index 6c924d9..5f18856 100644 --- a/octave/examples/subscribeAndPlot.m +++ b/octave/examples/subscribeAndPlot.m @@ -10,12 +10,13 @@ function subscribeAndPlot() streams = {'eeg'}; durationSeconds = 90; -% 2. Connection / Authentication, then headset connection and subscription. +% 2. Connection / Authentication, then create a headset session and subscribe to streams. % Credentials are loaded from config/config.json. cortex = cortexConnect(); connectionCleanup = onCleanup(@() cortexDisconnect(cortex)); if cortex.isConnected - cortex = cortexSubscribe(cortex, headsetId, streams); + cortex = cortexSession(cortex, headsetId); + cortex = cortexSubscribe(cortex, streams); end % 3. Acquisition Loop: each cortexGetData call collects new samples into the buffer. diff --git a/octave/functions/cortexConnect.m b/octave/functions/cortexConnect.m index be15fcd..24472fa 100644 --- a/octave/functions/cortexConnect.m +++ b/octave/functions/cortexConnect.m @@ -8,7 +8,8 @@ % userInfo user details returned by Cortex ([] on failure) % licenseInfo license details returned by Cortex ([] on failure) % manager internal handle used by cortexDisconnect; do not modify -% isSubscribed, headsetId, sessionId, streams filled in by cortexSubscribe +% isSubscribed, headsetId, sessionId, streams filled in by headset connection/subscription +% isRecording, recordId filled in by cortexRecord functionsDir = fileparts(mfilename('fullpath')); addpath(fullfile(functionsDir, '..', 'internal')); @@ -16,7 +17,8 @@ [clientId, clientSecret, port] = cortexCredentials(); cortex = struct('isConnected', false, 'token', '', 'userInfo', [], 'licenseInfo', [], 'manager', [], ... - 'isSubscribed', false, 'headsetId', '', 'sessionId', '', 'streams', {{}}); + 'isSubscribed', false, 'isRecording', false, 'recordId', '', ... + 'headsetId', '', 'sessionId', '', 'streams', {{}}); manager = CortexManager(clientId, clientSecret, 'localhost', port); cortex.manager = manager; diff --git a/octave/functions/cortexDisconnect.m b/octave/functions/cortexDisconnect.m index 1c1c121..413f91e 100644 --- a/octave/functions/cortexDisconnect.m +++ b/octave/functions/cortexDisconnect.m @@ -4,12 +4,18 @@ if isstruct(cortex) && isfield(cortex, 'manager') && ~isempty(cortex.manager) fprintf('[cortexDisconnect] Closing Cortex connection...\n'); - cortex.manager.close(); + try + cortex.manager.close(); + catch failure + fprintf('[cortexDisconnect] Could not close the Cortex connection cleanly: %s\n', failure.message); + end cortex.manager = []; end cortex.isConnected = false; cortex.isSubscribed = false; +cortex.isRecording = false; +cortex.recordId = ''; cortex.headsetId = ''; cortex.sessionId = ''; cortex.streams = {}; diff --git a/octave/functions/cortexMarker.m b/octave/functions/cortexMarker.m new file mode 100644 index 0000000..5a78550 --- /dev/null +++ b/octave/functions/cortexMarker.m @@ -0,0 +1,58 @@ +function [cortex, result] = cortexMarker(cortex, action, varargin) +% cortexMarker Inject an instance marker or update it to an interval marker. +% [cortex, result] = cortexMarker(cortex, 'inject', label, value, ...) +% Optional inject name/value pairs: time (epoch milliseconds), port, extras. +% [cortex, result] = cortexMarker(cortex, 'update', markerId, endTime, ...) +% Optional update name/value pair: extras. Markers require an active record. + +result = []; +try + if ~isstruct(cortex) || ~isfield(cortex, 'isConnected') || ~cortex.isConnected || ... + ~isfield(cortex, 'isRecording') || ~cortex.isRecording || ... + ~isfield(cortex, 'manager') || isempty(cortex.manager) + error('cortex:markerFailed', 'Start a record before using marker operations.'); + end + if ~ischar(action) + error('cortex:markerFailed', 'action must be ''inject'' or ''update''.'); + end + + marker = cortex.manager.getMarker(); + switch lower(action) + case 'inject' + if numel(varargin) < 2 + error('cortex:markerFailed', 'Inject requires a label and value.'); + end + label = varargin{1}; + value = varargin{2}; + options = parseOptions(varargin(3:end), struct('time', [], 'port', 'Software', 'extras', struct())); + result = marker.inject(label, value, options.time, options.port, options.extras); + case 'update' + if numel(varargin) < 2 + error('cortex:markerFailed', 'Update requires a markerId and endTime.'); + end + markerId = varargin{1}; + endTime = varargin{2}; + options = parseOptions(varargin(3:end), struct('extras', struct())); + result = marker.update(markerId, endTime, options.extras); + otherwise + error('cortex:markerFailed', 'action must be ''inject'' or ''update''.'); + end +catch failure + if strcmp(failure.identifier, 'cortex:markerFailed'), rethrow(failure); end + error('cortex:markerFailed', 'Marker operation failed: %s', failure.message); +end +end + +function options = parseOptions(arguments, options) +if mod(numel(arguments), 2) ~= 0 + error('cortex:markerFailed', 'Marker options must be supplied as name/value pairs.'); +end +for index = 1:2:numel(arguments) + name = arguments{index}; + if ~ischar(name) || ~isfield(options, name) + error('cortex:markerFailed', 'Unknown marker option.'); + end + options.(name) = arguments{index + 1}; +end +end + diff --git a/octave/functions/cortexRecord.m b/octave/functions/cortexRecord.m new file mode 100644 index 0000000..b42391f --- /dev/null +++ b/octave/functions/cortexRecord.m @@ -0,0 +1,47 @@ +function [cortex, result] = cortexRecord(cortex, action, varargin) +% cortexRecord Start or stop a record for the active Cortex session. +% [cortex, result] = cortexRecord(cortex, 'start', title, description, tags) +% [cortex, result] = cortexRecord(cortex, 'stop') +% Starting updates cortex.isRecording and cortex.recordId. The result is the +% Cortex result struct: record and sessionId. + +result = []; +try + if ~isstruct(cortex) || ~isfield(cortex, 'isConnected') || ~cortex.isConnected || ... + ~isfield(cortex, 'manager') || isempty(cortex.manager) + error('cortex:recordFailed', 'Connect to Cortex before using record operations.'); + end + if ~ischar(action) + error('cortex:recordFailed', 'action must be ''start'' or ''stop''.'); + end + + record = cortex.manager.getRecord(); + switch lower(action) + case 'start' + if isempty(varargin) || numel(varargin) > 3 + error('cortex:recordFailed', 'Start requires title and accepts optional description and tags.'); + end + title = varargin{1}; + description = ''; + tags = {}; + if numel(varargin) >= 2, description = varargin{2}; end + if numel(varargin) >= 3, tags = varargin{3}; end + result = record.create(title, description, tags); + case 'stop' + if ~isempty(varargin) + error('cortex:recordFailed', 'Stop does not accept additional arguments.'); + end + result = record.stop(); + otherwise + error('cortex:recordFailed', 'action must be ''start'' or ''stop''.'); + end + + context = cortex.manager.getContext(); + cortex.isRecording = context.isRecording; + cortex.recordId = context.recordId; +catch failure + if strcmp(failure.identifier, 'cortex:recordFailed'), rethrow(failure); end + error('cortex:recordFailed', 'Record operation failed: %s', failure.message); +end +end + diff --git a/octave/functions/cortexSession.m b/octave/functions/cortexSession.m new file mode 100644 index 0000000..3fb52f6 --- /dev/null +++ b/octave/functions/cortexSession.m @@ -0,0 +1,75 @@ +function cortex = cortexSession(cortex, headsetId, varargin) +% cortexSession Connect a headset and create or reuse its active Cortex session. +% cortex = cortexSession(cortex, 'INSIGHT-XXXXXXXX') creates one session for the headset. +% Calling it again for the same active headset reuses that session; another headset +% cannot be connected until the current Cortex connection is closed. +% Optional name/value pairs: autoRescan (true), queryInterval (3 seconds), +% and scanTimeout (30 seconds). + +options = struct('autoRescan', true, 'queryInterval', 3, 'scanTimeout', 30); +if mod(numel(varargin), 2) ~= 0 + error('cortex:sessionFailed', 'Options must be supplied as name/value pairs.'); +end +for index = 1:2:numel(varargin) + name = varargin{index}; + if ~ischar(name) || ~isfield(options, name) + error('cortex:sessionFailed', 'Unknown headset session option.'); + end + options.(name) = varargin{index + 1}; +end + +if ~isstruct(cortex) || ~isfield(cortex, 'isConnected') || ~cortex.isConnected || ... + ~isfield(cortex, 'manager') || isempty(cortex.manager) + error('cortex:sessionFailed', 'Connect and authorize Cortex before creating a headset session.'); +end +if ~ischar(headsetId) || isempty(headsetId) + error('cortex:sessionFailed', 'headsetId must be a non-empty character vector.'); +end + +manager = cortex.manager; +context = manager.getContext(); +if ~isempty(context.sessionId) + if ~strcmp(context.headsetId, headsetId) + error('cortex:sessionFailed', ... + 'A session for headset %s is already active. Disconnect before selecting headset %s.', ... + context.headsetId, headsetId); + end + cortex = syncSessionState(cortex, context); + return; +end + +headset = []; +try + headset = manager.getHeadset(); + headset.scanHeadset(options.autoRescan, options.queryInterval); + + if ~headset.waitForHeadset(headsetId, options.scanTimeout) + error('CortexHeadset:notFound', 'Headset %s was not found within %g seconds.', ... + headsetId, options.scanTimeout); + end + + [ok, message] = headset.connectToHeadset(headsetId, {}); + if ~ok + error('CortexHeadset:connect', '%s', message); + end + + cortex = syncSessionState(cortex, manager.getContext()); +catch failure + if ~isempty(headset) + try + headset.stopScan(); + catch + end + end + error('cortex:sessionFailed', 'Could not create a headset session: %s', failure.message); +end +end + +function cortex = syncSessionState(cortex, context) +cortex.headsetId = context.headsetId; +cortex.sessionId = context.sessionId; +cortex.streams = context.subscribedStreams; +cortex.isSubscribed = ~isempty(context.subscribedStreams); +cortex.isRecording = context.isRecording; +cortex.recordId = context.recordId; +end diff --git a/octave/functions/cortexSubscribe.m b/octave/functions/cortexSubscribe.m index 4d70173..b5397a9 100644 --- a/octave/functions/cortexSubscribe.m +++ b/octave/functions/cortexSubscribe.m @@ -1,30 +1,17 @@ -function cortex = cortexSubscribe(cortex, headsetId, streams, varargin) -% cortexSubscribe Scan for a headset, connect it, open a session and subscribe to data streams. -% cortex = cortexSubscribe(cortex, 'INSIGHT-XXXXXXXX', {'eeg','mot','dev','eq','pow','met'}) -% updates the fields: +function cortex = cortexSubscribe(cortex, streams) +% cortexSubscribe Subscribe to streams on an active headset session. +% cortex = cortexSubscribe(cortex, {'eeg','mot','dev','eq','pow','met'}) +% Update the fields: % isSubscribed true when the streams are being received -% headsetId the connected headset -% sessionId the Cortex session id % streams the subscribed stream names -% Optional name/value pairs: -% 'autoRescan' rescan automatically when a scan finishes (default true) -% 'queryInterval' seconds between headset list queries (default 3) -% 'scanTimeout' seconds to wait for the headset to appear (default 30) % Read the samples with cortexGetData. -options = struct('autoRescan', true, 'queryInterval', 3, 'scanTimeout', 30); -for index = 1:2:numel(varargin) - 1 - if ~isfield(options, varargin{index}) - fprintf('[cortexSubscribe] ERROR Unknown option "%s".\n', varargin{index}); - return; - end - options.(varargin{index}) = varargin{index + 1}; -end - -if ~cortex.isConnected +if ~isstruct(cortex) || ~isfield(cortex, 'isConnected') || ~cortex.isConnected || ... + ~isfield(cortex, 'manager') || isempty(cortex.manager) fprintf('[cortexSubscribe] ERROR Call cortexConnect successfully first.\n'); return; end + if ischar(streams), streams = {streams}; end validStreams = {'eeg', 'mot', 'dev', 'eq', 'pow', 'met'}; if ~iscellstr(streams) || isempty(streams) || ~all(ismember(streams, validStreams)) @@ -32,30 +19,22 @@ return; end -cortex.isSubscribed = false; try - headset = cortex.manager.getHeadset(); - headset.scanHeadset(options.autoRescan, options.queryInterval); - - if ~headset.waitForHeadset(headsetId, options.scanTimeout) - fprintf(['[cortexSubscribe] Headset %s was not found within %g seconds. ' ... - 'Check that it is switched on and in range.\n'], headsetId, options.scanTimeout); - headset.stopScan(); + context = cortex.manager.getContext(); + if isempty(context.sessionId) + fprintf('[cortexSubscribe] ERROR Call cortexSession first to create an active session.\n'); return; end - [ok, message] = headset.connectToHeadset(headsetId, streams); - if ~ok - fprintf('[cortexSubscribe] ERROR %s\n', message); - return; - end + headset = cortex.manager.getHeadset(); + headset.subscribe(streams); context = cortex.manager.getContext(); cortex.headsetId = context.headsetId; cortex.sessionId = context.sessionId; cortex.streams = context.subscribedStreams; - cortex.isSubscribed = true; - fprintf('[cortexSubscribe] Receiving data from %s.\n', headsetId); + cortex.isSubscribed = ~isempty(context.subscribedStreams); + fprintf('[cortexSubscribe] Receiving data from %s.\n', context.headsetId); catch failure fprintf('[cortexSubscribe] ERROR %s: %s\n', failure.identifier, failure.message); end diff --git a/octave/internal/CortexContext.m b/octave/internal/CortexContext.m index 3324c31..b012c14 100644 --- a/octave/internal/CortexContext.m +++ b/octave/internal/CortexContext.m @@ -10,6 +10,8 @@ headsetId = '' sessionId = '' subscribedStreams = {} + isRecording = false + recordId = '' streamLabels = struct() % one field per stream, each a cell array of column labels buffer = [] % CortexDataBuffer holding the received samples end diff --git a/octave/internal/CortexHeadset.m b/octave/internal/CortexHeadset.m index 4b8811b..c636377 100644 --- a/octave/internal/CortexHeadset.m +++ b/octave/internal/CortexHeadset.m @@ -1,10 +1,10 @@ classdef CortexHeadset < handle - % CortexHeadset Scans for headsets, connects one, opens a session and subscribes to data streams. + % CortexHeadset Scans for headsets, connects one and opens a session, optionally subscribing to streams. % The client is single-threaded, so the scan "timer" runs inside update(), which also reads % the incoming data samples into the context buffer. properties - connectTimeout = 5 % seconds to wait for warning 104 (headset connected) + connectTimeout = 10 % seconds to wait for warning 104 (headset connected) end properties (SetAccess = private) @@ -99,7 +99,7 @@ function stopScan(obj) end function [ok, message] = connectToHeadset(obj, headsetId, streams) - % Connects the headset if needed, creates an active session, then subscribes to streams. + % Connects the headset if needed, creates an active session, and subscribes when streams are provided. if nargin < 3, streams = {}; end ok = false; message = ''; @@ -231,6 +231,8 @@ function onSessionClosed(obj, warning) obj.context.sessionId = ''; obj.context.headsetId = ''; obj.context.subscribedStreams = {}; + obj.context.isRecording = false; + obj.context.recordId = ''; obj.parser.reset(); fprintf('[CortexHeadset] Session %s was closed by Cortex.\n', warning.message.sessionId); end diff --git a/octave/internal/CortexManager.m b/octave/internal/CortexManager.m index 5ae25d3..93a4cab 100644 --- a/octave/internal/CortexManager.m +++ b/octave/internal/CortexManager.m @@ -13,6 +13,8 @@ properties (Access = private) auth = [] headset = [] + record = [] + marker = [] end methods @@ -53,7 +55,19 @@ headset = obj.headset; end - % Future getRecord/getBCI follow the getAuth pattern: create lazily with getClient() and getAuth(). + function record = getRecord(obj) + if isempty(obj.record) + obj.record = CortexRecord(obj.getClient(), obj.context); + end + record = obj.record; + end + + function marker = getMarker(obj) + if isempty(obj.marker) + obj.marker = CortexMarker(obj.getClient(), obj.context); + end + marker = obj.marker; + end function close(obj) % Only closes what was actually created. @@ -67,6 +81,8 @@ function close(obj) if ~isempty(obj.auth) obj.auth.close(); end + obj.record = []; + obj.marker = []; end end end diff --git a/octave/internal/CortexMarker.m b/octave/internal/CortexMarker.m new file mode 100644 index 0000000..4e79a78 --- /dev/null +++ b/octave/internal/CortexMarker.m @@ -0,0 +1,78 @@ +classdef CortexMarker < handle + % CortexMarker Injects instance markers and updates them to interval markers. + + properties (Access = private) + client + context + end + + methods + function obj = CortexMarker(client, context) + obj.client = client; + obj.context = context; + end + + function result = inject(obj, label, value, time, port, extras) + if nargin < 4 || isempty(time) + time = double(javaMethod('currentTimeMillis', 'java.lang.System')); + end + if nargin < 5 || isempty(port), port = 'Software'; end + if nargin < 6 || isempty(extras), extras = struct(); end + obj.validateRecording(); + if ~ischar(label) || isempty(label) + error('CortexMarker:label', 'label must be a non-empty character vector.'); + end + if ~(ischar(value) || (isnumeric(value) && isscalar(value) && isfinite(value))) + error('CortexMarker:value', 'value must be a character vector or finite numeric scalar.'); + end + if ~isnumeric(time) || ~isscalar(time) || ~isfinite(time) || time < 0 + error('CortexMarker:time', 'time must be a non-negative numeric epoch timestamp in milliseconds.'); + end + if ~ischar(port) || isempty(port) + error('CortexMarker:port', 'port must be a non-empty character vector.'); + end + if ~isstruct(extras) || numel(extras) ~= 1 + error('CortexMarker:extras', 'extras must be a scalar struct.'); + end + + params = struct('cortexToken', obj.context.token, 'session', obj.context.sessionId, ... + 'time', double(time), 'value', value, 'label', label, 'port', port); + if ~isempty(fieldnames(extras)), params.extras = extras; end + + response = obj.client.sendRequest('injectMarker', params); + result = response.result; + end + + function result = update(obj, markerId, endTime, extras) + if nargin < 4 || isempty(extras), extras = struct(); end + obj.validateRecording(); + if ~ischar(markerId) || isempty(markerId) + error('CortexMarker:markerId', 'markerId must be a non-empty character vector.'); + end + if ~isnumeric(endTime) || ~isscalar(endTime) || ~isfinite(endTime) || endTime < 0 + error('CortexMarker:time', 'endTime must be a non-negative numeric epoch timestamp in milliseconds.'); + end + if ~isstruct(extras) || numel(extras) ~= 1 + error('CortexMarker:extras', 'extras must be a scalar struct.'); + end + + params = struct('cortexToken', obj.context.token, 'session', obj.context.sessionId, ... + 'markerId', markerId, 'time', double(endTime)); + if ~isempty(fieldnames(extras)), params.extras = extras; end + + response = obj.client.sendRequest('updateMarker', params); + result = response.result; + end + end + + methods (Access = private) + function validateRecording(obj) + if isempty(obj.context.sessionId) + error('CortexMarker:noSession', 'Create an active session before using markers.'); + end + if ~obj.context.isRecording + error('CortexMarker:notRecording', 'Markers can only be used while a record is active.'); + end + end + end +end \ No newline at end of file diff --git a/octave/internal/CortexRecord.m b/octave/internal/CortexRecord.m new file mode 100644 index 0000000..93f5a32 --- /dev/null +++ b/octave/internal/CortexRecord.m @@ -0,0 +1,60 @@ +classdef CortexRecord < handle + % CortexRecord Creates and stops records for the active Cortex session. + + properties (Access = private) + client + context + end + + methods + function obj = CortexRecord(client, context) + obj.client = client; + obj.context = context; + end + + function result = create(obj, title, description, tags) + if isempty(obj.context.sessionId) + error('CortexRecord:noSession', 'Create an active session before starting a record.'); + end + if obj.context.isRecording + error('CortexRecord:alreadyRecording', 'Stop the active record before starting another.'); + end + if ~ischar(title) || isempty(title) || numel(title) > 200 + error('CortexRecord:title', 'title must be a non-empty character vector of at most 200 characters.'); + end + if ~ischar(description) + error('CortexRecord:description', 'description must be a character vector.'); + end + if ischar(tags), tags = {tags}; end + if ~iscellstr(tags) + error('CortexRecord:tags', 'tags must be a character vector or a cell array of character vectors.'); + end + + params = struct('cortexToken', obj.context.token, 'session', obj.context.sessionId, ... + 'title', title); + if ~isempty(description), params.description = description; end + if ~isempty(tags), params.tags = tags; end + + response = obj.client.sendRequest('createRecord', params); + result = response.result; + if ~isfield(result, 'record') || ~isfield(result.record, 'uuid') + error('CortexRecord:response', 'Cortex did not return the created record id.'); + end + + obj.context.recordId = char(result.record.uuid); + obj.context.isRecording = true; + end + + function result = stop(obj) + if ~obj.context.isRecording || isempty(obj.context.sessionId) + error('CortexRecord:notRecording', 'There is no active record to stop.'); + end + + params = struct('cortexToken', obj.context.token, 'session', obj.context.sessionId); + response = obj.client.sendRequest('stopRecord', params); + result = response.result; + obj.context.isRecording = false; + obj.context.recordId = ''; + end + end +end \ No newline at end of file