@@ -56,8 +56,8 @@ class RecordNodeTests : public testing::Test {
5656 return inputBuffer;
5757 }
5858
59- void writeBlock (AudioBuffer<float > &buffer, TTLEvent* maybeTtlEvent = nullptr ) {
60- auto outBuffer = tester->processBlock (processor, buffer, maybeTtlEvent);
59+ void writeBlock (AudioBuffer<float > &buffer, TTLEvent* maybeTtlEvent = nullptr , const double * sampleTimestamps = nullptr ) {
60+ auto outBuffer = tester->processBlock (processor, buffer, maybeTtlEvent, sampleTimestamps );
6161 // Assert the buffer hasn't changed after process()
6262 ASSERT_EQ (outBuffer.getNumSamples (), buffer.getNumSamples ());
6363 ASSERT_EQ (outBuffer.getNumChannels (), buffer.getNumChannels ());
@@ -179,6 +179,55 @@ class RecordNodeTests : public testing::Test {
179179 *output = loadNpyFileBinaryFullpath (npyFilePath.string ());
180180 }
181181
182+ template <typename T>
183+ std::vector<T> parseNpyPayload (const std::vector<char >& binary) {
184+ if (binary.size () < static_cast <size_t >(10 )) {
185+ ADD_FAILURE () << " NPY payload is too small to contain a valid header" ;
186+ return {};
187+ }
188+
189+ const uint16_t headerLength = static_cast <uint8_t >(binary[8 ])
190+ | (static_cast <uint16_t >(static_cast <uint8_t >(binary[9 ])) << 8 );
191+ const size_t payloadOffset = 10 + headerLength;
192+
193+ if (payloadOffset > binary.size ()) {
194+ ADD_FAILURE () << " NPY payload header exceeds file size" ;
195+ return {};
196+ }
197+
198+ if (((binary.size () - payloadOffset) % sizeof (T)) != static_cast <size_t >(0 )) {
199+ ADD_FAILURE () << " NPY payload size is not aligned to element size" ;
200+ return {};
201+ }
202+
203+ std::vector<T> values ((binary.size () - payloadOffset) / sizeof (T));
204+
205+ if (!values.empty ()) {
206+ memcpy (values.data (), binary.data () + payloadOffset, values.size () * sizeof (T));
207+ }
208+
209+ return values;
210+ }
211+
212+ template <typename T>
213+ std::vector<T> loadNpyData (const std::string& basename) {
214+ bool success = false ;
215+ std::vector<char > binary;
216+ loadNpyFileBinary (basename, &binary, &success);
217+
218+ if (!success) {
219+ ADD_FAILURE () << " Failed to load NPY file: " << basename;
220+ return {};
221+ }
222+
223+ return parseNpyPayload<T>(binary);
224+ }
225+
226+ template <typename T>
227+ std::vector<T> loadNpyDataFullpath (const std::filesystem::path& path) {
228+ return parseNpyPayload<T>(loadNpyFileBinaryFullpath (path.string ()));
229+ }
230+
182231
183232 void compareBinaryFilesHex (const std::string& filename, const std::vector<char > binData, const std::string& expectedBinDataHex) {
184233 std::vector<char > expectedBinData;
@@ -223,6 +272,32 @@ class RecordNodeTests : public testing::Test {
223272 float sampleRate = 1.0 ;
224273};
225274
275+ class HardwareSynced_RecordNodeTests : public RecordNodeTests {
276+ protected:
277+ void SetUp () override {
278+ sampleRate = 100 .0f ;
279+ numChannels = 8 ;
280+ tester = std::make_unique<ProcessorTester>(TestSourceNodeBuilder
281+ (FakeSourceNodeParams{
282+ numChannels,
283+ sampleRate,
284+ bitVolts,
285+ 1 ,
286+ 0 ,
287+ true
288+ }));
289+
290+ parentRecordingDir = std::filesystem::temp_directory_path () / " record_node_hardware_sync_tests" ;
291+ if (std::filesystem::exists (parentRecordingDir)) {
292+ std::filesystem::remove_all (parentRecordingDir);
293+ }
294+ std::filesystem::create_directory (parentRecordingDir);
295+
296+ tester->setRecordingParentDirectory (parentRecordingDir.string ());
297+ processor = tester->createProcessor <RecordNode>(Plugin::Processor::RECORD_NODE );
298+ }
299+ };
300+
226301TEST_F (RecordNodeTests, TestInputOutput_Continuous_Single) {
227302 int numSamples = 100 ;
228303 tester->startAcquisition (true );
@@ -421,6 +496,32 @@ TEST_F(RecordNodeTests, Test_PersistsSampleNumbersAndTimestamps) {
421496 compareBinaryFilesHex (" timestamps.npy" , timeStampsBin, expectedTimeStampsHex);
422497}
423498
499+ TEST_F (HardwareSynced_RecordNodeTests, Test_PersistsPerSampleHardwareTimestamps) {
500+ tester->startAcquisition (true );
501+
502+ const int numSamples = 5 ;
503+ auto firstBuffer = createBuffer (1000 .0f , 20 .0f , numChannels, numSamples);
504+ std::vector<double > firstTimestamps { 10.000 , 10.011 , 10.021 , 10.034 , 10.048 };
505+ writeBlock (firstBuffer, nullptr , firstTimestamps.data ());
506+
507+ auto secondBuffer = createBuffer (2000 .0f , 20 .0f , numChannels, numSamples);
508+ std::vector<double > secondTimestamps { 10.061 , 10.073 , 10.084 , 10.098 , 10.113 };
509+ writeBlock (secondBuffer, nullptr , secondTimestamps.data ());
510+
511+ tester->stopAcquisition ();
512+
513+ auto persistedTimestamps = loadNpyData<double >(" timestamps.npy" );
514+
515+ std::vector<double > expectedTimestamps = firstTimestamps;
516+ expectedTimestamps.insert (expectedTimestamps.end (), secondTimestamps.begin (), secondTimestamps.end ());
517+
518+ ASSERT_EQ (persistedTimestamps.size (), expectedTimestamps.size ());
519+
520+ for (size_t index = 0 ; index < expectedTimestamps.size (); ++index) {
521+ EXPECT_DOUBLE_EQ (persistedTimestamps[index], expectedTimestamps[index]);
522+ }
523+ }
524+
424525TEST_F (RecordNodeTests, Test_PersistsStructureOeBin) {
425526 tester->startAcquisition (true );
426527
@@ -530,6 +631,37 @@ TEST_F(RecordNodeTests, Test_PersistsEvents) {
530631 compareBinaryFilesHex (" full_words.npy" , fullWordsBin, expectedFullWordsHex);
531632}
532633
634+ TEST_F (HardwareSynced_RecordNodeTests, Test_PersistsHardwareEventTimestampFromBlockArray) {
635+ processor->setRecordEvents (true );
636+ processor->updateSettings ();
637+
638+ tester->startAcquisition (true );
639+
640+ const int numSamples = 5 ;
641+ auto streamId = processor->getDataStreams ()[0 ]->getStreamId ();
642+ auto eventChannels = tester->getSourceNodeDataStream (streamId)->getEventChannels ();
643+ ASSERT_GE (eventChannels.size (), 1 );
644+
645+ TTLEventPtr eventPtr = TTLEvent::createTTLEvent (
646+ eventChannels[0 ],
647+ 1 ,
648+ 2 ,
649+ true );
650+
651+ auto inputBuffer = createBuffer (1000 .0f , 20 .0f , numChannels, numSamples);
652+ std::vector<double > blockTimestamps { 20.000 , 20.031 , 20.047 , 20.062 , 20.081 };
653+ writeBlock (inputBuffer, eventPtr.get (), blockTimestamps.data ());
654+
655+ tester->stopAcquisition ();
656+
657+ std::filesystem::path timestampPath;
658+ ASSERT_TRUE (eventsPathFor (" timestamps.npy" , ×tampPath));
659+ auto eventTimestamps = loadNpyDataFullpath<double >(timestampPath);
660+
661+ ASSERT_EQ (eventTimestamps.size (), static_cast <size_t >(1 ));
662+ EXPECT_DOUBLE_EQ (eventTimestamps[0 ], blockTimestamps[1 ]);
663+ }
664+
533665// ============================================================================
534666// SEQUENTIAL BLOCK FILE BATCH WRITE TESTS
535667// ============================================================================
0 commit comments