feat(pe-guide): Respiratory system with synthesized sounds library
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).
This commit is contained in:
parent
4e1a870fe2
commit
83a78fa8cd
4 changed files with 511 additions and 4 deletions
|
|
@ -48,6 +48,9 @@
|
|||
<button class="wv-subtab-btn" data-pesystem="neuro">
|
||||
<i class="fas fa-brain"></i> Neurologic
|
||||
</button>
|
||||
<button class="wv-subtab-btn" data-pesystem="resp">
|
||||
<i class="fas fa-lungs"></i> Respiratory
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="pe-content">
|
||||
|
|
|
|||
|
|
@ -447,6 +447,7 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -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 += '<div class="card" style="margin-bottom:14px;border-left:4px solid ' + accent + ';">';
|
||||
html += ' <div class="card-header" style="border:0;padding:14px 16px 6px;"><h3 style="margin:0;font-size:16px;color:' + accent + ';"><i class="fas fa-' + (currentSystem === 'msk' ? 'bone' : 'brain') + '"></i> ' + esc(group.label) + ' — ' + (currentSystem === 'msk' ? 'Musculoskeletal' : 'Neurologic') + '</h3></div>';
|
||||
html += ' <div class="card-header" style="border:0;padding:14px 16px 6px;"><h3 style="margin:0;font-size:16px;color:' + accent + ';"><i class="fas fa-' + icon + '"></i> ' + esc(group.label) + ' — ' + sysLabel + '</h3></div>';
|
||||
html += ' <div style="padding:0 16px 14px;font-size:13px;line-height:1.65;color:var(--g700);">' + esc(section.overview) + '</div>';
|
||||
html += '</div>';
|
||||
|
||||
// ─ Respiratory sounds library (only for resp system) ─
|
||||
if (currentSystem === 'resp') {
|
||||
html += '<div class="card" style="margin-bottom:14px;border:1px solid ' + accent + '33;">';
|
||||
html += ' <div class="card-header" style="background:' + accentTint + ';"><h3 style="margin:0;font-size:14px;color:' + accent + ';"><i class="fas fa-volume-high"></i> Respiratory sounds library <span style="font-weight:400;font-size:11px;color:var(--g500);margin-left:6px;">synthesised teaching samples — listen for the pattern</span></h3></div>';
|
||||
html += ' <div style="padding:10px 14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px;">';
|
||||
RESP_SOUNDS.forEach(function (s) {
|
||||
html += '<div style="border:1px solid var(--g200);border-radius:8px;padding:10px;background:#fff;">';
|
||||
html += ' <div style="display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:4px;">';
|
||||
html += ' <div style="font-weight:600;font-size:13px;color:var(--g800);">' + esc(s.title) + '</div>';
|
||||
html += ' <button type="button" class="btn-sm btn-primary resp-sound-play" data-resp-sound="' + esc(s.key) + '" title="Play sample" style="padding:4px 10px;"><i class="fas fa-play"></i></button>';
|
||||
html += ' </div>';
|
||||
html += ' <div style="font-size:11px;color:var(--g500);margin-bottom:4px;"><strong>Where:</strong> ' + esc(s.where) + '</div>';
|
||||
html += ' <div style="font-size:12px;color:var(--g700);line-height:1.5;margin-bottom:4px;"><strong>Features:</strong> ' + esc(s.features) + '</div>';
|
||||
html += ' <div style="font-size:11px;color:var(--g600);line-height:1.5;"><strong>Clinical:</strong> ' + esc(s.clinical) + '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += ' </div>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// ─ 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 = '<i class="fas fa-volume-high"></i>';
|
||||
setTimeout(function () { btn.innerHTML = '<i class="fas fa-play"></i>'; }, 3500);
|
||||
} else {
|
||||
showToast('Audio library not loaded', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleStatus(btn) {
|
||||
|
|
|
|||
300
public/js/respiratorySounds.js
Normal file
300
public/js/respiratorySounds.js
Normal file
|
|
@ -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)
|
||||
};
|
||||
})();
|
||||
Loading…
Reference in a new issue