vyluong commited on
Commit
b09cd72
·
verified ·
1 Parent(s): 77f83aa

Update app/services/alignment.py

Browse files
Files changed (1) hide show
  1. app/services/alignment.py +429 -138
app/services/alignment.py CHANGED
@@ -1,11 +1,19 @@
1
  """
2
- Precision alignment service - Word-center-based speaker assignment.
 
 
 
 
 
 
 
3
  Merges word-level transcription with speaker diarization using precise timestamps.
4
  """
5
  import logging
6
  from pathlib import Path
7
  from typing import List, Tuple, Optional
8
  from dataclasses import dataclass
 
9
 
10
  from app.core.config import get_settings
11
  from app.services.transcription import WordTimestamp
@@ -25,6 +33,7 @@ class WordWithSpeaker:
25
  start: float
26
  end: float
27
  speaker: str
 
28
 
29
 
30
  class AlignmentService:
@@ -32,12 +41,30 @@ class AlignmentService:
32
  Precision alignment service.
33
  Uses word-center-based algorithm for accurate speaker-to-text mapping.
34
  """
35
-
36
- PAUSE_THRESHOLD = 0.45
37
- CENTER_TOL = 0.15 # s (150 ms)
38
- OVERLAP_TH = 0.12 # > x% segments
39
- DIA_MERGE_GAP = 0.25
40
- MAX_SEGMENT_DURATION = 7.5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  @staticmethod
43
  def get_word_center(word: WordTimestamp) -> float:
@@ -52,25 +79,77 @@ class AlignmentService:
52
  return overlap / dur
53
 
54
 
55
- # Diarization merge
56
  @classmethod
57
- def merge_dia_segments(cls, segments: List[SpeakerSegment]) -> List[SpeakerSegment]:
 
 
 
 
58
  if not segments:
59
  return []
60
 
61
- segments = sorted(segments, key=lambda s: s.start)
62
- merged = [segments[0]]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
- for s in segments[1:]:
65
- p = merged[-1]
66
- if s.speaker == p.speaker and (s.start - p.end) <= cls.DIA_MERGE_GAP:
67
- p.end = s.end
68
  else:
69
- merged.append(s)
70
 
71
  return merged
72
 
73
-
 
74
  @classmethod
75
  def find_speaker_center(
76
  cls,
@@ -79,26 +158,43 @@ class AlignmentService:
79
  ) -> Optional[str]:
80
 
81
  for seg in speaker_segments:
82
- if seg.start - cls.CENTER_TOL <= time <= seg.end + cls.CENTER_TOL:
 
 
 
 
 
83
  return seg.speaker
 
84
  return None
85
 
86
  @staticmethod
87
- def find_closest_speaker(time: float, speaker_segments: List[SpeakerSegment]) -> str:
 
 
 
 
88
  if not speaker_segments:
89
- return "Unknown"
90
 
91
- min_dist = float("inf")
92
- closest = "Unknown"
93
 
94
  for seg in speaker_segments:
95
- d = min(abs(time - seg.start), abs(time - seg.end))
96
- if d < min_dist:
97
- min_dist = d
98
- closest = seg.speaker
99
 
100
- return closest
 
 
 
 
 
 
 
 
 
 
101
 
 
102
 
103
  @classmethod
104
  def assign_speakers_to_words(
@@ -107,161 +203,342 @@ class AlignmentService:
107
  speaker_segments: List[SpeakerSegment],
108
  ) -> List[WordWithSpeaker]:
109
 
110
- words = [w for w in words if w.word and w.word.strip()]
 
 
 
 
 
 
 
 
 
 
111
 
 
112
  if not speaker_segments:
113
- logger.warning("No diarization, fallback single speaker")
114
  return [
115
- WordWithSpeaker(w.word, w.start, w.end, "Speaker 1")
 
 
 
 
 
 
116
  for w in words
117
  ]
118
 
119
- speaker_segments = cls.merge_dia_segments(speaker_segments)
120
-
121
  results = []
122
-
123
  for word in words:
 
124
  center = cls.get_word_center(word)
125
 
126
- # 1. CENTER
127
- speaker = cls.find_speaker_center(center, speaker_segments)
 
 
128
 
 
129
  if speaker is None:
130
- # 2. OVERLAP
131
- best_ratio = 0
132
  best_spk = None
133
 
134
  for seg in speaker_segments:
135
- r = cls.overlap_ratio(word.start, word.end, seg.start, seg.end)
 
 
 
 
 
 
 
136
  if r > best_ratio:
137
  best_ratio = r
138
  best_spk = seg.speaker
139
 
140
  if best_ratio >= cls.OVERLAP_TH:
141
  speaker = best_spk
142
- else:
143
- # 3. CLOSEST
144
- speaker = cls.find_closest_speaker(center, speaker_segments)
 
 
 
 
 
145
 
146
  results.append(
147
- WordWithSpeaker(word.word, word.start, word.end, speaker)
 
 
 
 
 
 
148
  )
149
-
150
  return results
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
152
  @classmethod
153
  def reconstruct_segments(
154
  cls,
155
- words_with_speakers: List[WordWithSpeaker]
156
  ) -> List[TranscriptSegment]:
157
- """
158
- Step 3d: Reconstruct sentence segments from words.
159
-
160
- Groups consecutive words of the same speaker into segments.
161
- Creates new segment when:
162
- - Speaker changes
163
- - Pause > PAUSE_THRESHOLD between words
164
-
165
- Args:
166
- words_with_speakers: List of words with speaker assignments
167
-
168
- Returns:
169
- List of TranscriptSegment with complete sentences
170
- """
171
  if not words_with_speakers:
172
  return []
173
-
174
  segments = []
175
-
176
- # Start first segment
177
- current_speaker = words_with_speakers[0].speaker
178
- current_start = words_with_speakers[0].start
179
- current_end = words_with_speakers[0].end
180
- current_words = [words_with_speakers[0].word]
181
-
182
  for i in range(1, len(words_with_speakers)):
183
- word = words_with_speakers[i]
184
- prev_word = words_with_speakers[i - 1]
185
-
186
- # Calculate pause between words
187
- pause = word.start - prev_word.end
188
-
189
- # Check if we need to start a new segment
190
- speaker_changed = word.speaker != current_speaker
191
- significant_pause = pause > cls.PAUSE_THRESHOLD
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
 
193
- segment_duration = current_end - current_start
194
- too_long = segment_duration > cls.MAX_SEGMENT_DURATION and pause > 0.15
195
 
196
- if speaker_changed or significant_pause or too_long:
197
- # Save current segment
198
- segments.append(TranscriptSegment(
199
- start=current_start,
200
- end=current_end,
201
- speaker=current_speaker,
202
- role="UNKNOWN",
203
- text=" ".join(current_words)
204
- ))
205
-
206
- # Start new segment
207
- current_speaker = word.speaker
208
- current_start = word.start
209
- current_end = word.end
210
- current_words = [word.word]
211
  else:
212
- # Continue current segment
213
- current_end = word.end
214
- current_words.append(word.word)
215
-
 
 
 
216
 
217
- if current_words:
218
- segments.append(TranscriptSegment(
219
- start=current_start,
220
- end=current_end,
221
- speaker=current_speaker,
222
- role="UNKNOWN",
223
- text=" ".join(current_words)
224
- ))
225
-
226
- logger.debug(f"Reconstructed {len(segments)} segments from {len(words_with_speakers)} words")
227
  return segments
228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  @classmethod
230
  def resize_and_merge_segments(
231
  cls,
232
- segments: List[TranscriptSegment]
233
  ) -> List[TranscriptSegment]:
234
- """
235
- Merge consecutive segments of the same speaker if the gap is small.
236
- Also filters out extremely short segments.
237
- """
238
- if not segments:
239
- return []
240
-
241
- # Filter 1: Remove extremely short blips (noise)
242
- segments = [s for s in segments if (s.end - s.start) >= settings.min_segment_duration_s]
243
-
244
  if not segments:
245
  return []
246
-
247
- merged = []
248
- curr = segments[0]
249
-
250
- for i in range(1, len(segments)):
251
- next_seg = segments[i]
252
-
253
- # If same speaker and gap is small, merge
254
- gap = next_seg.start - curr.end
255
- if next_seg.speaker == curr.speaker and gap < settings.merge_threshold_s:
256
- curr.end = next_seg.end
257
- curr.text += " " + next_seg.text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  else:
259
- merged.append(curr)
260
- curr = next_seg
261
-
262
- merged.append(curr)
263
-
264
- logger.debug(f"Merged segments: {len(segments)} -> {len(merged)}")
265
  return merged
266
 
267
  @classmethod
@@ -280,15 +557,29 @@ class AlignmentService:
280
  Returns:
281
  List of TranscriptSegment with proper speaker assignments
282
  """
