ThaaLam architecture

This document explains how a settings change turns into a click at an exact sample frame, how talas map onto the same engine, and where the platform boundaries are.

1. Layers

UI (Compose / SwiftUI)
  │ user intent
  ▼
Controller / ViewModel            (Android: MetronomeController, iOS: MetronomeViewModel)
  │ immutable MetronomeSettings
  ▼
Shared domain                     (Kotlin, no platform imports)
  ├── MetronomeSettings.normalized()
  ├── RhythmPattern.build(settings)
  ├── PracticeLogic (gap, mute, ramp)
  └── TempoMath (BPM ↔ frames)
  │ plain pattern + settings snapshot
  ▼
Platform audio engine             (AudioTrack / AVAudioSourceNode)
  │ sample-accurate click events + UI tick snapshots
  ▼
UI beat map, haptics, flash, notification

The shared module never references Android or Apple frameworks. The audio engines never call back into musical domain logic while rendering: the pattern is precomputed on the main thread, and the render path only walks an array of steps.

2. The settings snapshot

MetronomeSettings is an immutable, serializable value:

Field Meaning
bpm 10–400, fractional allowed
meter pulses per bar, beat unit, additive grouping
subdivision ticks per pulse (1–9)
accentPattern one BeatKind per pulse: accent / normal / ghost / mute
layers up to three additional voices: pulses, cycle, accents, sound, gain, offset, step offsets, swing, solo
swingPercent 50–75, applied to odd subdivisions of even grids
soundSet click timbre
volume master gain
countInBars 0–4
gapPlayBars, gapSilentBars legacy deterministic gap trainer
gapPattern, gapFadeBars, gapMaxSilentBars, gapKeepSam Gap 2.0 custom bar pattern, fade, repair limit and optional sam protection
muteProbability, protectFirstBeat stochastic muting
rampEnabled, rampTargetBpm, rampStepBpm, rampEveryBars tempo automation
talaId provenance for UI display and presets
talaPlayback kalam (1/2/4×), kalai (1/2) and eduppu phase for the active tala

effectiveBpm = bpm × kalam is the performance akshara rate; it is derived, not stored, so 2× and 4× never overwrite the base tempo. effectiveTicksPerBeat = subdivision × kalai is what the primary grid builder uses.

Every mutation goes through normalized(), so range checks and pattern resizing exist in one place. Swift uses SettingsMutations rather than Kotlin’s all-arguments copy().

3. The click grid

RhythmPattern.build(settings) produces one bar:

for beat in 0 until meter.beatsPerBar:
    for sub in 0 until settings.effectiveTicksPerBeat:
        offset = beat + sub / ticks
        if swing applies and sub is odd:
            offset += (swingRatio - 0.5) * 2 / ticks
        kind = if (sub == 0) accentPattern[beat] else NORMAL

The result is sorted by offset and flattened. gapBeatsAfter(i) returns the distance to the next step, wrapping to the next bar for the last step. Swing is only applied when the grid is evenly divisible (ticks % 2 == 0); triplets, quintuplets and septuplets are already tuplet grids and stay straight.

Accent edits apply live. Accent is resolved from the current settings at tick time (settings.accentPattern[step.beat]), not baked into the precomputed pattern, so tapping a pulse never restarts the bar. Meter, subdivision, kalai, eduppu and swing changes do rebuild the grid and restart it from the current buffer. Kalam is deliberately excluded from gridKey because it only changes the tempo applied to the same grid.

Kalai multiplies the tick count (subdivision × kalai), so 2-kalai adds a second click inside every akshara. Eduppu rotates the compiled step list by the requested phase and leaves leadInBeats before the first click; the cycle length and pulse identities are preserved. gapBeatsAfter includes that lead-in when wrapping, so an eduppu that falls between two pulses stays on the frame grid.

Layers

LayerCompiler.compile(settings) turns the primary grid and every enabled LayerSettings into a list of VoicePattern values. A layer is defined by pulses evenly spaced across cycleBeats:

  • a subdivision mixer layer uses pulses = beats × ticks over one bar;
  • a polyrhythm layer uses a different pulse count over the primary cycle;
  • a polymeter layer uses its own cycle length, so the cycles realign at their least common multiple.

The primary voice keeps the familiar pulse/beat metadata for the UI. Layer steps carry a pulse index for live accent resolution and an offsetMs that already includes the layer offset plus that pulse’s micro-timing. gridKey changes only when the grid itself changes, so gain, sound, offset, step-timing and accent edits update the running voice without restarting it.

