pediatric-ai-scribe-v3/public/js/respiratorySounds.js
Daniel 3ddd598d74 feat(pe-guide): real audio, innocent-murmur panel, APTM image, all-ages resp+CV
Four changes rolled together:

1. REAL AUDIO from Wikimedia Commons (CC BY-SA 3.0, attribution to follow
   in privacy policy per Daniel). Embedded in /public/audio/ — synthesis
   stays as fallback for sounds not available from Wikimedia.
   respiratorySounds.js now tries the real OGG first; falls back to Web
   Audio synthesis if file missing.

2. REAL APTM IMAGE from Daniel's Nextcloud share, placed at
   /public/images/pe-guide/aptm.png (134 KB PNG). Replaces the inline SVG.
   Kept the side legend with A/P/E/T/M colour-coded points.

3. INNOCENT MURMUR REFERENCE PANEL below the APTM diagram with the 5
   classic innocent murmurs (Still's, pulmonary flow, venous hum, carotid
   bruit, PPS) — age, location, character, confirming maneuver — plus
   the 7 "S" criteria summary.

4. ALL-AGE RESP + CV DATA. Before: only adolescent. Now every age group
   has age-appropriate resp + cv content (newborn through adolescent).
   Newborn: Silverman, pre/postductal sats, duct-dependent lesion screen.
   Infant: bronchiolitis, CHF diaphoresis, VSD, early CHD.
   Toddler: croup/FB/epiglottitis, innocent murmur peak age.
   Preschool + school-age: adult-pattern transition, sports screening.
2026-04-22 21:00:42 +02:00

332 lines
13 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
};
// Real audio files served from /public/audio/respiratory/. Where a real
// recording is available, it plays instead of the synthesizer. Source:
// Wikimedia Commons (J. Heilman MD, CC BY-SA 3.0). Missing entries fall
// back to synthesis.
var REAL_AUDIO = {
'wheeze': '/audio/respiratory/wheeze.ogg',
'stridor': '/audio/respiratory/stridor.ogg',
'finecrackles': '/audio/respiratory/crackles-fine.ogg',
'coarsecrackles': '/audio/respiratory/crackles-coarse.ogg'
// normal, rhonchi, pleuralrub, grunting → synthesis only
};
var _audioEl = null;
function playReal(url) {
try {
stopCurrent();
if (!_audioEl) _audioEl = new Audio();
_audioEl.src = url;
_audioEl.play().catch(function () { /* ignore autoplay errors */ });
_currentStop = function () { try { _audioEl.pause(); _audioEl.currentTime = 0; } catch (e) {} };
return true;
} catch (e) {
return false;
}
}
window.RespSounds = {
play: function (name) {
var key = (name || '').toLowerCase();
// Prefer real recording if available
if (REAL_AUDIO[key]) {
if (playReal(REAL_AUDIO[key])) return true;
}
var fn = PLAYERS[key];
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;
},
isReal: function (name) { return !!REAL_AUDIO[(name || '').toLowerCase()]; },
stop: stopCurrent,
list: Object.keys(PLAYERS)
};
})();