From 83a78fa8cdeb147c3eae7c542f13db3ba36ff6c4 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 22 Apr 2026 20:09:45 +0200 Subject: [PATCH] feat(pe-guide): Respiratory system with synthesized sounds library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third PE system added. Adolescent respiratory fully fleshed out with the same teaching-focused depth as neuro: overview, grading scales, per-component significance + pearls, detailed step methods with HOW and NORMAL labels, and a watch-for red-flag block. New respiratorySounds.js uses the Web Audio API to synthesize 8 classic breath sounds on demand — no network, no audio files, no licensing: - Normal vesicular - Wheeze (two-partial + vibrato, filtered sawtooth) - Stridor (inspiratory, bandpass-filtered sawtooth sweep) - Fine crackles (dense brief high-freq noise bursts, late inspiration) - Coarse crackles (sparser, longer, lower-freq bursts) - Rhonchi (low-pitched warbled sawtooth, expiratory) - Pleural friction rub (bandpass noise, biphasic) - Expiratory grunting (square-wave short grunts) Sounds are synthesised approximations intended to teach the pattern (what makes a wheeze a wheeze vs a stridor). Labelled as such in the UI. Controls: one play at a time, auto-stop ~3s. Respiratory-specific grading scales: - RR by age (WHO tachypnea cutoffs) - Pulse ox (SpO2) with hypoxemia thresholds - Silverman–Andersen (neonatal retractions, 0–10) - Westley croup severity score Components in adolescent respiratory: 1. Inspection (observation-first — RR, pattern, WOB, audible sounds, chest shape, colour, clubbing with Schamroth sign) 2. Palpation (trachea, expansion symmetry, tactile fremitus, tenderness, subcutaneous emphysema) 3. Percussion (technique + systematic zones + cardiac/hepatic dullness + diaphragmatic excursion) 4. Auscultation — normal breath sounds (vesicular, bronchovesicular, bronchial) with systematic side-to-side comparison 5. Auscultation — adventitious sounds with per-sound listen buttons linking directly to the sounds library 6. Special maneuvers — bronchophony, egophony, whispered pectoriloquy Older age groups (newborn through school-age) will get their own resp blocks incrementally — v1 focused on adolescent for the quality bar. UI: new sub-tab pill "Respiratory" with lung icon, sky-blue accent. renderSystem refactored to use accent/icon maps instead of per-system if/else — scales to future systems (cardiovascular coming next). --- public/components/pe-guide.html | 3 + public/index.html | 1 + public/js/peGuide.js | 211 +++++++++++++++++++++- public/js/respiratorySounds.js | 300 ++++++++++++++++++++++++++++++++ 4 files changed, 511 insertions(+), 4 deletions(-) create mode 100644 public/js/respiratorySounds.js 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 += '

' + esc(group.label) + ' — ' + (currentSystem === 'msk' ? 'Musculoskeletal' : 'Neurologic') + '

'; + html += '

' + esc(group.label) + ' — ' + sysLabel + '

'; html += '
' + esc(section.overview) + '
'; html += '
'; + // ─ Respiratory sounds library (only for resp system) ─ + if (currentSystem === 'resp') { + html += '
'; + html += '

Respiratory sounds library synthesised teaching samples — listen for the pattern

'; + 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) + }; +})();