Architecture

Framework layout

Murmur Studio ships as a single App Store binary that links one free open-source framework and three paid extension frameworks. The split is source distribution, not binary distribution: the single Murmur Pro in-app purchase unlocks all paid frameworks at runtime.

┌─────────────────────────────────────────────────────────────────┐
│  Murmur (app target — slim launcher, sandboxed)                 │
│    MurmurApp.swift, Info.plist, Assets.xcassets (AppIcon)       │
└─────────────────────────────────────────────────────────────────┘
                              │ links
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  MurmurCore  (free, MIT, open-source — this repo)               │
│    Data Engine + Waveform Canvas + Control Overlay              │
│    FindingProducer protocol  ←─── the seam paid frameworks use  │
│    SyntheticFindingProducer  (baseline impl, ships free)        │
└─────────────────────────────────────────────────────────────────┘
                              │ depended on by (private frameworks)
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  MurmurAnnotation  (annotation authoring)      ┐                │
│  MurmurMetrics     (ECG metrics, intervals)    ├─ Murmur Pro    │
│  MurmurInference   (VT/VF detection)           ┘                │
│    Each conforms to FindingProducer.                            │
│    Source lives in the private kvnlng/Murmur-Extensions repo.   │
└─────────────────────────────────────────────────────────────────┘

The three paid framework targets aren’t in this repo — they live in the private kvnlng/Murmur-Extensions repo and pull MurmurCore in via SPM. Their source isn’t public; their behaviour is part of the App Store distribution.

This document covers MurmurCore, which is what you’re reading the source of.

Extension contract: FindingProducer

Every framework that examines a Recording and emits [Annotation] conforms to the FindingProducer protocol in MurmurCore. The host (BedsideView / FindingsPanel) discovers conformers via ProducerRegistry and drives them through a uniform UI surface — progress bar, cancel button, error reporting — regardless of whether the producer is a deterministic Swift port, a Core ML model, or a synthetic fixture.

protocol FindingProducer: Sendable {
    var id: String { get }            // → annotations[].source
    var displayName: String { get }
    func analyze(_ recording: Recording) -> AsyncThrowingStream<ProducerEvent, Error>
}

enum ProducerEvent {
    case progress(ProgressUpdate)
    case findings([Annotation])
    case warning(message: String, underlying: Error?)
}

Key design points:

  • Async stream, not single-value return. The output is AsyncThrowingStream<ProducerEvent> so progress + partial findings
    • per-window warnings can interleave. The host accumulates findings across multiple .findings events and shows the determinate progress bar from .progress events.
  • Cancellation lives in Task. Consumers cancel by exiting the for try await loop or by cancelling the parent task; producers honor it by calling try Task.checkCancellation() on window boundaries.
  • Partial results survive per-window failures. A failed window emits .warning(...); the run continues. Only a fully-irrecoverable state throws and terminates the stream.
  • Confidence is the producer’s responsibility. Annotation.confidence is documented as already-calibrated (Platt-scaled or equivalent), so hosts treat it as comparable across producers.
  • Entitlement gating is at the call site. The registry doesn’t know about purchases — the host filters registry.all() against the Murmur Pro entitlement in PurchaseStore before exposing producers to the UI.

The free viewer registers SyntheticFindingProducer (a deterministic LCG-seeded fixture) at launch so the producer pipeline is exercised end-to-end even when no paid framework is installed. Paid frameworks register their own conformances from their entry point on framework load.

Inside MurmurCore

Three layers, all independently testable.

1. Data Engine

Owns disk → memory → GPU.

Component Role
WFDBHeaderParser Reads .hea files. Honors the per-signal format[xspf] suffix so a record can mix high-rate ECG (16x250) with 1-min feature signals (16x1) at one base frame rate.
WFDBSampleDecoder Format-16 and format-212 decoder. Groups signals by .dat filename — single-file records and per-signal-file records both work.
WFDBImporter Folder → bundle pipeline. Validates every .dat, decodes per signal, builds pyramids, writes manifest. Multi-frequency record support means low-rate trend channels share a record with the ECG signals.
BinaryRecordingFile Float32-packed channel file format. Header v2 (64 bytes) + Float32 sample body, little-endian.
MappedSampleAccess mmap-backed reader. Data(contentsOf:options:.mappedIfSafe).
PyramidBuilder Single-pass cascading min/max bins. Stride 10, up to 6 levels.
PyramidLevelFile (min, max) Float64 pair binary format with its own mmap reader.
ChannelView LOD-aware reader. selectLevel(samplesPerPixel:) returns the deepest level that fits.
RecordingViewport @Observable @MainActor shared time window for every channel in a Recording.

