Charred logs breaking into orange coals, glowing from within the cracks against a dark background
Open-source

Sustain — I was told to play didgeridoo for my snoring, so I built an open-source practice coach

14 min read Isaac Rowntree

Zack Design has published Sustain, an open-source deliberate-practice engine for musical instruments. The first instrument pack is didgeridoo, because I snore, and a randomised controlled trial says four months of didgeridoo practice roughly halves obstructive sleep apnoea. The trial’s dose was at least 20 minutes a day, five days a week, for four months — about 120 sessions — and the pack encodes that as 16 weeks in four phases, with the browser’s microphone checking that the 20 minutes were spent actually droning. It is 4,352 lines of TypeScript across five packages, runs entirely in the browser with no server, and is MIT licensed.

This post walks through the parts I had to get wrong first: why the microphone has to be opened with WebRTC’s processing switched off, why pitch comes from a 4096-sample autocorrelation window and not an FFT, the 400 ms onset and 250 ms grace that stop a wobble ending your record, how a “perfect week” is prorated when you join on a Wednesday, and what the three.js lane does at 4 s, 3.2 s and 9 s.

Source → github.com/isaacrowntree/sustain (MIT) · clone it and pnpm dev — there’s no hosted demo, and the app never sends your data anywhere.

The intervention is turning up 120 times

I played trombone at a decent level and then stopped for years. I also snore, and the suggestion that came back was: play the didgeridoo.

That sounds like folk advice. It isn’t. Puhan et al., BMJ 2006 is a randomised controlled trial: 25 people with moderate obstructive sleep apnoea, four months, and the didgeridoo group’s apnoea–hypopnoea index dropped by about half, with significant improvements in daytime sleepiness and in how much their partners were disturbed. It later won the 2017 Ig Nobel Peace Prize, which makes people assume the science is a joke. The mechanism is unglamorous: circular breathing and sustained droning are resistance training for the upper airway, and the same effect shows up in the myofunctional-therapy literature with no instrument involved.

The part nobody puts on the poster is the dose. The protocol was at least 20 minutes a day, at least five days a week, for four months, and the participants beat it, averaging about 25 minutes across nearly six days. That is roughly 120 sessions. The intervention is not the didgeridoo; the intervention is turning up 120 times.

So the problem I had was not “how do I play the didgeridoo.” It was “what gets me to session 87.”

What already existed

Two things, worth knowing before reading mine. Silent Sleep Training is built by Alex Suarez’s company — Suarez taught the participants in the BMJ trial — and bundles a silicone instrument with an app that listens to a 15-minute daily session. Didge For Sleep is a real travel didgeridoo plus an apnoea-specific video curriculum, with no app and no tracking.

What did not exist was the overlap: a real didgeridoo, a 30–40 minute guided session, and a microphone that verifies you played. The mic-verified music trainers (Yousician, tonestro) need pitched repertoire and cannot score an unpitched drone. A GitHub search for a didgeridoo or circular-breathing trainer returns an acoustic-impedance calculator and an operating system.

So: build. But build the general thing, because a drone hold and a trombone long tone are the same exercise wearing different hats.

The instrument already keeps score

The easy version of this app is a Duolingo skin: streaks, XP, a celebration when the bar fills. I did not build that, because the didgeridoo already keeps score honestly. Your longest unbroken drone is a real number about your actual throat, and wrapping fake currency around a real measurement does not add motivation — the overjustification research suggests it displaces it.

So progression is the curriculum, and it is implemented as three mechanisms in packages/core.

Records, not scores. The pack declares two metrics, longest-drone and longest-unbroken, both direction: 'higher'. A record is a { value, date, verified } entry; verified is whether a microphone measured it or you typed it. The test in core.test.ts records 12, then 10, then 20: the first and third are personal records, the second is not, and the third reports previousBest: 12.

Unlock by demonstration. A drill lists prerequisites and unlockedDrills() in unlocks.ts is the whole gate:

    case 'metric': {
      const best = state.bests[req.metric];
      if (!best) return false;
      const def = pack.metrics.find((m) => m.id === req.metric);
      return def?.direction === 'lower' ? best.value <= req.gte : best.value >= req.gte;
    }
    case 'self-report':
      return state.selfReports[req.id] === true;
    case 'drill-completed':
      return (state.drillCompletions[req.drill] ?? 0) >= (req.times ?? 1);

The circular-breathing chain in the didgeridoo pack runs in a fixed order: cheek-puff breathing (three demonstrations), then water spray (two), then straw and glass (three, plus a self-report: “Can you keep the straw bubbles unbroken through a sniff?”), then circular breathing on the didgeridoo (two), then bridging the gap. After that, the unbroken-sets drill needs something measured rather than reported: a single unbroken note of 60 seconds. When nothing has been demonstrated yet, the session compiler does not skip the gate; it walks back down the chain, and the week-six test asserts that the session contains cheek-puff breathing and not circular breathing on the didgeridoo.

