Welcome to Mankunku

Jazz ear training — call and response. Pick your instrument to get started.

API Reference: Scoring

The scoring system aligns detected notes to expected notes and produces per-note pitch and rhythm accuracy scores.

Source: src/lib/scoring/


alignment.ts

Dynamic Time Warping (DTW) alignment of detected notes to expected notes.

alignNotes(expected, detected, tempo, swing?): AlignmentPair[]

Find the minimum-cost alignment between two note sequences.

ParameterTypeDefaultDescription
expectedNote[]Notes from the phrase (rests are filtered out internally)
detectedDetectedNote[]Notes captured from microphone
temponumberBPM for converting offsets to seconds
swingnumber0.5Swing ratio; shifts expected off-beat 8ths in the cost function to match swing playback

Returns: AlignmentPair[] where each pair is one of:

  • { expectedIndex, detectedIndex, cost } — matched pair
  • { expectedIndex, detectedIndex: null, cost } — missed note
  • { expectedIndex: null, detectedIndex, cost } — extra note

Cost function:

Match typeCost
Same MIDI note0.0 pitch + rhythm distance
1 semitone off0.5 pitch
2+ semitones off1.0 pitch (capped)
Skip (missed/extra)2.0 flat penalty
Rhythm|expectedOnset - detectedOnset| / beatDuration (capped at 1.0)

pitch-scoring.ts

Per-note pitch accuracy scoring.

scorePitch(expected, detected): number

CaseScore
Rest1.0
Wrong MIDI note0.0
Correct MIDI note1.0 + intonation bonus

Intonation bonus: 0.1 * max(0, 1 - |cents| / 50)

  • 0 cents: +0.10 (total 1.10)
  • 25 cents: +0.05 (total 1.05)
  • 50 cents: +0.00 (total 1.00)

The bonus is clamped to 1.0 at the composite score level.


rhythm-scoring.ts

Per-note rhythm accuracy scoring.

scoreRhythm(expected, detected, tempo, swing?): number

ParameterTypeDefaultDescription
expectedNoteExpected note from phrase
detectedDetectedNoteDetected note from mic
temponumberBPM for timing conversion
swingnumber0.5Swing ratio (0.5 = straight, 0.67 = triplet, 0.8 = heavy)
timingError = |detectedOnset - expectedOnset| / beatDuration
penalty     = min(1.0, 0.5 + tempo / 300)
rhythmScore = max(0, 1.0 - timingError * penalty)

The penalty is tempo-scaled: gentler at slow tempos (where a given absolute timing error is a smaller fraction of a beat) and tighter at fast tempos.

TempoPenalty0-score threshold
60 BPM0.70~1.43 beats off (~1430 ms)
100 BPM0.83~1.20 beats off (~720 ms)
200 BPM1.001.00 beat off (300 ms)

Swing awareness: When swing > 0.5 and the expected note falls on an off-beat eighth, the expected onset is adjusted to match swing playback timing.

Rests: If expected.pitch is null, the score is 1.0.


scorer.ts

Orchestrates the full scoring pipeline.

scoreAttempt(phrase, detected, tempo, transportSeconds?, swing?): Score

ParameterTypeDefaultDescription
phrasePhraseThe expected phrase
detectedDetectedNote[]Detected notes from mic
temponumberBPM used during the attempt
transportSecondsnumber0Transport position when recording started
swingnumber0.5Swing ratio for rhythm scoring adjustment

Pipeline:

  1. Grid anchoring — Snap detected onsets to the nearest bar downbeat using Transport position
  2. DTW alignmentalignNotes() matches detected to expected
  3. Latency correction — Compute median timing offset of matched pairs and subtract from all detected onsets (absorbs ~100–300ms constant delay)
  4. Per-note scoringscorePitch() and scoreRhythm() for each matched pair
  5. Composite scoreoverall = pitchAccuracy * 0.6 + rhythmAccuracy * 0.4
  6. Grade assignmentscoreToGrade(overall)

Returns: Score object:

{
  pitchAccuracy: number;       // 0-1, average of pitch scores
  rhythmAccuracy: number;      // 0-1, average of rhythm scores
  overall: number;             // 0-1, weighted composite
  grade: Grade;                // 'perfect' | 'great' | 'good' | 'fair' | 'try-again'
  noteResults: NoteResult[];   // Per-note breakdown
  notesHit: number;            // Count of correct pitches
  notesTotal: number;          // Total expected notes
  timing: TimingDiagnostics;   // Offset statistics after latency correction
}

TimingDiagnostics:

interface TimingDiagnostics {
  meanOffsetMs: number;                // Average signed offset after correction
  medianOffsetMs: number;
  stdDevMs: number;                    // Rhythmic consistency
  latencyCorrectionMs: number;         // Median offset that was subtracted
  perNoteOffsetMs: (number | null)[];  // null = missed / extra
}

grades.ts

Score-to-grade mapping and display constants.

scoreToGrade(overall): Grade

GradeThreshold
'perfect'>= 95%
'great'>= 85%
'good'>= 70%
'fair'>= 55%
'try-again'< 55%

GRADE_LABELS: Record

Display labels: 'Perfect', 'Great', 'Good', 'Fair', 'Try Again'.

GRADE_COLORS: Record

CSS color variables: perfect/great--color-success, good--color-accent, fair--color-warning, try-again--color-error.