diff --git a/public/components/pe-guide.html b/public/components/pe-guide.html
index 74eb39b..ff4f3d9 100644
--- a/public/components/pe-guide.html
+++ b/public/components/pe-guide.html
@@ -48,6 +48,9 @@
+
diff --git a/public/index.html b/public/index.html
index fa46a96..d716dc7 100644
--- a/public/index.html
+++ b/public/index.html
@@ -447,6 +447,7 @@
+
diff --git a/public/js/peGuide.js b/public/js/peGuide.js
index f474605..237f7aa 100644
--- a/public/js/peGuide.js
+++ b/public/js/peGuide.js
@@ -70,14 +70,97 @@
['5–6°', 'Borderline — re-check at each visit'],
['≥ 7°', 'Refer for PA/lateral spine x-ray + orthopedic evaluation']
]
+ },
+ rr: {
+ title: 'Respiratory rate — upper limit by age (awake)',
+ icon: 'fa-lungs',
+ rows: [
+ ['Newborn', '≤ 60 /min'],
+ ['< 2 months', '≤ 60 /min (WHO tachypnea cutoff)'],
+ ['2–12 months', '≤ 50 /min (WHO tachypnea cutoff)'],
+ ['1–5 years', '≤ 40 /min (WHO tachypnea cutoff)'],
+ ['6–11 years', '≤ 30 /min'],
+ ['≥ 12 years', '≤ 20 /min (adult pattern)']
+ ]
+ },
+ spo2: {
+ title: 'Pulse oximetry (SpO₂) — at room air',
+ icon: 'fa-heart-pulse',
+ rows: [
+ ['≥ 95%', 'Normal'],
+ ['92–94%', 'Mild hypoxemia — investigate cause'],
+ ['< 92%', 'Moderate hypoxemia — supplemental O₂'],
+ ['< 88%', 'Severe — urgent intervention; target ≥ 90% acutely']
+ ]
+ },
+ silverman: {
+ title: 'Silverman–Andersen retraction score (neonatal, 0–10)',
+ icon: 'fa-baby',
+ rows: [
+ ['0', 'No respiratory distress'],
+ ['1–3', 'Mild — close observation'],
+ ['4–6', 'Moderate distress — consider CPAP / support'],
+ ['7–10', 'Severe — imminent respiratory failure, intubate']
+ ]
+ },
+ westley: {
+ title: 'Westley croup severity score',
+ icon: 'fa-stethoscope',
+ rows: [
+ ['≤ 2', 'Mild — home management, cool mist, oral dexamethasone'],
+ ['3–5', 'Moderate — nebulised epinephrine + dexamethasone'],
+ ['6–11', 'Severe — admit, continuous monitoring'],
+ ['≥ 12', 'Impending respiratory failure — ICU / airway management']
+ ]
}
};
// Which scales are relevant per system
var SYSTEM_SCALES = {
msk: ['atr', 'beighton'],
- neuro: ['mrc', 'dtr', 'plantar']
+ neuro: ['mrc', 'dtr', 'plantar'],
+ resp: ['rr', 'spo2', 'silverman', 'westley']
};
+ // ────────────────────────────────────────────────────────────
+ // 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.
+ var RESP_SOUNDS = [
+ { key: 'normal', 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.' },
+ { key: 'wheeze', title: 'Wheeze',
+ where: 'Diffuse in asthma; localised in foreign body',
+ features: 'Continuous, high-pitched, musical. Usually expiratory; biphasic if severe.',
+ clinical: 'Lower-airway narrowing — asthma, bronchiolitis, foreign body, bronchomalacia. Silent chest in severe asthma is an ominous sign.' },
+ { key: 'stridor', title: 'Stridor',
+ where: 'Louder over neck than chest — upper airway',
+ features: 'Continuous, high-pitched, harsh. Classically inspiratory (extrathoracic obstruction); biphasic if fixed.',
+ clinical: 'Croup, epiglottitis, foreign body, laryngomalacia (infant). Distinguish from wheeze by auscultating the neck — stridor is loudest there.' },
+ { key: 'finecrackles', title: 'Fine (end-inspiratory) crackles',
+ where: 'Bibasilar in pulmonary edema/fibrosis; focal in pneumonia',
+ features: 'Discontinuous, brief, high-pitched. "Velcro" quality. Late inspiratory, do NOT clear with cough.',
+ clinical: 'Alveolar opening — pulmonary fibrosis, pulmonary edema, early pneumonia, atelectasis.' },
+ { key: 'coarsecrackles', title: 'Coarse crackles',
+ 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', 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', 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', 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.' }
+ ];
+
// ────────────────────────────────────────────────────────────
// DATA
// ────────────────────────────────────────────────────────────
@@ -708,6 +791,85 @@
{ label: 'Palmomental', method: 'Stroke thenar eminence', normal: 'No ipsilateral chin twitch' }
], abnormalHints: ['Presence suggests frontal lobe pathology, neurodegenerative disease, or severe TBI — rare in adolescence but relevant in post-concussion workup'] }
]
+ },
+ resp: {
+ overview: 'Systematic respiratory exam: inspection → palpation → percussion → auscultation → special maneuvers. Always start from observation — rate, pattern, work of breathing, and audible sounds (stridor, grunting) can be diagnostic before the stethoscope touches the chest.',
+ components: [
+ { name: 'Inspection — observation before touching',
+ significance: 'Detects respiratory distress and localises the level of airway compromise before any equipment is used. High yield: RR, WOB, audible sounds, chest shape, colour.',
+ pearl: 'Audible stridor at rest from across the room = upper-airway obstruction, often urgent. Grunting in an infant = significant distress — never dismiss as fussiness.',
+ steps: [
+ { label: 'Respiratory rate', method: 'Count over a full 60 seconds (not 15×4) — children normally breathe irregularly. Count while the patient is calm, before any interaction.', normal: 'Within age-appropriate range (see scales card above)' },
+ { label: 'Respiratory pattern', method: 'Observe depth, regularity, and inspiration:expiration ratio. Watch for prolonged expiration, paradoxical chest-abdominal movement, or apneas.', normal: 'Regular, I:E ratio ~1:2, no pauses > 10 s in an infant' },
+ { label: 'Work of breathing', method: 'Inspect for nasal flaring, suprasternal/intercostal/subcostal retractions, accessory muscle use (SCM, abdominals), tripod positioning, head-bobbing in infants.', normal: 'No retractions; breathing effortless' },
+ { label: 'Audible sounds (no stethoscope)', method: 'Listen at the bedside without the stethoscope. Grunting? Stridor? Wheezing audible across the room? Hoarse voice?', normal: 'No audible stridor, grunting, or wheeze' },
+ { label: 'Chest shape and symmetry', method: 'Inspect from front and lateral. Note AP-to-transverse diameter, pectus excavatum/carinatum, chest wall asymmetry.', normal: 'AP:transverse ~1:2 (not barrel-chested); symmetric' },
+ { label: 'Colour and perfusion', method: 'Inspect lips, tongue, nail beds for central cyanosis. Check peripheral perfusion (capillary refill, mottling).', normal: 'Pink, cap refill < 2 s, no cyanosis' },
+ { label: 'Clubbing', method: 'Inspect fingernails: Schamroth sign (reverse a finger against its mirror — normal forms a diamond-shaped window, clubbed does not).', normal: 'Normal nail angle, Schamroth window present' }
+ ],
+ abnormalHints: ['Audible stridor — upper airway (croup, epiglottitis, foreign body, laryngomalacia)', 'Grunting in infant — significant distress', 'Tripod positioning, accessory muscle use — severe distress', 'Barrel chest — chronic air-trapping (asthma, CF)', 'Central cyanosis — significant hypoxemia', 'Clubbing in a child — cystic fibrosis, chronic hypoxemia, bronchiectasis, cyanotic CHD'] },
+
+ { name: 'Palpation',
+ significance: 'Localises pathology: consolidation increases tactile fremitus; pneumothorax/effusion decreases it. Trachea deviates AWAY from expanding lesions and TOWARD collapsing ones.',
+ pearl: 'Tracheal deviation is one of the fastest bedside clues to mediastinal shift — tension pneumothorax pushes it away, lobar collapse pulls it toward. Palpate with the middle finger in the suprasternal notch.',
+ steps: [
+ { label: 'Tracheal position', method: 'Patient sitting upright, neck slightly extended. Place middle finger in the suprasternal notch, check equal distance to each SCM.', normal: 'Midline' },
+ { label: 'Chest expansion — symmetry', method: 'Hands on lateral chest wall with thumbs meeting at the spine (posterior) or xiphoid (anterior). Patient takes a deep breath. Watch thumbs separate symmetrically.', normal: 'Symmetric 3–5 cm separation' },
+ { label: 'Tactile fremitus', method: 'Ulnar surface of hand on chest wall. Ask patient to say "ninety-nine" repeatedly. Move hand systematically across each zone, comparing sides.', normal: 'Equal mild vibration bilaterally over lung fields' },
+ { label: 'Chest wall tenderness', method: 'Palpate ribs, costochondral junctions, sternum, and intercostal spaces.', normal: 'No tenderness' },
+ { label: 'Subcutaneous emphysema', method: 'Gentle palpation along clavicles, neck, chest wall.', normal: 'No crepitus under skin' }
+ ],
+ abnormalHints: ['Tracheal deviation — tension pneumothorax, large pleural effusion (away); upper lobe collapse (toward)', 'Asymmetric expansion — pneumothorax, large effusion, lobar collapse, phrenic palsy', 'Increased fremitus — consolidation (pneumonia), lobar pneumonia', 'Decreased/absent fremitus — pleural effusion, pneumothorax, obstruction', 'Costochondral tenderness — costochondritis, trauma', 'Subcutaneous emphysema — pneumothorax, tracheobronchial injury'] },
+
+ { name: 'Percussion',
+ significance: 'Differentiates air (hyper-resonant), fluid (dull), and consolidated lung (dull) without imaging. Well-performed percussion detects a pleural effusion > 300 mL or a pneumothorax with ~90% sensitivity.',
+ pearl: 'Pleximeter fingertip must be flat against the chest wall — lift other fingers off. The "feel" of a percussion note is as informative as the sound: dullness has a dense, reflected quality; hyper-resonance feels hollow and springy.',
+ steps: [
+ { label: 'Technique', method: 'Place middle finger of non-dominant hand (pleximeter) flat on chest wall; strike distal IP joint with tip of dominant middle finger (plexor) using a quick wrist flick.', normal: 'N/A — technique step' },
+ { label: 'Systematic zones', method: 'Percuss from apex to base, comparing side-to-side at each level. Include anterior, lateral (mid-axillary), and posterior fields.', normal: 'Resonant throughout lung fields' },
+ { label: 'Cardiac dullness', method: 'Percuss from resonant lung toward the heart border. Left sternal border dullness starts at the 3rd–5th ICS.', normal: 'Dullness beginning at the expected cardiac border' },
+ { label: 'Hepatic dullness', method: 'Right 5th–6th ICS mid-clavicular line transitions from resonant to dull.', normal: 'Liver edge dullness at expected level' },
+ { label: 'Diaphragmatic excursion', method: 'Patient inhales fully then exhales fully; mark level of dullness at each end. Difference is diaphragm excursion.', normal: '3–5 cm excursion bilaterally' }
+ ],
+ abnormalHints: ['Hyper-resonant — pneumothorax, emphysematous bulla, severe asthma attack', 'Dull — consolidation, pleural effusion (stony dull), atelectasis, pleural thickening, large mass', 'Raised diaphragm (loss of excursion) — effusion, paralysis, subdiaphragmatic pathology'] },
+
+ { name: 'Auscultation — normal breath sounds',
+ significance: 'Breath sound quality varies by location. Bronchial sounds heard peripherally = consolidation; absent breath sounds = pneumothorax, effusion, obstruction.',
+ pearl: 'Always compare corresponding points side-to-side sequentially — your ear calibrates to "normal" one side and immediately hears asymmetry. Listen through a full respiratory cycle at each zone.',
+ steps: [
+ { label: 'Technique', method: 'Diaphragm of stethoscope directly on skin (not over clothing). Patient breathes slowly and deeply through an open mouth.', normal: 'N/A — technique' },
+ { label: 'Vesicular sounds (peripheral)', method: 'Listen over lung fields away from the sternum. Play the "Normal vesicular" sample above for reference.', normal: 'Soft, low-pitched, inspiration > expiration in length and loudness' },
+ { label: 'Bronchovesicular (over main bronchi)', method: 'Listen at the 1st–2nd ICS anteriorly and between scapulae posteriorly.', normal: 'Intermediate pitch, inspiration = expiration' },
+ { label: 'Bronchial (over trachea)', method: 'Listen directly over the manubrium or trachea.', normal: 'Harsh, high-pitched, expiration > inspiration' },
+ { label: 'Systematic comparison', method: 'Six zones anteriorly (upper/mid/lower × L/R), four lateral, six posterior. Compare side-to-side at each zone.', normal: 'Symmetric breath sounds at every paired zone' }
+ ],
+ abnormalHints: ['Bronchial sounds heard peripherally — consolidation (pneumonia)', 'Absent/diminished breath sounds — pneumothorax, effusion, severe obstruction, obesity / muscular chest', 'Prolonged expiration — lower airway obstruction (asthma, bronchiolitis)'] },
+
+ { name: 'Auscultation — adventitious sounds',
+ significance: 'Adventitious (added) sounds are the key diagnostic finding. Timing (inspiratory vs expiratory vs biphasic), character (continuous vs discontinuous), and location are all informative.',
+ pearl: 'Ask the patient to cough and re-listen. Secretions (rhonchi, some coarse crackles) clear or change; fine crackles of fibrosis or early pneumonia do not. The cough test separates two differential groups in one maneuver.',
+ steps: [
+ { label: 'Listen for wheeze', method: 'Continuous musical sounds, typically expiratory. Use the "Wheeze" sample for reference.', normal: 'No wheeze' },
+ { label: 'Listen for crackles — fine', method: 'Short, high-pitched, discontinuous "Velcro" sounds. Typically end-inspiratory, bibasilar. Use the "Fine crackles" sample.', normal: 'No crackles' },
+ { label: 'Listen for crackles — coarse', method: 'Longer, lower-pitched, louder than fine. Use the "Coarse crackles" sample.', normal: 'No crackles' },
+ { 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: '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)'] },
+
+ { name: 'Special maneuvers — transmitted voice sounds',
+ significance: 'Vocal resonance tests detect consolidation (increased transmission) and effusion/pneumothorax (decreased). Useful when auscultation suggests asymmetry.',
+ pearl: 'Whispered pectoriloquy is the most sensitive of the three — whispered words transmitted clearly through consolidated lung. If "one, two, three" whispered becomes clearly audible over one lung zone, there is consolidation underneath.',
+ steps: [
+ { label: 'Bronchophony', method: 'Patient says "ninety-nine" in normal voice. Listen at each lung zone with the stethoscope.', normal: 'Muffled, indistinct sound' },
+ { label: 'Egophony', method: 'Patient says "ee" continuously. Listen over any suspicious area.', normal: '"Ee" sounds like "ee" (no change)' },
+ { label: 'Whispered pectoriloquy', method: 'Patient whispers "one, two, three" or "ninety-nine". Listen over each zone.', normal: 'Whisper is faint and indistinct' }
+ ],
+ abnormalHints: ['Bronchophony increased — consolidation', 'Egophony positive ("ee" → "A" / "ay") — consolidation, sometimes top of an effusion', 'Whispered pectoriloquy positive (whisper clearly audible) — consolidation'] }
+ ]
}
}
};
@@ -769,16 +931,42 @@
return;
}
var section = group[currentSystem];
- var accent = currentSystem === 'msk' ? '#0891b2' : '#7c3aed'; // cyan for MSK, purple for neuro
- var accentTint = currentSystem === 'msk' ? '#ecfeff' : '#f5f3ff';
+ var accentMap = { msk: '#0891b2', neuro: '#7c3aed', resp: '#0ea5e9' }; // cyan / purple / sky
+ var accentTintMap = { msk: '#ecfeff', neuro: '#f5f3ff', resp: '#f0f9ff' };
+ var iconMap = { msk: 'bone', neuro: 'brain', resp: 'lungs' };
+ var labelMap = { msk: 'Musculoskeletal', neuro: 'Neurologic', resp: 'Respiratory' };
+ var accent = accentMap[currentSystem] || '#0891b2';
+ var accentTint = accentTintMap[currentSystem] || '#ecfeff';
+ var icon = iconMap[currentSystem] || 'circle-info';
+ var sysLabel = labelMap[currentSystem] || currentSystem;
var html = '';
// ─ Overview banner ─
html += '
';
- html += ' ';
+ html += ' ';
html += '
' + esc(section.overview) + '
';
html += '
';
+ // ─ Respiratory sounds library (only for resp system) ─
+ if (currentSystem === 'resp') {
+ html += '
';
+ html += ' ';
+ html += '
';
+ RESP_SOUNDS.forEach(function (s) {
+ html += '
';
+ html += '
';
+ html += '
' + esc(s.title) + '
';
+ html += '
';
+ html += '
';
+ html += '
Where: ' + esc(s.where) + '
';
+ html += '
Features: ' + esc(s.features) + '
';
+ html += '
Clinical: ' + esc(s.clinical) + '
';
+ html += '
';
+ });
+ html += '
';
+ html += '
';
+ }
+
// ─ Grading scales reference (collapsible) ─
var scaleKeys = SYSTEM_SCALES[currentSystem] || [];
if (scaleKeys.length) {
@@ -871,6 +1059,21 @@
if (state[key]) state[key].note = inp.value;
});
});
+ // Respiratory sound-library play buttons
+ content.querySelectorAll('.resp-sound-play').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var name = btn.dataset.respSound;
+ if (window.RespSounds && window.RespSounds.play) {
+ var ok = window.RespSounds.play(name);
+ if (!ok) showToast('Audio not available', 'error');
+ // Visual feedback
+ btn.innerHTML = '
';
+ setTimeout(function () { btn.innerHTML = '
'; }, 3500);
+ } else {
+ showToast('Audio library not loaded', 'error');
+ }
+ });
+ });
}
function handleStatus(btn) {
diff --git a/public/js/respiratorySounds.js b/public/js/respiratorySounds.js
new file mode 100644
index 0000000..4b9c208
--- /dev/null
+++ b/public/js/respiratorySounds.js
@@ -0,0 +1,300 @@
+// ============================================================
+// 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
+ };
+
+ 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)
+ };
+})();