@@ -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
0 commit comments