const PHONETIC = { alpha:'A', alfa:'A', bravo:'B', charlie:'C', delta:'D', echo:'E', foxtrot:'F', zulu:'Z', golf:'G', hotel:'H' };
const NUM_WORDS = { zero:0, oh:0, one:1, two:2, to:2, too:2, three:3, four:4, for:4, five:5, six:6, seven:7, eight:8, nine:9, ten:10, eleven:11, twelve:12, thirteen:13, fourteen:14, fifteen:15, sixteen:16, seventeen:17, eighteen:18, nineteen:19, twenty:20, thirty:30, forty:40, fifty:50, sixty:60, seventy:70, eighty:80, ninety:90, hundred:100 };
function wordsToDigits(s) {
return s.replace(/\b((?:zero|oh|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred)(?:[\s-]+(?:zero|oh|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred))*)\b/g, (m) => {
const parts = m.toLowerCase().split(/[\s-]+/); let total = 0, cur = 0;
for (const p of parts) { const v = NUM_WORDS[p]; if (v === 100) { cur = (cur || 1) * 100; } else if (v >= 20) { cur += v; } else { cur += v; } }
total = cur; return String(total);
});
}
function normalize(t) {
let s = ' ' + t.toLowerCase().replace(/[.,!?]/g, ' ').replace(/\s+/g, ' ') + ' ';
s = s.replace(/\bblood pressure\b/g, 'bp').replace(/\bheart rate\b|\bpulse\b|\bhr\b/g, 'hr').replace(/\brespiratory rate\b|\brespirations?\b|\bresp rate\b|\bresps?\b|\bbreathing rate\b/g, 'rr')
.replace(/\b(?:o2 )?sat(?:uration)?s?\b|\bspo2\b|\bsp o2\b|\boxygen\b|\bpulse ox\b/g, 'spo2').replace(/\btourniquet\b|\bturn a kit\b|\btq\b/g, 'tq').replace(/\bmilligrams?\b|\bmigs?\b/g, 'mg').replace(/\bmicrograms?\b|\bmics?\b/g, 'mcg').replace(/\bmillilit(?:er|re)s?\b|\bmils?\b|\bcc\b/g, 'ml').replace(/\bkilos?\b|\bkilograms?\b/g, 'kg')
.replace(/\bpositive\b|\bpos\b/g, '+').replace(/\bnegative\b|\bneg\b/g, '-').replace(/\bover\b/g, '/');
s = wordsToDigits(s);
s = s.replace(/\b(1 ?8|18|180)\s*(alpha|alfa|bravo|charlie|delta|echo|foxtrot|zulu)\s*(1|2|one|two)?\b/g, (m, n, ph, k) => n.replace(' ', '') + PHONETIC[ph].toLowerCase() + (k ? (k === 'one' ? '1' : k === 'two' ? '2' : k) : ''));
return s.replace(/\s+/g, ' ');
}
const sideOf = (txt) => /\bleft\b|\bl\b/.test(txt) ? 'L' : /\bright\b|\br\b/.test(txt) ? 'R' : null;
const fuzzyFind = (needle, list, key = x => x) => {
const n = needle.trim().toLowerCase(); if (!n) return null;
const wantSide = sideOf(n);
const toks = n.split(' ').filter(t => t && !/^(left|right|l|r|the|a|an|on|to)$/.test(t));
let best = null, bestScore = 0;
for (const item of list) {
const hay = key(item).toLowerCase(); const haySide = sideOf(hay);
if (wantSide && haySide && haySide !== wantSide) continue;           // never invert laterality
let sc = 0; for (const tk of toks) if (tk.length > 1 && hay.includes(tk)) sc += tk.length;
if (hay.includes(toks.join(' '))) sc += 10;
if (wantSide && haySide === wantSide) sc += 8;
if (sc > bestScore) { bestScore = sc; best = item; }
}
return bestScore >= 3 ? best : null;
};
const LIMBS = [[/\bright (?:upper )?arm\b|\bright arm\b/, 'rArm'], [/\bleft (?:upper )?arm\b|\bleft arm\b/, 'lArm'], [/\bright (?:leg|thigh)\b/, 'rLeg'], [/\bleft (?:leg|thigh)\b/, 'lLeg']];
const TIME_RE = /\b(?:at |time |@ )?([01]?\d|2[0-3]):?([0-5]\d)\b(?: ?(?:hours|hrs|zulu|local))?/;
function parseVoice(raw, ctx) {
const s = normalize(raw), cmds = [], push = (c) => cmds.push(c);
const m = (re) => s.match(re);
let r;
if (m(/\b(?:exit|leave|close) focus\b|\bfocus (?:off|out|zero)\b/)) push({ type:'focus', level:0, label:'Exit focus' });
else if ((r = m(/\bfocus (?:level |times |x )?(1|2|3|one|two|three|on)\b/))) { const lv = ({one:1,two:2,three:3,on:1})[r[1]] || +r[1]; push({ type:'focus', level:lv, label:`Focus ×${lv}` }); }
if (m(/\bnext (?:casualty|patient|man)\b/)) push({ type:'step', d:1, label:'Next casualty' });
if (m(/\b(?:previous|prior|last) (?:casualty|patient|man)\b/)) push({ type:'step', d:-1, label:'Previous casualty' });
if ((r = m(/\b(?:open|show) (settings|export|display)\b/))) push({ type:'drawer', k:r[1], label:`Open ${r[1]}` });
if (m(/\bclose (?:drawer|settings|export|display|panel)\b/)) push({ type:'drawer', k:null, label:'Close drawer' });
if ((r = m(/\b(?:tab|go to|show|open) (march|treatments?|burn|meds?|medications?|drip|blood|nine ?line|9 ?line|ref(?:erence)?)\b/))) { const k = /march|treat/.test(r[1]) ? 'march' : /burn/.test(r[1]) ? 'burn' : /med/.test(r[1]) ? 'meds' : /drip/.test(r[1]) ? 'drip' : /blood/.test(r[1]) ? 'blood' : /nine|9/.test(r[1]) ? 'nine' : 'ref'; push({ type:'tab', k, label:`Tab ${k}` }); }
if (m(/\b(?:un ?mute|sound on)\b/)) push({ type:'sound', on:true, label:'Sound on' }); else if (m(/\bmute\b|\bsound off\b/)) push({ type:'sound', on:false, label:'Mute' });
if (m(/\bundo\b|\bscratch that\b|\bcancel that\b/)) push({ type:'undo', label:'Undo' });
if (m(/\bstop listening\b|\bvoice off\b/)) push({ type:'voiceOff', label:'Voice off' });
if ((r = m(/\b(?:select|switch to|pull up|show me|go to|casualty|patient) (?:the )?([a-z0-9 ]{2,30}?)(?= (?:hr|bp|rr|spo2|pain|triage|evac|tq|give|note|status|ammo|weight)\b| $)/))) {
const q = r[1].trim();
const byId = ctx.team.find(x => x.id.toLowerCase() === q.replace(/\s/g, ''));
const hit = byId || fuzzyFind(q, ctx.team, x => `${x.id} ${x.role} ${x.title} ${x.name}`.toLowerCase());
if (hit) push({ type:'select', id:hit.id, label:`Select ${hit.role} ${hit.name || hit.id}` });
}
const vit = {};
if ((r = m(/\bhr (\d{2,3})\b/))) vit.hr = r[1];
if ((r = m(/\bbp (\d{2,3}) ?\/ ?(\d{2,3})\b/)) || (r = m(/\bbp (\d{2,3}) (\d{2,3})\b/))) { vit.sbp = r[1]; vit.dbp = r[2]; } else if ((r = m(/\b(?:bp|systolic) (\d{2,3})\b/))) vit.sbp = r[1];
if ((r = m(/\brr (\d{1,2})\b/))) vit.rr = r[1];
if ((r = m(/\bspo2 (\d{2,3})\b/))) vit.spo2 = r[1];
if ((r = m(/\bpain (?:score |level |is )?(\d{1,2})\b/))) vit.pain = r[1];
if (Object.keys(vit).length) push({ type:'vitals', vit, label:'Vitals ' + Object.entries(vit).map(([k, v]) => `${k.toUpperCase()} ${v}`).join(' ') });
if ((r = m(/\bavpu (alert|verbal|pain|unresponsive|a|v|p|u)\b/)) || (r = m(/\b(?:patient|casualty|he|she) is (alert|verbal|unresponsive)\b/))) push({ type:'avpu', v:r[1][0].toUpperCase(), label:`AVPU ${r[1][0].toUpperCase()}` });
if (m(/\b(?:record|log|save|commit) (?:the )?vitals\b|\bvitals (?:recorded|logged)\b/)) push({ type:'recordVitals', label:'Record vitals' });
if ((r = m(/\btriage (immediate|delayed|minimal|expectant)\b/))) push({ type:'set', k:'triage', v:r[1], label:`Triage ${r[1]}` });
if ((r = m(/\bevac(?:uation)? (?:priority )?(urgent|priority|routine)\b/)) || (r = m(/\b(urgent|priority|routine) (?:evac|casualty)\b/))) push({ type:'set', k:'evac', v:r[1], label:`EVAC ${r[1]}` });
if ((r = m(/\b(?:readiness|status) (green|amber|red|black|auto|up|wounded|urgent|kia)\b/))) { const v = ({up:'green',wounded:'amber',urgent:'red',kia:'black'})[r[1]] || r[1]; push({ type:'set', k:'status', v, label:`Status ${v}` }); }
if ((r = m(/\bammo (green|amber|red)\b/))) push({ type:'set', k:'ammo', v:r[1], label:`Ammo ${r[1]}` });
if ((r = m(/\b(?:equipment|eqp|gear) (green|amber|red)\b/))) push({ type:'set', k:'equip', v:r[1], label:`Equipment ${r[1]}` });
if ((r = m(/\bweight (\d{2,3})\b/))) push({ type:'set', k:'weight', v:+r[1], label:`Weight ${r[1]} kg` });
if ((r = m(/\bblood type (o|a|b|ab) ?([+-])\b/))) push({ type:'set', k:'bloodType', v:r[1].toUpperCase() + r[2], label:`Blood type ${r[1].toUpperCase()}${r[2]}` });
if ((r = m(/\ballerg(?:y|ies|ic to) (?:to )?([a-z ]{3,30}?)(?= (?:hr|bp|rr|spo2|pain|triage|evac|tq|give|note)\b| $)/))) { const v = /none|nkda|no known/.test(r[1]) ? 'NKDA' : r[1].trim().toUpperCase(); push({ type:'set', k:'allergies', v, label:`Allergies ${v}` }); }
if ((r = m(/\bmechanism (?:of injury )?(?:is )?([a-z ]{2,30}?)(?= (?:hr|bp|rr|spo2|pain|triage|evac|tq|give|note|finding)\b| $)/))) { r[1].split(/ and | plus |,/).forEach(part => { const hit = fuzzyFind(part.replace(/\bgunshot( wound)?\b/, 'gsw'), ctx.mechs); if (hit) push({ type:'toggleIn', k:'mechanism', v:hit, on:true, label:`Mechanism ${hit}` }); }); }
if ((r = m(/\b(?:acute )?finding(?:s)? (?:is |of )?([a-z ]{3,30}?)(?= (?:hr|bp|rr|spo2|pain|triage|evac|tq|give|note)\b| $)/))) { const hit = fuzzyFind(r[1], ctx.findings); if (hit) push({ type:'toggleIn', k:'acuteFindings', v:hit, on:true, label:`Finding ${hit}` }); }
if (m(/\btq\b/)) { const limb = LIMBS.find(([re]) => re.test(s)); if (limb) { const tm = s.match(TIME_RE); const type = (s.match(/\b(cat|soft-?t|sam-?xt|tmt|rats|junctional|improvised)\b/) || [])[1]; const pos = /proximal|high and tight|high/.test(s) ? 'Proximal' : /mid/.test(s) ? 'Mid-Limb' : /distal/.test(s) ? 'Distal' : /above (?:the )?wound/.test(s) ? '2-3" Above Wound' : ''; const off = /\b(?:remove|off|release|convert(?:ed)?)\b/.test(s); push({ type:'tq', limb:limb[1], time: tm ? tm[1].padStart(2, '0') + tm[2] : null, tqType: type ? type.toUpperCase().replace('SOFTT', 'SOFT-T') : '', pos, off, label:`TQ ${limb[1]} ${off ? 'off' : 'on'}${tm ? ' @' + tm[1].padStart(2, '0') + tm[2] : ''}` }); } }
if ((r = m(/\b(?:mark|treatment|intervention|did|done|performed|applied|placed|check)(?: off)? ([a-z0-9 \/\-]{3,50}?)(?= (?:hr|bp|rr|spo2|pain|triage|evac|give|note)\b| $)/)) && !/\btq\b/.test(s)) { const hit = fuzzyFind(r[1], ctx.marchItems, x => x.split(':')[1].toLowerCase()); if (hit) push({ type:'toggleIn', k:'interventions', v:hit, on:!/\bun(?:mark|do|check)\b/.test(s), label:`Tx ${hit.split(':')[1]}` }); }
if ((r = m(/\b(?:give|gave|given|administer(?:ed)?|push(?:ed)?|med(?:ication)?) ([a-z][a-z \-]{2,30}?) (\d+(?:\.\d+)?) ?(mg|mcg|g|grams?|units?|ml)\b/))) { const u = r[3].replace(/grams?/, 'g').replace(/units?/, 'units'); const isFluid = /blood|ltowb|plasma|prbc|lr|ringer|saline|hextend|crystalloid/.test(r[1]) || u === 'ml'; if (isFluid) push({ type:'fluid', name:r[1].trim(), vol:r[2], label:`Fluid ${r[1].trim()} ${r[2]} mL` }); else push({ type:'med', name:r[1].trim(), dose:`${r[2]} ${u}`, label:`Med ${r[1].trim()} ${r[2]} ${u}` }); }
else if ((r = m(/\b(?:fluid|hung|hang|started) (whole blood|ltowb|plasma|prbc|lr|normal saline|hextend|crystalloid)(?: (\d{2,4}))?/))) push({ type:'fluid', name:r[1], vol:r[2] || '', label:`Fluid ${r[1]}` });
if ((r = m(/\btime of burn (\d{1,2}):?(\d{2})\b/))) push({ type:'burn', patch:{ burnTime:r[1].padStart(2, '0') + r[2] }, label:`Time of burn ${r[1].padStart(2, '0')}${r[2]}` });
if ((r = m(/\binhalation (?:injury )?(yes|no|on|off|positive|\+|-)\b/))) push({ type:'burn', patch:{ inhal:/yes|on|\+|positive/.test(r[1]) }, label:'Inhalation ' + r[1] });
if ((r = m(/\bcircumferential (yes|no|on|off)\b/))) push({ type:'burn', patch:{ circ:/yes|on/.test(r[1]) }, label:'Circumferential ' + r[1] });
if ((r = m(/\b(?:urine|uop|urine output) (\d{1,4})\b/))) push({ type:'uop', ml:+r[1], label:`UOP ${r[1]} mL` });
if ((r = m(/\bformula (parkland|brooke|rule of (?:ten|10))\b/))) push({ type:'burn', patch:{ formula:/rule/.test(r[1]) ? 'rule10' : r[1] }, label:`Formula ${r[1]}` });
if ((r = m(/\bline (1|one|2|two|4|four|6|six|7|seven|8|eight|9|nine) (.{1,60}?)(?= line \b| $)/))) { const n = ({one:1,two:2,four:4,six:6,seven:7,eight:8,nine:9})[r[1]] || +r[1]; let v = r[2].trim(); if ([4,6,7,8].includes(n)) { const ph = v.match(/\b(alpha|alfa|bravo|charlie|delta|echo|november|papa|x-?ray|a|b|c|d|e|n|p|x)\b/); if (ph) v = ({november:'N',papa:'P','x-ray':'X',xray:'X'})[ph[1]] || PHONETIC[ph[1]] || ph[1].toUpperCase(); } push({ type:'nine', n, v:n === 1 ? v.toUpperCase() : v, label:`9-Line L${n} ${v}` }); }
if ((r = raw.match(/\b(?:note|notes|narrative|remark)[:,]? (.{3,})$/i))) push({ type:'note', text:r[1].trim(), label:`Note “${r[1].trim().slice(0, 40)}${r[1].length > 40 ? '…' : ''}”` });
return cmds;
}
function useVoice({ enabled, lang = 'en-US', onFinal, onInterim, onError }) {
const recRef = React.useRef(null), wantRef = React.useRef(enabled);
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
wantRef.current = enabled;
React.useEffect(() => {
if (!SR) return;
if (!enabled) { if (recRef.current) { try { recRef.current.stop(); } catch (e) {} recRef.current = null; } return; }
const rec = new SR(); rec.continuous = true; rec.interimResults = true; rec.lang = lang; rec.maxAlternatives = 1;
rec.onresult = (e) => { let interim = ''; for (let i = e.resultIndex; i < e.results.length; i++) { const res = e.results[i]; if (res.isFinal) onFinal(res[0].transcript, res[0].confidence); else interim += res[0].transcript; } onInterim(interim); };
rec.onerror = (e) => { onError && onError(e.error); if (e.error === 'not-allowed' || e.error === 'service-not-allowed') wantRef.current = false; };
rec.onend = () => { if (wantRef.current && recRef.current === rec) { try { rec.start(); } catch (e) {} } };
recRef.current = rec; try { rec.start(); } catch (e) {}
return () => { wantRef.current = false; try { rec.stop(); } catch (e) {} if (recRef.current === rec) recRef.current = null; };
}, [enabled, lang]);
return { supported: !!SR };
}
function VoiceHUD({ listening, interim, log, onClose, supported, error, wake }) {
return <div className={'vhud' + (listening ? ' live' : '')} data-screen-label="Voice HUD">
<div className="vhud-h"><span className="vled"></span><b>VOICE</b><span className="vhud-sub">{!supported ? 'not supported in this browser' : error ? `mic: ${error}` : listening ? (wake ? `say “${wake} …” then a command` : 'listening · on-device') : 'off'}</span><span style={{ flex:1 }}></span><button className="dz-x" onClick={onClose}>×</button></div>
<div className="vhud-live">{interim || <span style={{ color:'var(--fg-3)' }}>{listening ? '…' : 'Tap VOICE to start'}</span>}</div>
<div className="vhud-log">{log.slice(-6).reverse().map((l, i) => <div key={l.id} className={'vlog ' + l.kind} style={{ opacity: 1 - i * .13 }}><span className="vt">{l.t}</span><span className="vq">“{l.heard}”</span><span className="va">{l.kind === 'ok' ? '→ ' + l.did : l.kind === 'warn' ? '? not understood' : l.did}</span></div>)}</div>
<div className="vhud-help">Try: “select 18 bravo one” · “HR 110 BP 104 over 68 sat 97 pain 4” · “record vitals” · “TQ right leg at 1342 CAT proximal” · “mark chest seal left” · “give ketamine 30 mg” · “triage immediate” · “evac urgent” · “note bleeding controlled” · “tab burn” · “focus two” · “undo”</div>
</div>;
}
Object.assign(window, { parseVoice, useVoice, VoiceHUD, normalize });