283
- # Step 3c: Assign speakers to words
284
  words_with_speakers = cls.assign_speakers_to_words(words, speaker_segments)
285
 
286
- # Step 3d: Reconstruct segments
287
  segments = cls.reconstruct_segments(words_with_speakers)
288
 
289
- # Step 3e: Clustering/Merging (Optimization)
 
 
 
 
 
 
 
 
 
290
  segments = cls.resize_and_merge_segments(segments)
291
 
 
 
 
 
 
292
  return segments
293
 
294
 
 
1
  """
2
+ - Precision alignment service - Word-center-based speaker assignment.
3
+ - Keep softformer diarization service
4
+ - Remove diarization noise using conf + duration
5
+ - Preserve DOUBLE_TALK word by word
6
+ - Reduce transcript fragmentation
7
+ - Better KH/NV continuity
8
+ - Stable realtime transcript rendering
9
+
10
  Merges word-level transcription with speaker diarization using precise timestamps.
11
  """
12
  import logging
13
  from pathlib import Path
14
  from typing import List, Tuple, Optional
15
  from dataclasses import dataclass
16
+ from collections import Counter
17
 
18
  from app.core.config import get_settings
19
  from app.services.transcription import WordTimestamp
 
33
  start: float
34
  end: float
35
  speaker: str
