feat: 의료탭 시진·청진 캡처 — 라이브 카메라와 원음 청진 녹음
- 시진: camera.js 라이브 카메라(메시지마다 프레임 자동 첨부) + 현재 화면을 환자 기록 자료로 저장 - 청진: 잡음제거·자동음량을 끈 원음 녹음(입력장치 선택, 실시간 파형, 부위 태그) → 무손실 WAV로 기록에 저장 - 자료 탭에 청진·오디오 분류와 재생 플레이어 추가(서버 분류는 다른 앱과 공유라 확장자로 클라이언트 보정) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -224,6 +224,20 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
.dr-img-viewer-body{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;overflow:hidden;cursor:grab;}
|
||||
.dr-img-viewer-body.dragging{cursor:grabbing;}
|
||||
.dr-img-viewer-body img{max-width:96%;max-height:96%;object-fit:contain;transform-origin:center center;transition:transform .08s linear;user-select:none;}
|
||||
/* 시진·청진 캡처 패널 */
|
||||
.dr-cap-panel{display:flex;flex-direction:column;gap:6px;padding:8px 10px;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}
|
||||
#camera-panel.dr-cap-panel{flex-direction:row;align-items:center;flex-wrap:wrap;gap:10px}
|
||||
#camera-panel video{height:96px;max-width:48%;border-radius:8px;background:#000;object-fit:cover}
|
||||
.dr-cap-ctl{display:flex;flex-direction:column;gap:6px}
|
||||
.dr-cap-ctl label{display:flex;align-items:center;gap:6px;cursor:pointer}
|
||||
.dr-cap-btns,.dr-cap-row{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
|
||||
.dr-cap-panel button,.dr-cap-panel select{background:none;border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:12px;color:var(--text);cursor:pointer;font-family:inherit}
|
||||
.dr-cap-panel select{max-width:48%;background:var(--bg,transparent)}
|
||||
.dr-cap-panel button:hover{border-color:var(--acc,#22c55e)}
|
||||
.dr-cap-hint{font-size:11px;color:var(--muted);line-height:1.4}
|
||||
#steth-wave{width:100%;height:56px;background:#0b0f0c;border-radius:6px}
|
||||
.app-mic-btn.on{border-color:#ef4444;color:#ef4444}
|
||||
.dr-msg .dr-cam-shot{display:block;max-width:140px;border-radius:6px;margin-bottom:4px}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/vendor/katex/katex.min.css">
|
||||
<script src="/vendor/katex/katex.min.js"></script>
|
||||
@@ -231,6 +245,8 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
<script src="/vendor/katex/mhchem.min.js"></script>
|
||||
<script src="../js/app/katex-inline.js"></script>
|
||||
<script src="../js/voice/voice-utils.js"></script>
|
||||
<script src="../js/app/camera.js"></script>
|
||||
<script src="../js/app/doctor-capture.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -452,11 +468,48 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
<button class="dr-chat-clear" onclick="clearChat()" title="대화 지우기">🗑</button>
|
||||
</div>
|
||||
<div class="dr-msgs" id="chat-msgs"></div>
|
||||
<!-- 시진: 라이브 카메라 패널 (js/app/camera.js) — 켜 두면 메시지마다 현재 화면 자동 첨부 -->
|
||||
<div id="camera-panel" class="dr-cap-panel" style="display:none">
|
||||
<video id="camera-video" autoplay muted playsinline></video>
|
||||
<div class="dr-cap-ctl">
|
||||
<label><input type="checkbox" id="camera-auto" checked> 메시지마다 현재 화면 자동 첨부</label>
|
||||
<div class="dr-cap-btns">
|
||||
<button onclick="cameraSnapAttach()" title="지금 화면을 입력창에 첨부">📸 촬영</button>
|
||||
<button onclick="drCameraSaveToCase()" title="지금 화면을 이 기록의 자료로 저장">🗂 기록에 저장</button>
|
||||
<button onclick="cameraFlip()" title="전면/후면 전환">🔄</button>
|
||||
<button onclick="cameraClose()" title="카메라 끄기">✕</button>
|
||||
</div>
|
||||
<span id="dr-cam-status" class="dr-cap-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 청진: 원음 녹음 패널 (js/app/doctor-capture.js) -->
|
||||
<div id="steth-panel" class="dr-cap-panel dr-steth" style="display:none">
|
||||
<div class="dr-cap-row">
|
||||
<select id="steth-site" title="청진 부위">
|
||||
<optgroup label="심음"><option>심음-대동맥판(우2늑간)</option><option>심음-폐동맥판(좌2늑간)</option><option>심음-삼첨판(좌흉골연)</option><option>심음-승모판(심첨부)</option></optgroup>
|
||||
<optgroup label="폐음"><option>폐-우상엽</option><option>폐-좌상엽</option><option>폐-우하엽</option><option>폐-좌하엽</option><option>폐-등(후면)</option></optgroup>
|
||||
<optgroup label="기타"><option>경동맥</option><option>복부-장음</option><option>기타</option></optgroup>
|
||||
</select>
|
||||
<select id="steth-dev" onchange="stethChangeDevice(this.value)" title="입력 장치(전자청진기·청진기 마이크)"></select>
|
||||
</div>
|
||||
<canvas id="steth-wave" width="320" height="56"></canvas>
|
||||
<div class="dr-cap-row">
|
||||
<button id="steth-rec" onclick="stethRecToggle()">⏺ 녹음</button>
|
||||
<span id="steth-time" class="dr-cap-hint">0:00</span>
|
||||
<button id="steth-save" onclick="stethSave()" style="display:none">💾 기록에 저장</button>
|
||||
<button onclick="stethClose()" title="닫기">✕</button>
|
||||
</div>
|
||||
<audio id="steth-audio" controls style="display:none;width:100%;height:32px"></audio>
|
||||
<span id="steth-status" class="dr-cap-hint"></span>
|
||||
<span class="dr-cap-hint">잡음 제거·자동 음량을 꺼서 원음 그대로 무손실(WAV)로 저장합니다. 최대 60초. 일반 폰 마이크로는 심음·호흡음이 잘 안 잡히므로 전자청진기나 청진기용 마이크를 입력 장치로 고르세요.</span>
|
||||
</div>
|
||||
<div class="dr-input-bar">
|
||||
<textarea class="dr-textarea" id="chat-input" rows="1" placeholder="증상이나 건강 관련 질문을 입력하세요..." onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
|
||||
<input type="file" id="file-input" accept=".pdf,.txt" style="display:none" onchange="onFilePicked(this.files[0])">
|
||||
<button class="app-mic-btn" onclick="document.getElementById('file-input').click()" title="검사결과·진단서 첨부 (PDF/TXT)">📎</button>
|
||||
<button class="app-mic-btn" id="mic-btn" onclick="toggleMicRecording()" title="음성 입력">🎤</button>
|
||||
<button class="app-mic-btn" id="camera-btn" onclick="toggleCamera()" title="카메라 켜기 (시진 — 메시지마다 현재 화면 첨부)">📷</button>
|
||||
<button class="app-mic-btn" id="steth-btn" onclick="toggleSteth()" title="청진 녹음">🎧</button>
|
||||
<button class="dr-send" id="send-btn" onclick="sendMessage()">전송</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -557,7 +610,7 @@ async function loadHistory(){
|
||||
const el=document.getElementById('chat-msgs');
|
||||
const sep=document.createElement('div');sep.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep.textContent='─ 이전 대화 ─';el.appendChild(sep);
|
||||
for(const m of recent){
|
||||
if(m.role==='user')addMsg('user',escHtml(m.content||''));
|
||||
if(m.role==='user')addMsg('user',userMsgHtml(m.content));
|
||||
else addMsg('assistant',renderMd(m.content||''));
|
||||
}
|
||||
const sep2=document.createElement('div');sep2.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep2.textContent='─ 이어서 ─';el.appendChild(sep2);
|
||||
@@ -991,9 +1044,11 @@ function fmtFileSize(n){
|
||||
}
|
||||
|
||||
const DR_TEXT_EDIT_EXTS=/\.(txt|md|csv)$/i;
|
||||
const DR_CATEGORY_LABELS={photo:'📷 사진',video:'🎥 동영상',document:'📄 문서',other:'📦 기타'};
|
||||
const DR_CATEGORY_ORDER=['photo','video','document','other'];
|
||||
const DR_CATEGORY_ICONS={photo:'📷',video:'🎥',document:'📄',other:'📦'};
|
||||
const DR_CATEGORY_LABELS={photo:'📷 사진',audio:'🎧 청진·오디오',video:'🎥 동영상',document:'📄 문서',other:'📦 기타'};
|
||||
const DR_CATEGORY_ORDER=['photo','audio','video','document','other'];
|
||||
const DR_CATEGORY_ICONS={photo:'📷',audio:'🎧',video:'🎥',document:'📄',other:'📦'};
|
||||
// 서버 분류(case-storage.ts)는 다른 앱과 공유라 오디오 카테고리가 없다 — 여기서 확장자로 보정
|
||||
const DR_AUDIO_EXTS=/\.(wav|mp3|m4a|aac|ogg|oga|flac)$/i;
|
||||
|
||||
// Files are separated by which tab's upload button was actually used
|
||||
// (uploadOneCaseFile prefixes with 'attachments/' or 'submissions/'), not by
|
||||
@@ -1030,7 +1085,7 @@ async function loadCaseDocs(){
|
||||
function renderCaseFileList(list,files){
|
||||
if(!files.length){list.innerHTML='<div class="dr-cl-empty">자료를 업로드하세요.</div>';return;}
|
||||
const groups={};
|
||||
files.forEach(f=>{(groups[f.category||'other']=groups[f.category||'other']||[]).push(f);});
|
||||
files.forEach(f=>{if((f.category||'other')==='other'&&DR_AUDIO_EXTS.test(f.name))f.category='audio';(groups[f.category||'other']=groups[f.category||'other']||[]).push(f);});
|
||||
list.innerHTML=DR_CATEGORY_ORDER.filter(cat=>groups[cat]?.length).map(cat=>{
|
||||
const items=groups[cat];
|
||||
return `<div class="dr-ev-group-title">${DR_CATEGORY_LABELS[cat]} (${items.length})</div>`
|
||||
@@ -1049,7 +1104,8 @@ function renderCaseFileRow(f){
|
||||
:isImage
|
||||
?`<a href="javascript:void(0)" onclick="openImageViewer('${escJsAttr(f.url)}','${escJsAttr(f.name)}')" style="flex:1;display:flex;align-items:center;gap:6px;color:var(--text);text-decoration:none;font-size:12px;word-break:break-all"><img src="${escAttr(f.url)}" alt="" style="width:28px;height:28px;object-fit:cover;border-radius:4px;flex-shrink:0">${escHtml(f.name)}</a>`
|
||||
:`<a href="${escAttr(f.url)}" target="_blank" style="flex:1;color:var(--text);text-decoration:none;font-size:12px;word-break:break-all">${escHtml(f.name)}</a>`;
|
||||
return `<div class="dr-cl-item">
|
||||
const isAudio=f.category==='audio';
|
||||
return `<div class="dr-cl-item"${isAudio?' style="flex-wrap:wrap"':''}>
|
||||
<span style="flex-shrink:0">${icon}</span>
|
||||
${nameHtml}
|
||||
<span style="font-size:10px;color:var(--muted);flex-shrink:0">${fmtFileSize(f.size)}</span>
|
||||
@@ -1057,6 +1113,7 @@ function renderCaseFileRow(f){
|
||||
${isCloudEditable?`<a href="/api/doctor/collabora-open?caseId=${encodeURIComponent(currentCaseId)}&relPath=${encodeURIComponent(f.relPath)}" target="_blank" class="dr-cl-del" style="opacity:.7" title="Collabora에서 바로 편집합니다. 입력하는 대로 기록 폴더에 자동 저장됩니다.">🌐</a>`:''}
|
||||
<a href="${escAttr(f.url)}" download="${escAttr(f.name)}" class="dr-cl-del" title="기기에 다운로드">💾</a>
|
||||
<button class="dr-cl-del" onclick="deleteCaseFile('${escJsAttr(f.relPath)}')" title="삭제">✕</button>
|
||||
${isAudio?`<audio controls preload="none" src="${escAttr(f.url)}" style="width:100%;height:30px;flex-basis:100%"></audio>`:''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -1343,14 +1400,25 @@ function renderMd(text){
|
||||
.replace(/<p><\/p>/g,'').replace(/<p>(<[hup])/g,'$1').replace(/(<\/[hup][^>]*>)<\/p>/g,'$1');
|
||||
}
|
||||
|
||||
// 사용자 메시지 HTML — 카메라 프레임 마크다운()은 썸네일로, 나머지는 이스케이프 텍스트로.
|
||||
// escHtml은 ! [ ] ( ) / 를 건드리지 않으므로 이스케이프한 뒤에 치환해도 안전하다.
|
||||
function userMsgHtml(content){
|
||||
return escHtml(content||'').replace(/!\[📷[^\]]*\]\((\/api\/files\/[^)]+)\)\s*/g,
|
||||
(m,url)=>'<img class="dr-cam-shot" src="'+url+'" alt="카메라 화면">');
|
||||
}
|
||||
|
||||
async function sendMessage(){
|
||||
const input=document.getElementById('chat-input');
|
||||
const msg=input.value.trim();
|
||||
if(!msg||document.getElementById('send-btn').disabled)return;
|
||||
input.value='';autoResize(input);
|
||||
if(window.innerWidth<=1024) mobToggle('chat');
|
||||
addMsg('user',escHtml(msg));
|
||||
await callChat(msg);
|
||||
// 시진: 라이브 카메라(camera.js)가 켜져 있으면 지금 화면을 찍어 이 메시지에 붙인다 — 실패해도 메시지는 그대로 전송.
|
||||
let cam='';
|
||||
if(typeof window.cameraFrameForSend==='function'){try{cam=await window.cameraFrameForSend();}catch{cam='';}}
|
||||
const full=cam?cam+'\n\n'+msg:msg;
|
||||
addMsg('user',userMsgHtml(full));
|
||||
await callChat(full);
|
||||
}
|
||||
|
||||
function sendQuick(msg){
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// ── 의료탭(닥터앱) 시진·청진 캡처 ─────────────────────────────────────────────
|
||||
// 시진(사진): camera.js의 라이브 카메라를 그대로 쓰고, "기록에 저장"으로 현재 화면을 환자 기록 자료에 넣는다.
|
||||
// 청진(오디오): 잡음 제거·자동 음량을 끈 원음 녹음 → 무손실 WAV → 기록 자료에 저장.
|
||||
// (voice-call은 echoCancellation/noiseSuppression을 켠다 — 청진에선 심음·호흡음이 "잡음"으로 깎여 나가므로 반드시 끈다)
|
||||
// 의존: doctor-app.html의 currentCaseId, uploadOneCaseFile, loadCaseFiles, activeCaseTab, escHtml, addMsg, authH
|
||||
(function () {
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function pad(n) { return String(n).padStart(2, '0'); }
|
||||
function stamp() {
|
||||
var d = new Date();
|
||||
return d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) + '-' + pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds());
|
||||
}
|
||||
function safeSeg(s) { return String(s || '').replace(/[\\/:*?"<>|\s]+/g, '_'); }
|
||||
function refreshCaseFiles() {
|
||||
try { if (typeof activeCaseTab !== 'undefined' && activeCaseTab === 'files') loadCaseFiles(); } catch (e) {}
|
||||
}
|
||||
|
||||
// ── 시진: 카메라 화면을 환자 기록에 저장 ────────────────────────────────────
|
||||
window.drCameraSaveToCase = async function () {
|
||||
if (!currentCaseId) { alert('기록을 먼저 선택하세요.'); return; }
|
||||
var blob = await window.cameraCaptureBlob();
|
||||
if (!blob) { alert('카메라 화면을 아직 읽을 수 없습니다.'); return; }
|
||||
var st = $('dr-cam-status');
|
||||
if (st) st.textContent = '저장 중…';
|
||||
try {
|
||||
var file = new File([blob], '시진_' + stamp() + '.jpg', { type: 'image/jpeg' });
|
||||
await uploadOneCaseFile(file, 'attachments');
|
||||
if (st) { st.textContent = '✅ 기록에 저장됨'; setTimeout(function () { st.textContent = ''; }, 2500); }
|
||||
refreshCaseFiles();
|
||||
} catch (e) {
|
||||
if (st) st.textContent = '❌ 저장 실패';
|
||||
}
|
||||
};
|
||||
|
||||
// ── 청진: 원음 녹음 ─────────────────────────────────────────────────────────
|
||||
var MAX_SEC = 60;
|
||||
var stream = null, ctx = null, node = null, analyser = null, src = null;
|
||||
var chunks = [], recording = false, startedAt = 0, timer = null, raf = 0;
|
||||
var lastBlob = null, lastUrl = null;
|
||||
|
||||
function setRecUI(rec) {
|
||||
var btn = $('steth-rec');
|
||||
if (btn) btn.textContent = rec ? '⏹ 정지' : '⏺ 녹음';
|
||||
var b = $('steth-btn');
|
||||
if (b) { b.classList.toggle('recording', !!stream); b.classList.toggle('on', !!stream); }
|
||||
}
|
||||
|
||||
async function fillDevices(selectedId) {
|
||||
var sel = $('steth-dev');
|
||||
if (!sel || !navigator.mediaDevices.enumerateDevices) return;
|
||||
var devs = (await navigator.mediaDevices.enumerateDevices()).filter(function (d) { return d.kind === 'audioinput'; });
|
||||
sel.innerHTML = devs.map(function (d, i) {
|
||||
return '<option value="' + escHtml(d.deviceId) + '"' + (d.deviceId === selectedId ? ' selected' : '') + '>' + escHtml(d.label || ('입력장치 ' + (i + 1))) + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function openMic(deviceId) {
|
||||
closeMic();
|
||||
// 원음: 잡음제거·에코제거·자동음량 모두 끔
|
||||
var audio = { channelCount: 1, echoCancellation: false, noiseSuppression: false, autoGainControl: false };
|
||||
if (deviceId) audio.deviceId = { exact: deviceId };
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: audio, video: false });
|
||||
ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
await ctx.audioWorklet.addModule('/js/app/steth-worklet.js');
|
||||
src = ctx.createMediaStreamSource(stream);
|
||||
analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 1024;
|
||||
node = new AudioWorkletNode(ctx, 'steth-capture-processor');
|
||||
node.port.onmessage = function (e) { if (recording) chunks.push(e.data); };
|
||||
src.connect(analyser);
|
||||
src.connect(node);
|
||||
// node는 출력에 연결하지 않는다(자기 소리가 스피커로 되먹임되어 하울링나는 것 방지)
|
||||
await fillDevices(stream.getAudioTracks()[0] && stream.getAudioTracks()[0].getSettings().deviceId);
|
||||
drawWave();
|
||||
setRecUI(false);
|
||||
}
|
||||
|
||||
function closeMic() {
|
||||
if (raf) cancelAnimationFrame(raf); raf = 0;
|
||||
try { node && node.disconnect(); } catch (e) {}
|
||||
try { src && src.disconnect(); } catch (e) {}
|
||||
try { ctx && ctx.close(); } catch (e) {}
|
||||
try { stream && stream.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {}
|
||||
node = src = analyser = ctx = stream = null;
|
||||
recording = false;
|
||||
if (timer) clearInterval(timer); timer = null;
|
||||
}
|
||||
|
||||
// 실시간 파형 — 청진기 접촉 상태·소리 크기를 눈으로 확인하는 용도
|
||||
function drawWave() {
|
||||
var cv = $('steth-wave');
|
||||
if (!cv || !analyser) return;
|
||||
var g = cv.getContext('2d');
|
||||
var buf = new Uint8Array(analyser.fftSize);
|
||||
(function loop() {
|
||||
if (!analyser) return;
|
||||
analyser.getByteTimeDomainData(buf);
|
||||
g.clearRect(0, 0, cv.width, cv.height);
|
||||
g.strokeStyle = recording ? '#ef4444' : '#22c55e';
|
||||
g.lineWidth = 1.5;
|
||||
g.beginPath();
|
||||
for (var i = 0; i < buf.length; i++) {
|
||||
var x = i / buf.length * cv.width;
|
||||
var y = buf[i] / 255 * cv.height;
|
||||
i ? g.lineTo(x, y) : g.moveTo(x, y);
|
||||
}
|
||||
g.stroke();
|
||||
raf = requestAnimationFrame(loop);
|
||||
})();
|
||||
}
|
||||
|
||||
function encodeWav(float32Chunks, sampleRate) {
|
||||
var len = 0; float32Chunks.forEach(function (c) { len += c.length; });
|
||||
var buffer = new ArrayBuffer(44 + len * 2);
|
||||
var v = new DataView(buffer);
|
||||
function str(o, s) { for (var i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); }
|
||||
str(0, 'RIFF'); v.setUint32(4, 36 + len * 2, true); str(8, 'WAVE'); str(12, 'fmt ');
|
||||
v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
|
||||
v.setUint32(24, sampleRate, true); v.setUint32(28, sampleRate * 2, true);
|
||||
v.setUint16(32, 2, true); v.setUint16(34, 16, true); str(36, 'data'); v.setUint32(40, len * 2, true);
|
||||
var o = 44;
|
||||
float32Chunks.forEach(function (c) {
|
||||
for (var i = 0; i < c.length; i++) {
|
||||
var s = Math.max(-1, Math.min(1, c[i]));
|
||||
v.setInt16(o, s < 0 ? s * 0x8000 : s * 0x7fff, true); o += 2;
|
||||
}
|
||||
});
|
||||
return new Blob([buffer], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
window.toggleSteth = async function () {
|
||||
var panel = $('steth-panel');
|
||||
if (stream) { window.stethClose(); return; }
|
||||
try {
|
||||
await openMic(null);
|
||||
} catch (e) {
|
||||
alert('마이크 권한이 필요합니다: ' + e.message);
|
||||
closeMic();
|
||||
return;
|
||||
}
|
||||
panel.style.display = 'flex';
|
||||
};
|
||||
|
||||
window.stethClose = function () {
|
||||
closeMic();
|
||||
var panel = $('steth-panel'); if (panel) panel.style.display = 'none';
|
||||
setRecUI(false);
|
||||
};
|
||||
|
||||
window.stethChangeDevice = async function (id) {
|
||||
if (recording) return;
|
||||
try { await openMic(id); } catch (e) { alert('장치를 열 수 없습니다: ' + e.message); }
|
||||
};
|
||||
|
||||
window.stethRecToggle = function () {
|
||||
if (!stream) return;
|
||||
if (!recording) {
|
||||
chunks = []; lastBlob = null;
|
||||
if (lastUrl) { URL.revokeObjectURL(lastUrl); lastUrl = null; }
|
||||
var au = $('steth-audio'); if (au) { au.removeAttribute('src'); au.style.display = 'none'; }
|
||||
var sv = $('steth-save'); if (sv) sv.style.display = 'none';
|
||||
recording = true; startedAt = Date.now();
|
||||
setRecUI(true);
|
||||
timer = setInterval(function () {
|
||||
var sec = (Date.now() - startedAt) / 1000;
|
||||
$('steth-time').textContent = Math.floor(sec / 60) + ':' + pad(Math.floor(sec % 60));
|
||||
if (sec >= MAX_SEC) window.stethRecToggle();
|
||||
}, 200);
|
||||
} else {
|
||||
recording = false;
|
||||
clearInterval(timer); timer = null;
|
||||
setRecUI(false);
|
||||
if (!chunks.length) return;
|
||||
lastBlob = encodeWav(chunks, ctx.sampleRate);
|
||||
lastUrl = URL.createObjectURL(lastBlob);
|
||||
var a = $('steth-audio'); a.src = lastUrl; a.style.display = '';
|
||||
$('steth-save').style.display = '';
|
||||
$('steth-time').textContent += ' · ' + (lastBlob.size / 1048576).toFixed(1) + 'MB';
|
||||
}
|
||||
};
|
||||
|
||||
window.stethSave = async function () {
|
||||
if (!lastBlob) return;
|
||||
if (!currentCaseId) { alert('기록을 먼저 선택하세요.'); return; }
|
||||
var site = $('steth-site').value || '청진';
|
||||
var name = '청진_' + safeSeg(site) + '_' + stamp() + '.wav';
|
||||
var st = $('steth-status');
|
||||
st.textContent = '저장 중…';
|
||||
try {
|
||||
await uploadOneCaseFile(new File([lastBlob], name, { type: 'audio/wav' }), 'attachments');
|
||||
st.textContent = '✅ 저장됨: ' + name;
|
||||
$('steth-save').style.display = 'none';
|
||||
refreshCaseFiles();
|
||||
} catch (e) { st.textContent = '❌ 저장 실패'; }
|
||||
};
|
||||
|
||||
window.addEventListener('pagehide', function () { closeMic(); });
|
||||
})();
|
||||
@@ -0,0 +1,10 @@
|
||||
// 청진 녹음용 AudioWorklet — 입력 채널 0의 원본 Float32 샘플을 그대로 메인 스레드로 넘긴다.
|
||||
// (리샘플/압축 없음: 심음·호흡음 원음 보존이 목적이라 voice-call의 16kHz 캡처와 별개로 둔다)
|
||||
class StethCaptureProcessor extends AudioWorkletProcessor {
|
||||
process(inputs) {
|
||||
const ch = inputs[0] && inputs[0][0];
|
||||
if (ch && ch.length) this.port.postMessage(ch.slice(0));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('steth-capture-processor', StethCaptureProcessor);
|
||||
Reference in New Issue
Block a user