diff --git a/app/src/main/java/com/example/medilightv2android/AppState.kt b/app/src/main/java/com/example/medilightv2android/AppState.kt index 6dc103f..26822e6 100644 --- a/app/src/main/java/com/example/medilightv2android/AppState.kt +++ b/app/src/main/java/com/example/medilightv2android/AppState.kt @@ -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) { diff --git a/app/src/main/java/com/example/medilightv2android/MainActivity.kt b/app/src/main/java/com/example/medilightv2android/MainActivity.kt index 3d33f77..b2a88a4 100644 --- a/app/src/main/java/com/example/medilightv2android/MainActivity.kt +++ b/app/src/main/java/com/example/medilightv2android/MainActivity.kt @@ -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) } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/medilightv2android/models/ClinicalSession.kt b/app/src/main/java/com/example/medilightv2android/models/ClinicalSession.kt index 1fad5bf..5b1a2e7 100644 --- a/app/src/main/java/com/example/medilightv2android/models/ClinicalSession.kt +++ b/app/src/main/java/com/example/medilightv2android/models/ClinicalSession.kt @@ -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 diff --git a/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt b/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt index fbac0b6..7a24992 100644 --- a/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt +++ b/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt @@ -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))) diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt index 9c9576f..e60ebd8 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt @@ -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) } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalLiveView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalLiveView.kt new file mode 100644 index 0000000..c989b46 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalLiveView.kt @@ -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>(emptyList()) } + var lastImu by remember { mutableStateOf>(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, + 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, + 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)) + } + } + } +}