The circular-breathing drill chain in order: cheek-puff breathing three times, water spray twice, straw and glass three times plus a self-report, circular breathing on the didgeridoo twice, bridge the gap, and finally unbroken sets, which needs a measured 60-second note. Each drill unlocks only when the one before it has been demonstrated.

Perfect weeks, not daily streaks. The evidence protocol is five days a week, so rest days are scheduled, not failures; the habit-formation work found a single missed day does not impair habit formation. Two details in adherence.ts cost more thought than the rest of the file:

    // The join week only asks for the practice days that were still ahead.
    let target = fullTarget;
    if (wk === mondayOf(join)) {
      const missedDays = Math.min(daysBetween(wk, join), fullTarget);
      target = Math.max(1, fullTarget - missedDays);
    }

Join on a Wednesday, practise Wednesday to Friday, and the test asserts target is 3, the week is perfect, and the streak is 1. Nothing is more discouraging than a program that opens by scoring you against days that had gone by before you downloaded it.

The second is the make-up: compileSession() returns null on a rest day unless called with { makeup: true }, and the adherence test practises Monday to Thursday, misses Friday, plays Saturday, and asserts the week is perfect. Nearly every habit app treats the miss as the punishable event. The thing worth protecting is the return.

Sustain home screen: week 4 of 16, Breath mechanics, a Start session button, this week's days as filled circles, records of 34 seconds and 22 seconds, and a 16-week journey shown as a bar chart

That bar chart is the sixteen-week journey, one bar per week, height being sessions completed and colour the phase. It is the only “gamified” element and it is a picture of the truth.

A session is a lane

Thirty-five minutes of staring at a countdown is how a practice habit dies. So a session is a lane of timed segments flowing toward you in a three.js night scene — the Guitar Hero sustain bar stretched to the length of a breath.

Sustain session screen: a dark night scene with a lane of teal segments receding into the distance, a glowing amber ember at the near end, a 17-second countdown and the cue 'Puff your cheeks. Breathe through your nose. Keep them puffed.'

The whole scene is one 449-line class, PracticeScene in apps/web/src/scene/scene.ts. A segment is seg.seconds * SCALE units long with SCALE = 0.55, playing segments are 0.42 units tall and the full 2.2-unit lane width, rests are 0.1 tall and 45% width, and the lane group is translated by elapsed * SCALE each frame so the strike point never moves. Most of the rest is borrowed from software that solved these problems years ago:

  • The next segment brightens when remaining < 4 seconds, ramping its opacity linearly over the 4 s — telegraphing, not a countdown. The audio pre-cue chime is separate and earlier-tuned: it fires at 3.2 s, and only for segments of 10 s or more, so a run of short rests does not chime continuously.
  • Rests get a breathing halo on an 8 s cycle, 0.5 - 0.5 * Math.cos((t % 8) * (2π / 8)), paced like the Apple Watch Breathe animation, because in a didgeridoo program the rests are breathing exercises. Under prefers-reduced-motion it holds still.
  • The HUD fades after 9,000 ms without a pointermove, pointerdown or keydown, by adding a quiet class; any input removes it.
  • The played trail rides behind the strike point, a fill mesh whose scale.z grows with progress: bright where the drone lived, dim where it dropped.

Cues before the next segment arrives: the next segment sits dim, a chime sounds 3.2 seconds before it, it brightens steadily over the last four seconds, and the strike point is when it begins; the chime only plays for segments of ten seconds or more.

One thing I got wrong and fixed in a nine-line commit: the camera was at (0, 2.4, 4.6) looking at (0, 0.5, -7), and from there the upcoming segments stacked into each other. Raising it to (0, 3.1, 4.4) and looking further down the lane at (0, 0.35, -8) made the lane ahead readable. Same commit dropped the bloom multiplier from 0.8 to 0.5; the ember was flaring the whole scene.

Listening without a server

Verification is tiered, and the app is fully usable at tier one. The engine consumes a PracticeSource and does not care which tier produced the frames:

 *  - 'timer'  — always playing while running (honor system, no mic)
 *  - 'energy' — mic loudness gate (reactive visuals, coarse credit)
 *  - 'pitch'  — pitch-verified playing (auto-measured metrics)

The timer is the default until the mic is granted; it is how the BMJ trial ran, with a paper diary. The energy tier is an eight-line class over an adaptive noise floor — gate() is max(0.01, noiseFloor * 3), and the floor is learned only from frames that are already quiet. The pitch tier is where the work was.

The browser fights you. Opening the microphone with default constraints silently attenuates a drone. mic.ts explains why in the comment above the call:

/**
 * Open the mic with browser audio processing disabled. WebRTC noise
 * suppression classifies sustained tones as background noise and attenuates
 * them, and auto-gain pumps a continuous drone — both must be off.
 */

So it is echoCancellation: false, noiseSuppression: false, autoGainControl: false, all three.

Not an FFT. Pitch is pitchy’s McLeod Pitch Method, an autocorrelation-family detector, over a time-domain buffer:

