feat(pe-guide): real lung recordings for normal/rhonchi/pleural-rub;

drop grunting + dead synth module

Three lung sounds that were Web-Audio syntheses now play real clinical
recordings sourced from the HLS-CMDS manikin dataset (MIT license):

- normal-vesicular.ogg
- rhonchi.ogg
- pleural-rub.ogg

Dropped expiratory grunting from the library entirely — no
openly-licensed clinical recording located across Wikimedia, Freesound
CC0, SPRSound, Pixabay, Internet Archive, or Littmann/EasyAuscultation
(all proprietary). Card is honest by omission rather than hiding a
synth behind a Play-only UI.

All seven remaining entries now use the same native <audio controls>
player (pause, seek, volume). The synth fallback branch in
renderSoundCard, the stopAllExcept synth reset loop, the script tag,
and the entire public/js/respiratorySounds.js (332 lines of Web Audio)
are removed since nothing references them anymore.
This commit is contained in:
Daniel 2026-04-23 15:36:26 +02:00
parent 02e7281e52
commit 015eaf9945
6 changed files with 12 additions and 411 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -447,7 +447,6 @@
<script defer src="/js/chartReview.js"></script>
<script defer src="/js/soap.js"></script>
<script defer src="/js/milestones.js"></script>
<script defer src="/js/respiratorySounds.js"></script>
<script defer src="/js/peGuide.js"></script>
<script defer src="/js/extensions.js"></script>
<script defer src="/js/nextcloud.js"></script>

View file

