github-actions[bot] commited on
Commit
c7f658d
·
1 Parent(s): f392205

Auto-deploy from GitHub: 04cc223e18cbd02ac0c9c51c435c666a643d8207

Browse files
Files changed (3) hide show
  1. app/api/routes.py +16 -9
  2. app/services/streaming.py +120 -66
  3. index.html +30 -9
app/api/routes.py CHANGED
@@ -159,14 +159,19 @@ async def websocket_transcribe(websocket: WebSocket):
159
 
160
  async def bg_process():
161
  while True:
162
- await asyncio.sleep(1.5)
163
  try:
164
- results = await loop.run_in_executor(None, stt.process)
165
- for r in results:
166
- try:
167
- await websocket.send_json({"type": "transcript", **r})
168
- except Exception:
169
- return
 
 
 
 
 
170
  except asyncio.CancelledError:
171
  return
172
  except Exception as e:
@@ -210,8 +215,10 @@ async def websocket_transcribe(websocket: WebSocket):
210
  if connected:
211
  try:
212
  remaining = stt.flush()
213
- for r in remaining:
214
- await websocket.send_json({"type": "transcript", **r, "is_final": True})
 
 
215
  await websocket.send_json({"type": "done"})
216
  await websocket.close()
217
  except Exception:
 
159
 
160
  async def bg_process():
161
  while True:
162
+ await asyncio.sleep(1.0)
163
  try:
164
+ result = await loop.run_in_executor(None, stt.process)
165
+ if not result:
166
+ continue
167
+ try:
168
+ if result["commit"]:
169
+ await websocket.send_json({"type": "commit", **result["commit"]})
170
+ await websocket.send_json(
171
+ {"type": "tentative", "text": result["tentative"]}
172
+ )
173
+ except Exception:
174
+ return
175
  except asyncio.CancelledError:
176
  return
177
  except Exception as e:
 
215
  if connected:
216
  try:
217
  remaining = stt.flush()
218
+ if remaining and remaining["commit"]:
219
+ await websocket.send_json(
220
+ {"type": "commit", **remaining["commit"], "is_final": True}
221
+ )
222
  await websocket.send_json({"type": "done"})
223
  await websocket.close()
224
  except Exception:
app/services/streaming.py CHANGED
@@ -42,6 +42,66 @@ def _release_model(model_name, device):
42
  del _MODEL_CACHE[key]
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  class StreamingSTT:
46
  def __init__(self, model_name="base", device="cpu", sample_rate=16000):
47
  if model_name not in ALLOWED_MODELS:
@@ -55,9 +115,8 @@ class StreamingSTT:
55
  # leading samples, so timestamps stay anchored to real audio time
56
  # instead of drifting after a trim.
57
  self.buffer_start = 0
58
- self.chunk_duration = 5
59
- self.stride_duration = 2
60
- self.last_segment_end = 0
61
  self.is_finalized = False
62
  # add_audio() runs on the event-loop thread while process()/flush() run
63
  # in an executor thread. Incoming audio is handed over through this
@@ -92,87 +151,82 @@ class StreamingSTT:
92
  self.processed_until -= trim_to
93
  self.buffer_start += trim_to
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  def process(self):
96
  if self.is_finalized:
97
- return []
98
 
99
  self._drain_incoming()
100
 
101
- chunk_samples = self.chunk_duration * self.sample_rate
102
- stride_samples = self.stride_duration * self.sample_rate
 
103
 
104
- if len(self.buffer) - self.processed_until < chunk_samples:
105
- return []
106
-
107
- chunk = self.buffer[self.processed_until : self.processed_until + chunk_samples]
108
  time_offset = (self.buffer_start + self.processed_until) / self.sample_rate
109
- self.processed_until += chunk_samples - stride_samples
110
- self._trim_buffer()
111
-
112
  try:
113
- segments, _ = self.model.transcribe(
114
- chunk, beam_size=1, vad_filter=True, language="en"
115
- )
116
- results = []
117
- for seg in segments:
118
- start = seg.start + time_offset
119
- end = seg.end + time_offset
120
- text = seg.text.strip()
121
- if not text:
122
- continue
123
- # Consecutive chunks overlap by stride_duration, so the overlap
124
- # region is transcribed twice. Skip segments that fall within
125
- # time we've already emitted (small tolerance for boundary jitter).
126
- if end <= self.last_segment_end + 0.2:
127
- continue
128
- self.last_segment_end = max(self.last_segment_end, end)
129
- results.append({
130
- "start": round(start, 2),
131
- "end": round(end, 2),
132
- "text": text,
133
- })
134
- return results
135
  except Exception as e:
136
  logger.error(f"[StreamingSTT] process error: {e}")
137
- return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
  def flush(self):
140
  if self.is_finalized:
141
- return []
142
  self.is_finalized = True
143
 
144
  self._drain_incoming()
145
 
146
- remaining = self.buffer[self.processed_until:]
147
- if len(remaining) < self.sample_rate * 0.5:
148
- return []
149
-
150
- try:
151
- segments, _ = self.model.transcribe(
152
- remaining, beam_size=1, vad_filter=True, language="en"
153
- )
154
  time_offset = (self.buffer_start + self.processed_until) / self.sample_rate