Why a pyramid

Drawing the trace from raw samples is fine at high zoom (10 s @ 250 Hz = 2500 vertices) but ruinous at low zoom (30 min @ 360 Hz = 650 000). The pyramid pre-computes min/max envelopes at 10×, 100×, 1000×, … strides at import time. At render time the LOD selector picks the deepest level whose binSamples ≤ samplesPerPixel, then renders ~one quad per pixel regardless of recording length.

Viewport invariants

RecordingViewport is the single mutable time-window state. Every channel in a recording observes it. All mutators (pan, setStart, setWidth, jump) clamp to recording bounds and respect a 100 ms minimum window so the user can’t accidentally zoom into a 1-sample slice.

2. Waveform Canvas

Pure Metal, no Swift Charts.

Component Role
WaveformCanvas NSViewRepresentable over MTKView.
WaveformRenderer MTKViewDelegate. Owns pipelines, buffers, draw loop.
WaveformShaders.metal Vertex/fragment functions.

Per-frame render

  1. Clear to paper color.
  2. Range annotations — translucent quads, one bucket per category.
  3. Grid minor — line list, salmon @ 65% alpha.
  4. Grid major — line list, red-pink @ 55% alpha.
  5. Grid landmark — line list, deep salmon @ 85% alpha (every 5th major; the standard 1 s / 2.5 mV reference).
  6. Trace OR envelope — triangle-strip polyline OR instanced quads.
  7. Point annotations — line list per category, constant 0.85 alpha.

Trace vertex strategy

The trace shader reads samples[vertex_id / 2] directly. The sample buffer is uploaded once at channel load (zero-copy mmap → GPU). Pan/zoom updates only a 32-byte uniforms block. No vertex-buffer rebuild ever happens at interactive rates.

The trace is rendered as a triangle strip — each sample produces two vertices (above + below the centerline). The vertex shader extrudes both vertices perpendicular to the local segment direction in screen-pixel space, so line width stays constant in points regardless of zoom (Metal has no glLineWidth equivalent).

Out-of-range samples (outside ±5 mV) emit a NaN clip-space position so the ribbon gaps cleanly at off-scale events. A SwiftUI overlay marks each gap with a ▲/▼ chevron at the chart edge.

Buffer lifecycle

Buffer Built when Size
Sample (Float32) Channel load sampleCount × 4 bytes
Pyramid (Float32 pairs) Pyramid-level change binCount × 8 bytes
Grid minor / major Viewport change hundreds of vertices
Annotation buckets Viewport change per-category
Uniforms Every frame 16-32 bytes inline (setVertexBytes)

3. Control Overlay

Pure SwiftUI on top of the Metal layer.

Component Role
BedsideView Top-level shell. Partitions channels into ECG (Metal canvas) and low-rate (context strips). Owns the shared viewport, the filter, and the analyst disposition store.
LeadChipBar Focus/Strips mode toggle + one chip per ECG lead.
ChannelPanel Per-lead container: header, voltage axis, Metal canvas, time axis. In focus mode the panel sits inside the pinned stage.
WaveformTimeAxis / WaveformVoltageAxis Tick labels positioned by viewport math.
WaveformAnnotationOverlay Category-colored symbol labels at the top of each canvas.
WaveformClippingOverlay ▲/▼ chevrons at off-scale events.
OverviewMap Merged whole-recording locator + finding density strip pinned with the stage (retired OverviewRibbon + FindingDensityTimeline 2026-07-04). Compact by default; expand to per-category lanes.
BeatCalipers Focus-beat PR/QRS/QT/QTc readout with deltas vs the per-patient template. Docks beside the trace in the pinned stage; travels with the pin.
VariabilityLane Rolling HRV lane in the scrolling context below the pinned stage. Shared time axis with the pinned trace.
IntervalTrendLane Third view of the fiducial store — QTc/PR/QRS trended over the whole recording. User-set threshold guides + analyst-authored event markers.
RecordContextPanel .hea header comments + Markdown notes editor next to the recording bundle.
FindingsSummaryHeader Compact chip row rendered in the scrolling context — one chip per category with count or duration, plus the analyst’s disposition tally.
FindingsPanel Right-side inspector = deviation-ranked review queue. Groups by category with human labels + provenance; collapsed by default; rhythm-context banner reads recording.headerComments; sort default = Departure.

