Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
49 changes: 36 additions & 13 deletions app/src/main/java/de/uol/neuropsy/viewa/service/LSLService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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){
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -90,17 +87,13 @@ class LivePlotFragment : Fragment(R.layout.fragment_live_plot),
super.onViewCreated(view, savedInstanceState)
val recycler = view.findViewById<RecyclerView>(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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pair<Float, String>> = emptyList(),
val windowStartX: Float = 0f,
val latestX: Float = 0f
)

class LivePlotViewModel : ViewModel() {

private val maxPoints = 500
Expand All @@ -41,14 +49,17 @@ class LivePlotViewModel : ViewModel() {
private val _uiState =
MutableStateFlow<Map<String, ChartUiState>>(emptyMap())
val uiState: StateFlow<Map<String, ChartUiState>> = _uiState.asStateFlow()

// Marker stream support
val markerStreams: MutableSet<String> = mutableSetOf()
private val markerBuffers = mutableMapOf<String, ArrayDeque<Pair<Float, String>>>()

private val _markerUiState = MutableStateFlow<Map<String, MarkerUiState>>(emptyMap())
val markerUiState: StateFlow<Map<String, MarkerUiState>> = _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<String> = emptySet()


Expand All @@ -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
Expand Down Expand Up @@ -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<Entry>(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<Entry>(bufferSize) }
.toMutableMap()
allTimeMin[streamName] = Float.POSITIVE_INFINITY
allTimeMax[streamName] = Float.NEGATIVE_INFINITY
}
}

fun updateSelection(newStreams: Set<String>) {
Expand All @@ -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
}
Expand Down
Loading
Loading