Skip to content

Commit 2a32652

Browse files
authored
Merge pull request #25 from luongndcoder/fix/soniox-realtime-speaker-timestamp
fix(stt): Soniox realtime speaker attribution + segment timestamps
2 parents e8449e7 + 9280b56 commit 2a32652

7 files changed

Lines changed: 118 additions & 37 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "voicescribe",
33
"private": true,
4-
"version": "1.2.8",
4+
"version": "1.2.9",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

src-python/stt.py

Lines changed: 95 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -763,11 +763,19 @@ def stop(self):
763763
def results(self):
764764
"""Blocking generator that yields transcript results from Soniox.
765765
766-
Uses chunk_id-based replace-in-place strategy:
767-
- Accumulates final tokens globally
768-
- Each event: yields (final + non-final) text with same chunk_id
769-
- Frontend replaceLastPartText updates in-place -> smooth real-time
770-
- Speaker change -> new chunk_id -> new transcript block
766+
Per-token streaming with proper speaker-change handling:
767+
- Process each token sequentially; current_speaker updates IMMEDIATELY
768+
when a final token from a new speaker arrives (the previous version
769+
only updated current_speaker at end-of-event, so tokens mid-event
770+
from a different speaker got mis-attributed to the last speaker).
771+
- On speaker change, flush the accumulated segment (yielding its full
772+
text under the OLD speaker) and start a fresh chunk_id for the new
773+
one. Frontend `sameChunk` then produces a new transcript block.
774+
- Each segment carries its own `start_ms` / `end_ms` (from the actual
775+
Soniox token offsets) so the UI shows accurate time ranges instead
776+
of falling back to the recording-timer's current second.
777+
- Each event also re-yields the in-progress segment so non-final
778+
tokens flow into the UI live (frontend `replaceLastPartText`).
771779
"""
772780
if not self._session:
773781
return
@@ -778,8 +786,13 @@ def results(self):
778786
# Running accumulator of ALL final token texts for current segment
779787
accumulated_final = []
780788
accumulated_translation = [] # Translation tokens for current segment
781-
current_speaker = 0
789+
# Use a sentinel so the FIRST final token can initialize without
790+
# triggering a spurious "speaker change" flush against an empty buffer.
791+
current_speaker = -1
782792
current_chunk_id = f"soniox-{int(time.time() * 1000)}-{uuid4().hex[:6]}"
793+
# Segment time range (in ms, relative to Soniox stream start)
794+
segment_start_ms = None
795+
segment_end_ms = None
783796

784797
event_count = 0
785798
try:
@@ -810,12 +823,11 @@ def results(self):
810823
if not event.tokens:
811824
continue
812825

813-
# Separate final and non-final tokens from this event
814-
new_final = []
815826
non_final = []
816-
new_translation = [] # Translation tokens from this event
817-
non_final_translation = [] # Non-final translation tokens
818-
final_speaker = None # Track speaker from final tokens only
827+
non_final_translation = []
828+
# Track the latest end_ms across non-final tokens so the live
829+
# display reflects the running edge of the segment.
830+
latest_non_final_end_ms = None
819831

820832
for token in event.tokens:
821833
speaker_id = int(getattr(token, "speaker", 0) or 0)
@@ -827,45 +839,76 @@ def results(self):
827839
translation_status = getattr(token, "translation_status", "none") or "none"
828840
if translation_status == "translation":
829841
if token.is_final:
830-
new_translation.append(token_text)
842+
accumulated_translation.append(token_text)
831843
else:
832844
non_final_translation.append(token_text)
833845
continue
834846