Low-rate context strips

When a multi-frequency record carries sub-5-Hz channels (the Medallion feature store’s 1-min vitals, alarms, state probabilities, quality ratios), BedsideView.LowRatePartition routes them by name into four strips stacked below the canvas:

Strip Channels Visual
ChannelTrendStrip HR, SpO₂, etCO₂, BPM, tidal volume, anything numeric without a routing-suffix One Swift Charts sparkline per channel, time-locked to the viewport
AlarmStrip Anything ending in _alarm, _status, or _silenced One lane per channel; active runs as colored bars; tap to jump
StateBackdropStrip prob_state_spontaneous + prob_state_assist_control pair One row of colored cells per minute — warm = spontaneous, cool = assist-control, opacity = certainty
QualityStrip Anything ending in _ratio or containing artifact_ratio (Medallion ecg_artifact_ratio) Gray heat band with threshold-outlined cells past 0.1

Each strip renders only when its inputs exist — plain ECG records show none of the new chrome.

Disposition workflow

DispositionStore (@Observable) owns the analyst’s review state for one recording. It reads <bundle>/dispositions.json on init, exposes record(for:), state(for:), tally(for:), and mutates via confirm, dismiss, reset, clear. Every mutation writes the whole sidecar — files are tiny.

States are three-way: unreviewed (implicit by absence from the map), confirmed (with optional VT / VF sub-kind), and dismissed. Re-running the producer (which regenerates <recordName>.annotations.json) never overwrites the sidecar, so analyst work survives every re-import.

Visual feedback flows from the same store: the findings panel rows show inline buttons (gated by the toolbar lock), the summary chip row shows a tally, the density timeline outlines confirmed events and dims dismissed ones.

Entry surfaces

Component Role
LaunchShellView The no-record launch shell (#242): an idle flatline over one inline Open Record Folder… line. Recents live in File ▸ Open Recent.
RecentFoldersStore Persists up to 10 security-scoped bookmarks to UserDefaults so a sandboxed app can re-open a folder next launch.
ContentView App root. Picks between LaunchShellView (empty), browsing shell (sidebar + detail), and direct-view shell.

The overview ribbon is the one piece in the bedside still using Swift Charts, alongside ChannelTrendStrip. Both are tiny widgets; replacing them wouldn’t move the needle.

Invariants worth knowing

  • All channels in a Recording share one RecordingViewport — leads scroll and zoom in lock-step like a clinical monitor.
  • ECG channels go through the Metal canvas; anything sub-5-Hz is a trend channel and goes to a context strip below it (Channel.isTrendChannel). Producers don’t need any new metadata — naming + sample rate are the routing inputs.
  • The app is sandboxed. File picker selects a folder; security scope covers all child files. Recent folders persist as security-scoped bookmarks, not raw paths.
  • Internal storage is Float32 (BinaryRecordingHeader.currentVersion = 2).
  • WFDB baseline defaults to adcZero when not explicitly written in the gain field (matters for MIT-BIH where adcZero = 1024).
  • Producer outputs are authoritative; the viewer never re-derives them. Analyst-side annotations (disposition + notes) live in their own sidecars (dispositions.json, notes.md) so re-running a producer never destroys analyst work. This is one clause of a wider boundary — Murmur transmits assertions rather than authoring them; see What Murmur asserts for the full set of rules and where each one lives in the source.
  • ECG is the only domain. The CSV plotter and standalone vent pipeline were both removed during the 2026-06-14 pivot, though low-rate vent features can ride alongside the ECG in a multi-frequency WFDB.

© 2026 Kevin Long.

This site uses Just the Docs, a documentation theme for Jekyll.