diff --git a/README.md b/README.md index fb0fc3d..2efd893 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ > **If you use VIEWA in your research, please cite the following publication:** > -> Haupt et al. — *Title TBA upon publication* — accepted. Citation will be updated once a DOI is available. +> Haupt T, Maanen P, Daeglau M et al. — Enhancing mobile brain and body imaging: Open-source solutions for real-world research applications. *iScience*, 2026; 29. VIEWA is provided **without any warranty**, and without guarantee of fitness for a particular purpose. Use it at your own risk. See [LICENSE](LICENSE) for the full terms. diff --git a/app/src/main/java/de/uol/neuropsy/viewa/service/LSLService.kt b/app/src/main/java/de/uol/neuropsy/viewa/service/LSLService.kt index cb7a77a..c02a781 100644 --- a/app/src/main/java/de/uol/neuropsy/viewa/service/LSLService.kt +++ b/app/src/main/java/de/uol/neuropsy/viewa/service/LSLService.kt @@ -21,6 +21,9 @@ class LSLService : LifecycleService() { private val timeoutMs = 500.0 // half-second + /** LSL channel-format constant for variable-length strings (mirrors LSL.ChannelFormat.string = 3) */ + private val LSL_FORMAT_STRING = 3 + // Multicast lock: prevents Android's WiFi chip from filtering incoming multicast // UDP packets (stream discovery responses) when WiFi is connected. private var multicastLock: WifiManager.MulticastLock? = null @@ -48,7 +51,9 @@ class LSLService : LifecycleService() { } } - data class StreamConfig(val streamName:String,val channelCount:Int, val samplingRate : Double) : ServiceEvent() + data class StreamConfig(val streamName:String, val channelCount:Int, val samplingRate: Double, val isMarker: Boolean = false) : ServiceEvent() + + data class MarkerSample(val streamName: String, val timestamp: Double, val label: String) : ServiceEvent() } // This SharedFlow handles all data flow from the service to the view model. @@ -105,28 +110,46 @@ class LSLService : LifecycleService() { Log.w("LSLService", "Could not find stream '$streamName' within timeout") return } + val isMarker = info.channel_format() == LSL_FORMAT_STRING val inlet = StreamInlet(info) val job = lifecycleScope.launch(Dispatchers.IO) { - Log.i("LSLService","Emitting config for ${info.name()}") + Log.i("LSLService","Emitting config for ${info.name()} (isMarker=$isMarker)") _dataFlow.emit( ServiceEvent.StreamConfig( info.name(), info.channel_count(), - info.nominal_srate() + info.nominal_srate(), + isMarker ) ) - val buf = FloatArray(info.channel_count()) try { - while (isActive) { - val timestamp = inlet.pull_sample(buf, timeoutMs) - if (timestamp > 0) { - _dataFlow.tryEmit( - ServiceEvent.DataSample( - streamName, - timestamp, - buf.copyOf() + if (isMarker) { + val buf = Array(info.channel_count()) { "" } + while (isActive) { + val timestamp = inlet.pull_sample(buf, timeoutMs) + if (timestamp > 0) { + _dataFlow.tryEmit( + ServiceEvent.MarkerSample( + streamName, + timestamp, + buf.firstOrNull() ?: "" + ) + ) + } + } + } else { + val buf = FloatArray(info.channel_count()) + while (isActive) { + val timestamp = inlet.pull_sample(buf, timeoutMs) + if (timestamp > 0) { + _dataFlow.tryEmit( + ServiceEvent.DataSample( + streamName, + timestamp, + buf.copyOf() + ) ) - ) + } } } } catch (e: Exception){ diff --git a/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/LivePlotFragment.kt b/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/LivePlotFragment.kt index 171a9e1..c6ef725 100644 --- a/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/LivePlotFragment.kt +++ b/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/LivePlotFragment.kt @@ -12,11 +12,9 @@ import android.os.IBinder import android.os.PowerManager import android.text.Html import android.text.method.LinkMovementMethod -import android.view.GestureDetector import android.view.Menu import android.view.MenuInflater import android.view.MenuItem -import android.view.MotionEvent import android.view.View import android.widget.TextView import androidx.appcompat.app.AlertDialog @@ -33,7 +31,6 @@ import de.uol.neuropsy.viewa.R import de.uol.neuropsy.viewa.service.LSLService import de.uol.neuropsy.viewa.ui.selection.StreamSelectionFragment import de.uol.neuropsy.viewa.ui.settings.SettingsDialog -import kotlinx.coroutines.flow.sample class LivePlotFragment : Fragment(R.layout.fragment_live_plot), @@ -90,17 +87,13 @@ class LivePlotFragment : Fragment(R.layout.fragment_live_plot), super.onViewCreated(view, savedInstanceState) val recycler = view.findViewById(R.id.plotsRecycler) recycler.layoutManager = LinearLayoutManager(requireContext()) - adapter = StreamPlotAdapter(viewModel) { name -> onPlotClicked(name) } + recycler.itemAnimator = null // prevent insert animations from blocking chart redraws + adapter = StreamPlotAdapter(viewModel, viewLifecycleOwner.lifecycleScope) { name -> onPlotClicked(name) } recycler.adapter = adapter adapter.submitList(viewModel.activeStreams.toList()) - // When *any* chartData updates, ask the adapter to re-bind visible ViewHolders - // Use sample() to throttle to 60 FPS - viewLifecycleOwner.lifecycleScope.launchWhenStarted { - viewModel.uiState.sample(16).collect { _ -> - adapter.notifyDataSetChanged() - } - } + // Each ViewHolder now drives its own chart coroutine (started in onViewAttachedToWindow), + // so no adapter-level notifyDataSetChanged() loop is needed here. childFragmentManager.setFragmentResultListener( "streamSelection", // requestKey diff --git a/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/LivePlotViewModel.kt b/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/LivePlotViewModel.kt index f866eba..604baa0 100644 --- a/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/LivePlotViewModel.kt +++ b/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/LivePlotViewModel.kt @@ -26,6 +26,14 @@ data class ChartUiState( val yMax: Float = Float.NEGATIVE_INFINITY ) +/** UI state for a marker/event stream: a list of (relativeTimestamp, label) pairs + * within the current time window. */ +data class MarkerUiState( + val markers: List> = emptyList(), + val windowStartX: Float = 0f, + val latestX: Float = 0f +) + class LivePlotViewModel : ViewModel() { private val maxPoints = 500 @@ -41,14 +49,17 @@ class LivePlotViewModel : ViewModel() { private val _uiState = MutableStateFlow>(emptyMap()) val uiState: StateFlow> = _uiState.asStateFlow() + + // Marker stream support + val markerStreams: MutableSet = mutableSetOf() + private val markerBuffers = mutableMapOf>>() + + private val _markerUiState = MutableStateFlow>(emptyMap()) + val markerUiState: StateFlow> = _markerUiState.asStateFlow() + private var service: LSLService? = null // Set of the names of active streams currently plotted - // Unfortunately we need this set of extra bookkeeping as - // I think getting the list of the currently active streams from the - // LSLService might induce a race condition when whe change - // new active streams faster than the service can open new - // outlets, see also updateSelection() var activeStreams: Set = emptySet() @@ -61,12 +72,35 @@ class LivePlotViewModel : ViewModel() { when (ev) { is LSLService.ServiceEvent.StreamConfig -> handleConfigurationEvent(ev) is LSLService.ServiceEvent.DataSample -> handleDataEvent(ev) + is LSLService.ServiceEvent.MarkerSample -> handleMarkerEvent(ev) } } } } } + private fun handleMarkerEvent(ev: LSLService.ServiceEvent.MarkerSample) { + val name = ev.streamName + val baseline = timestampBaseline.getOrPut(name) { ev.timestamp } + val t = (ev.timestamp - baseline).toFloat() + val buf = markerBuffers[name] ?: return + + buf.addLast(Pair(t, ev.label)) + // Remove entries older than the sliding window + val windowStart = t - bufferSizeInSeconds.toFloat() + while (buf.isNotEmpty() && buf.first().first < windowStart) { + buf.removeFirst() + } + + _markerUiState.value = _markerUiState.value.toMutableMap().apply { + put(name, MarkerUiState( + markers = buf.toList(), + windowStartX = windowStart, + latestX = t + )) + } + } + private fun handleDataEvent(sampleEv: LSLService.ServiceEvent.DataSample) { val name = sampleEv.streamName // Use a per-stream baseline so that X values start near 0 and fit accurately in Float @@ -121,16 +155,24 @@ class LivePlotViewModel : ViewModel() { } private fun handleConfigurationEvent(configEvent: LSLService.ServiceEvent.StreamConfig) { - val channelCount = configEvent.channelCount val streamName = configEvent.streamName - val bufferSize = - if (configEvent.samplingRate == LSL.IRREGULAR_RATE) maxPoints else (bufferSizeInSeconds * configEvent.samplingRate + 1).toInt() - buffers[streamName] = (0 until channelCount) - .associateWith { ArrayDeque(bufferSize) } - .toMutableMap() - allTimeMin[streamName] = Float.POSITIVE_INFINITY - allTimeMax[streamName] = Float.NEGATIVE_INFINITY timestampBaseline.remove(streamName) + + if (configEvent.isMarker) { + markerStreams.add(streamName) + markerBuffers[streamName] = ArrayDeque() + _markerUiState.value = _markerUiState.value.toMutableMap().apply { + put(streamName, MarkerUiState()) + } + } else { + val bufferSize = + if (configEvent.samplingRate == LSL.IRREGULAR_RATE) maxPoints else (bufferSizeInSeconds * configEvent.samplingRate + 1).toInt() + buffers[streamName] = (0 until configEvent.channelCount) + .associateWith { ArrayDeque(bufferSize) } + .toMutableMap() + allTimeMin[streamName] = Float.POSITIVE_INFINITY + allTimeMax[streamName] = Float.NEGATIVE_INFINITY + } } fun updateSelection(newStreams: Set) { @@ -139,7 +181,10 @@ class LivePlotViewModel : ViewModel() { viewModelScope.launch { // serialized on the same coroutine context, // so no two diffs run in parallel - toStop.forEach { service?.stopInlet(it) } + toStop.forEach { + service?.stopInlet(it) + markerStreams.remove(it) + } toStart.forEach { service?.startInlet(it) } activeStreams = newStreams } diff --git a/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/StreamPlotAdapter.kt b/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/StreamPlotAdapter.kt index f80e8af..ce3cb07 100644 --- a/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/StreamPlotAdapter.kt +++ b/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/StreamPlotAdapter.kt @@ -8,27 +8,46 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.github.mikephil.charting.components.Description +import com.github.mikephil.charting.components.LimitLine import com.github.mikephil.charting.data.LineData import de.uol.neuropsy.viewa.R +import de.uol.neuropsy.viewa.databinding.ItemMarkerPlotBinding import de.uol.neuropsy.viewa.databinding.ItemStreamPlotBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch class StreamPlotAdapter( - private val viewModel: LivePlotViewModel, private val listener: (String)->Unit -) : ListAdapter(DiffCallback) { + private val viewModel: LivePlotViewModel, + private val scope: CoroutineScope, + private val listener: (String) -> Unit +) : ListAdapter(DiffCallback) { interface OnPlotClickListener { fun onPlotClicked(item: String) } companion object { + private const val VIEW_DATA = 0 + private const val VIEW_MARKER = 1 + private val DiffCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(old: String, new: String) = old == new override fun areContentsTheSame(old: String, new: String) = true } } - inner class PlotVH(val binding: ItemStreamPlotBinding) - : RecyclerView.ViewHolder(binding.root){ + override fun getItemViewType(position: Int): Int { + val streamName = getItem(position) + return if (viewModel.markerStreams.contains(streamName)) VIEW_MARKER else VIEW_DATA + } + + inner class PlotVH(val binding: ItemStreamPlotBinding) : RecyclerView.ViewHolder(binding.root) { + var streamName: String? = null + var updateJob: Job? = null private val button: AppCompatImageButton = itemView.findViewById(R.id.show_fullscreen_btn) init { @@ -38,7 +57,7 @@ class StreamPlotAdapter( false } button.setOnClickListener { - Log.e("PlotVH","Button clicked: $adapterPosition") + Log.e("PlotVH", "Button clicked: $adapterPosition") val pos = adapterPosition if (pos != RecyclerView.NO_POSITION) { listener(getItem(adapterPosition)) @@ -47,56 +66,177 @@ class StreamPlotAdapter( } } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PlotVH { - val inflater = LayoutInflater.from(parent.context) - val binding = ItemStreamPlotBinding.inflate(inflater, parent, false) + inner class MarkerVH(val binding: ItemMarkerPlotBinding) : RecyclerView.ViewHolder(binding.root) { + var streamName: String? = null + var updateJob: Job? = null + } - return PlotVH(binding) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + VIEW_MARKER -> MarkerVH(ItemMarkerPlotBinding.inflate(inflater, parent, false)) + else -> PlotVH(ItemStreamPlotBinding.inflate(inflater, parent, false)) + } } - override fun onBindViewHolder(holder: PlotVH, position: Int) { + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val streamName = getItem(position) + when (holder) { + is PlotVH -> bindDataHolder(holder, streamName) + is MarkerVH -> bindMarkerHolder(holder, streamName) + } + } + + // Start per-ViewHolder coroutines once the view is attached to the window (layout complete). + override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) { + super.onViewAttachedToWindow(holder) + when (holder) { + is PlotVH -> startDataUpdates(holder) + is MarkerVH -> startMarkerUpdates(holder) + } + } + + override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { + super.onViewDetachedFromWindow(holder) + when (holder) { + is PlotVH -> holder.updateJob?.cancel() + is MarkerVH -> holder.updateJob?.cancel() + } + } + + // onBindViewHolder only sets up static chart config (colors, axes). Data updates happen in + // the per-ViewHolder coroutine started in onViewAttachedToWindow, which avoids calling + // notifyDataSetChanged() on the whole adapter at 60 fps and prevents animation conflicts. + private fun bindDataHolder(holder: PlotVH, streamName: String) { + holder.streamName = streamName val binding = holder.binding - // Set the text of the title TV binding.streamTitle.text = streamName - // Fetch the latest DataSets for this stream - val dataSets = viewModel.uiState.value[streamName]?.entries ?: emptyList() - Log.d("LivePlot", "[Adapter] $streamName datasets=${dataSets.size} " + - dataSets.mapIndexed { i, ds -> - "Ch$i: entries=${ds.entryCount} visible=${ds.isVisible} " + - "yRange=[${if (ds.entryCount > 0) ds.yMin else Float.NaN}, ${if (ds.entryCount > 0) ds.yMax else Float.NaN}]" - }.joinToString(" | ") - ) - binding.streamChart.description=Description().apply {isEnabled=false} - binding.streamChart.axisRight.isEnabled=false - // Pick label colour based on current night-mode setting + + binding.streamChart.description = Description().apply { isEnabled = false } + binding.streamChart.axisRight.isEnabled = false + val nightMask = holder.itemView.context.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK val labelColor = if (nightMask == android.content.res.Configuration.UI_MODE_NIGHT_YES) - 0xFFCCCCCC.toInt() // light grey for dark mode + 0xFFCCCCCC.toInt() else - android.graphics.Color.DKGRAY // dark grey for light mode + android.graphics.Color.DKGRAY binding.streamChart.xAxis.textColor = labelColor binding.streamChart.axisLeft.textColor = labelColor binding.streamChart.legend.textColor = labelColor - binding.streamChart.apply { - data = LineData(*dataSets.toTypedArray()) - // Only apply axis limits when we have finite values (guard against initial ±Infinity) - val yMin = viewModel.uiState.value[streamName]?.yMin ?: Float.NaN - val yMax = viewModel.uiState.value[streamName]?.yMax ?: Float.NaN - if (yMin.isFinite() && yMax.isFinite()) { - val range = yMax - yMin - val padding = if (range > 0f) range * 0.1f else Math.abs(yMax) * 0.1f + 1f - axisLeft.axisMaximum = yMax + padding - axisLeft.axisMinimum = yMin - padding - } else { - axisLeft.resetAxisMaximum() - axisLeft.resetAxisMinimum() - } - // Auto-scroll to the latest data so the chart viewport follows incoming samples - moveViewToX(data?.xMax ?: 0f) - notifyDataSetChanged() - invalidate() + // Initialise with empty data so MPAndroidChart renders axes immediately + // instead of showing "No chart data available" while the coroutine starts up. + if (binding.streamChart.data == null) binding.streamChart.data = LineData() + } + + private fun startDataUpdates(holder: PlotVH) { + holder.updateJob?.cancel() + val name = holder.streamName ?: return + holder.updateJob = scope.launch { + viewModel.uiState + .mapNotNull { it[name] } + .conflate() // drop intermediate values if the collector is busy + .collect { state -> + val binding = holder.binding + val dataSets = state.entries + Log.d("LivePlot", "[Adapter] $name datasets=${dataSets.size} " + + dataSets.mapIndexed { i, ds -> + "Ch$i: entries=${ds.entryCount} visible=${ds.isVisible} " + + "yRange=[${if (ds.entryCount > 0) ds.yMin else Float.NaN}, " + + "${if (ds.entryCount > 0) ds.yMax else Float.NaN}]" + }.joinToString(" | ") + ) + binding.streamChart.apply { + data = LineData(*dataSets.toTypedArray()) + if (state.yMin.isFinite() && state.yMax.isFinite()) { + val range = state.yMax - state.yMin + val padding = if (range > 0f) range * 0.1f + else Math.abs(state.yMax) * 0.1f + 1f + axisLeft.axisMaximum = state.yMax + padding + axisLeft.axisMinimum = state.yMin - padding + } else { + axisLeft.resetAxisMaximum() + axisLeft.resetAxisMinimum() + } + notifyDataSetChanged() + val xMax = data?.xMax ?: 0f + if (width > 0) { + moveViewToX(xMax) + invalidate() + } else { + post { + moveViewToX(xMax) + invalidate() + } + } + } + delay(16) // throttle to ~60 fps; conflate() drops values we can't keep up with + } + } + } + + private fun bindMarkerHolder(holder: MarkerVH, streamName: String) { + holder.streamName = streamName + val binding = holder.binding + binding.markerStreamTitle.text = streamName + + binding.markerChart.apply { + description.isEnabled = false + legend.isEnabled = false + setTouchEnabled(false) + axisLeft.isEnabled = false + axisRight.isEnabled = false + } + + val nightMask = holder.itemView.context.resources.configuration.uiMode and + android.content.res.Configuration.UI_MODE_NIGHT_MASK + binding.markerChart.xAxis.textColor = + if (nightMask == android.content.res.Configuration.UI_MODE_NIGHT_YES) + 0xFFCCCCCC.toInt() else android.graphics.Color.DKGRAY + } + + private fun startMarkerUpdates(holder: MarkerVH) { + holder.updateJob?.cancel() + val name = holder.streamName ?: return + holder.updateJob = scope.launch { + viewModel.markerUiState + .mapNotNull { it[name] } + .conflate() + .collect { ui -> + val binding = holder.binding + val nightMask = holder.itemView.context.resources.configuration.uiMode and + android.content.res.Configuration.UI_MODE_NIGHT_MASK + val labelColor = if (nightMask == android.content.res.Configuration.UI_MODE_NIGHT_YES) + 0xFFCCCCCC.toInt() else android.graphics.Color.DKGRAY + val markerLineColor = if (nightMask == android.content.res.Configuration.UI_MODE_NIGHT_YES) + 0xFFFF6B6B.toInt() else 0xFFCC0000.toInt() + + binding.markerChart.apply { + xAxis.textColor = labelColor + xAxis.removeAllLimitLines() + + val windowEnd = if (ui.latestX > 0f) ui.latestX else 10f + val windowStart = windowEnd - 10f + xAxis.axisMinimum = windowStart + xAxis.axisMaximum = windowEnd + xAxis.setDrawLimitLinesBehindData(false) + + ui.markers.forEach { (x, label) -> + xAxis.addLimitLine(LimitLine(x, label).apply { + lineColor = markerLineColor + lineWidth = 1.5f + textColor = labelColor + textSize = 9f + labelPosition = LimitLine.LimitLabelPosition.RIGHT_TOP + }) + } + + if (data == null) data = LineData() + notifyDataSetChanged() + invalidate() + } + delay(16) + } } } } \ No newline at end of file diff --git a/app/src/main/res/drawable/stream_selection_mock.png b/app/src/main/res/drawable/stream_selection_mock.png deleted file mode 100644 index 17b7530..0000000 Binary files a/app/src/main/res/drawable/stream_selection_mock.png and /dev/null differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index ef0eddf..98c877b 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -5,7 +5,7 @@ android:layout_height="match_parent" android:orientation="vertical"> - + + + + + + + diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml index 5ead2e0..51a56e4 100644 --- a/app/src/main/res/values-night/styles.xml +++ b/app/src/main/res/values-night/styles.xml @@ -1,12 +1,12 @@ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fbf72a8..52c4588 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - Viewa + VIEWA Messages Sync diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 1995236..3f5aded 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,8 +1,8 @@