835847
# STT tokens (translation_status: "none" or "original")
848+
tok_start = getattr(token, "start_ms", None)
849+
tok_end = getattr(token, "end_ms", None)
850+
if tok_start is not None:
851+
tok_start = int(tok_start)
852+
if tok_end is not None:
853+
tok_end = int(tok_end)
854+
836855
if token.is_final:
837-
# Speaker changed — flush current segment, start new
838-
if accumulated_final and speaker_id != current_speaker:
856+
# First-ever final token: just adopt its speaker.
857+
if current_speaker == -1:
858+
current_speaker = speaker_id
859+
860+
# Speaker change INSIDE the event: flush whatever's
861+
# been accumulated under the OLD speaker before we
862+
# touch the new token. Without this, tokens from S1
863+
# and S2 within the same event used to collapse onto
864+
# whichever speaker happened to be last.
865+
elif speaker_id != current_speaker and accumulated_final:
839866
full_text = "".join(accumulated_final).strip()
840867
if full_text:
841-
log.info("[stt:soniox-stream] Speaker change: S%d -> S%d, flushing: '%s...'", current_speaker+1, speaker_id+1, full_text[:50])
868+
log.info(
869+
"[stt:soniox-stream] Speaker change mid-event: S%d -> S%d, flushing: '%s...'",
870+
current_speaker + 1, speaker_id + 1, full_text[:50],
871+
)
842872
result = {
843873
"text": full_text,
844874
"is_final": True,
845875
"chunk_id": current_chunk_id,
846876
"speaker": f"Speaker {current_speaker + 1}",
847877
"speaker_id": current_speaker,
848878
}
849-
# Attach accumulated translation
879+
if segment_start_ms is not None:
880+
result["start_ms"] = segment_start_ms
881+
if segment_end_ms is not None:
882+
result["end_ms"] = segment_end_ms
850883
tl_text = "".join(accumulated_translation).strip()
851884
if tl_text:
852885
result["translation"] = tl_text
853886
yield result
854-
# Start new segment
887+
888+
# Reset for the new speaker's segment
855889
accumulated_final = []
856890
accumulated_translation = []
891+
segment_start_ms = None
892+
segment_end_ms = None
857893
current_chunk_id = f"soniox-{int(time.time() * 1000)}-{uuid4().hex[:6]}"
894+
current_speaker = speaker_id
895+
896+
# Update current_speaker even when no flush happened
897+
# (e.g. token's speaker_id matches the existing one,
898+
# or we just initialized from -1)
899+
current_speaker = speaker_id
858900

859-
new_final.append(token_text)
860-
final_speaker = speaker_id
901+
# Track segment time range from the actual token offsets
902+
if tok_start is not None and segment_start_ms is None:
903+
segment_start_ms = tok_start
904+
if tok_end is not None:
905+
segment_end_ms = tok_end
906+
907+
accumulated_final.append(token_text)
861908
else:
862909
non_final.append(token_text)
863-
864-
# Add new tokens to accumulators
865-
accumulated_final.extend(new_final)
866-
accumulated_translation.extend(new_translation)
867-
if final_speaker is not None:
868-
current_speaker = final_speaker
910+
if tok_end is not None:
911+
latest_non_final_end_ms = tok_end
869912

870913
# Build display text: all final so far + current non-final
871914
display_text = "".join(accumulated_final + non_final).strip()
@@ -875,14 +918,30 @@ def results(self):
875918
# Build translation text
876919
translation_text = "".join(accumulated_translation + non_final_translation).strip()
877920

