Practice Tracker
Build a practice app that tracks singing scores over time and shows improvement.
What You'll Build
A progress tracking system:
- Record practice sessions with scores
- Show score history per song/segment
- Visualize improvement over time
- Set and track goals
Data Models
data class PracticeSession(
val id: String = UUID.randomUUID().toString(),
val songId: String,
val timestamp: Long = System.currentTimeMillis(),
val segmentScores: List<SegmentScore>,
val overallScore: Float
)
data class SegmentScore(
val segmentIndex: Int,
val score: Float,
val attemptNumber: Int
)
data class Song(
val id: String,
val title: String,
val artist: String,
val segments: List<SegmentInfo>
)
data class SegmentInfo(
val index: Int,
val name: String, // "Verse 1", "Chorus", etc.
val difficulty: Difficulty
)
enum class Difficulty { EASY, MEDIUM, HARD }
data class Goal(
val id: String = UUID.randomUUID().toString(),
val songId: String,
val targetScore: Float,
val deadline: Long?,
val achieved: Boolean = false
)
Repository
class PracticeRepository(private val database: AppDatabase) {
// Save practice session
suspend fun savePracticeSession(session: PracticeSession) {
database.practiceDao().insert(session.toEntity())
}
// Get sessions for a song
suspend fun getSessionsForSong(songId: String): List<PracticeSession> {
return database.practiceDao().getBySongId(songId).map { it.toModel() }
}
// Get score history for a specific segment
suspend fun getSegmentHistory(songId: String, segmentIndex: Int): List<Float> {
return getSessionsForSong(songId)
.flatMap { it.segmentScores }
.filter { it.segmentIndex == segmentIndex }
.map { it.score }
}
// Get best score for a song
suspend fun getBestScore(songId: String): Float? {
return getSessionsForSong(songId).maxOfOrNull { it.overallScore }
}
// Get average score over last N sessions
suspend fun getRecentAverage(songId: String, count: Int = 5): Float? {
return getSessionsForSong(songId)
.sortedByDescending { it.timestamp }
.take(count)
.map { it.overallScore }
.average()
.takeIf { !it.isNaN() }
?.toFloat()
}
// Get improvement trend (positive = improving)
suspend fun getImprovementTrend(songId: String): Float {
val sessions = getSessionsForSong(songId)
.sortedBy { it.timestamp }
.takeLast(10)
if (sessions.size < 2) return 0f
val firstHalf = sessions.take(sessions.size / 2).map { it.overallScore }.average()
val secondHalf = sessions.takeLast(sessions.size / 2).map { it.overallScore }.average()
return (secondHalf - firstHalf).toFloat()
}
}
ViewModel
class PracticeTrackerViewModel(
private val repository: PracticeRepository
) : ViewModel() {
private val _state = MutableStateFlow(TrackerState())
val state: StateFlow<TrackerState> = _state.asStateFlow()
data class TrackerState(
val songs: List<SongProgress> = emptyList(),
val selectedSong: SongProgress? = null,
val goals: List<Goal> = emptyList()
)
data class SongProgress(
val song: Song,
val bestScore: Float?,
val recentAverage: Float?,
val trend: Float,
val practiceCount: Int,
val segmentProgress: List<SegmentProgress>
)
data class SegmentProgress(
val segment: SegmentInfo,
val bestScore: Float?,
val recentAverage: Float?,
val history: List<Float>
)
fun loadSongProgress(songId: String) {
viewModelScope.launch {
val song = repository.getSong(songId)
val sessions = repository.getSessionsForSong(songId)
val segmentProgress = song.segments.map { segment ->
val history = repository.getSegmentHistory(songId, segment.index)
SegmentProgress(
segment = segment,
bestScore = history.maxOrNull(),
recentAverage = history.takeLast(5).average().toFloat(),
history = history
)
}
val progress = SongProgress(
song = song,
bestScore = repository.getBestScore(songId),
recentAverage = repository.getRecentAverage(songId),
trend = repository.getImprovementTrend(songId),
practiceCount = sessions.size,
segmentProgress = segmentProgress
)
_state.update { it.copy(selectedSong = progress) }
}
}
fun recordPractice(
songId: String,
segmentResults: List<SegmentResult>
) {
viewModelScope.launch {
val session = PracticeSession(
songId = songId,
segmentScores = segmentResults.map {
SegmentScore(
segmentIndex = it.segment.index,
score = it.score,
attemptNumber = it.attemptNumber
)
},
overallScore = segmentResults.map { it.score }.average().toFloat()
)
repository.savePracticeSession(session)
// Check goals
checkGoals(songId, session.overallScore)
// Refresh progress
loadSongProgress(songId)
}
}
private suspend fun checkGoals(songId: String, score: Float) {
val goals = repository.getGoalsForSong(songId)
.filter { !it.achieved && score >= it.targetScore }
goals.forEach { goal ->
repository.markGoalAchieved(goal.id)
// Notify user
}
}
}
UI Components
Progress Dashboard
@Composable
fun ProgressDashboard(progress: SongProgress) {
Column(modifier = Modifier.padding(16.dp)) {
// Overall stats
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatCard(
label = "Best",
value = "${((progress.bestScore ?: 0f) * 100).toInt()}%"
)
StatCard(
label = "Average",
value = "${((progress.recentAverage ?: 0f) * 100).toInt()}%"
)
StatCard(
label = "Practices",
value = progress.practiceCount.toString()
)
}
Spacer(modifier = Modifier.height(16.dp))
// Trend indicator
TrendIndicator(trend = progress.trend)
Spacer(modifier = Modifier.height(24.dp))
// Segment breakdown
Text("Segment Progress", style = MaterialTheme.typography.titleMedium)
progress.segmentProgress.forEach { segment ->
SegmentProgressRow(segment)
}
}
}
@Composable
fun TrendIndicator(trend: Float) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Icon(
imageVector = when {
trend > 0.05f -> Icons.Default.TrendingUp
trend < -0.05f -> Icons.Default.TrendingDown
else -> Icons.Default.TrendingFlat
},
contentDescription = null,
tint = when {
trend > 0.05f -> Color.Green
trend < -0.05f -> Color.Red
else -> Color.Gray
}
)
Text(
text = when {
trend > 0.05f -> "Improving!"
trend < -0.05f -> "Keep practicing"
else -> "Steady"
},
modifier = Modifier.padding(start = 8.dp)
)
}
}
@Composable
fun SegmentProgressRow(progress: SegmentProgress) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = progress.segment.name,
modifier = Modifier.weight(1f)
)
// Mini sparkline
HistorySparkline(
history = progress.history,
modifier = Modifier.width(60.dp).height(24.dp)
)
// Best score
Text(
text = "${((progress.bestScore ?: 0f) * 100).toInt()}%",
fontWeight = FontWeight.Bold,
modifier = Modifier.width(50.dp)
)
}
}
History Chart
@Composable
fun HistoryChart(sessions: List<PracticeSession>, modifier: Modifier = Modifier) {
Canvas(modifier = modifier) {
if (sessions.isEmpty()) return@Canvas
val width = size.width
val height = size.height
val padding = 16f
val maxScore = 1f
val minScore = 0f
val points = sessions.mapIndexed { index, session ->
val x = padding + (index.toFloat() / (sessions.size - 1).coerceAtLeast(1)) * (width - 2 * padding)
val y = height - padding - (session.overallScore / maxScore) * (height - 2 * padding)
Offset(x, y)
}
// Draw line
if (points.size > 1) {
val path = Path().apply {
moveTo(points.first().x, points.first().y)
points.drop(1).forEach { lineTo(it.x, it.y) }
}
drawPath(path, Color.Blue, style = Stroke(width = 3f))
}
// Draw points
points.forEach { point ->
drawCircle(Color.Blue, radius = 6f, center = point)
}
}
}
Integration with CalibraLiveEval
class PracticeSession(
private val viewModel: PracticeTrackerViewModel,
private val song: Song
) {
private var session: CalibraLiveEval? = null
private val results = mutableListOf<SegmentResult>()
suspend fun start(player: SonixPlayer, recorder: SonixRecorder) {
val detector = CalibraPitch.createDetector()
session = CalibraLiveEval.create(
reference = song.lessonMaterial,
detector = detector,
player = player,
recorder = recorder
)
session?.prepareSession()
// Collect segment results
session?.onSegmentComplete { result ->
results.add(result)
}
// When session completes, save to tracker
session?.onSessionComplete {
viewModel.recordPractice(song.id, results)
}
// Start first segment
session?.startPracticingSegment(0)
}
fun close() {
session?.closeSession()
}
}
Next Steps
- Live Evaluation Guide - Scoring details
- Karaoke App Recipe - Full karaoke implementation
- Demo App - Full source