Spaces:
Sleeping
Sleeping
Commit ·
28674f1
1
Parent(s): 320a9c2
feat: implement real-time text-to-speech highlighting and document management within chat interface
Browse files
backend/app/api/api_v1/endpoints/chat.py
CHANGED
|
@@ -303,20 +303,35 @@ async def speak(request: Request, speak_data: SpeakRequest):
|
|
| 303 |
|
| 304 |
# Internal stop signal for THIS specific request
|
| 305 |
import threading
|
|
|
|
| 306 |
disconnect_event = threading.Event()
|
| 307 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
# Generator wrapper to monitor disconnection
|
| 309 |
async def disconnect_monitor_gen():
|
| 310 |
generator = stream_tts_wav_chunks(clean_text, disconnect_event)
|
| 311 |
try:
|
| 312 |
for chunk in generator:
|
| 313 |
-
if
|
| 314 |
-
disconnect_event.set()
|
| 315 |
break
|
| 316 |
yield chunk
|
| 317 |
except Exception as e:
|
| 318 |
disconnect_event.set()
|
| 319 |
raise e
|
|
|
|
|
|
|
|
|
|
| 320 |
|
| 321 |
return StreamingResponse(
|
| 322 |
disconnect_monitor_gen(),
|
|
|
|
| 303 |
|
| 304 |
# Internal stop signal for THIS specific request
|
| 305 |
import threading
|
| 306 |
+
import asyncio
|
| 307 |
disconnect_event = threading.Event()
|
| 308 |
|
| 309 |
+
async def watch_disconnect():
|
| 310 |
+
try:
|
| 311 |
+
while not disconnect_event.is_set():
|
| 312 |
+
if await request.is_disconnected():
|
| 313 |
+
disconnect_event.set()
|
| 314 |
+
break
|
| 315 |
+
await asyncio.sleep(0.1)
|
| 316 |
+
except asyncio.CancelledError:
|
| 317 |
+
pass
|
| 318 |
+
|
| 319 |
+
watch_task = asyncio.create_task(watch_disconnect())
|
| 320 |
+
|
| 321 |
# Generator wrapper to monitor disconnection
|
| 322 |
async def disconnect_monitor_gen():
|
| 323 |
generator = stream_tts_wav_chunks(clean_text, disconnect_event)
|
| 324 |
try:
|
| 325 |
for chunk in generator:
|
| 326 |
+
if disconnect_event.is_set():
|
|
|
|
| 327 |
break
|
| 328 |
yield chunk
|
| 329 |
except Exception as e:
|
| 330 |
disconnect_event.set()
|
| 331 |
raise e
|
| 332 |
+
finally:
|
| 333 |
+
disconnect_event.set()
|
| 334 |
+
watch_task.cancel()
|
| 335 |
|
| 336 |
return StreamingResponse(
|
| 337 |
disconnect_monitor_gen(),
|
backend/app/services/tts.py
CHANGED
|
@@ -12,6 +12,7 @@ SPEECH_SPEED = 1 # Slower than default 1.0
|
|
| 12 |
|
| 13 |
# Global event to stop any ongoing TTS generation across streams
|
| 14 |
stop_tts_event = threading.Event()
|
|
|
|
| 15 |
|
| 16 |
def clean_text_for_speech(text: str) -> str:
|
| 17 |
"""Removes citations and other non-spoken markers from text, and formats dates."""
|
|
@@ -109,7 +110,7 @@ def stream_tts_wav_chunks(text, cancel_event=None):
|
|
| 109 |
|
| 110 |
if not raw_sentences:
|
| 111 |
return
|
| 112 |
-
|
| 113 |
import soundfile as sf
|
| 114 |
import base64
|
| 115 |
import json
|
|
@@ -127,12 +128,13 @@ def stream_tts_wav_chunks(text, cancel_event=None):
|
|
| 127 |
if not s_clean:
|
| 128 |
continue
|
| 129 |
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
| 136 |
|
| 137 |
if sentence_audio_chunks:
|
| 138 |
full_audio = np.concatenate(sentence_audio_chunks)
|
|
|
|
| 12 |
|
| 13 |
# Global event to stop any ongoing TTS generation across streams
|
| 14 |
stop_tts_event = threading.Event()
|
| 15 |
+
tts_generation_lock = threading.Lock()
|
| 16 |
|
| 17 |
def clean_text_for_speech(text: str) -> str:
|
| 18 |
"""Removes citations and other non-spoken markers from text, and formats dates."""
|
|
|
|
| 110 |
|
| 111 |
if not raw_sentences:
|
| 112 |
return
|
| 113 |
+
|
| 114 |
import soundfile as sf
|
| 115 |
import base64
|
| 116 |
import json
|
|
|
|
| 128 |
if not s_clean:
|
| 129 |
continue
|
| 130 |
|
| 131 |
+
with tts_generation_lock:
|
| 132 |
+
generator = pipeline(s_clean, voice=voice, speed=SPEECH_SPEED)
|
| 133 |
+
sentence_audio_chunks = []
|
| 134 |
+
for gs, ps, audio in generator:
|
| 135 |
+
if (cancel_event and cancel_event.is_set()) or stop_tts_event.is_set() or local_stop_signal.is_set():
|
| 136 |
+
break
|
| 137 |
+
sentence_audio_chunks.append(audio)
|
| 138 |
|
| 139 |
if sentence_audio_chunks:
|
| 140 |
full_audio = np.concatenate(sentence_audio_chunks)
|
frontend-react/src/App.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useState, useEffect } from 'react';
|
| 2 |
import Auth from './components/Auth';
|
| 3 |
import Sidebar from './components/Sidebar';
|
| 4 |
import ChatWindow from './components/ChatWindow';
|
|
@@ -27,14 +27,14 @@ function App() {
|
|
| 27 |
const [refreshSessions, setRefreshSessions] = useState(0);
|
| 28 |
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
| 29 |
|
| 30 |
-
const handleLoginSuccess = (newToken, newEmail) => {
|
| 31 |
setToken(newToken);
|
| 32 |
setEmail(newEmail);
|
| 33 |
localStorage.setItem('rag_token', newToken);
|
| 34 |
localStorage.setItem('rag_email', newEmail);
|
| 35 |
-
};
|
| 36 |
|
| 37 |
-
const handleLogout = () => {
|
| 38 |
setToken('');
|
| 39 |
setEmail('');
|
| 40 |
setMessages([]);
|
|
@@ -42,26 +42,30 @@ function App() {
|
|
| 42 |
setCurrentView('chat');
|
| 43 |
localStorage.removeItem('rag_token');
|
| 44 |
localStorage.removeItem('rag_email');
|
| 45 |
-
};
|
| 46 |
|
| 47 |
-
const handleNewChat = () => {
|
| 48 |
setCurrentSessionId(generateId());
|
| 49 |
setMessages([]);
|
| 50 |
setSessionDocuments([]);
|
| 51 |
setCurrentView('chat');
|
| 52 |
if (window.innerWidth <= 768) setIsSidebarCollapsed(true);
|
| 53 |
-
};
|
| 54 |
|
| 55 |
-
const handleSelectSession = (sessionId) => {
|
| 56 |
setCurrentSessionId(sessionId);
|
| 57 |
setCurrentView('chat');
|
| 58 |
if (window.innerWidth <= 768) setIsSidebarCollapsed(true);
|
| 59 |
-
};
|
| 60 |
|
| 61 |
-
const handleViewLibrary = () => {
|
| 62 |
setCurrentView('library');
|
| 63 |
if (window.innerWidth <= 768) setIsSidebarCollapsed(true);
|
| 64 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
if (!token) {
|
| 67 |
return <Auth onLoginSuccess={handleLoginSuccess} />;
|
|
@@ -112,9 +116,9 @@ function App() {
|
|
| 112 |
messages={messages}
|
| 113 |
setMessages={setMessages}
|
| 114 |
sessionId={currentSessionId}
|
| 115 |
-
onFirstMessage={
|
| 116 |
setIsUploading={setIsUploading}
|
| 117 |
-
onUploadSuccess={
|
| 118 |
isSidebarCollapsed={isSidebarCollapsed}
|
| 119 |
setIsSidebarCollapsed={setIsSidebarCollapsed}
|
| 120 |
sessionDocuments={sessionDocuments}
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback } from 'react';
|
| 2 |
import Auth from './components/Auth';
|
| 3 |
import Sidebar from './components/Sidebar';
|
| 4 |
import ChatWindow from './components/ChatWindow';
|
|
|
|
| 27 |
const [refreshSessions, setRefreshSessions] = useState(0);
|
| 28 |
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
| 29 |
|
| 30 |
+
const handleLoginSuccess = useCallback((newToken, newEmail) => {
|
| 31 |
setToken(newToken);
|
| 32 |
setEmail(newEmail);
|
| 33 |
localStorage.setItem('rag_token', newToken);
|
| 34 |
localStorage.setItem('rag_email', newEmail);
|
| 35 |
+
}, []);
|
| 36 |
|
| 37 |
+
const handleLogout = useCallback(() => {
|
| 38 |
setToken('');
|
| 39 |
setEmail('');
|
| 40 |
setMessages([]);
|
|
|
|
| 42 |
setCurrentView('chat');
|
| 43 |
localStorage.removeItem('rag_token');
|
| 44 |
localStorage.removeItem('rag_email');
|
| 45 |
+
}, []);
|
| 46 |
|
| 47 |
+
const handleNewChat = useCallback(() => {
|
| 48 |
setCurrentSessionId(generateId());
|
| 49 |
setMessages([]);
|
| 50 |
setSessionDocuments([]);
|
| 51 |
setCurrentView('chat');
|
| 52 |
if (window.innerWidth <= 768) setIsSidebarCollapsed(true);
|
| 53 |
+
}, []);
|
| 54 |
|
| 55 |
+
const handleSelectSession = useCallback((sessionId) => {
|
| 56 |
setCurrentSessionId(sessionId);
|
| 57 |
setCurrentView('chat');
|
| 58 |
if (window.innerWidth <= 768) setIsSidebarCollapsed(true);
|
| 59 |
+
}, []);
|
| 60 |
|
| 61 |
+
const handleViewLibrary = useCallback(() => {
|
| 62 |
setCurrentView('library');
|
| 63 |
if (window.innerWidth <= 768) setIsSidebarCollapsed(true);
|
| 64 |
+
}, []);
|
| 65 |
+
|
| 66 |
+
const handleRefreshSessions = useCallback(() => {
|
| 67 |
+
setRefreshSessions(prev => prev + 1);
|
| 68 |
+
}, []);
|
| 69 |
|
| 70 |
if (!token) {
|
| 71 |
return <Auth onLoginSuccess={handleLoginSuccess} />;
|
|
|
|
| 116 |
messages={messages}
|
| 117 |
setMessages={setMessages}
|
| 118 |
sessionId={currentSessionId}
|
| 119 |
+
onFirstMessage={handleRefreshSessions}
|
| 120 |
setIsUploading={setIsUploading}
|
| 121 |
+
onUploadSuccess={handleRefreshSessions}
|
| 122 |
isSidebarCollapsed={isSidebarCollapsed}
|
| 123 |
setIsSidebarCollapsed={setIsSidebarCollapsed}
|
| 124 |
sessionDocuments={sessionDocuments}
|
frontend-react/src/components/ChatWindow.jsx
CHANGED
|
@@ -405,8 +405,16 @@ export default function ChatWindow({
|
|
| 405 |
const handleSpeak = async (text, index) => {
|
| 406 |
// If clicking same button while playing, STOP everything
|
| 407 |
if (speakingIdx === index) {
|
| 408 |
-
if (audioRef.current)
|
| 409 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
|
| 411 |
|
| 412 |
audioQueueRef.current.forEach(item => URL.revokeObjectURL(item.url));
|
|
@@ -418,7 +426,13 @@ export default function ChatWindow({
|
|
| 418 |
}
|
| 419 |
|
| 420 |
// Stop current if any
|
| 421 |
-
if (audioRef.current)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
if (abortControllerRef.current) abortControllerRef.current.abort();
|
| 423 |
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
|
| 424 |
|
|
@@ -478,13 +492,13 @@ export default function ChatWindow({
|
|
| 478 |
playNextInQueue();
|
| 479 |
}
|
| 480 |
} catch (e) {
|
| 481 |
-
console.error("Error parsing audio chunk", e);
|
| 482 |
}
|
| 483 |
}
|
| 484 |
}
|
| 485 |
} catch (err) {
|
| 486 |
if (err.name !== 'AbortError') {
|
| 487 |
-
console.error("TTS Stream error", err);
|
| 488 |
setSpeakingIdx(null);
|
| 489 |
}
|
| 490 |
}
|
|
|
|
| 405 |
const handleSpeak = async (text, index) => {
|
| 406 |
// If clicking same button while playing, STOP everything
|
| 407 |
if (speakingIdx === index) {
|
| 408 |
+
if (audioRef.current) {
|
| 409 |
+
audioRef.current.onplay = null;
|
| 410 |
+
audioRef.current.onended = null;
|
| 411 |
+
audioRef.current.pause();
|
| 412 |
+
audioRef.current.src = "";
|
| 413 |
+
audioRef.current.load();
|
| 414 |
+
}
|
| 415 |
+
if (abortControllerRef.current) {
|
| 416 |
+
abortControllerRef.current.abort();
|
| 417 |
+
}
|
| 418 |
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
|
| 419 |
|
| 420 |
audioQueueRef.current.forEach(item => URL.revokeObjectURL(item.url));
|
|
|
|
| 426 |
}
|
| 427 |
|
| 428 |
// Stop current if any
|
| 429 |
+
if (audioRef.current) {
|
| 430 |
+
audioRef.current.onplay = null;
|
| 431 |
+
audioRef.current.onended = null;
|
| 432 |
+
audioRef.current.pause();
|
| 433 |
+
audioRef.current.src = "";
|
| 434 |
+
audioRef.current.load();
|
| 435 |
+
}
|
| 436 |
if (abortControllerRef.current) abortControllerRef.current.abort();
|
| 437 |
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
|
| 438 |
|
|
|
|
| 492 |
playNextInQueue();
|
| 493 |
}
|
| 494 |
} catch (e) {
|
| 495 |
+
console.error("Error parsing audio chunk JSON:", e);
|
| 496 |
}
|
| 497 |
}
|
| 498 |
}
|
| 499 |
} catch (err) {
|
| 500 |
if (err.name !== 'AbortError') {
|
| 501 |
+
console.error("TTS Stream error:", err);
|
| 502 |
setSpeakingIdx(null);
|
| 503 |
}
|
| 504 |
}
|