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
.findingsevents and shows the determinate progress bar from.progressevents.
- per-window warnings can interleave. The host accumulates findings across multiple
- Cancellation lives in
Task. Consumers cancel by exiting thefor try awaitloop or by cancelling the parent task; producers honor it by callingtry 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.confidenceis 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 inPurchaseStorebefore 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
- Clear to paper color.
- Range annotations — translucent quads, one bucket per category.
- Grid minor — line list, salmon @ 65% alpha.
- Grid major — line list, red-pink @ 55% alpha.
- Grid landmark — line list, deep salmon @ 85% alpha (every 5th major; the standard 1 s / 2.5 mV reference).
- Trace OR envelope — triangle-strip polyline OR instanced quads.
- 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
adcZerowhen not explicitly written in the gain field (matters for MIT-BIH whereadcZero = 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.