khattab273 commited on
Commit
dea1bee
·
verified ·
1 Parent(s): 979cf4e

Voice: wait 1.3s before replying; new speech cancels the reply in progress

Browse files
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
- text = (text or "").strip()
96
- label = ("🎤 " if spoken else "") + (text or ("📎 " + Path(file).name if file else ""))
97
- chat.history.append({"role": "user", "content": label})
98
- yield ("history", chat.history)
99
- if chat.mode == "intake":
100
- chat.history.append({"role": "assistant", "content": ""})
101
- said, st = [], None
102
- for kind, data in intake.send_stream(chat.id, text, file=file):
103
- if kind == "say":
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
- chat.mode = "match"
118
- yield ("mode", chat.mode)
119
- yield from _search(chat, matchmaker.find(chat.id), spoken)
120
- if chat.matches:
121
- chat.history.append({"role": "assistant", "content": FOLLOW_UP})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  yield ("history", chat.history)
 
 
 
 
 
 
 
 
 
123
  return
124
- yield from _search(chat, matchmaker.chat(chat.id, text), spoken)
 
 
 
 
 
 
 
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 = 800, 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,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) { S.inSpeech = true; S.speechMs = 0; S.cur = S.pre.slice(); stopPlayback(); status('Listening…'); }
 
 
 
 
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"