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:
parent
02e7281e52
commit
015eaf9945
6 changed files with 12 additions and 411 deletions
BIN
public/audio/respiratory/normal-vesicular.ogg
Normal file
BIN
public/audio/respiratory/normal-vesicular.ogg
Normal file
Binary file not shown.
BIN
public/audio/respiratory/pleural-rub.ogg
Normal file
BIN
public/audio/respiratory/pleural-rub.ogg
Normal file
Binary file not shown.
BIN
public/audio/respiratory/rhonchi.ogg
Normal file
BIN
public/audio/respiratory/rhonchi.ogg
Normal file
Binary file not shown.
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ~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)
|
||||
};
|
||||
})();
|
||||
Loading…
Reference in a new issue