feat: 임상 Live 6채널 시각화 화면 (ClinicalLiveView)

vesiscan_test Section 2 프로토콜 패턴을 안드로이드 앱 안에서 직접 구현.
실제 몸에 센서 붙인 채 6채널 실시간 그래프 보면서 측정 + 저장 가능.

신규 화면 ClinicalLiveView:
- 600ms 간격 mtb? 자동 송신 (Piezo 6ch + IMU 동시)
- 2×3 grid 라이브 차트 (raw ADC waveform, CH0~CH5 색상별)
  · y축 0~4095 (12-bit ADC) 고정 + 가로 grid 4분할
  · 각 채널 peak 값 표시
- IMU 카드 — ImuAnalysis 기반 자세 + tilt + shake (흔들림 시 빨강 경고)
- 상단: 세션 라벨 + S=Subject TV=TrueVol Ab=Abdomen 한 줄 + #cycle ✓captured
- 컨트롤: Pause/Resume · Capture (수동 저장) · Auto Capture 토글 · End Session

ClinicalSession 모델 확장:
- subjectId / trueVolumeMl / abdomenThicknessMm 필드 추가
- folderName 에 subjectId prefix (예: "S001_VBT26050202_SUPINE_ALIGN_0CM_...")
- meta.json 에 subject_id / true_volume_ml / abdomen_thickness_mm 자동 기록

ClinicalSessionStore:
- lastSubjectId / lastTrueVolumeMl / lastAbdomenThicknessMm 기억 (다음 측정 자동 채움)

ClinicalHomeView 입력 폼 확장:
- Subject ID 텍스트필드
- True Volume (mL) + Abdomen (mm) 2-컬럼 숫자 입력
- Start Live Measurement 버튼 → CLINICAL_LIVE 화면으로 직행
  (이전: alignment/BV step에 따라 PlacementGuide/PiezoMonitoring 분기)

AppState/MainActivity:
- AppScreen.CLINICAL_LIVE 추가
- MainActivity Back: LIVE 시 endMeasurement + ClinicalHome 복귀

데이터 흐름:
  Start Live → ClinicalLiveView 진입 → mtb? 600ms loop
    → piezoCollector → 6채널 buffer (자동 또는 수동 capture)
    → AdcCsvLogger.log → adc.csv 누적
    → imuCollector → 15 IMU samples
    → ImuCsvLogger.log → imu.csv 누적
  End/Back → endMeasurement → meta.json finalize → ClinicalHome 복귀