When any enabled layer is soloed, LayerCompiler.compile() keeps the primary pulse and only the soloed layers. The primary remains the reference so soloing a layer never removes the downbeat.

4. Android render loop

MetronomeAudioEngine owns a Thread:

Process.setThreadPriority(URGENT_AUDIO)
AudioTrack(48 kHz, mono, PCM_FLOAT, LOW_LATENCY, MODE_STREAM)
buffer = FloatArray(256)
framePosition = 0
barIndex = 1
runtimes[4] = primary + up to three layer cursors
pendingBarAdvance = false

Per iteration:

  1. Read the latest immutable EngineConfig (volatile reference).
  2. Diff each voice by its stable id and gridKey. A changed grid resets that voice from the current frame; accent, gain, sound and offset changes only replace the pattern snapshot.
  3. Repeatedly pick the voice whose next tick is earliest inside the buffer:
    • advance the primary bar only when its next tick is due (layers between the last tick of a bar and the next downbeat still belong to the old bar);
    • at the last primary step, compute the bar-line frame and schedule the next pattern at boundary + nextPattern.leadInBeats; this keeps a count-in → musical-bar transition exact even when eduppu gives the two grids different first-click offsets;
    • resolve the live accent, gesture and practice gates;
    • trigger ClickSynth at the exact sample offset if audible;
    • emit an EngineTick for the primary voice, including its anga gesture;
    • advance that voice’s step and nextTickFrame += gapBeats * framesPerBeat.

Kalam changes arrive as a new EngineConfig.bpm; because the grid key is unchanged, the running step cursor and bar position are preserved and only the frame interval changes. A pending bar line is re-timed with the new tempo.

  1. Render all active click voices into the buffer, clipping to [-1, 1].
  2. Block-write the buffer; advance framePosition by 256.

Count-in uses the primary voice only. Layers advance silently through the count-in so they enter in phase on the first musical bar. Gap bars mute every voice; stochastic muting applies to the primary voice. The clock always advances.

The engine is app-scoped through MetronomeController, so rotation and Activity recreation do not interrupt playback. The foreground service only keeps the process alive and exposes a transport notification; it does not own the audio thread.

Click synthesis

ClickSynth keeps thirty-two voices. Each voice has a start delay, phase, harmonic phase, exponential envelope, noise amount and square/sine flag. When a bell tail is longer than the buffer, remaining and the envelope carry into the next buffer. New clicks are placed at startFrame, so a click scheduled for frame 180 is mixed at frame 180 rather than at the start of the buffer.

Sound presets are parameter tables (base frequency, harmonic ratio, decay, noise, waveform). Accents raise pitch and gain; subdivisions lower gain and shorten decay; ghost notes are softer. An anga gesture adds a final colour: clap adds noise, finger count is brighter and shorter, wave is lower and longer, tali is a slightly darker clap, and khali is quieter. No sample assets, no resampling latency.

5. iOS render loop

MetronomeAudioEngine attaches an AVAudioSourceNode to an AVAudioEngine. Its render block runs on the real-time audio thread and uses the same algorithm as Android, with plain Swift structs:

  • EngineVoice mirrors one compiled VoicePattern (steps, gaps, live accents, sound, gain, frame offset and grid key).
  • EngineConfig holds the primary voice, count-in voice, layer voices, the kalam-scaled BPM and practice settings. A monotonic revision lets the render thread detect a new snapshot without comparing arrays.
  • RenderState owns the frame position, one VoiceRuntime per voice, the bar index and the LCG. pendingBarAdvance moves the primary bar only when its next tick is due.
  • Configuration updates happen on the main thread and replace the state inside an OSAllocatedUnfairLock; the render block takes a snapshot at buffer start.
  • The latest tick is stored in a lock-protected EngineTickSnapshot. A 60 Hz main-thread timer publishes it to @Published properties; the audio thread never performs UI work or dispatch allocations.

ClickSynth mirrors the Android voice model with the same parameter tables and a thirty-two voice pool. No Kotlin call is made inside the render callback.

The audio session is .playback with .mixWithOthers, and Info.plist declares UIBackgroundModes: [audio]. Playback stops on an audio interruption (AVAudioSession.interruptionNotification).

6. Practice gating

For each tick the engines evaluate, in order:

if count-in bar:              no gap / no stochastic mute
else if gapSilentBar:
    if gapKeepSam && primary sam: play softly (0.45 gain)
    else:                       mute every voice (clock still advances)
