Add Level 1 push-to-talk voice chat (STT + TTS)
- POST /api/voice/stt: Whisper-cpp transcription of webm/ogg audio - POST /api/voice/tts: Piper Korean TTS returning ogg audio - Web UI: 🎤 mic button in chat input; click to start/stop recording MediaRecorder → /api/voice/stt → auto-fills chat input - Web UI: 🔊 TTS button on each AI message; click to play/stop - config.json: add absolute model/binary paths for whisper-cli and piper Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+128
-1
@@ -1021,6 +1021,10 @@
|
||||
|
||||
.btn { padding: 10px 16px; font-size: 12px; }
|
||||
.btn-sm { padding: 6px 10px; font-size: 11px; border-radius: 10px; }
|
||||
#mic-btn.recording { background: #ef4444 !important; animation: mic-pulse 1s ease-in-out infinite; }
|
||||
@keyframes mic-pulse { 0%,100%{opacity:1} 50%{opacity:0.55} }
|
||||
.tts-btn { background:none;border:none;cursor:pointer;font-size:13px;padding:1px 4px;border-radius:4px;opacity:0.45;vertical-align:middle;line-height:1; }
|
||||
.tts-btn:hover,.tts-btn.playing{opacity:1;background:var(--panel-2);}
|
||||
.export-dropdown { position:relative; display:inline-block; }
|
||||
.export-menu { display:none; position:absolute; right:0; top:calc(100% + 4px); background:var(--panel-2); border:1px solid var(--border); border-radius:8px; box-shadow:0 4px 16px rgba(0,0,0,0.18); z-index:2000; min-width:160px; overflow:hidden; }
|
||||
.export-menu button { display:block; width:100%; padding:8px 14px; text-align:left; background:none; border:none; cursor:pointer; font-size:12px; color:var(--text); white-space:nowrap; }
|
||||
@@ -2420,6 +2424,7 @@
|
||||
<div class="chat-input-row">
|
||||
<input type="file" id="file-upload" accept="image/*,.pdf,.txt,.md,.csv,.xlsx,.xls,.docx,.pptx" style="display:none" onchange="handleFileUpload(this)" />
|
||||
<button class="send-btn" onclick="document.getElementById('file-upload').click()" title="파일 첨부 (이미지·PDF·Excel·txt 등)" style="font-size:18px;width:44px;height:44px" aria-label="파일 첨부">📎</button>
|
||||
<button class="send-btn" id="mic-btn" onclick="toggleMicRecording()" title="음성 입력 (클릭: 시작/중지)" style="font-size:18px;width:44px;height:44px" aria-label="음성 입력">🎤</button>
|
||||
<textarea
|
||||
id="chat-input"
|
||||
placeholder="메시지를 입력하세요... (Enter로 전송, Shift+Enter로 줄바꿈)"
|
||||
@@ -4745,7 +4750,7 @@ function renderChatMessages() {
|
||||
<div class="msg ${msg.role}${pinClass}${selectedClass}${confirmedClass}"${clickHandler}>
|
||||
<div class="msg-avatar">${msg.role === 'user' ? 'U' : '<img src="cherry_logo.png" style="width:48px;height:48px;border-radius:6px">'}</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-role">${msg.role === 'user' ? 'You' : (msg.mode === 'agentic' ? 'CherryClaw (Agent)' : 'CherryClaw')}</div>
|
||||
<div class="msg-role">${msg.role === 'user' ? 'You' : (msg.mode === 'agentic' ? 'CherryClaw (Agent)' : 'CherryClaw')}${msg.role === 'ai' ? ` <button class="tts-btn" onclick="playTTSMsg(this,${idx})" title="음성으로 듣기">🔊</button>` : ''}</div>
|
||||
${msg.role === 'ai' ? renderAssistantContent(msg.content) : `<div class="msg-content">${renderMarkdown(msg.display !== undefined ? msg.display : msg.content, { hideDownload: true })}</div>`}
|
||||
${msg.role === 'ai' ? renderArtifacts(msg.artifacts) : ''}
|
||||
${msg.role === 'ai' ? renderFileDownloads(msg.steps, msg.processEntries, msg.content) : ''}
|
||||
@@ -8827,6 +8832,128 @@ function initLuckyModalInteraction() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Voice: STT (마이크 입력) ─────────────────────────────────────────────
|
||||
var _mediaRecorder = null;
|
||||
var _audioChunks = [];
|
||||
var _isRecording = false;
|
||||
var _micMimeType = '';
|
||||
|
||||
function toggleMicRecording() {
|
||||
if (_isRecording) stopMicRecording();
|
||||
else startMicRecording();
|
||||
}
|
||||
|
||||
function startMicRecording() {
|
||||
navigator.mediaDevices.getUserMedia({ audio: true, video: false })
|
||||
.then(function(stream) {
|
||||
_audioChunks = [];
|
||||
_micMimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus'
|
||||
: MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm'
|
||||
: MediaRecorder.isTypeSupported('audio/ogg;codecs=opus') ? 'audio/ogg;codecs=opus'
|
||||
: '';
|
||||
var opts = _micMimeType ? { mimeType: _micMimeType } : {};
|
||||
_mediaRecorder = new MediaRecorder(stream, opts);
|
||||
_mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) _audioChunks.push(e.data); };
|
||||
_mediaRecorder.onstop = function() {
|
||||
stream.getTracks().forEach(function(t) { t.stop(); });
|
||||
var blob = new Blob(_audioChunks, { type: _micMimeType || 'audio/webm' });
|
||||
sendToSTT(blob);
|
||||
};
|
||||
_mediaRecorder.start();
|
||||
_isRecording = true;
|
||||
var btn = document.getElementById('mic-btn');
|
||||
if (btn) { btn.textContent = '⏹'; btn.title = '녹음 중지 (클릭)'; btn.classList.add('recording'); }
|
||||
})
|
||||
.catch(function(e) { alert('마이크 접근 실패: ' + e.message); });
|
||||
}
|
||||
|
||||
function stopMicRecording() {
|
||||
if (_mediaRecorder && _isRecording) {
|
||||
_mediaRecorder.stop();
|
||||
_isRecording = false;
|
||||
var btn = document.getElementById('mic-btn');
|
||||
if (btn) { btn.textContent = '⌛'; btn.title = '인식 중…'; btn.classList.remove('recording'); }
|
||||
}
|
||||
}
|
||||
|
||||
function sendToSTT(blob) {
|
||||
var ct = _micMimeType || 'audio/webm';
|
||||
fetch('/api/voice/stt', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': ct }, authHeaders()),
|
||||
credentials: 'include',
|
||||
body: blob,
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var btn = document.getElementById('mic-btn');
|
||||
if (btn) { btn.textContent = '🎤'; btn.title = '음성 입력 (클릭: 시작/중지)'; }
|
||||
if (data.success && data.text) {
|
||||
var input = document.getElementById('chat-input');
|
||||
if (input) {
|
||||
input.value = (input.value ? input.value + ' ' : '') + data.text;
|
||||
input.focus();
|
||||
input.dispatchEvent(new Event('input'));
|
||||
}
|
||||
} else if (!data.success) {
|
||||
alert('음성 인식 실패: ' + (data.error || '알 수 없는 오류'));
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
var btn = document.getElementById('mic-btn');
|
||||
if (btn) { btn.textContent = '🎤'; btn.title = '음성 입력 (클릭: 시작/중지)'; }
|
||||
alert('STT 오류: ' + e.message);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Voice: TTS (음성 출력) ───────────────────────────────────────────────
|
||||
var _ttsAudio = null;
|
||||
var _ttsBtnActive = null;
|
||||
|
||||
function playTTSMsg(btn, idx) {
|
||||
var msg = chatHistory[idx];
|
||||
if (!msg) return;
|
||||
var text = (msg.content || '').replace(/!\[.*?\]\(.*?\)/g, '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/```[\s\S]*?```/g, '').replace(/[#*`_~>]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (!text) return;
|
||||
// Toggle off if same button is playing
|
||||
if (_ttsBtnActive === btn && _ttsAudio) {
|
||||
_ttsAudio.pause(); _ttsAudio = null;
|
||||
btn.textContent = '🔊'; btn.classList.remove('playing');
|
||||
_ttsBtnActive = null;
|
||||
return;
|
||||
}
|
||||
// Stop previous
|
||||
if (_ttsAudio) { _ttsAudio.pause(); _ttsAudio = null; }
|
||||
if (_ttsBtnActive) { _ttsBtnActive.textContent = '🔊'; _ttsBtnActive.classList.remove('playing'); }
|
||||
btn.textContent = '⌛'; btn.classList.add('playing');
|
||||
_ttsBtnActive = btn;
|
||||
fetch('/api/voice/tts', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ text: text.slice(0, 2000) }),
|
||||
})
|
||||
.then(function(r) { if (!r.ok) throw new Error('TTS 오류 ' + r.status); return r.blob(); })
|
||||
.then(function(blob) {
|
||||
var url = URL.createObjectURL(blob);
|
||||
_ttsAudio = new Audio(url);
|
||||
_ttsAudio.onended = function() {
|
||||
URL.revokeObjectURL(url); _ttsAudio = null;
|
||||
btn.textContent = '🔊'; btn.classList.remove('playing'); _ttsBtnActive = null;
|
||||
};
|
||||
_ttsAudio.onerror = function() {
|
||||
URL.revokeObjectURL(url); _ttsAudio = null;
|
||||
btn.textContent = '🔊'; btn.classList.remove('playing'); _ttsBtnActive = null;
|
||||
};
|
||||
btn.textContent = '⏹';
|
||||
_ttsAudio.play();
|
||||
})
|
||||
.catch(function(e) {
|
||||
btn.textContent = '🔊'; btn.classList.remove('playing'); _ttsBtnActive = null;
|
||||
alert('TTS 오류: ' + e.message);
|
||||
});
|
||||
}
|
||||
|
||||
function saveLuckyModal() {
|
||||
var url = _luckyCurrentSaveUrl;
|
||||
if (!url) { alert('저장할 파일 경로가 없습니다.'); return; }
|
||||
|
||||
Reference in New Issue
Block a user