pediatric-ai-scribe-v3/public/js/respiratorySounds.js
Daniel 83a78fa8cd feat(pe-guide): Respiratory system with synthesized sounds library
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).
2026-04-22 20:09:54 +02:00

300 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============================================================
// 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 ~34 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.21.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)
};
})();