@ -247,11 +247,10 @@
// ────────────────────────────────────────────────────────────
// RESPIRATORY SOUNDS LIBRARY
// ────────────────────────────────────────────────────────────
// Each entry maps to a key in window.RespSounds (respiratorySounds.js).
// Displayed as a dedicated card at the top of the Respiratory system.
// src = real recording path; if absent, the player falls back to synthesis
// src = real recording path served from /public/audio/respiratory/.
var RESP_SOUNDS = [
{ key: 'normal', src: null, title: 'Normal vesicular breath sounds',
{ key: 'normal', src: '/audio/respiratory/normal-vesicular.ogg', title: 'Normal vesicular breath sounds',
where: 'Peripheral lung fields',
features: 'Soft, rustling. Inspiration louder and longer than expiration.',
clinical: 'Baseline — deviation elsewhere is what you listen for.' },
@ -271,18 +270,14 @@
where: 'Lower lobes; either side',
features: 'Discontinuous, longer and louder than fine crackles. Lower-pitched. Can be early or late inspiratory; often clear partly with cough.',
clinical: 'Secretions in larger airways — bronchitis, later pneumonia, bronchiectasis, aspiration.' },
{ key: 'rhonchi', src: null, title: 'Rhonchi',
{ key: 'rhonchi', src: '/audio/respiratory/rhonchi.ogg', title: 'Rhonchi',
where: 'Central or anywhere with airway secretions',
features: 'Continuous, low-pitched, snore-like. Typically expiratory. Clear or change with cough.',
clinical: 'Large-airway secretions — bronchitis, pneumonia with large-airway involvement, cystic fibrosis, bronchiectasis.' },
{ key: 'pleuralrub', src: null, title: 'Pleural friction rub',
{ key: 'pleuralrub', src: '/audio/respiratory/pleural-rub.ogg', title: 'Pleural friction rub',
where: 'Focal, often lateral or posterior lower chest',
features: 'Grating, creaky — "leather on leather". Biphasic (heard in inspiration and expiration). Does NOT clear with cough.',
clinical: 'Pleural inflammation — pleuritis, pulmonary embolism, pneumonia with pleural involvement, viral pleurisy.' },
{ key: 'grunting', src: null, title: 'Expiratory grunting',
where: 'Audible without stethoscope in infants',
features: 'Short, low-pitched sound at the end of each expiration. Physiologic PEEP against a partially closed glottis.',
clinical: 'Sign of significant respiratory distress in an infant — RDS, pneumonia, sepsis, congenital heart disease with heart failure.' }
clinical: 'Pleural inflammation — pleuritis, pulmonary embolism, pneumonia with pleural involvement, viral pleurisy.' }
];
// ────────────────────────────────────────────────────────────
@ -1252,7 +1247,7 @@
{ label: 'Listen for rhonchi', method: 'Low-pitched, continuous, snore-like. Often change with cough. Use the "Rhonchi" sample.', normal: 'No rhonchi' },
{ label: 'Listen for pleural rub', method: 'Grating, creaky, biphasic, does NOT clear with cough. Use the "Pleural rub" sample.', normal: 'No pleural rub' },
{ label: 'Listen at the neck (for stridor)', method: 'Place stethoscope over the anterior neck. Stridor is loudest here and differentiates from wheeze (loudest over chest). Use the "Stridor" sample.', normal: 'No stridor' },
{ label: 'Listen for expiratory grunting (infants)', method: 'Often audible without a stethoscope at the bedside. Use the "Grunting" sample.', normal: 'No grunting' },
{ label: 'Listen for expiratory grunting (infants)', method: 'Often audible without a stethoscope at the bedside — short, low-pitched sound at the end of each expiration (glottal closure against exhaled air).', normal: 'No grunting' },
{ label: 'Cough re-listen', method: 'Have patient cough forcefully; re-listen to any abnormal area. Note if the sound clears or changes.', normal: 'Any secretion-based sound should clear or change with cough' }
],
abnormalHints: ['Wheeze — asthma, bronchiolitis, foreign body (localised), anaphylaxis', 'Fine crackles — pulmonary edema, interstitial lung disease, early pneumonia', 'Coarse crackles — bronchitis, pneumonia, bronchiectasis, aspiration', 'Rhonchi — large-airway secretions', 'Pleural rub — pleurisy, PE, pneumonia with pleural involvement', 'Stridor — upper airway obstruction (croup, epiglottitis, FB)'] },
@ -1398,16 +1393,7 @@
// free on all platforms incl. mobile). Falls back to a synth play button
// when the sound has no real recording.
function renderSoundCard(s) {
var isReal = !!s.src;
var player = isReal
? '<audio class="pe-audio" controls preload="none" src="' + esc(s.src) + '" style="width:100%;height:36px;"></audio>'
: '<div class="synth-sound" data-synth-key="' + esc(s.key) + '" style="display:flex;align-items:center;gap:8px;">' +
'<button type="button" class="btn-sm btn-primary synth-play-btn" title="Play ~3 s sample" style="padding:6px 10px;min-width:80px;"><i class="fas fa-play"></i> Play</button>' +
'<button type="button" class="btn-sm btn-ghost synth-stop-btn" title="Stop" style="padding:6px 10px;display:none;"><i class="fas fa-stop"></i></button>' +
'<div class="synth-progress" style="flex:1;height:6px;background:var(--g200);border-radius:3px;overflow:hidden;">' +
'<div class="synth-progress-bar" style="height:100%;width:0%;background:#0ea5e9;transition:width 50ms linear;"></div>' +
'</div>' +
'</div>';
var player = '<audio class="pe-audio" controls preload="none" src="' + esc(s.src) + '" style="width:100%;height:36px;"></audio>';
var html = '';
html += '<div style="border:1px solid var(--g200);border-radius:8px;padding:10px;background:#fff;">';
html += ' <div style="font-weight:600;font-size:13px;color:var(--g800);margin-bottom:4px;">' + esc(s.title) + '</div>';
@ -1612,64 +1598,12 @@
if (state[key]) state[key].note = inp.value;
});
});
// ── Single-playback policy: only one sound at a time across the whole
// ── page. When any <audio> starts playing, pause every other <audio>
// ── and stop any synth sound. When any synth starts, pause all <audio>.
function stopAllExcept(exception) {
content.querySelectorAll('.pe-audio').forEach(function (a) {
if (a !== exception && !a.paused) { a.pause(); }
});
if (exception !== 'synth' && window.RespSounds && window.RespSounds.stop) {
window.RespSounds.stop();
}
// Reset all synth progress bars
content.querySelectorAll('.synth-sound').forEach(function (c) {
if (c !== exception) {
var pb = c.querySelector('.synth-play-btn'); if (pb) { pb.innerHTML = '<i class="fas fa-play"></i> Play'; pb.style.display = ''; }
var sb = c.querySelector('.synth-stop-btn'); if (sb) sb.style.display = 'none';
var bar = c.querySelector('.synth-progress-bar'); if (bar) bar.style.width = '0%';
}
});
}
// Single-playback policy: when any <audio> starts, pause every other one.
content.querySelectorAll('.pe-audio').forEach(function (a) {
a.addEventListener('play', function () { stopAllExcept(a); });
});
// Synth sound play/stop with progress bar
content.querySelectorAll('.synth-sound').forEach(function (container) {
var key = container.dataset.synthKey;
var playBtn = container.querySelector('.synth-play-btn');
var stopBtn = container.querySelector('.synth-stop-btn');
var bar = container.querySelector('.synth-progress-bar');
var timer = null;
var startTime = 0;
var durationMs = 3200; // synth sounds are all ~3 s
function finish() {
if (timer) { clearInterval(timer); timer = null; }
playBtn.innerHTML = '<i class="fas fa-play"></i> Play';
playBtn.style.display = '';
stopBtn.style.display = 'none';
bar.style.width = '0%';
}
playBtn.addEventListener('click', function () {
if (!window.RespSounds || !window.RespSounds.play) { showToast('Audio library not loaded', 'error'); return; }
// Stop every other playing sound (audio + other synth) first
stopAllExcept(container);
var ok = window.RespSounds.play(key);
if (!ok) { showToast('Audio not available', 'error'); return; }
playBtn.style.display = 'none';
stopBtn.style.display = '';
startTime = Date.now();
bar.style.width = '0%';
timer = setInterval(function () {
var pct = Math.min(100, ((Date.now() - startTime) / durationMs) * 100);
bar.style.width = pct + '%';
if (pct >= 100) finish();
}, 50);
});
stopBtn.addEventListener('click', function () {
if (window.RespSounds && window.RespSounds.stop) window.RespSounds.stop();
finish();
a.addEventListener('play', function () {
content.querySelectorAll('.pe-audio').forEach(function (other) {
if (other !== a && !other.paused) other.pause();
});
});
});
}

View file

@ -1,332 +0,0 @@
// ============================================================
// 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)
};
})();