else: apply gap fade gain
     if first pulse && protect: audibly play
     else if random < probability: mute
     else: play
  • PracticeLogic.isGapSilentBar(bar, play, silent) uses 1-based musical bars.
  • PracticeLogic.shouldMuteTick receives a deterministic LCG value from the engine.
  • PracticeLogic.bpmForBar computes the stepped ramp at bar boundaries.
  • PracticeLogic.gapCycle, isGapSilentBar(bar, settings) and gapGainForBar implement Gap 2.0. A custom pattern is a bounded list of play/gap booleans; consecutive gaps are repaired at the configured maximum. The fade returns a gain for the last play bars before a gap, and the engines multiply the click volume by it.
  • On a silent bar, gapKeepSam lets the primary sam click through at GAP_SAM_GAIN (0.45) so the player can find the downbeat again. The next full play bar re-enters on its first beat.

The count-in uses a quarter-note version of the same bar and accent pattern, and is announced separately to the UI (isCountIn, countInBarsRemaining). The musical bar counter starts at 1 only after count-in.

Layer rules:

  • Count-in voices only the primary; layers advance silently so they enter in phase on the first musical bar.
  • Gap bars mute every voice, except the optional protected sam on the primary voice. Stochastic muting applies to the primary voice.
  • A new layer starts at the current frame, never at frame zero, so adding a voice mid-performance cannot emit a burst of catch-up clicks.
  • Removing or disabling a layer drops its runtime; re-enabling starts a fresh phase from the next buffer.

7. Tala compilation

TalaApplication.apply(tala, base, nadai, playback) produces ordinary settings:

meter.beatsPerBar = tala.aksharas
meter.grouping    = tala.angas.map { it.length }
accentPattern     = tala.beatKinds
subdivision       = nadai (or quarter)
talaId            = tala.id
talaPlayback      = kalam, kalai, eduppu

Kalam, kalai and eduppu are kept on the settings value rather than folded into bpm. That gives the UI a stable base tempo, a live effective-BPM readout and a grid that can be rotated without changing the underlying cycle.

Tala.beatKinds walks the angas:

  • every anga start is ACCENT;
  • if the anga marker is "0" (khali), that start is GHOST;
  • all other aksharas are NORMAL.

TalaLibrary generates the 35 Suladi talas from the seven canonical anga shapes and five jathis (laghu length 3, 4, 5, 7, 9), then adds the Chapu and Hindustani cycles. Hindustani vibhags carry explicit markers (X, 2, 3, 0), and theka syllables are stored one per akshara. The catalogue is data-only and unit-tested.

Nadai is mapped to the ordinary subdivision enum:

Nadai Ticks per akshara
Tisra 3
Chatusra 4
Khanda 5
Misra 7
Sankeerna 9

Kalam, kalai and eduppu

  • Kalam multiplies the akshara rate (effectiveBpm). The base bpm is unchanged, and kalam is not part of primaryGridKey, so a live change does not restart the bar.
  • Kalai multiplies the primary tick count. The count-in deliberately keeps one click per akshara so the count stays unambiguous; the main grid may have more clicks than the count-in, which the bar-line re-timing handles.
  • Eduppu is a phase in aksharas, snapped to 0.25. Negative is atita (before sam), positive is anagata (after sam). LayerCompiler.rotateForEduppu rotates the compiled steps and leadInBeats holds the gap before the first click. The count-in is rotated by the same phase, so its final click lands immediately before the first musical click.

Anga gestures

Tala.gestures derives one gesture per akshara:

  • laghu: clap then finger counts;
  • dhrutam: clap then wave;
  • anudhrutam: clap;
  • chapu: clap at the group start then finger counts;
  • Hindustani vibhag: tali at X/tali markers, khali at 0, no gesture inside the vibhag (the theka fills those aksharas).

VoiceStep.gesture carries the value into the compiled grid. The engines pass it to ClickSynth, and the UI shows the same symbol/label on the pulse map and tala cycle. Gestures are resolved from the tala when the primary grid is compiled, so they do not affect accent edits or the grid key.

8. Practice intelligence

The practice log is independent of the metronome settings and preset stores. It uses the shared thaalam.practice.v1 JSON key and is capped at 500 sessions.

PracticeSession stores a snapshot, not a reference to live settings:

id, startedAtEpochMs, endedAtEpochMs, durationMs
startBpm, endBpm, effectiveStartBpm, effectiveEndBpm
meterLabel, groupingLabel, talaId, talaName, presetName
layerCount, countInBars, gapEnabled, gapPattern, rampEnabled,
muteProbability, kalam, kalai, tap

PracticeFactory.session() derives the musical context from two settings snapshots (start and stop), so Android and iOS cannot produce different labels. The platforms supply monotonic duration plus epoch timestamps; a killed process does not retroactively invent a session.