155
- results = []
156
- for seg in segments:
157
- start = seg.start + time_offset
158
- end = seg.end + time_offset
159
- text = seg.text.strip()
160
- if not text:
161
- continue
162
- # The tail still overlaps the last emitted chunk by stride_duration;
163
- # drop anything already covered.
164
- if end <= self.last_segment_end + 0.2:
165
- continue
166
- self.last_segment_end = max(self.last_segment_end, end)
167
- results.append({
168
- "start": round(start, 2),
169
- "end": round(end, 2),
170
- "text": text,
171
- })
172
- return results
173
- except Exception as e:
174
- logger.error(f"[StreamingSTT] flush error: {e}")
175
- return []
176
 
177
  def cleanup(self):
178
  if self.model is not None:
 
42
  del _MODEL_CACHE[key]
43
 
44
 
45
+ class _HypothesisBuffer:
46
+ """LocalAgreement-2 commit policy.
47
+
48
+ Each window re-transcribes the unconfirmed audio. A word is only *committed*
49
+ once two consecutive windows agree on it (longest common prefix); everything
50
+ after the agreed prefix stays *tentative* and may be revised by the next
51
+ window. This removes the duplicated/unstable output that naive overlapping
52
+ re-transcription produces. (Macháček et al., whisper_streaming.)
53
+ """
54
+
55
+ def __init__(self):
56
+ self.committed = [] # confirmed (start, end, word)
57
+ self.buffer = [] # previous window's tentative tail
58
+ self.new = []
59
+ self.last_committed_time = 0.0
60
+
61
+ def insert(self, words):
62
+ # words: list of (start, end, text) in absolute seconds.
63
+ self.new = [w for w in words if w[0] > self.last_committed_time - 0.1]
64
+ if self.new and self.committed:
65
+ # Drop a leading n-gram that repeats the tail we already committed
66
+ # (whisper sometimes re-emits the previous words verbatim).
67
+ if abs(self.new[0][0] - self.last_committed_time) < 1.0:
68
+ cn, nn = len(self.committed), len(self.new)
69
+ for i in range(1, min(cn, nn, 5) + 1):
70
+ tail = " ".join(self.committed[-j][2] for j in range(i, 0, -1))
71
+ head = " ".join(self.new[j][2] for j in range(i))
72
+ if tail == head:
73
+ del self.new[:i]
74
+ break
75
+
76
+ def flush(self):
77
+ """Commit the longest common prefix of this window and the last."""
78
+ commit = []
79
+ while self.new and self.buffer:
80
+ if self.new[0][2] == self.buffer[0][2]:
81
+ commit.append(self.new[0])
82
+ self.last_committed_time = self.new[0][1]
83
+ self.buffer.pop(0)
84
+ self.new.pop(0)
85
+ else:
86
+ break
87
+ self.buffer = self.new
88
+ self.new = []
89
+ self.committed.extend(commit)
90
+ # Only the last few committed words are needed for n-gram dedup.
91
+ if len(self.committed) > 100:
92
+ self.committed = self.committed[-100:]
93
+ return commit
94
+
95
+ def complete(self):
96
+ """Return remaining tentative words as final (no more audio coming)."""
97
+ rest = self.buffer
98
+ self.buffer = []
99
+ return rest
100
+
101
+ def tentative_text(self):
102
+ return " ".join(w[2] for w in self.buffer)
103
+
104
+
105
  class StreamingSTT:
106
  def __init__(self, model_name="base", device="cpu", sample_rate=16000):
107
  if model_name not in ALLOWED_MODELS:
 
115
  # leading samples, so timestamps stay anchored to real audio time
116
  # instead of drifting after a trim.
117
  self.buffer_start = 0
118
+ self.min_chunk = 1.0 # seconds of new audio before a window is run
119
+ self.hyp = _HypothesisBuffer()
 
120
  self.is_finalized = False
121
  # add_audio() runs on the event-loop thread while process()/flush() run
122
  # in an executor thread. Incoming audio is handed over through this
 
151
  self.processed_until -= trim_to
152
  self.buffer_start += trim_to
153
 
154
+ def _transcribe_words(self, audio, time_offset):
155
+ """Transcribe audio, returning [(start, end, text), ...] in absolute time."""
156
+ segments, _ = self.model.transcribe(
157
+ audio, beam_size=1, vad_filter=True, language="en", word_timestamps=True
158
+ )
159
+ words = []
160
+ for seg in segments:
161
+ for w in seg.words or []:
162
+ text = w.word.strip()
163
+ if text:
164
+ words.append((w.start + time_offset, w.end + time_offset, text))
165
+ return words
166
+
167
+ @staticmethod
168
+ def _as_chunk(words):
169
+ """Join committed words into a single transcript chunk, or None."""
170
+ if not words:
171
+ return None
172
+ return {
173
+ "start": round(words[0][0], 2),
174
+ "end": round(words[-1][1], 2),
175
+ "text": " ".join(w[2] for w in words),
176
+ }
177
+
178
  def process(self):
