// ============================================================ // RESPIRATORY SOUND SYNTHESIZER // ============================================================ // Synthesises the characteristic *pattern* of each classic respiratory // sound via the Web Audio API. These are NOT clinical recordings — // they approximate the acoustic signature so a learner can internalise // the difference (e.g. wheeze vs stridor vs crackles vs rhonchi). For // diagnostic use, always rely on real recordings and bedside teaching. // // Exposes window.RespSounds.play(name) — name ∈ { // 'normal', 'wheeze', 'stridor', 'finecrackles', 'coarsecrackles', // 'rhonchi', 'pleuralrub', 'grunting' // } // // All sounds take ~3–4 seconds and auto-stop. One playback at a time. // ============================================================ (function () { var _ctx = null; var _currentStop = null; // cleanup function for any in-flight sound function ctx() { if (_ctx) return _ctx; var AC = window.AudioContext || window.webkitAudioContext; if (!AC) return null; _ctx = new AC(); return _ctx; } function stopCurrent() { if (_currentStop) { try { _currentStop(); } catch (e) {} _currentStop = null; } } // Utility: create a gain node with an AD envelope (attack, decay to 0) function envelope(ac, peak, t0, attack, hold, decay) { var g = ac.createGain(); g.gain.setValueAtTime(0, t0); g.gain.linearRampToValueAtTime(peak, t0 + attack); g.gain.setValueAtTime(peak, t0 + attack + hold); g.gain.linearRampToValueAtTime(0, t0 + attack + hold + decay); return g; } // Pink-ish noise buffer generator function noiseBuffer(ac, durationSec) { var sr = ac.sampleRate; var buf = ac.createBuffer(1, Math.floor(sr * durationSec), sr); var data = buf.getChannelData(0); // simple 1-pole lowpass of white noise → pinkish / breath-like var last = 0; for (var i = 0; i < data.length; i++) { var w = Math.random() * 2 - 1; last = 0.7 * last + 0.3 * w; data[i] = last * 0.5; } return buf; } // ─── Normal vesicular breath ─────────────────────────── // Soft, inspiration-dominant pink noise with a gentle expiratory fade. function playNormal() { var ac = ctx(); if (!ac) return; stopCurrent(); var now = ac.currentTime + 0.05; var cycle = 2.8; // one full respiratory cycle var src = ac.createBufferSource(); src.buffer = noiseBuffer(ac, cycle * 2 + 0.5); var lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 900; var g = ac.createGain(); g.gain.setValueAtTime(0, now); // Inspiration: rise 1.2s, hold, fall g.gain.linearRampToValueAtTime(0.18, now + 0.6); g.gain.linearRampToValueAtTime(0.05, now + 1.4); // Expiration: soft, shorter, quieter g.gain.linearRampToValueAtTime(0.08, now + 1.9); g.gain.linearRampToValueAtTime(0.02, now + 2.6); g.gain.linearRampToValueAtTime(0, now + cycle); src.connect(lp).connect(g).connect(ac.destination); src.start(now); src.stop(now + cycle + 0.1); _currentStop = function () { try { src.stop(); } catch (e) {} }; } // ─── Wheeze — continuous high-pitched, expiratory dominant ─── // Model: two sine partials (400 & 700 Hz) with slight vibrato, amplitude // shaped by a long expiratory envelope. function playWheeze() { var ac = ctx(); if (!ac) return; stopCurrent(); var now = ac.currentTime + 0.05; var dur = 3.2; // Brief quiet inspiration var insp = ac.createBufferSource(); insp.buffer = noiseBuffer(ac, 1.0); var insg = ac.createGain(); insg.gain.setValueAtTime(0, now); insg.gain.linearRampToValueAtTime(0.04, now + 0.3); insg.gain.linearRampToValueAtTime(0, now + 0.8); insp.connect(insg).connect(ac.destination); insp.start(now); // Expiratory wheeze — two slightly modulated sines var expStart = now + 0.9; var expEnd = expStart + 2.0; function partial(freq, gainLvl) { var o = ac.createOscillator(); o.type = 'sine'; o.frequency.setValueAtTime(freq, expStart); // subtle vibrato var lfo = ac.createOscillator(); lfo.frequency.value = 5; var lfoGain = ac.createGain(); lfoGain.gain.value = freq * 0.02; lfo.connect(lfoGain).connect(o.frequency); var g = ac.createGain(); g.gain.setValueAtTime(0, expStart); g.gain.linearRampToValueAtTime(gainLvl, expStart + 0.3); g.gain.linearRampToValueAtTime(gainLvl * 0.7, expEnd - 0.4); g.gain.linearRampToValueAtTime(0, expEnd); o.connect(g).connect(ac.destination); o.start(expStart); lfo.start(expStart); o.stop(expEnd + 0.1); lfo.stop(expEnd + 0.1); return { o: o, lfo: lfo }; } var p1 = partial(440, 0.12); var p2 = partial(780, 0.08); _currentStop = function () { try { p1.o.stop(); p1.lfo.stop(); p2.o.stop(); p2.lfo.stop(); insp.stop(); } catch (e) {} }; } // ─── Stridor — inspiratory high-pitched monophonic ──────── function playStridor() { var ac = ctx(); if (!ac) return; stopCurrent(); var now = ac.currentTime + 0.05; var inspStart = now + 0.1, inspEnd = inspStart + 1.6; var o = ac.createOscillator(); o.type = 'sawtooth'; o.frequency.setValueAtTime(600, inspStart); o.frequency.linearRampToValueAtTime(850, inspStart + 0.8); o.frequency.linearRampToValueAtTime(700, inspEnd); var bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 800; bp.Q.value = 4; var g = ac.createGain(); g.gain.setValueAtTime(0, inspStart); g.gain.linearRampToValueAtTime(0.10, inspStart + 0.4); g.gain.linearRampToValueAtTime(0.08, inspEnd - 0.3); g.gain.linearRampToValueAtTime(0, inspEnd); o.connect(bp).connect(g).connect(ac.destination); o.start(inspStart); o.stop(inspEnd + 0.1); // Quiet expiration var expSrc = ac.createBufferSource(); expSrc.buffer = noiseBuffer(ac, 1.0); var eg = ac.createGain(); eg.gain.setValueAtTime(0, inspEnd); eg.gain.linearRampToValueAtTime(0.04, inspEnd + 0.3); eg.gain.linearRampToValueAtTime(0, inspEnd + 1.0); expSrc.connect(eg).connect(ac.destination); expSrc.start(inspEnd); _currentStop = function () { try { o.stop(); expSrc.stop(); } catch (e) {} }; } // ─── Fine crackles — end-inspiratory short high-freq pops ─── function playFineCrackles() { _playCrackles(15, 0.008, 3500, 0.06); } // ─── Coarse crackles — fewer, longer, lower-freq bursts ──── function playCoarseCrackles() { _playCrackles(8, 0.025, 1400, 0.10); } function _playCrackles(count, burstDur, freq, peak) { var ac = ctx(); if (!ac) return; stopCurrent(); var now = ac.currentTime + 0.05; // Underlying quiet vesicular breath var base = ac.createBufferSource(); base.buffer = noiseBuffer(ac, 3.0); var bg = ac.createGain(); bg.gain.setValueAtTime(0.03, now); bg.gain.linearRampToValueAtTime(0, now + 3.0); var lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 700; base.connect(lp).connect(bg).connect(ac.destination); base.start(now); // Crackles clustered in late inspiration (~1.2–1.8s) var cracks = []; for (var i = 0; i < count; i++) { var t = now + 1.2 + (Math.random() * 0.6); // late inspiration var s = ac.createBufferSource(); s.buffer = noiseBuffer(ac, burstDur + 0.02); var bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = 6; var g = ac.createGain(); g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(peak, t + 0.003); g.gain.linearRampToValueAtTime(0, t + burstDur); s.connect(bp).connect(g).connect(ac.destination); s.start(t); s.stop(t + burstDur + 0.02); cracks.push(s); } _currentStop = function () { try { base.stop(); cracks.forEach(function (s) { s.stop(); }); } catch (e) {} }; } // ─── Rhonchi — low-pitched continuous snore-like ────────── function playRhonchi() { var ac = ctx(); if (!ac) return; stopCurrent(); var now = ac.currentTime + 0.05; var t0 = now + 0.3, t1 = t0 + 2.2; var o = ac.createOscillator(); o.type = 'sawtooth'; o.frequency.setValueAtTime(140, t0); // mild warble var lfo = ac.createOscillator(); lfo.frequency.value = 3; var lfoG = ac.createGain(); lfoG.gain.value = 20; lfo.connect(lfoG).connect(o.frequency); var lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 500; var g = ac.createGain(); g.gain.setValueAtTime(0, t0); g.gain.linearRampToValueAtTime(0.10, t0 + 0.4); g.gain.linearRampToValueAtTime(0.08, t1 - 0.4); g.gain.linearRampToValueAtTime(0, t1); o.connect(lp).connect(g).connect(ac.destination); o.start(t0); lfo.start(t0); o.stop(t1 + 0.1); lfo.stop(t1 + 0.1); _currentStop = function () { try { o.stop(); lfo.stop(); } catch (e) {} }; } // ─── Pleural friction rub — grating, biphasic ───────────── function playPleuralRub() { var ac = ctx(); if (!ac) return; stopCurrent(); var now = ac.currentTime + 0.05; function rub(t, duration) { var s = ac.createBufferSource(); s.buffer = noiseBuffer(ac, duration + 0.05); var bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 250; bp.Q.value = 3; var g = ac.createGain(); g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.14, t + 0.05); g.gain.linearRampToValueAtTime(0.10, t + duration - 0.05); g.gain.linearRampToValueAtTime(0, t + duration); s.connect(bp).connect(g).connect(ac.destination); s.start(t); s.stop(t + duration + 0.02); return s; } var a = rub(now + 0.3, 0.9); // inspiration rub var b = rub(now + 1.7, 0.7); // expiration rub _currentStop = function () { try { a.stop(); b.stop(); } catch (e) {} }; } // ─── Expiratory grunting ────────────────────────────────── function playGrunting() { var ac = ctx(); if (!ac) return; stopCurrent(); var now = ac.currentTime + 0.05; function grunt(t) { var o = ac.createOscillator(); o.type = 'square'; o.frequency.setValueAtTime(180, t); o.frequency.linearRampToValueAtTime(110, t + 0.45); var lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 600; var g = ac.createGain(); g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.12, t + 0.08); g.gain.linearRampToValueAtTime(0.08, t + 0.35); g.gain.linearRampToValueAtTime(0, t + 0.5); o.connect(lp).connect(g).connect(ac.destination); o.start(t); o.stop(t + 0.55); return o; } var g1 = grunt(now + 0.5); var g2 = grunt(now + 1.6); var g3 = grunt(now + 2.7); _currentStop = function () { try { g1.stop(); g2.stop(); g3.stop(); } catch (e) {} }; } var PLAYERS = { 'normal': playNormal, 'wheeze': playWheeze, 'stridor': playStridor, 'finecrackles': playFineCrackles, 'coarsecrackles': playCoarseCrackles, 'rhonchi': playRhonchi, 'pleuralrub': playPleuralRub, 'grunting': playGrunting }; window.RespSounds = { play: function (name) { var fn = PLAYERS[(name || '').toLowerCase()]; if (!fn) return false; // Ensure audio context is running (browsers suspend until a user gesture) var ac = ctx(); if (!ac) { showToast && showToast('Audio not supported', 'error'); return false; } if (ac.state === 'suspended') ac.resume(); fn(); return true; }, stop: stopCurrent, list: Object.keys(PLAYERS) }; })();