const WINDOW = 4096; // ~85 ms at 48 kHz — 5-7 periods of a 60-90 Hz drone

findPitch returns a frequency and a clarity, and a frame counts as playing when clarity >= minClarity && pitchHz >= minHz && pitchHz <= maxHz. The thresholds come from the pack, not the detector, and the didgeridoo pack sets minHz: 50, maxHz: 100, minClarity: 0.8, stabilitySemitones: 3, with a comment that a beginner’s drone wobbles and too strict a gate reads as “the app can’t hear me”. The 50–100 Hz band is what keeps speech and room noise out, so the other two can afford to be forgiving. Two further gates: over any 1 s window with at least five samples, a pitch spread wider than 3 semitones is not a drone; and a reading within 1 Hz of 50 or 60 Hz at RMS below 0.02 is mains hum, not you.

Hysteresis lives in the clock, not the detector. PlayingClock in clock.ts has two constants, onsetMs = 400 and graceMs = 250. A frame stream has to be “playing” for 400 ms before the clock starts, and then the whole qualifying window is credited retroactively — you were playing during it, the clock was just waiting to be sure. Once running, a gap shorter than 250 ms keeps crediting through the wobble. Per-frame credit is capped at 500 ms and a gap over 2,000 ms is treated as a discontinuity (a backgrounded tab), so a stall cannot mint minutes. Swapping sources resets the clocks, so timer time can never be reported as verified.

Frame by frame: the detector hears a drone, the clock starts only after 400 ms of it and then credits those 400 ms back; a wobble of under 250 ms is bridged; a gap over two seconds is a break, with no credit and the clock waiting 400 ms again. Each frame can credit at most 500 ms, so a stalled tab cannot mint minutes.

All of it runs in the browser. Progress and recordings live in two IndexedDB object stores, progress and recordings, through a 79-line hand-rolled wrapper — the web app’s only runtime dependency is three. There is no account, no server and no telemetry. The network requests the app makes are to Google Fonts, for Fraunces and Karla; nothing else.

Packs are data

An instrument pack is metrics, a curriculum of phases and drills with prerequisites, and an analyzer spec. The engine’s entry point is one function:

export function compileSession(
  pack: InstrumentPack,
  day: ProgramDay,
  unlockedDrillIds: Set<string>,
  options: CompileOptions = {},
): CompiledSession | null {

Session length ramps across a phase by linear interpolation on phaseProgress, which is week position within the phase’s [start, end]. The didgeridoo pack is daysPerWeek: 5, totalWeeks: 16 in four phases — Foundation (weeks 1–2), Breath mechanics (3–5), Connection (6–9), Endurance & voice (10–16) — each closed by a measured assessment on the phase’s last practice day: 120 s per metric plus 45 s rest. The third one is named “The loop” and its subtitle is “Sixty seconds is the gate.”

The useful accident is that the pitch analyzer is instrument-agnostic. A trombone pack is the same detector with a different minHz/maxHz, and gets more out of it, because a detector that reports hertz can measure long-tone steadiness in cents. Guitar needs a polyphonic analyzer, which is a different problem, and is marked help-wanted rather than pretended at.

Where it’s at

pnpm -r test is green: 42 tests (17 in core, 13 in apps/web, 5 each in audio and pack-sdk, 2 in the didgeridoo pack) cover the engine, the clock, and the web app’s storage; the scene is checked by looking at it. The pack’s baseline-recording on day one and summit-recording at week 16, both 30 s of MediaRecorder keyed ${pack.id}:${sessionDate}:${drillId}, play back to back from a Compare view on the home screen — earliest day one, latest summit, and “not recorded yet, week N of 16” until the summit exists. Export is a versioned bundle, { version: 1, exportedAt, progress, recordings }, one JSON file with every recording inline as base64, and import validates the version and the pack, writes the recordings back into IndexedDB, and will not replace existing progress without a confirmation. The engine, the didgeridoo pack, all three microphone tiers and the web app work end to end, and for a local-first app with no backend, pnpm dev is the deployment.

The instrument is a PVC didgeridoo from African Drumming. The BMJ trial handed its participants plastic instruments because they are easier to learn on, and the effect comes from what your airway is doing, not what the tube is made of.

So there is a curriculum, a coach, an instrument, and an empty progress file. What I learned building it: the browser’s audio defaults are tuned to remove exactly the signal a drone is, the clock needs more care than the detector, and the hard part of a habit app is the week you join and the day you miss. The day-one recording is on disk, and the summit will play back beside it when it lands. The trombone pack is next. Ask me in December.

A note worth making plainly: the didgeridoo is an Aboriginal Australian instrument, known as the yidaki to the Yolŋu people of north-east Arnhem Land, with a ceremonial history that long predates its use as a snoring intervention. Sustain is a practice timer for a breathing exercise. It makes no claim on any of that.

Header photo by Wil Stewart on Unsplash.