36
+ confidence: float = 1.0
37
 
38
 
39
  class AlignmentService:
 
41
  Precision alignment service.
42
  Uses word-center-based algorithm for accurate speaker-to-text mapping.
43
  """
44
+ CENTER_TOL = 0.18 # 180 ms
45
+ OVERLAP_TH = 0.10 # > x% segments
46
+
47
+ # diarization
48
+ DIA_MERGE_GAP = 0.35
49
+ MIN_DIAR_DURATION = 0.12
50
+ MIN_DIAR_CONFIDENCE = 0.45
51
+
52
+ # segment
53
+ PAUSE_THRESHOLD = 0.65
54
+ MAX_SEGMENT_DURATION = 12.0
55
+
56
+ # merge
57
+ MERGE_GAP = 0.55
58
+ MAX_MERGED_DURATION = 10.0
59
+
60
+ # noise
61
+ MIN_SEGMENT_DURATION = 0.35
62
+ MIN_SEGMENT_AVG_CONF = 0.28
63
+
64
+ # interruption
65
+ SHORT_INTERRUPT_MAX_WORDS = 2
66
+ SHORT_INTERRUPT_MAX_DURATION = 1.25
67
+
68
 
69
  @staticmethod
70
  def get_word_center(word: WordTimestamp) -> float:
 
79
  return overlap / dur
80
 
81
 
 
82
  @classmethod
83
+ def clean_diarization_segments(
84
+ cls,
85
+ segments: List[SpeakerSegment],
86
+ ) -> List[SpeakerSegment]:
87
+
88
  if not segments:
89
  return []
90
 
91
+ segments = sorted(
92
+ segments,
93
+ key=lambda x: x.start
94
+ )
95
+
96
+ cleaned = []
97
+
98
+ for seg in segments:
99
+
100
+ dur = seg.end - seg.start
101
+
102
+ conf = getattr(
103
+ seg,
104
+ "confidence",
105
+ 1.0
106
+ )
107
+
108
+ # obvious diarization noise
109
+ if (
110
+ dur < cls.MIN_DIAR_DURATION
111
+ and conf < cls.MIN_DIAR_CONFIDENCE
112
+ ):
113
+ continue
114
+
115
+ cleaned.append(seg)
116
+
117
+
118
+ if not cleaned:
119
+ return []
120
+
121
+ merged = [cleaned[0]]
122
+
123
+ for seg in cleaned[1:]:
124
+
125
+ prev = merged[-1]
126
+
127
+ gap = seg.start - prev.end
128
+
129
+ if (
130
+ seg.speaker == prev.speaker
131
+ and gap <= cls.DIA_MERGE_GAP
132
+ ):
133
+
134
+ prev.end = max(
135
+ prev.end,
136
+ seg.end
137
+ )
138
+
139
+ if hasattr(prev, "confidence"):
140
+
141
+ prev.confidence = max(
142
+ getattr(prev, "confidence", 1.0),
143
+ getattr(seg, "confidence", 1.0)
144
+ )
145
 
 
 
 
 
146
  else:
147
+ merged.append(seg)
148
 
149
  return merged
150
 
151
+ # FIND SPEAKER
152
+
153
  @classmethod
154
  def find_speaker_center(
155
  cls,
 
158
  ) -> Optional[str]:
159
 
160
  for seg in speaker_segments:
161
+
162
+ if (
163
+ seg.start - cls.CENTER_TOL
164
+ <= time
165
+ <= seg.end + cls.CENTER_TOL
166
+ ):
167
  return seg.speaker
168
+
169
  return None
170
 
171
  @staticmethod
172
+ def find_closest_speaker(
173
+ time: float,
174
+ speaker_segments: List[SpeakerSegment],
175
+ ) -> str:
176
+
177
  if not speaker_segments:
178
+ return "UNKNOWN"
179
 
180
+ best_dist = float("inf")
181
+ best_spk = "UNKNOWN"
182
 
183
  for seg in speaker_segments:
 
 
 
 
184
 
185
+ d = min(
186
+ abs(time - seg.start),
187
+ abs(time - seg.end)
188
+ )
189
+
190
+ if d < best_dist:
191
+ best_dist = d
192
+ best_spk = seg.speaker
193
+
194
+ return best_spk
195
+
196
 
197
+ # ASSIGN SPEAKER TO WORDS
198
 
199
  @classmethod
200
  def assign_speakers_to_words(
 
203
  speaker_segments: List[SpeakerSegment],
204
  ) -> List[WordWithSpeaker]:
205
 
206
+ words = [
207
+ w for w in words
208
+ if w.word and w.word.strip()
209
+ ]
210
+
211
+ if not words:
212
+ return []
213
+
214
+ speaker_segments = cls.clean_diarization_segments(
215
+ speaker_segments
216
+ )
217
 
218
+ # fallback
219
  if not speaker_segments:
220
+
221
  return [
222
+ WordWithSpeaker(
223
+ word=w.word,
224
+ start=w.start,
225
+ end=w.end,
226
+ speaker="Speaker 1",
227
+ confidence=getattr(w, "confidence", 1.0)
228
+ )
229
  for w in words
230
  ]
231
 
 
 
232
  results = []
 
233
  for word in words:
234
+
235
  center = cls.get_word_center(word)
236
 
237
+ speaker = cls.find_speaker_center(
238
+ center,
239
+ speaker_segments
240
+ )
241
 
242
+ # overlap fallback
243
  if speaker is None:
244
+
245
+ best_ratio = 0.0
246
  best_spk = None
247
 
248
  for seg in speaker_segments:
249
+
250
+ r = cls.overlap_ratio(
251
+ word.start,
252
+ word.end,
253
+ seg.start,
254
+ seg.end
255
+ )
256
+
257
  if r > best_ratio:
258
  best_ratio = r
259
  best_spk = seg.speaker
260
 
261
  if best_ratio >= cls.OVERLAP_TH:
262
  speaker = best_spk
263
+
264
+ # nearest fallback
265
+ if speaker is None:
266
+
267
+ speaker = cls.find_closest_speaker(
268
+ center,
269
+ speaker_segments
270
+ )
271
 
272
  results.append(
273
+ WordWithSpeaker(
274
+ word=word.word,
275
+ start=word.start,
276
+ end=word.end,
277
+ speaker=speaker,
278
+ confidence=getattr(word, "confidence", 1.0)
279
+ )
280
  )
281
+
282
  return results
283
+
284
+ # ========================================================
285
+ # BUILD SEGMENT
286
+ # ========================================================
287
+
288
+ @classmethod
289
+ def build_segment(
290
+ cls,
291
+ words: List[WordWithSpeaker],
292
+ ) -> TranscriptSegment:
293
+
294
+ if not words:
295
+ return None
296
+
297
+ speaker_votes = [
298
+ w.speaker for w in words
299
+ ]
300
+
301
+ speaker = Counter(
302
+ speaker_votes
303
+ ).most_common(1)[0][0]
304
+
305
+ avg_conf = (
306
+ sum(w.confidence for w in words)
307
+ / max(1, len(words))
308
+ )
309
+
310
+ segment = TranscriptSegment(
311
+ start=words[0].start,
312
+ end=words[-1].end,
313
+ speaker=speaker,
314
+ role="UNKNOWN",
315
+ text=" ".join(
316
+ w.word for w in words
317
+ ),
318
+ )
319
+
320
+ # INTERNAL ONLY
321
+ setattr(segment, "_avg_conf", avg_conf)
322
+ setattr(segment, "_word_count", len(words))
323
+
324
+ return segment
325
+
326
+
327
 
328
  @classmethod
329
  def reconstruct_segments(
330
  cls,
331
+ words_with_speakers: List[WordWithSpeaker],
332
  ) -> List[TranscriptSegment]:
333
+
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  if not words_with_speakers:
335
  return []
336
+
337
  segments = []
338
+
339
+ cur_words = [words_with_speakers[0]]
340
+
 
 
 
 
341
  for i in range(1, len(words_with_speakers)):
342
+
343
+ prev = words_with_speakers[i - 1]
344
+ curr = words_with_speakers[i]
345
+
346
+ pause = curr.start - prev.end
347
+
348
+ speaker_changed = (
349
+ curr.speaker != prev.speaker
350
+ )
351
+
352
+ long_pause = (
353
+ pause > cls.PAUSE_THRESHOLD
354
+ )
355
+
356
+ current_duration = (
357
+ cur_words[-1].end
358
+ - cur_words[0].start
359
+ )
360
+
361
+ too_long = (
362
+ current_duration > cls.MAX_SEGMENT_DURATION
363
+ and pause > 0.25
364
+ )
365
+
366
+ # =================================================
367
+ # SHORT INTERRUPTION
368
+ # =================================================
369
+
370
+ if speaker_changed:
371
+
372
+ lookahead = []
373
+
374
+ for j in range(
375
+ i,
376
+ min(i + 3, len(words_with_speakers))
377
+ ):
378
+ lookahead.append(
379
+ words_with_speakers[j]
380
+ )
381
+
382
+ interrupt_duration = (
383
+ lookahead[-1].end
384
+ - lookahead[0].start
385
+ )
386
+
387
+ interrupt_speakers = [
388
+ x.speaker
389
+ for x in lookahead
390
+ ]
391
+
392
+ interrupt_same = (
393
+ len(set(interrupt_speakers)) == 1
394
+ )
395
+
396
+ tiny_interrupt = (
397
+ interrupt_same
398
+ and len(lookahead)
399
+ <= cls.SHORT_INTERRUPT_MAX_WORDS
400
+ and interrupt_duration
401
+ <= cls.SHORT_INTERRUPT_MAX_DURATION
402
+ )
403
+
404
+
405
+ # preserve continuity
406
+ if tiny_interrupt:
407
+
408
+ cur_words.append(curr)
409
+ continue
410
+
411
+ # real speaker switch
412
+ segments.append(
413
+ cls.build_segment(cur_words)
414
+ )
415
+
416
+ cur_words = [curr]
417
+ continue
418
 
 
 
419
 
420
+ # =================================================
421
+ # SPLIT
422
+ # =================================================
423
+
424
+ if long_pause or too_long:
425
+
426
+ segments.append(
427
+ cls.build_segment(cur_words)
428
+ )
429
+
430
+ cur_words = [curr]
431
+
 
 
 
432
  else:
433
+ cur_words.append(curr)
434
+
435
+ if cur_words:
436
+
437
+ segments.append(
438
+ cls.build_segment(cur_words)
439
+ )
440
 
 
 
 
 
 
 
 
 
 
 
441
  return segments
442
 
443
+ # ========================================================
444
+ # FILTER NOISE
445
+ # ========================================================
446
+
447
+ @classmethod
448
+ def filter_noise_segments(
449
+ cls,
450
+ segments: List[TranscriptSegment],
451
+ ) -> List[TranscriptSegment]:
452
+
453
+ filtered = []
454
+
455
+ for seg in segments:
456
+
457
+ duration = seg.end - seg.start
458
+
459
+ avg_conf = getattr(
460
+ seg,
461
+ "_avg_conf",
462
+ 1.0
463
+ )
464
+
465
+ word_count = getattr(
466
+ seg,
467
+ "_word_count",
468
+ len(seg.text.split())
469
+ )
470
+
471
+ # hallucination/noise
472
+ if (
473
+ duration < cls.MIN_SEGMENT_DURATION
474
+ and avg_conf < cls.MIN_SEGMENT_AVG_CONF
475
+ ):
476
+ continue
477
+
478
+ # single-word garbage
479
+ if (
480
+ word_count <= 1
481
+ and avg_conf < 0.20
482
+ ):
483
+ continue
484
+
485
+ filtered.append(seg)
486
+
487
+ return filtered
488
+
489
+ # ========================================================
490
+ # REDUCE FRAGMENTATION
491
+ # ========================================================
492
+
493
  @classmethod
494
  def resize_and_merge_segments(
495
  cls,
496
+ segments: List[TranscriptSegment],
497
  ) -> List[TranscriptSegment]:
498
+
 
 
 
 
 
 
 
 
 
499
  if not segments:
500
  return []
501
+
502
+ segments = sorted(
503
+ segments,
504
+ key=lambda x: x.start
505
+ )
506
+
507
+ merged = [segments[0]]
508
+
509
+ for seg in segments[1:]:
510
+
511
+ prev = merged[-1]
512
+
513
+ gap = seg.start - prev.end
514
+
515
+ combined_duration = (
516
+ seg.end - prev.start
517
+ )
518
+
519
+ same_speaker = (
520
+ seg.speaker == prev.speaker
521
+ )
522
+
523
+ can_merge = (
524
+ same_speaker
525
+ and gap <= cls.MERGE_GAP
526
+ and combined_duration <= cls.MAX_MERGED_DURATION
527
+ )
528
+
529
+ if can_merge:
530
+
531
+ prev.text = (
532
+ prev.text.strip()
533
+ + " "
534
+ + seg.text.strip()
535
+ ).strip()
536
+
537
+ prev.end = seg.end
538
+
539
  else:
540
+ merged.append(seg)
541
+
 
 
 
 
542
  return merged
543
 
544
  @classmethod
 
557
  Returns:
558
  List of TranscriptSegment with proper speaker assignments
559
  """
560
+ # Step 1: Assign speakers to words
561
  words_with_speakers = cls.assign_speakers_to_words(words, speaker_segments)
562
 
563
+ # Step 2: Reconstruct segments
564
  segments = cls.reconstruct_segments(words_with_speakers)
565
 
566
+
567
+ # Step 3: Remove noise
568
+
569
+ segments = cls.filter_noise_segments(
570
+ segments,
571
+ words_with_speakers
572
+ )
573
+
574
+
575
+ # Step 4: Clustering/Merging (Optimization)
576
  segments = cls.resize_and_merge_segments(segments)
577
 
578
+
579
+ logger.info(
580
+ f"Alignment output segments = {len(segments)}"
581
+ )
582
+
583
  return segments
584
 
585