Third PE system added. Adolescent respiratory fully fleshed out with
the same teaching-focused depth as neuro: overview, grading scales,
per-component significance + pearls, detailed step methods with HOW
and NORMAL labels, and a watch-for red-flag block.
New respiratorySounds.js uses the Web Audio API to synthesize 8 classic
breath sounds on demand — no network, no audio files, no licensing:
- Normal vesicular
- Wheeze (two-partial + vibrato, filtered sawtooth)
- Stridor (inspiratory, bandpass-filtered sawtooth sweep)
- Fine crackles (dense brief high-freq noise bursts, late inspiration)
- Coarse crackles (sparser, longer, lower-freq bursts)
- Rhonchi (low-pitched warbled sawtooth, expiratory)
- Pleural friction rub (bandpass noise, biphasic)
- Expiratory grunting (square-wave short grunts)
Sounds are synthesised approximations intended to teach the pattern
(what makes a wheeze a wheeze vs a stridor). Labelled as such in the UI.
Controls: one play at a time, auto-stop ~3s.
Respiratory-specific grading scales:
- RR by age (WHO tachypnea cutoffs)
- Pulse ox (SpO2) with hypoxemia thresholds
- Silverman–Andersen (neonatal retractions, 0–10)
- Westley croup severity score
Components in adolescent respiratory:
1. Inspection (observation-first — RR, pattern, WOB, audible sounds,
chest shape, colour, clubbing with Schamroth sign)
2. Palpation (trachea, expansion symmetry, tactile fremitus,
tenderness, subcutaneous emphysema)
3. Percussion (technique + systematic zones + cardiac/hepatic
dullness + diaphragmatic excursion)
4. Auscultation — normal breath sounds (vesicular, bronchovesicular,
bronchial) with systematic side-to-side comparison
5. Auscultation — adventitious sounds with per-sound listen buttons
linking directly to the sounds library
6. Special maneuvers — bronchophony, egophony, whispered pectoriloquy
Older age groups (newborn through school-age) will get their own resp
blocks incrementally — v1 focused on adolescent for the quality bar.
UI: new sub-tab pill "Respiratory" with lung icon, sky-blue accent.
renderSystem refactored to use accent/icon maps instead of per-system
if/else — scales to future systems (cardiovascular coming next).
300 lines
12 KiB
JavaScript
300 lines
12 KiB
JavaScript
// ============================================================
|
||
// 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)
|
||
};
|
||
})();
|