921+
# Resolve display end_ms: prefer the live non-final token's
922+
# offset so the time range grows in real time while the user
923+
# is still speaking. Fall back to the latest final token's
924+
# end_ms otherwise.
925+
display_end_ms = (
926+
latest_non_final_end_ms if latest_non_final_end_ms is not None
927+
else segment_end_ms
928+
)
929+
878930
# Yield with same chunk_id -> frontend replaceLastPartText
931+
speaker_label = (
932+
f"Speaker {current_speaker + 1}" if current_speaker >= 0 else "Speaker 1"
933+
)
879934
result = {
880935
"text": display_text,
881936
"is_final": True,
882937
"chunk_id": current_chunk_id,
883-
"speaker": f"Speaker {current_speaker + 1}",
884-
"speaker_id": current_speaker,
938+
"speaker": speaker_label,
939+
"speaker_id": max(0, current_speaker),
885940
}
941+
if segment_start_ms is not None:
942+
result["start_ms"] = segment_start_ms
943+
if display_end_ms is not None:
944+
result["end_ms"] = display_end_ms
886945
# Only include translation when it has actually changed (reduce frontend re-renders)
887946
if translation_text:
888947
result["translation"] = translation_text
@@ -892,13 +951,20 @@ def results(self):
892951
if accumulated_final:
893952
full_text = "".join(accumulated_final).strip()
894953
if full_text:
954+
speaker_label = (
955+
f"Speaker {current_speaker + 1}" if current_speaker >= 0 else "Speaker 1"
956+
)
895957
result = {
896958
"text": full_text,
897959
"is_final": True,
898960
"chunk_id": current_chunk_id,
899-
"speaker": f"Speaker {current_speaker + 1}",
900-
"speaker_id": current_speaker,
961+
"speaker": speaker_label,
962+
"speaker_id": max(0, current_speaker),
901963
}
964+
if segment_start_ms is not None:
965+
result["start_ms"] = segment_start_ms
966+
if segment_end_ms is not None:
967+
result["end_ms"] = segment_end_ms
902968
tl_text = "".join(accumulated_translation).strip()
903969
if tl_text:
904970
result["translation"] = tl_text

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "voicescribe"
3-
version = "1.2.8"
3+
version = "1.2.9"
44
description = "Scribble - Meeting Minutes"
55
authors = ["you"]
66
edition = "2021"

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Scribble",
4-
"version": "1.2.8",
4+
"version": "1.2.9",
55
"identifier": "com.scribble.app",
66
"build": {
77
"beforeDevCommand": "pnpm dev",

src/components/recording/use-streaming-stt.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,17 @@ function handleFinalTranscript(data: SttWsMessage, text: string, ts: string, sta
194194
useAppStore.getState().setInterimText("");
195195
useAppStore.getState().setInterimSpeaker(speaker, speakerId);
196196

197+
// Prefer the backend's token-level offsets (Soniox sends start_ms / end_ms
198+
// per segment, which is the truth). Fall back to the recording timer for
199+
// STT sources that don't provide ms offsets, with a 3-second look-back on
200+
// segment start to roughly match the spoken phrase boundary.
201+
const startSec = data.start_ms != null
202+
? Math.floor(data.start_ms / 1000)
203+
: Math.max(0, state.seconds - 3);
204+
const endSec = data.end_ms != null
205+
? Math.floor(data.end_ms / 1000)
206+
: state.seconds;
207+
197208
const lastPart = state.transcriptParts[state.transcriptParts.length - 1];
198209
const lastChunkIds = new Set<string>();
199210
if (lastPart?.chunkId) lastChunkIds.add(lastPart.chunkId);
@@ -204,10 +215,10 @@ function handleFinalTranscript(data: SttWsMessage, text: string, ts: string, sta
204215
const sameSpeaker = Boolean(lastPart) && Number(lastPart.speakerId) === Number(speakerId);
205216

206217
if (sameChunk) {
207-
useAppStore.getState().replaceLastPartText(text, String(state.seconds), chunkId || undefined);
218+
useAppStore.getState().replaceLastPartText(text, String(endSec), chunkId || undefined);
208219
} else if (sameSpeaker) {
209220
// Don't clear interimTranslation here — accumulated translation will update via handleTranslationEvent
210-
useAppStore.getState().appendToLastPart(text, String(state.seconds), chunkId || undefined);
221+
useAppStore.getState().appendToLastPart(text, String(endSec), chunkId || undefined);
211222
} else {
212223
// New speaker — commit pending translation
213224
const prevTranslation = useAppStore.getState().interimTranslation;
@@ -221,8 +232,8 @@ function handleFinalTranscript(data: SttWsMessage, text: string, ts: string, sta
221232
speakerId,
222233
chunkId: chunkId || undefined,
223234
chunkIds: chunkId ? [chunkId] : undefined,
224-
startTime: String(Math.max(0, state.seconds - 3)),
225-
endTime: String(state.seconds),
235+
startTime: String(startSec),
236+
endTime: String(endSec),
226237
timestamp: ts,
227238
translation: "",
228239
});

src/types/stt.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ export interface SttWsMessage {
2525
append?: boolean;
2626
error?: string;
2727
segments?: SttSegment[];
28+
/** Soniox token-level offsets (ms from stream start). Present on
29+
* real-time STT events when the upstream tokens carry timestamps. */
30+
start_ms?: number;
31+
end_ms?: number;
2832
}
2933

3034
export interface DiagnoseResult {

0 commit comments

Comments
 (0)