github-actions[bot] commited on
Commit ·
c7f658d
1
Parent(s): f392205
Auto-deploy from GitHub: 04cc223e18cbd02ac0c9c51c435c666a643d8207
Browse files- app/api/routes.py +16 -9
- app/services/streaming.py +120 -66
- 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.
|
| 163 |
try:
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 214 |
-
await websocket.send_json(
|
|
|
|
|
|
|
| 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.
|
| 59 |
-
self.
|
| 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 |
-
|
| 102 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 147 |
-
|
| 148 |
-
|
| 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 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 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 |
-
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
-
|
| 1033 |
-
|
| 1034 |
-
|
|
|
|
| 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 |
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
| 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') {
|