Spaces:
Sleeping
Sleeping
adding voice feature (tts - sts)
Browse files- api.py +18 -1
- index.html +195 -2
- speak.py +28 -0
- transcribe.py +26 -0
api.py
CHANGED
|
@@ -19,6 +19,8 @@ from fastapi.responses import StreamingResponse
|
|
| 19 |
from chatbot import stream_chat,generate_title,chat
|
| 20 |
import json
|
| 21 |
from sqlalchemy import delete
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
@asynccontextmanager
|
|
@@ -146,7 +148,6 @@ async def chat_stream_route(data: MessageSend, db: AsyncSession = Depends(get_db
|
|
| 146 |
messages = result.scalars().all()
|
| 147 |
history = [{"role":m.role,"content":m.content} for m in messages]
|
| 148 |
|
| 149 |
-
|
| 150 |
if len(history) == 0:
|
| 151 |
generated_title = await generate_title(data.message)
|
| 152 |
await db.execute(
|
|
@@ -190,3 +191,19 @@ async def chat_stream_route(data: MessageSend, db: AsyncSession = Depends(get_db
|
|
| 190 |
|
| 191 |
return StreamingResponse(generate(),media_type="text/event-stream",background=background_tasks)
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
from chatbot import stream_chat,generate_title,chat
|
| 20 |
import json
|
| 21 |
from sqlalchemy import delete
|
| 22 |
+
from fastapi import UploadFile,File,Response
|
| 23 |
+
from transcribe import transcribe_audio
|
| 24 |
|
| 25 |
|
| 26 |
@asynccontextmanager
|
|
|
|
| 148 |
messages = result.scalars().all()
|
| 149 |
history = [{"role":m.role,"content":m.content} for m in messages]
|
| 150 |
|
|
|
|
| 151 |
if len(history) == 0:
|
| 152 |
generated_title = await generate_title(data.message)
|
| 153 |
await db.execute(
|
|
|
|
| 191 |
|
| 192 |
return StreamingResponse(generate(),media_type="text/event-stream",background=background_tasks)
|
| 193 |
|
| 194 |
+
|
| 195 |
+
@app.post("/transcribe")
|
| 196 |
+
async def transcribe_endpoint(audio: UploadFile = File(...)):
|
| 197 |
+
audio_bytes = await audio.read()
|
| 198 |
+
text = await transcribe_audio(audio_bytes,audio.filename)
|
| 199 |
+
return {"text": text}
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
from speak import text_to_speach
|
| 203 |
+
|
| 204 |
+
class SpeakRequest(BaseModel):
|
| 205 |
+
text: str
|
| 206 |
+
|
| 207 |
+
@app.post("/speak")
|
| 208 |
+
async def speak(request: SpeakRequest):
|
| 209 |
+
return await text_to_speach(request.text)
|
index.html
CHANGED
|
@@ -1147,6 +1147,45 @@
|
|
| 1147 |
color: white;
|
| 1148 |
border-color: var(--accent);
|
| 1149 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1150 |
</style>
|
| 1151 |
</head>
|
| 1152 |
<body>
|
|
@@ -1203,6 +1242,9 @@
|
|
| 1203 |
<textarea id="messageInput" placeholder="Message KAI..." rows="1"
|
| 1204 |
oninput="autoResize(this); toggleSend()"
|
| 1205 |
onkeydown="handleKey(event)"></textarea>
|
|
|
|
|
|
|
|
|
|
| 1206 |
<button id="sendBtn" onclick="sendMessage()">
|
| 1207 |
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
| 1208 |
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
|
|
@@ -1252,8 +1294,8 @@
|
|
| 1252 |
</main>
|
| 1253 |
|
| 1254 |
<script>
|
| 1255 |
-
const API = '
|
| 1256 |
-
const REVIEW_API = '
|
| 1257 |
let currentConvId = null;
|
| 1258 |
let isTyping = false;
|
| 1259 |
let webSearchOn = false;
|
|
@@ -1261,6 +1303,96 @@
|
|
| 1261 |
let currentMode = 'chat';
|
| 1262 |
let sidebarCollapsed = false;
|
| 1263 |
let conversationToDelete = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1264 |
|
| 1265 |
// ββ Sidebar Toggle ββββββββββββββββββββββββββββββββββ
|
| 1266 |
function toggleSidebar() {
|
|
@@ -1560,6 +1692,8 @@
|
|
| 1560 |
if (streamDone) break;
|
| 1561 |
}
|
| 1562 |
msgEl.innerHTML = marked.parse(fullText);
|
|
|
|
|
|
|
| 1563 |
msgEl.style.display = 'none';
|
| 1564 |
msgEl.offsetHeight;
|
| 1565 |
msgEl.style.display = '';
|
|
@@ -1590,6 +1724,7 @@
|
|
| 1590 |
</div>
|
| 1591 |
`;
|
| 1592 |
area.appendChild(row);
|
|
|
|
| 1593 |
scrollToBottom();
|
| 1594 |
}
|
| 1595 |
|
|
@@ -1674,6 +1809,64 @@
|
|
| 1674 |
}
|
| 1675 |
}
|
| 1676 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1677 |
// ββ CODE REVIEW LOGIC βββββββββββββββββββββββββββββββββ
|
| 1678 |
const codeInput = document.getElementById('codeInput');
|
| 1679 |
const reviewQuestionInput = document.getElementById('reviewQuestionInput');
|
|
|
|
| 1147 |
color: white;
|
| 1148 |
border-color: var(--accent);
|
| 1149 |
}
|
| 1150 |
+
.mic-btn {
|
| 1151 |
+
width: 34px;
|
| 1152 |
+
height: 34px;
|
| 1153 |
+
background: transparent;
|
| 1154 |
+
border: 1px solid var(--border);
|
| 1155 |
+
border-radius: 9px;
|
| 1156 |
+
cursor: pointer;
|
| 1157 |
+
display: flex;
|
| 1158 |
+
align-items: center;
|
| 1159 |
+
justify-content: center;
|
| 1160 |
+
flex-shrink: 0;
|
| 1161 |
+
transition: all 0.2s;
|
| 1162 |
+
color: var(--text-muted);
|
| 1163 |
+
font-size: 16px;
|
| 1164 |
+
}
|
| 1165 |
+
|
| 1166 |
+
.mic-btn.recording {
|
| 1167 |
+
background: var(--red);
|
| 1168 |
+
border-color: var(--red);
|
| 1169 |
+
color: white;
|
| 1170 |
+
animation: pulse 1.5s infinite;
|
| 1171 |
+
}
|
| 1172 |
+
|
| 1173 |
+
.speak-btn {
|
| 1174 |
+
background: transparent;
|
| 1175 |
+
border: 1px solid var(--border);
|
| 1176 |
+
border-radius: 6px;
|
| 1177 |
+
cursor: pointer;
|
| 1178 |
+
padding: 4px 8px;
|
| 1179 |
+
color: var(--text-muted);
|
| 1180 |
+
font-size: 14px;
|
| 1181 |
+
margin-top: 6px;
|
| 1182 |
+
transition: all 0.2s;
|
| 1183 |
+
}
|
| 1184 |
+
|
| 1185 |
+
.speak-btn:hover {
|
| 1186 |
+
background: var(--surface2);
|
| 1187 |
+
color: var(--accent);
|
| 1188 |
+
}
|
| 1189 |
</style>
|
| 1190 |
</head>
|
| 1191 |
<body>
|
|
|
|
| 1242 |
<textarea id="messageInput" placeholder="Message KAI..." rows="1"
|
| 1243 |
oninput="autoResize(this); toggleSend()"
|
| 1244 |
onkeydown="handleKey(event)"></textarea>
|
| 1245 |
+
<button class="mic-btn" id="micBtn" onclick="toggleRecording()" title="Voice input">
|
| 1246 |
+
π€
|
| 1247 |
+
</button>
|
| 1248 |
<button id="sendBtn" onclick="sendMessage()">
|
| 1249 |
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
| 1250 |
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
|
|
|
|
| 1294 |
</main>
|
| 1295 |
|
| 1296 |
<script>
|
| 1297 |
+
const API = 'http://localhost:8000';
|
| 1298 |
+
const REVIEW_API = 'http://localhost:8000/review';
|
| 1299 |
let currentConvId = null;
|
| 1300 |
let isTyping = false;
|
| 1301 |
let webSearchOn = false;
|
|
|
|
| 1303 |
let currentMode = 'chat';
|
| 1304 |
let sidebarCollapsed = false;
|
| 1305 |
let conversationToDelete = null;
|
| 1306 |
+
let mediaRecorder = null;
|
| 1307 |
+
let audioChunks = [];
|
| 1308 |
+
let isRecording = false;
|
| 1309 |
+
|
| 1310 |
+
async function toggleRecording() {
|
| 1311 |
+
if (isRecording) {
|
| 1312 |
+
await stopRecording();
|
| 1313 |
+
} else {
|
| 1314 |
+
await startRecording();
|
| 1315 |
+
}
|
| 1316 |
+
}
|
| 1317 |
+
|
| 1318 |
+
async function startRecording() {
|
| 1319 |
+
try {
|
| 1320 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 1321 |
+
mediaRecorder = new MediaRecorder(stream);
|
| 1322 |
+
audioChunks = [];
|
| 1323 |
+
|
| 1324 |
+
mediaRecorder.ondataavailable = (e) => {
|
| 1325 |
+
audioChunks.push(e.data);
|
| 1326 |
+
};
|
| 1327 |
+
|
| 1328 |
+
mediaRecorder.onstop = async () => {
|
| 1329 |
+
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
|
| 1330 |
+
await sendAudioToBackend(audioBlob);
|
| 1331 |
+
stream.getTracks().forEach(track => track.stop());
|
| 1332 |
+
};
|
| 1333 |
+
|
| 1334 |
+
mediaRecorder.start();
|
| 1335 |
+
isRecording = true;
|
| 1336 |
+
document.getElementById('micBtn').classList.add('recording');
|
| 1337 |
+
document.getElementById('micBtn').innerHTML = 'π΄';
|
| 1338 |
+
} catch (err) {
|
| 1339 |
+
alert('Microphone access denied. Please allow microphone access.');
|
| 1340 |
+
}
|
| 1341 |
+
}
|
| 1342 |
+
|
| 1343 |
+
async function stopRecording() {
|
| 1344 |
+
if (mediaRecorder) {
|
| 1345 |
+
mediaRecorder.stop();
|
| 1346 |
+
isRecording = false;
|
| 1347 |
+
document.getElementById('micBtn').classList.remove('recording');
|
| 1348 |
+
document.getElementById('micBtn').innerHTML = 'π€';
|
| 1349 |
+
}
|
| 1350 |
+
}
|
| 1351 |
+
|
| 1352 |
+
async function sendAudioToBackend(audioBlob) {
|
| 1353 |
+
const formData = new FormData();
|
| 1354 |
+
formData.append('audio', audioBlob, 'recording.webm');
|
| 1355 |
+
|
| 1356 |
+
console.log("Sending audio, size:", audioBlob.size);
|
| 1357 |
+
|
| 1358 |
+
try {
|
| 1359 |
+
const res = await fetch(`${API}/transcribe`, {
|
| 1360 |
+
method: 'POST',
|
| 1361 |
+
body: formData
|
| 1362 |
+
});
|
| 1363 |
+
|
| 1364 |
+
console.log("Response status:", res.status);
|
| 1365 |
+
|
| 1366 |
+
if (!res.ok) {
|
| 1367 |
+
const errorText = await res.text();
|
| 1368 |
+
console.error("Transcription error:", errorText);
|
| 1369 |
+
alert('Transcription failed: ' + errorText);
|
| 1370 |
+
return;
|
| 1371 |
+
}
|
| 1372 |
+
|
| 1373 |
+
const data = await res.json();
|
| 1374 |
+
console.log("Transcription result:", data);
|
| 1375 |
+
|
| 1376 |
+
const transcribedText = data.text.text; // β fixed
|
| 1377 |
+
|
| 1378 |
+
if (!transcribedText || transcribedText.trim() === '') {
|
| 1379 |
+
alert('No text detected. Please try again.');
|
| 1380 |
+
return;
|
| 1381 |
+
}
|
| 1382 |
+
|
| 1383 |
+
document.getElementById('messageInput').value = transcribedText;
|
| 1384 |
+
toggleSend();
|
| 1385 |
+
autoResize(document.getElementById('messageInput'));
|
| 1386 |
+
|
| 1387 |
+
if (transcribedText.trim()) {
|
| 1388 |
+
sendMessage();
|
| 1389 |
+
}
|
| 1390 |
+
|
| 1391 |
+
} catch (err) {
|
| 1392 |
+
console.error('Network error:', err);
|
| 1393 |
+
alert('Failed to connect to server.');
|
| 1394 |
+
}
|
| 1395 |
+
}
|
| 1396 |
|
| 1397 |
// ββ Sidebar Toggle ββββββββββββββββββββββββββββββββββ
|
| 1398 |
function toggleSidebar() {
|
|
|
|
| 1692 |
if (streamDone) break;
|
| 1693 |
}
|
| 1694 |
msgEl.innerHTML = marked.parse(fullText);
|
| 1695 |
+
addSpeakButton(msgRow);
|
| 1696 |
+
speakText(fullText);
|
| 1697 |
msgEl.style.display = 'none';
|
| 1698 |
msgEl.offsetHeight;
|
| 1699 |
msgEl.style.display = '';
|
|
|
|
| 1724 |
</div>
|
| 1725 |
`;
|
| 1726 |
area.appendChild(row);
|
| 1727 |
+
if (!isUser) addSpeakButton(row);
|
| 1728 |
scrollToBottom();
|
| 1729 |
}
|
| 1730 |
|
|
|
|
| 1809 |
}
|
| 1810 |
}
|
| 1811 |
|
| 1812 |
+
// function speakText(text) {
|
| 1813 |
+
// if ('speechSynthesis' in window) {
|
| 1814 |
+
// window.speechSynthesis.cancel();
|
| 1815 |
+
// const utterance = new SpeechSynthesisUtterance(text);
|
| 1816 |
+
// utterance.lang = 'hi-IN'
|
| 1817 |
+
// utterance.rate = 1;
|
| 1818 |
+
// utterance.pitch = 1;
|
| 1819 |
+
// window.speechSynthesis.speak(utterance);
|
| 1820 |
+
// }
|
| 1821 |
+
// }
|
| 1822 |
+
|
| 1823 |
+
function addSpeakButton(row) {
|
| 1824 |
+
const msgText = row.querySelector('.message-text');
|
| 1825 |
+
const text = msgText.textContent;
|
| 1826 |
+
|
| 1827 |
+
const speakBtn = document.createElement('button');
|
| 1828 |
+
speakBtn.className = 'speak-btn';
|
| 1829 |
+
speakBtn.innerHTML = 'π';
|
| 1830 |
+
speakBtn.title = 'Listen';
|
| 1831 |
+
speakBtn.onclick = () => speakText(text);
|
| 1832 |
+
|
| 1833 |
+
row.querySelector('.message-content').appendChild(speakBtn);
|
| 1834 |
+
}
|
| 1835 |
+
|
| 1836 |
+
async function speakText(text) {
|
| 1837 |
+
try {
|
| 1838 |
+
const res = await fetch(`${API}/speak`, {
|
| 1839 |
+
method: 'POST',
|
| 1840 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1841 |
+
body: JSON.stringify({ text: text })
|
| 1842 |
+
});
|
| 1843 |
+
|
| 1844 |
+
const audioBlob = await res.blob();
|
| 1845 |
+
|
| 1846 |
+
// Check if blob is valid
|
| 1847 |
+
if (audioBlob.size < 100) {
|
| 1848 |
+
console.error("Audio too small, something wrong");
|
| 1849 |
+
return;
|
| 1850 |
+
}
|
| 1851 |
+
|
| 1852 |
+
const audioUrl = URL.createObjectURL(audioBlob);
|
| 1853 |
+
const audio = new Audio(audioUrl);
|
| 1854 |
+
|
| 1855 |
+
// Must be triggered by user click - already is since speakText is called from onclick
|
| 1856 |
+
audio.load();
|
| 1857 |
+
await audio.play();
|
| 1858 |
+
|
| 1859 |
+
} catch (err) {
|
| 1860 |
+
console.error('Failed to speak:', err);
|
| 1861 |
+
// Fallback to browser TTS
|
| 1862 |
+
if ('speechSynthesis' in window) {
|
| 1863 |
+
const utterance = new SpeechSynthesisUtterance(text);
|
| 1864 |
+
utterance.lang = 'hi-IN';
|
| 1865 |
+
window.speechSynthesis.speak(utterance);
|
| 1866 |
+
}
|
| 1867 |
+
}
|
| 1868 |
+
}
|
| 1869 |
+
|
| 1870 |
// ββ CODE REVIEW LOGIC βββββββββββββββββββββββββββββββββ
|
| 1871 |
const codeInput = document.getElementById('codeInput');
|
| 1872 |
const reviewQuestionInput = document.getElementById('reviewQuestionInput');
|
speak.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
from fastapi.responses import Response
|
| 3 |
+
import os
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY")
|
| 9 |
+
ELEVENLABS_VOICE_ID = os.getenv("ELEVENLABS_VOICE_ID")
|
| 10 |
+
|
| 11 |
+
async def text_to_speach(text: str):
|
| 12 |
+
url = f"https://api.elevenlabs.io/v1/text-to-speech/{ELEVENLABS_VOICE_ID}"
|
| 13 |
+
|
| 14 |
+
async with httpx.AsyncClient() as client:
|
| 15 |
+
response = await client.post(
|
| 16 |
+
url,
|
| 17 |
+
headers={"xi-api-key": ELEVENLABS_API_KEY},
|
| 18 |
+
json={
|
| 19 |
+
"text": text,
|
| 20 |
+
"model_id": "eleven_multilingual_v2",
|
| 21 |
+
"voice_settings": {
|
| 22 |
+
"stability": 0.5,
|
| 23 |
+
"similarity_boost": 0.75
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
return Response(content=response.content, media_type="audio/mpeg")
|
transcribe.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import tempfile
|
| 2 |
+
from groq import Groq
|
| 3 |
+
import os
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
client = Groq(api_key=os.getenv("GROQ_API_KEY"))
|
| 9 |
+
|
| 10 |
+
async def transcribe_audio(audio_file: bytes, filename: str):
|
| 11 |
+
suffix = "." + filename.split(".")[-1]
|
| 12 |
+
|
| 13 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
| 14 |
+
tmp.write(audio_file)
|
| 15 |
+
tmp_path = tmp.name
|
| 16 |
+
|
| 17 |
+
try:
|
| 18 |
+
with open(tmp_path,"rb") as f:
|
| 19 |
+
transcription = client.audio.transcriptions.create(
|
| 20 |
+
model="whisper-large-v3-turbo",
|
| 21 |
+
file=("audio.webm",f)
|
| 22 |
+
)
|
| 23 |
+
return {"text": transcription.text}
|
| 24 |
+
finally:
|
| 25 |
+
os.remove(tmp_path)
|
| 26 |
+
|