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.
332 lines
13 KiB
JavaScript
332 lines
13 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
|
||
};
|
||
|
||
// 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)
|
||
};
|
||
})();
|