장점:
- 검사자가 실제 시연 중 6채널 신호 품질 즉시 확인
- Auto Capture로 매 사이클 자동 저장 (Pause로 일시 멈춤)
- subject_id 기반 폴더명으로 피험자별 grouping 자연스럽게

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 11:17:55 +09:00
parent ec3be6fa5d
commit 38fa93c8b2
6 changed files with 430 additions and 9 deletions
@@ -15,7 +15,8 @@ enum class AppScreen {
NIRS_CALIBRATION, PIEZO_PERSONALIZATION, PLACEMENT_GUIDE, PIN_SETUP, PIN_ENTRY,
VIVAMYO_MONITORING, PIEZO_MONITORING, MEASUREMENT_HISTORY,
VOIDING_DIARY, URINE_CAMERA, REMINDER_SETTINGS,
CLINICAL_HOME // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면
CLINICAL_HOME, // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 (입력 폼)
CLINICAL_LIVE // ★ Dev 모드 한정 — 6채널 라이브 시각화 + Capture
}
class AppState(private val context: Context) {
@@ -40,6 +40,7 @@ import com.example.medilightv2android.ui.views.consent.ConsentView
import com.example.medilightv2android.ui.views.reminder.ReminderSettingsView
import com.example.medilightv2android.ui.views.sensorselect.SensorSelectView
import com.example.medilightv2android.ui.views.clinical.ClinicalHomeView
import com.example.medilightv2android.ui.views.clinical.ClinicalLiveView
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -117,6 +118,11 @@ fun MedilightApp(modifier: Modifier = Modifier) {
com.example.medilightv2android.services.ClinicalSessionStore.inClinicalFlow = false
appState.currentScreen = AppScreen.HOME
}
AppScreen.CLINICAL_LIVE -> {
// 측정 폴더 finalize 후 ClinicalHome 복귀
com.example.medilightv2android.services.ClinicalSessionStore.endMeasurement(context)
appState.currentScreen = AppScreen.CLINICAL_HOME
}
// 모니터링 하위 화면 → 모니터링
AppScreen.MEASUREMENT_HISTORY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
AppScreen.VOIDING_DIARY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
@@ -164,6 +170,7 @@ fun MedilightApp(modifier: Modifier = Modifier) {
onBack = { appState.backToMonitoring() }
)
AppScreen.CLINICAL_HOME -> ClinicalHomeView(appState)
AppScreen.CLINICAL_LIVE -> ClinicalLiveView(appState)
}
}
}
@@ -44,14 +44,22 @@ data class ClinicalSession(
val step: ClinicalStep,
val examiner: String = "",
val notes: String = "",
// 임상 시험 정보 (vesiscan_test Section 2-1 프로토콜과 매칭)
val subjectId: String = "", // 피험자 ID (예: "S001")
val trueVolumeMl: Double? = null, // 외부 초음파 측정기 참값 (mL)
val abdomenThicknessMm: Double? = null, // 복부 두께 측정기 참값 (mm)
val startedAt: Long = System.currentTimeMillis(),
var endedAt: Long? = null
) {
private val folderFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
/** "VBT26050202_SUPINE_ALIGN_0CM_2026-05-28_14-30-15" */
/** "S001_VBT26050202_SUPINE_ALIGN_0CM_2026-05-28_14-30-15"
* subjectId 비어있으면 기존 형식 유지 (deviceName_posture_step_timestamp). */
val folderName: String
get() = "${deviceName}_${posture.name}_${step.name}_${folderFmt.format(Date(startedAt))}"
get() = buildString {
if (subjectId.isNotBlank()) { append(subjectId); append('_') }
append("${deviceName}_${posture.name}_${step.name}_${folderFmt.format(Date(startedAt))}")
}
/** clinical_label 컬럼/필드 값 (라벨링 자동) */
val label: String
@@ -34,14 +34,20 @@ object ClinicalSessionStore {
var lastFinalizedFolder: File? = null
private set
/** 다음 측정 시 기본값으로 자동 채울 사용자 입력 (examiner/notes) */
/** 다음 측정 시 기본값으로 자동 채울 사용자 입력 — 같은 피험자/검사자 여러 측정 시 편의 */
var lastExaminer: String = ""
var lastNotes: String = ""
var lastSubjectId: String = ""
var lastTrueVolumeMl: String = "" // 문자열로 보관 (입력 폼과 동일 형식)
var lastAbdomenThicknessMm: String = ""
fun startMeasurement(context: Context, session: ClinicalSession) {
currentSession = session
lastExaminer = session.examiner
lastNotes = session.notes
lastSubjectId = session.subjectId
lastTrueVolumeMl = session.trueVolumeMl?.toString() ?: ""
lastAbdomenThicknessMm = session.abdomenThicknessMm?.toString() ?: ""
currentLogDir()?.mkdirs()
writeMeta(context)
}
@@ -68,6 +74,10 @@ object ClinicalSessionStore {
put("examiner", s.examiner)
put("notes", s.notes)
put("label", s.label)
// 임상 시험 참값 (외부 측정기, vesiscan_test 프로토콜)
if (s.subjectId.isNotBlank()) put("subject_id", s.subjectId)
s.trueVolumeMl?.let { put("true_volume_ml", it) }
s.abdomenThicknessMm?.let { put("abdomen_thickness_mm", it) }
put("started_at", s.startedAt)
put("started_at_iso",
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(s.startedAt)))
@@ -59,6 +59,10 @@ fun ClinicalHomeView(appState: AppState) {
var step by remember { mutableStateOf(ClinicalStep.ALIGN_0CM) }
var examiner by remember { mutableStateOf(ClinicalSessionStore.lastExaminer) }
var notes by remember { mutableStateOf(ClinicalSessionStore.lastNotes) }
// 임상 시험 정보 (vesiscan_test Section 2-1 프로토콜)
var subjectId by remember { mutableStateOf(ClinicalSessionStore.lastSubjectId) }
var trueVolText by remember { mutableStateOf(ClinicalSessionStore.lastTrueVolumeMl) }
var abdomenText by remember { mutableStateOf(ClinicalSessionStore.lastAbdomenThicknessMm) }
val lastFolder = ClinicalSessionStore.lastFinalizedFolder
@@ -165,6 +169,39 @@ fun ClinicalHomeView(appState: AppState) {
)
Spacer(modifier = Modifier.height(12.dp))
// 임상 시험 참값 — vesiscan_test Section 2-1 프로토콜과 매칭
OutlinedTextField(
value = subjectId,
onValueChange = { subjectId = it },
label = { Text("Subject ID (예: S001)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = trueVolText,
onValueChange = { trueVolText = it.filter { c -> c.isDigit() || c == '.' } },
label = { Text("True Volume (mL)") },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = abdomenText,
onValueChange = { abdomenText = it.filter { c -> c.isDigit() || c == '.' } },
label = { Text("Abdomen (mm)") },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
),
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = examiner,
onValueChange = { examiner = it },
@@ -192,7 +229,7 @@ fun ClinicalHomeView(appState: AppState) {
Spacer(modifier = Modifier.height(8.dp))
// Start Measurement
// Start Measurement — Live 6ch 시각화 화면으로 진입 (vesiscan_test run_stream 패턴)
Button(
onClick = {
ClinicalSessionStore.startMeasurement(
@@ -202,11 +239,13 @@ fun ClinicalHomeView(appState: AppState) {
posture = posture,
step = step,
examiner = examiner.trim(),
notes = notes.trim()
notes = notes.trim(),
subjectId = subjectId.trim(),
trueVolumeMl = trueVolText.toDoubleOrNull(),
abdomenThicknessMm = abdomenText.toDoubleOrNull(),
)
)
appState.currentScreen = if (step.isAlignment) AppScreen.PLACEMENT_GUIDE
else AppScreen.PIEZO_MONITORING
appState.currentScreen = AppScreen.CLINICAL_LIVE
},
enabled = isConnected,
modifier = Modifier.fillMaxWidth().height(56.dp),
@@ -215,7 +254,7 @@ fun ClinicalHomeView(appState: AppState) {
) {
Icon(Icons.Default.PlayArrow, null, tint = Color.White)
Spacer(modifier = Modifier.width(8.dp))
Text("Start Measurement", color = Color.White,
Text("Start Live Measurement", color = Color.White,
fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
@@ -0,0 +1,356 @@
package com.example.medilightv2android.ui.views.clinical
import android.widget.Toast
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.medilightv2android.AppScreen
import com.example.medilightv2android.AppState
import com.example.medilightv2android.ble.BleManager
import com.example.medilightv2android.ble.ImuSample
import com.example.medilightv2android.ble.PiezoChannelData
import com.example.medilightv2android.managers.ImuAnalysis
import com.example.medilightv2android.services.AdcCsvLogger
import com.example.medilightv2android.services.ClinicalSessionStore
import com.example.medilightv2android.services.ImuCsvLogger
import com.example.medilightv2android.services.MeasurementLogService
import com.example.medilightv2android.ui.theme.*
import kotlinx.coroutines.delay
import kotlin.math.max
import kotlin.math.min
/**
* Clinical Live 6채널 시각화 화면.
*
* vesiscan_test 의 run_stream 패턴을 안드로이드 앱 내에서 직접 구현:
* - 600ms 간격으로 mtb? 송신 → 6채널 + IMU 자동 수집
* - 2×3 grid 로 채널별 실시간 raw ADC 그래프
* - 상단: 세션 정보 (Subject ID / Posture / Step / True Vol / Abdomen)
* - 측정 상태 (cycle 번호, 6/6 채널 도착률, IMU 자세/흔들림)
* - 하단 컨트롤: Pause/Resume / Capture / End
* - Capture: 현재 시점 1 사이클을 Clinical 폴더에 저장
* - 자동 Capture 모드: 사이클마다 자동 저장 토글
* - End: 세션 finalize → ClinicalHome 복귀
*/
@Composable
fun ClinicalLiveView(appState: AppState) {
val context = LocalContext.current
val bleManager = remember { BleManager.getInstance(context) }
val session = ClinicalSessionStore.currentSession
// 세션 없으면 즉시 복귀 (잘못된 진입)
if (session == null) {
LaunchedEffect(Unit) { appState.currentScreen = AppScreen.CLINICAL_HOME }
return
}
// 측정 state
var isMeasuring by remember { mutableStateOf(true) } // 시작 시 자동 측정
var autoCapture by remember { mutableStateOf(true) } // 매 사이클 자동 저장
var cycleCount by remember { mutableIntStateOf(0) }
var capturedCount by remember { mutableIntStateOf(0) }
var lastChannels by remember { mutableStateOf<List<PiezoChannelData>>(emptyList()) }
var lastImu by remember { mutableStateOf<List<ImuSample>>(emptyList()) }
var lastCycleMs by remember { mutableLongStateOf(0L) }
// BLE 콜백 등록 — 측정 사이클 결과 받음
DisposableEffect(Unit) {
bleManager.piezoCollector.onMultiChannelComplete = { channels ->
lastChannels = channels
cycleCount++
lastCycleMs = System.currentTimeMillis()
// 자동 저장 모드면 매 사이클 저장
if (autoCapture && ClinicalSessionStore.currentSession != null) {
val rawADC = channels.sortedBy { it.channel }.map { it.buffer }
AdcCsvLogger.log(rawADC, null)
capturedCount++
}
}
bleManager.imuCollector.onComplete = { imuSamples ->
lastImu = imuSamples
if (autoCapture && ClinicalSessionStore.currentSession != null) {
ImuCsvLogger.log(imuSamples)
}
}
onDispose {
bleManager.piezoCollector.onMultiChannelComplete = null
bleManager.imuCollector.onComplete = null
}
}
// 측정 루프 — 600ms 간격
LaunchedEffect(isMeasuring) {
if (!isMeasuring) return@LaunchedEffect
while (isMeasuring) {
bleManager.sendMtb()
delay(600)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MlBackground)
.verticalScroll(rememberScrollState())
) {
// ── Top bar ──
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = {
ClinicalSessionStore.endMeasurement(context)
appState.currentScreen = AppScreen.CLINICAL_HOME
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MlPrimary)
}
Column(modifier = Modifier.weight(1f)) {
Text("Live · ${session.label}",
fontSize = 14.sp, fontWeight = FontWeight.Bold)
val subj = if (session.subjectId.isNotBlank()) "S=${session.subjectId} · " else ""
val tv = session.trueVolumeMl?.let { "TV=${it.toInt()}mL · " } ?: ""
val ab = session.abdomenThicknessMm?.let { "Ab=${"%.0f".format(it)}mm" } ?: ""
Text("$subj$tv$ab",
fontSize = 11.sp, color = MlSecondaryText)
}
// Cycle counter
Text("#$cycleCount ✓$capturedCount",
fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
// ── 6ch grid (2 rows × 3 cols) ──
SixChannelGrid(
channels = lastChannels,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)
)
Spacer(modifier = Modifier.height(8.dp))
// ── IMU 정보 ──
if (lastImu.isNotEmpty()) {
val posture = remember(lastImu) { ImuAnalysis.estimatePosture(lastImu) }
val tilt = remember(lastImu) { ImuAnalysis.tiltDegrees(lastImu) }
val shake = remember(lastImu) { ImuAnalysis.shakeRmsDps(lastImu) }
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = if (shake > 30f) Color(0xFFFFEBEE) else Color(0xFFE8F5E9)
)
) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("IMU", fontSize = 11.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.width(8.dp))
Text("$posture · tilt=${"%.0f".format(tilt)}° · shake=${"%.1f".format(shake)}dps",
fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
color = if (shake > 30f) Color.Red else Color(0xFF2E7D32))
Spacer(modifier = Modifier.weight(1f))
Text("${lastImu.size} samples", fontSize = 10.sp, color = MlSecondaryText)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
// ── 컨트롤 영역 ──
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Pause/Resume
Button(
onClick = { isMeasuring = !isMeasuring },
modifier = Modifier.weight(1f).height(48.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (isMeasuring) Color(0xFFFF9800) else Color(0xFF4CAF50)
)
) {
Icon(if (isMeasuring) Icons.Default.Stop else Icons.Default.PlayArrow,
null, tint = Color.White)
Spacer(modifier = Modifier.width(4.dp))
Text(if (isMeasuring) "Pause" else "Resume", color = Color.White,
fontWeight = FontWeight.SemiBold)
}
// Manual Capture (자동 OFF일 때만 의미 있음)
Button(
onClick = {
if (lastChannels.isNotEmpty()) {
val rawADC = lastChannels.sortedBy { it.channel }.map { it.buffer }
AdcCsvLogger.log(rawADC, null)
if (lastImu.isNotEmpty()) ImuCsvLogger.log(lastImu)
capturedCount++
Toast.makeText(context, "Captured #${capturedCount}",
Toast.LENGTH_SHORT).show()
}
},
enabled = lastChannels.isNotEmpty(),
modifier = Modifier.weight(1f).height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
) {
Icon(Icons.Default.Save, null, tint = Color.White)
Spacer(modifier = Modifier.width(4.dp))
Text("Capture", color = Color.White, fontWeight = FontWeight.SemiBold)
}
}
Spacer(modifier = Modifier.height(4.dp))
// 자동 캡쳐 토글
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Switch(
checked = autoCapture,
onCheckedChange = { autoCapture = it },
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Auto Capture (매 사이클 자동 저장)",
fontSize = 12.sp, color = MlSecondaryText)
}
Spacer(modifier = Modifier.height(8.dp))
// End Session
Button(
onClick = {
isMeasuring = false
val folder = ClinicalSessionStore.endMeasurement(context)
folder?.let {
Toast.makeText(context, "Saved ($capturedCount cycles): ${it.name}",
Toast.LENGTH_LONG).show()
}
appState.currentScreen = AppScreen.CLINICAL_HOME
},
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.85f))
) {
Text("End Session", color = Color.White, fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.height(8.dp))
Text("Cycle: ~600ms · Captured: $capturedCount · ${ClinicalSessionStore.currentLogDir()?.name ?: ""}",
modifier = Modifier.padding(horizontal = 12.dp),
fontSize = 10.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.height(16.dp))
}
}
/** 2×3 grid 6채널 라이브 차트 */
@Composable
private fun SixChannelGrid(
channels: List<PiezoChannelData>,
modifier: Modifier = Modifier
) {
val channelColors = listOf(
Color(0xFF1976D2), Color(0xFF388E3C), Color(0xFFE64A19),
Color(0xFF7B1FA2), Color(0xFF00838F), Color(0xFFD81B60),
)
Column(modifier = modifier) {
for (row in 0 until 2) {
Row(modifier = Modifier.fillMaxWidth()) {
for (col in 0 until 3) {
val ch = row * 3 + col
val data = channels.find { it.channel == ch }
Box(modifier = Modifier.weight(1f).padding(2.dp)) {
ChannelChart(
channelIdx = ch,
buffer = data?.buffer ?: emptyList(),
color = channelColors[ch],
)
}
}
}
}
}
}
/** 한 채널 그래프 (raw ADC waveform) */
@Composable
private fun ChannelChart(
channelIdx: Int,
buffer: List<UShort>,
color: Color,
) {
Card(
shape = RoundedCornerShape(6.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
modifier = Modifier.fillMaxWidth().height(120.dp)
) {
Column(modifier = Modifier.fillMaxSize().padding(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.clip(RoundedCornerShape(2.dp))
.background(color)
)
Spacer(modifier = Modifier.width(4.dp))
Text("CH$channelIdx", fontSize = 10.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.weight(1f))
if (buffer.isNotEmpty()) {
val peak = buffer.maxOrNull()?.toInt() ?: 0
Text("$peak", fontSize = 9.sp, color = MlSecondaryText)
}
}
Spacer(modifier = Modifier.height(2.dp))
Canvas(modifier = Modifier.fillMaxSize()) {
if (buffer.isEmpty()) return@Canvas
val w = size.width
val h = size.height
val n = buffer.size
if (n < 2) return@Canvas
// y 범위: 0~4095 (12-bit ADC), 일부 여백
val yMin = 0f
val yMax = 4095f
val yScale = h / (yMax - yMin)
val xScale = w / (n - 1).toFloat()
// 가로 grid (4분할)
for (i in 1..3) {
val y = h * i / 4f
drawLine(
Color.LightGray.copy(alpha = 0.3f),
Offset(0f, y), Offset(w, y),
strokeWidth = 0.5f
)
}
// waveform
val path = Path()
for (i in buffer.indices) {
val x = i * xScale
val y = h - (buffer[i].toInt() - yMin) * yScale
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
drawPath(path, color, style = Stroke(width = 1.5f))
}
}
}
}