179
  if self.is_finalized:
180
+ return None
181
 
182
  self._drain_incoming()
183
 
184
+ unprocessed = self.buffer[self.processed_until:]
185
+ if len(unprocessed) < self.min_chunk * self.sample_rate:
186
+ return None
187
 
 
 
 
 
188
  time_offset = (self.buffer_start + self.processed_until) / self.sample_rate
 
 
 
189
  try:
190
+ words = self._transcribe_words(unprocessed, time_offset)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  except Exception as e:
192
  logger.error(f"[StreamingSTT] process error: {e}")
193
+ return None
194
+
195
+ self.hyp.insert(words)
196
+ committed = self.hyp.flush()
197
+
198
+ # Advance past the committed audio; tentative words stay unprocessed so
199
+ # the next window can re-evaluate (and possibly correct) them.
200
+ if committed:
201
+ target = int(committed[-1][1] * self.sample_rate) - self.buffer_start
202
+ self.processed_until = min(max(self.processed_until, target), len(self.buffer))
203
+ self._trim_buffer()
204
+
205
+ return {
206
+ "commit": self._as_chunk(committed),
207
+ "tentative": self.hyp.tentative_text(),
208
+ }
209
 
210
  def flush(self):
211
  if self.is_finalized:
212
+ return None
213
  self.is_finalized = True
214
 
215
  self._drain_incoming()
216
 
217
+ unprocessed = self.buffer[self.processed_until:]
218
+ final = []
219
+ if len(unprocessed) >= 0.3 * self.sample_rate:
 
 
 
 
 
220
  time_offset = (self.buffer_start + self.processed_until) / self.sample_rate
221
+ try:
222
+ words = self._transcribe_words(unprocessed, time_offset)
223
+ self.hyp.insert(words)
224
+ final = self.hyp.flush()
225
+ except Exception as e:
226
+ logger.error(f"[StreamingSTT] flush error: {e}")
227
+ # No more audio is coming, so commit whatever tentative words remain.
228
+ final = final + self.hyp.complete()
229
+ return {"commit": self._as_chunk(final)}
 
 
 
 
 
 
 
 
 
 
 
 
230
 
231
  def cleanup(self):
232
  if self.model is not None:
index.html CHANGED
@@ -934,6 +934,26 @@
934
  let livePreBuffer = [];
935
  let wsReady = false;
936
  let liveFinalizeTimer = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
937
  const WS_SAMPLE_RATE = 16000;
938
 
939
  function openLiveModal() {
@@ -1023,15 +1043,16 @@
1023
  liveAudioCtx.sampleRate
1024
  );
1025
  }
1026
- liveTranscript.innerText = '🎤 Listening...';
1027
- } else if (msg.type === 'transcript') {
1028
- const line = `[${msg.start}s → ${msg.end}s] ${msg.text}`;
1029
- if (liveTranscript.innerText === '🎤 Listening...') {
1030
- liveTranscript.innerText = line;
1031
- } else {
1032
- liveTranscript.innerText += '\n' + line;
1033
- }
1034
- liveTranscript.scrollTop = liveTranscript.scrollHeight;
 
1035
  } else if (msg.type === 'done') {
1036
  teardownLiveStreaming();
1037
  } else if (msg.type === 'error') {
 
934
  let livePreBuffer = [];
935
  let wsReady = false;
936
  let liveFinalizeTimer = null;
937
+ let liveCommitted = '';
938
+ let liveTentative = '';
939
+
940
+ function escapeHtml(s) {
941
+ return s.replace(/[&<>"']/g, c => ({
942
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
943
+ }[c]));
944
+ }
945
+
946
+ function renderLiveTranscript() {
947
+ const committed = liveCommitted ? escapeHtml(liveCommitted) : '';
948
+ const tentative = liveTentative
949
+ ? `<span style="opacity:0.45">${escapeHtml(liveTentative)}</span>` : '';
950
+ if (!committed && !tentative) {
951
+ liveTranscript.innerText = '🎤 Listening...';
952
+ } else {
953
+ liveTranscript.innerHTML = (committed + (committed && tentative ? ' ' : '') + tentative);
954
+ }
955
+ liveTranscript.scrollTop = liveTranscript.scrollHeight;
956
+ }
957
  const WS_SAMPLE_RATE = 16000;
958
 
959
  function openLiveModal() {
 
1043
  liveAudioCtx.sampleRate
1044
  );
1045
  }
1046
+ liveCommitted = '';
1047
+ liveTentative = '';
1048
+ renderLiveTranscript();
1049
+ } else if (msg.type === 'commit') {
1050
+ liveCommitted += (liveCommitted ? ' ' : '') + msg.text;
1051
+ liveTentative = '';
1052
+ renderLiveTranscript();
1053
+ } else if (msg.type === 'tentative') {
1054
+ liveTentative = msg.text || '';
1055
+ renderLiveTranscript();
1056
  } else if (msg.type === 'done') {
1057
  teardownLiveStreaming();
1058
  } else if (msg.type === 'error') {