brainvision 0.0.1

Rust library and TUI for Brain Products BrainVision RDA EEG streams over TCP/IP
Documentation
//! Real-time BrainVision RDA terminal UI using ratatui.

use std::io;
use std::time::{Duration, Instant};

use brainvision::prelude::*;
use crossterm::{
    event::{self, Event, KeyCode, KeyEventKind},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{prelude::*, widgets::*};

const DISPLAY_SAMPLES: usize = 500;
const MAX_CHANNELS: usize = 8;
const FIXED_Y_MIN: f64 = -100.0;
const FIXED_Y_MAX: f64 = 100.0;

#[derive(Debug, Clone, Copy)]
enum ScaleMode {
    Auto,
    Fixed,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    env_logger::init();

    let host = std::env::var("BRAINVISION_HOST").unwrap_or_else(|_| "127.0.0.1".into());
    let port = std::env::var("BRAINVISION_PORT")
        .ok()
        .and_then(|s| s.parse::<u16>().ok())
        .unwrap_or(RDA_PORT_I16);

    println!("Connecting to {host}:{port} ...");
    let mut dev = BrainVisionDevice::connect(&host, port)?;
    let info = dev.wait_for_start()?;

    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let mut terminal = Terminal::new(CrosstermBackend::new(stdout))?;

    let mut ring: Vec<Vec<f64>> = Vec::new();
    let mut scale_mode = ScaleMode::Auto;
    let mut last_marker = String::from("-");
    let tick = Duration::from_millis(33);
    let mut last_tick = Instant::now();

    loop {
        match dev.next_block_resilient(2, Duration::from_millis(200)) {
            Ok(Some(block)) => {
                if let Some(m) = block.markers.last() {
                    last_marker = format!("{}:{}", m.kind, m.description);
                }
                let n_ch = info.channel_count as usize;
                for chunk in block.samples_uv.chunks(n_ch) {
                    if ring.len() >= DISPLAY_SAMPLES {
                        ring.remove(0);
                    }
                    ring.push(chunk.to_vec());
                }
            }
            Ok(None) => break, // STOP
            Err(_) => {}       // keep UI alive
        }

        terminal.draw(|f| {
            let n_ch = (info.channel_count as usize).clamp(1, MAX_CHANNELS);
            let areas = Layout::default()
                .direction(Direction::Vertical)
                .constraints(
                    std::iter::once(Constraint::Length(3))
                        .chain((0..n_ch).map(|_| Constraint::Min(3)))
                        .chain(std::iter::once(Constraint::Length(2)))
                        .collect::<Vec<_>>(),
                )
                .split(f.area());

            let stats = dev.stats();
            f.render_widget(
                Block::default()
                    .borders(Borders::ALL)
                    .title(format!(
                        " BrainVision RDA | {} ch @ {:.2} Hz | q=quit a=auto f=fixed ",
                        info.channel_count,
                        info.sampling_rate_hz(),
                    ))
                    .style(Style::default().fg(Color::Cyan)),
                areas[0],
            );

            let colors = [
                Color::Green,
                Color::Yellow,
                Color::Blue,
                Color::Magenta,
                Color::Red,
                Color::Cyan,
                Color::White,
                Color::LightGreen,
            ];
            for ch in 0..n_ch {
                let data: Vec<(f64, f64)> = ring
                    .iter()
                    .enumerate()
                    .filter_map(|(i, s)| s.get(ch).copied().map(|v| (i as f64, v)))
                    .collect();

                let (y0, y1) = match scale_mode {
                    ScaleMode::Fixed => (FIXED_Y_MIN, FIXED_Y_MAX),
                    ScaleMode::Auto => {
                        let y_min = data.iter().map(|d| d.1).fold(f64::INFINITY, f64::min);
                        let y_max = data.iter().map(|d| d.1).fold(f64::NEG_INFINITY, f64::max);
                        let m = (y_max - y_min).max(1.0) * 0.1;
                        (y_min - m, y_max + m)
                    }
                };

                let ds = Dataset::default()
                    .name(
                        info.channel_names
                            .get(ch)
                            .cloned()
                            .unwrap_or_else(|| format!("Ch{}", ch + 1)),
                    )
                    .marker(symbols::Marker::Braille)
                    .graph_type(GraphType::Line)
                    .style(Style::default().fg(colors[ch]))
                    .data(&data);

                f.render_widget(
                    Chart::new(vec![ds])
                        .block(
                            Block::default()
                                .borders(Borders::ALL)
                                .title(format!(" Channel {} ", ch + 1)),
                        )
                        .x_axis(Axis::default().bounds([0.0, DISPLAY_SAMPLES as f64]))
                        .y_axis(Axis::default().bounds([y0, y1])),
                    areas[1 + ch],
                );
            }

            let dt_ms = stats.last_block_dt.map(|d| d.as_millis()).unwrap_or(0);
            f.render_widget(
                Paragraph::new(format!(
                    "scale={:?} | dropped_blocks={} | last_block={:?} | dt={}ms | marker={}",
                    scale_mode, stats.dropped_blocks, stats.last_block, dt_ms, last_marker,
                ))
                .block(
                    Block::default()
                        .borders(Borders::ALL)
                        .title(" Stream stats "),
                )
                .style(Style::default().fg(Color::DarkGray)),
                areas[1 + n_ch],
            );
        })?;

        let timeout = tick.checked_sub(last_tick.elapsed()).unwrap_or_default();
        if event::poll(timeout)? {
            if let Event::Key(k) = event::read()? {
                if k.kind == KeyEventKind::Press {
                    match k.code {
                        KeyCode::Char('q') => break,
                        KeyCode::Char('a') => scale_mode = ScaleMode::Auto,
                        KeyCode::Char('f') => scale_mode = ScaleMode::Fixed,
                        _ => {}
                    }
                }
            }
        }
        if last_tick.elapsed() >= tick {
            last_tick = Instant::now();
        }
    }

    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    terminal.show_cursor()?;
    Ok(())
}