PracticeStatsCalculator computes today/weekly/all-time minutes, a seven-day strip, streak and best tempo per tala or preset. It converts epoch timestamps to the supplied time zone, splits a session that crosses local midnight, and counts a streak from today when there are minutes today, otherwise from yesterday so a streak does not disappear before the day ends.

TapGrader matches a tap to the nearest unused target within 250 ms and records the signed offset. Targets are added from main-beat engine ticks, not UI animation:

  • Android: EngineTick.hostTimeMs is SystemClock.elapsedRealtime(); output latency is estimated from PROPERTY_OUTPUT_FRAMES_PER_BUFFER and PROPERTY_OUTPUT_SAMPLE_RATE; a user calibration is added.
  • iOS: hostTimeMs comes from CACurrentMediaTime() in the render callback; AVAudioSession.outputLatency and the calibration are added.
  • A lock protects the Android grader because targets arrive on the audio thread while taps arrive on the UI thread. The render path uses tryLock and drops a target rather than blocking.

9. Persistence

Both platforms store JSON produced by the shared PresetCodec and PracticeCodec:

  • settings: MetronomeSettings.serializer()
  • presets: List<MetronomePreset>
  • practice log and goals: PracticeData.serializer()

Android uses SharedPreferences; iOS uses UserDefaults. The keys are single-purpose strings (thaalam.settings.v1, thaalam.presets.v1, thaalam.practice.v1). Unknown JSON fields are ignored and every decoded value is normalized, so a future schema can add fields without breaking old installs. Invalid JSON falls back to defaults rather than crashing.

10. UI state flow

Android:

MetronomeAudioEngine.onTick ──▶ MetronomeController._playback (StateFlow)
                                      │
                                      ▼
                              MainViewModel ──▶ Compose

iOS:

AVAudioSourceNode render ──▶ EngineTickSnapshot (lock)
                                      │ 60 Hz poll
                                      ▼
                            MetronomeViewModel @Published ──▶ SwiftUI

visualPlayback is a second state stream delayed by the calibration value (0–300 ms). Audio scheduling is never delayed; only the visual pulse is, so a Bluetooth listener sees the flash when they hear the click.

11. Testing strategy

  • Shared unit tests cover meter normalization, primary and layer pattern offsets and swing, polyrhythm/polymeter compilation, kalam/kalai/eduppu compilation and long-run drift, gesture mapping, a deterministic frame-level timing simulator, gap/stochastic/ramp logic, Gap 2.0 patterns/fade/sam protection, practice stats across midnight and time zones, goal/streak calculation, tap matching and summaries, practice JSON/CSV round-trips, tap tempo, preset round-trips including layers and tala playback, all 35 Suladi talas, khali markers, theka lengths, and the Swift interop mutations.
  • Android lint runs as a build gate; the app currently has warnings only.
  • Manual device/simulator checks verify layout, transport, tala playback, adding/removing layers while playing, background service, and light/dark rendering.
  • A hardware latency loopback test is still recommended before release, since emulator and simulator audio paths are not representative of real output latency.

12. Known limitations

  • Polyrhythm, polymeter and the subdivision mixer are implemented. Chained mixed-meter songs and setlists are not yet implemented; the engine and tala compiler are ready to feed a song scheduler.
  • Kalam, kalai, eduppu and anga gestures are implemented. The 108 Ashtottara talas are deferred until a verified published list and a practitioner review are available; the tala catalogue is data-only and can accept them later.
  • Layer solo and per-pulse timing are implemented; the composite view shows each voice’s own cycle rather than a single shared LCM ring.
  • No MIDI, Ableton Link or network sync.
  • No practice log or microphone-based rhythm grading.
  • No custom click sample import; the five synthesized timbres are built in.
  • The practice log is written on stop; a force-killed process loses the active session. This is deliberate: inventing an end time would corrupt the history.
  • Tap targets depend on the platform output-latency estimate plus user calibration. Android caches the estimate at controller start, so a route change (speaker → Bluetooth) needs a fresh calibration; iOS queries outputLatency on each target. A hardware loopback calibration is still recommended before treating absolute milliseconds as authoritative.
  • Tap targets are registered only for audible main beats, so silent gap bars and muted pulses do not become invisible targets.
  • Count-in voice and the microphone coach are not implemented. The microphone spike and its no-go criteria are documented in docs/microphone-spike.md.
  • Background behaviour on iOS depends on the audio background mode and the session being active; a full device test is required before App Store release.
  • The iOS UI uses fixed-size type for the stage-oriented layout; a full Dynamic Type audit is a good next accessibility task.