Spaces:
Sleeping
Sleeping
Voice: wait 1.3s before replying; new speech cancels the reply in progress
Browse files- core/conversation.py +49 -27
- scripts/chat_demo.py +6 -2
- tests/test_conversation.py +16 -0
core/conversation.py
CHANGED
|
@@ -40,6 +40,7 @@ class Chat:
|
|
| 40 |
matches: list = field(default_factory=list)
|
| 41 |
profile: dict = field(default_factory=dict)
|
| 42 |
lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
_chats: dict[str, Chat] = {}
|
|
@@ -91,37 +92,58 @@ def _search(chat: Chat, gen, spoken: bool):
|
|
| 91 |
|
| 92 |
def turn(chat_id: str, text: str = "", file: str | Path | None = None, spoken: bool = False):
|
| 93 |
chat = get(chat_id)
|
|
|
|
|
|
|
|
|
|
| 94 |
with chat.lock: # one turn at a time per chat (e.g. a voice turn and a typed turn can't interleave)
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
said.append(data)
|
| 105 |
-
chat.history[-1]["content"] = " ".join(said)
|
| 106 |
-
yield ("history", chat.history)
|
| 107 |
-
if spoken:
|
| 108 |
-
yield ("say", data)
|
| 109 |
-
else:
|
| 110 |
-
st = data
|
| 111 |
-
chat.history[-1]["content"] = st["reply"]
|
| 112 |
-
chat.profile = {**st["profile"], "_missing": st["missing"], "_done": st["done"]}
|
| 113 |
-
yield ("history", chat.history)
|
| 114 |
-
yield ("profile", chat.profile)
|
| 115 |
-
if not st["done"]:
|
| 116 |
return
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
yield ("history", chat.history)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
return
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
|
| 127 |
def spoken_turn(chat_id: str, audio_path: str):
|
|
|
|
| 40 |
matches: list = field(default_factory=list)
|
| 41 |
profile: dict = field(default_factory=dict)
|
| 42 |
lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
| 43 |
+
cancel: threading.Event | None = field(default=None, repr=False) # set to stop the turn in progress
|
| 44 |
|
| 45 |
|
| 46 |
_chats: dict[str, Chat] = {}
|
|
|
|
| 92 |
|
| 93 |
def turn(chat_id: str, text: str = "", file: str | Path | None = None, spoken: bool = False):
|
| 94 |
chat = get(chat_id)
|
| 95 |
+
mine = threading.Event()
|
| 96 |
+
if chat.cancel is not None:
|
| 97 |
+
chat.cancel.set() # a newer message replaces a reply still being written; it stops at its next step
|
| 98 |
with chat.lock: # one turn at a time per chat (e.g. a voice turn and a typed turn can't interleave)
|
| 99 |
+
chat.cancel = mine
|
| 100 |
+
yield from _guarded(chat, mine, _turn(chat, text, file, spoken))
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _guarded(chat: Chat, mine: threading.Event, gen):
|
| 104 |
+
"""Pass events through until a newer turn cancels this one; closing the generator stops the work early."""
|
| 105 |
+
try:
|
| 106 |
+
for event in gen:
|
| 107 |
+
if mine.is_set():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
return
|
| 109 |
+
yield event
|
| 110 |
+
finally:
|
| 111 |
+
gen.close()
|
| 112 |
+
if chat.cancel is mine:
|
| 113 |
+
chat.cancel = None
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _turn(chat: Chat, text: str, file, spoken: bool):
|
| 117 |
+
text = (text or "").strip()
|
| 118 |
+
label = ("🎤 " if spoken else "") + (text or ("📎 " + Path(file).name if file else ""))
|
| 119 |
+
chat.history.append({"role": "user", "content": label})
|
| 120 |
+
yield ("history", chat.history)
|
| 121 |
+
if chat.mode == "intake":
|
| 122 |
+
chat.history.append({"role": "assistant", "content": ""})
|
| 123 |
+
said, st = [], None
|
| 124 |
+
for kind, data in intake.send_stream(chat.id, text, file=file):
|
| 125 |
+
if kind == "say":
|
| 126 |
+
said.append(data)
|
| 127 |
+
chat.history[-1]["content"] = " ".join(said)
|
| 128 |
yield ("history", chat.history)
|
| 129 |
+
if spoken:
|
| 130 |
+
yield ("say", data)
|
| 131 |
+
else:
|
| 132 |
+
st = data
|
| 133 |
+
chat.history[-1]["content"] = st["reply"]
|
| 134 |
+
chat.profile = {**st["profile"], "_missing": st["missing"], "_done": st["done"]}
|
| 135 |
+
yield ("history", chat.history)
|
| 136 |
+
yield ("profile", chat.profile)
|
| 137 |
+
if not st["done"]:
|
| 138 |
return
|
| 139 |
+
chat.mode = "match"
|
| 140 |
+
yield ("mode", chat.mode)
|
| 141 |
+
yield from _search(chat, matchmaker.find(chat.id), spoken)
|
| 142 |
+
if chat.matches:
|
| 143 |
+
chat.history.append({"role": "assistant", "content": FOLLOW_UP})
|
| 144 |
+
yield ("history", chat.history)
|
| 145 |
+
return
|
| 146 |
+
yield from _search(chat, matchmaker.chat(chat.id, text), spoken)
|
| 147 |
|
| 148 |
|
| 149 |
def spoken_turn(chat_id: str, audio_path: str):
|
scripts/chat_demo.py
CHANGED
|
@@ -130,7 +130,7 @@ async def voice_turn(chat_id: str, request: Request):
|
|
| 130 |
VOICE_JS = r"""
|
| 131 |
<script>
|
| 132 |
(() => {
|
| 133 |
-
const SPEECH = 0.018, SPEECH_WHILE_PLAYING = 0.07, END_SILENCE_MS =
|
| 134 |
const S = {on: false, stream: null, ctx: null, proc: null, rate: 48000, pre: [], cur: null, inSpeech: false,
|
| 135 |
speechMs: 0, silenceMs: 0, abort: null, queue: [], playing: null, busy: false};
|
| 136 |
const el = id => document.getElementById(id);
|
|
@@ -198,7 +198,11 @@ VOICE_JS = r"""
|
|
| 198 |
const rms = Math.sqrt(sum / frame.length), thr = S.playing ? SPEECH_WHILE_PLAYING : SPEECH;
|
| 199 |
S.pre.push(frame); while (S.pre.length * frame.length > PREROLL_S * S.rate) S.pre.shift();
|
| 200 |
if (rms > thr) {
|
| 201 |
-
if (!S.inSpeech) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
S.silenceMs = 0;
|
| 203 |
} else if (S.inSpeech) { S.silenceMs += ms; }
|
| 204 |
if (S.inSpeech) {
|
|
|
|
| 130 |
VOICE_JS = r"""
|
| 131 |
<script>
|
| 132 |
(() => {
|
| 133 |
+
const SPEECH = 0.018, SPEECH_WHILE_PLAYING = 0.07, END_SILENCE_MS = 1300, MIN_SPEECH_MS = 350, PREROLL_S = 0.5;
|
| 134 |
const S = {on: false, stream: null, ctx: null, proc: null, rate: 48000, pre: [], cur: null, inSpeech: false,
|
| 135 |
speechMs: 0, silenceMs: 0, abort: null, queue: [], playing: null, busy: false};
|
| 136 |
const el = id => document.getElementById(id);
|
|
|
|
| 198 |
const rms = Math.sqrt(sum / frame.length), thr = S.playing ? SPEECH_WHILE_PLAYING : SPEECH;
|
| 199 |
S.pre.push(frame); while (S.pre.length * frame.length > PREROLL_S * S.rate) S.pre.shift();
|
| 200 |
if (rms > thr) {
|
| 201 |
+
if (!S.inSpeech) {
|
| 202 |
+
S.inSpeech = true; S.speechMs = 0; S.cur = S.pre.slice(); stopPlayback();
|
| 203 |
+
if (S.abort) { S.abort.abort(); S.abort = null; S.busy = false; } // they're talking again: drop the old reply
|
| 204 |
+
status('Listening…');
|
| 205 |
+
}
|
| 206 |
S.silenceMs = 0;
|
| 207 |
} else if (S.inSpeech) { S.silenceMs += ms; }
|
| 208 |
if (S.inSpeech) {
|
tests/test_conversation.py
CHANGED
|
@@ -39,3 +39,19 @@ def test_spoken_turn_reports_transcription_problems(monkeypatch):
|
|
| 39 |
chat = conversation.start()
|
| 40 |
assert list(conversation.spoken_turn(chat.id, "x.wav")) == [("error", voice.NOTHING_HEARD)]
|
| 41 |
assert len(chat.history) == 1 # nothing added to the chat for an empty recording
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
chat = conversation.start()
|
| 40 |
assert list(conversation.spoken_turn(chat.id, "x.wav")) == [("error", voice.NOTHING_HEARD)]
|
| 41 |
assert len(chat.history) == 1 # nothing added to the chat for an empty recording
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_new_message_cancels_the_reply_in_progress(monkeypatch):
|
| 45 |
+
import threading
|
| 46 |
+
fake_llm(monkeypatch, TURN)
|
| 47 |
+
chat = conversation.start()
|
| 48 |
+
first = conversation.turn(chat.id, "I'm Maha", spoken=True)
|
| 49 |
+
next(first) # first turn is running (holding the chat)
|
| 50 |
+
done = []
|
| 51 |
+
t = threading.Thread(target=lambda: done.append(list(conversation.turn(chat.id, "raising 1.5M", spoken=True))))
|
| 52 |
+
t.start()
|
| 53 |
+
import time; time.sleep(0.2) # the second turn has asked the first to stop
|
| 54 |
+
rest = list(first) # first turn stops at its next step
|
| 55 |
+
t.join(timeout=5)
|
| 56 |
+
assert not t.is_alive() and done and rest == []
|
| 57 |
+
assert chat.history[-2]["content"] == "🎤 raising 1.5M"
|