Download src/nida/data/segment.py from Bilal7Dev/nida-tts: direct link, hf CLI and curl.
- Browser
- Download file 24.1 kB
-
https://huggingface.co/Bilal7Dev/nida-tts/resolve/main/src/nida/data/segment.py
- Command line
-
hf download hf://Bilal7Dev/nida-tts/src/nida/data/segment.py
-
curl -L -o segment.py https://huggingface.co/Bilal7Dev/nida-tts/resolve/main/src/nida/data/segment.py
24.1 kB
| """S3 segmentation: turn VAD speech, speaker turns and overlap regions into segments. | |
| Pure functions over integer-millisecond intervals, all relative to the *source file* | |
| (``file_start_ms`` / ``file_end_ms``; half-open ``[start, end)``). Nothing here reads audio. | |
| Algorithm (see :func:`plan_segments`): | |
| a. *Single-speaker speech regions*: speech, minus the overlap regions expanded by | |
| ``overlap_guard_ms`` (the "forbidden zone"), restricted to one speaker's turns. | |
| b. Inside a run of one speaker's regions, speech separated by silences shorter than | |
| ``min_cut_silence_ms`` is merged into one chunk (a pause is not a cut point). | |
| c. Consecutive chunks are packed greedily into segments of at most ``max_segment_ms``; | |
| a cut falls only in a silence of at least ``min_cut_silence_ms``. A single chunk longer | |
| than ``max_segment_ms`` is never force-cut; it is rejected. | |
| d. Each segment is padded by ``pad_ms`` per side, but never beyond the file, the middle of | |
| the neighbouring silence, the forbidden zone, or another speaker's turn. | |
| e. Segments whose speech extent is shorter than ``min_segment_ms`` are rejected. | |
| Nothing is dropped silently: every millisecond of speech ends up either inside a planned | |
| segment or inside a :class:`RejectedSpan` with a machine-readable reason. | |
| """ | |
| from __future__ import annotations | |
| from bisect import bisect_left, bisect_right | |
| from collections.abc import Iterable, Mapping, Sequence | |
| from dataclasses import dataclass | |
| from typing import Literal, get_args | |
| __all__ = [ | |
| "REJECT_REASONS", | |
| "Interval", | |
| "PlannedSegment", | |
| "RejectReason", | |
| "RejectedSpan", | |
| "SegmentConfig", | |
| "SegmentPlan", | |
| "SpeakerTurn", | |
| "plan_segments", | |
| ] | |
| RejectReason = Literal["too_short", "too_long_without_pause", "overlap", "no_speaker"] | |
| REJECT_REASONS: tuple[str, ...] = get_args(RejectReason) | |
| # Internal representation: half-open (start_ms, end_ms). | |
| _Span = tuple[int, int] | |
| def _check_int(value: object, what: str) -> None: | |
| # bool is an int subclass; True as a time value is always a bug. | |
| if not isinstance(value, int) or isinstance(value, bool): | |
| raise ValueError(f"{what} must be an int number of milliseconds, got {value!r}") | |
| def _check_span(start_ms: int, end_ms: int, what: str) -> None: | |
| _check_int(start_ms, f"{what} file_start_ms") | |
| _check_int(end_ms, f"{what} file_end_ms") | |
| if start_ms < 0 or end_ms <= start_ms: | |
| raise ValueError(f"{what}: invalid interval {start_ms}..{end_ms} ms") | |
| # -------------------------------------------------------------------------------------- | |
| # Public records | |
| # -------------------------------------------------------------------------------------- | |
| class Interval: | |
| """A non-empty half-open interval ``[file_start_ms, file_end_ms)`` in the source file.""" | |
| file_start_ms: int | |
| file_end_ms: int | |
| def __post_init__(self) -> None: | |
| _check_span(self.file_start_ms, self.file_end_ms, "Interval") | |
| def duration_ms(self) -> int: | |
| return self.file_end_ms - self.file_start_ms | |
| class SpeakerTurn: | |
| """A diarization turn: ``speaker`` talks during ``[file_start_ms, file_end_ms)``.""" | |
| speaker: str | |
| file_start_ms: int | |
| file_end_ms: int | |
| def __post_init__(self) -> None: | |
| if not isinstance(self.speaker, str) or not self.speaker: | |
| raise ValueError(f"speaker label must be a non-empty string, got {self.speaker!r}") | |
| _check_span(self.file_start_ms, self.file_end_ms, f"turn of {self.speaker!r}") | |
| class PlannedSegment: | |
| """One output segment (padded), cut from the source file. | |
| Attributes: | |
| file_start_ms: Padded start, relative to the file. | |
| file_end_ms: Padded end (exclusive), relative to the file. | |
| speaker: Diarization label of the only speaker in the segment. | |
| speech_ms: Milliseconds of speech inside the segment (silences excluded). | |
| """ | |
| file_start_ms: int | |
| file_end_ms: int | |
| speaker: str | |
| speech_ms: int | |
| def __post_init__(self) -> None: | |
| _check_span(self.file_start_ms, self.file_end_ms, "PlannedSegment") | |
| _check_int(self.speech_ms, "PlannedSegment speech_ms") | |
| if not self.speaker: | |
| raise ValueError("PlannedSegment speaker must be non-empty") | |
| if not 0 < self.speech_ms <= self.file_end_ms - self.file_start_ms: | |
| raise ValueError( | |
| f"speech_ms {self.speech_ms} outside 1..{self.file_end_ms - self.file_start_ms}" | |
| ) | |
| def duration_ms(self) -> int: | |
| return self.file_end_ms - self.file_start_ms | |
| class RejectedSpan: | |
| """A stretch of speech that is not part of any planned segment, and why. | |
| Attributes: | |
| file_start_ms: Start of the rejected speech extent. | |
| file_end_ms: End (exclusive) of the rejected speech extent. | |
| reason: One of :data:`REJECT_REASONS`. | |
| speaker: The speaker, when known (``None`` for ``overlap`` / ``no_speaker``). | |
| """ | |
| file_start_ms: int | |
| file_end_ms: int | |
| reason: RejectReason | |
| speaker: str | None = None | |
| def __post_init__(self) -> None: | |
| _check_span(self.file_start_ms, self.file_end_ms, "RejectedSpan") | |
| if self.reason not in REJECT_REASONS: | |
| raise ValueError(f"unknown reject reason {self.reason!r}; expected {REJECT_REASONS}") | |
| def duration_ms(self) -> int: | |
| return self.file_end_ms - self.file_start_ms | |
| class SegmentPlan: | |
| """Result of :func:`plan_segments`: both lists are sorted by time.""" | |
| segments: tuple[PlannedSegment, ...] | |
| rejected: tuple[RejectedSpan, ...] | |
| def reject_counts(self) -> dict[str, int]: | |
| """Number of rejected spans per reason (every reason present, possibly 0).""" | |
| counts = dict.fromkeys(REJECT_REASONS, 0) | |
| for span in self.rejected: | |
| counts[span.reason] += 1 | |
| return counts | |
| class SegmentConfig: | |
| """S3 thresholds. All values are integer milliseconds; load with :meth:`from_mapping`. | |
| Attributes: | |
| min_segment_ms: Minimum speech extent of a segment (before padding). | |
| max_segment_ms: Maximum length of a segment, padding included. | |
| min_cut_silence_ms: A cut needs a silence at least this long; shorter pauses are | |
| merged over. | |
| pad_ms: Padding per side. | |
| overlap_guard_ms: Distance kept from overlapped-speech regions. | |
| """ | |
| min_segment_ms: int | |
| max_segment_ms: int | |
| min_cut_silence_ms: int | |
| pad_ms: int | |
| overlap_guard_ms: int | |
| def __post_init__(self) -> None: | |
| for name in _CONFIG_FIELDS: | |
| _check_int(getattr(self, name), f"SegmentConfig.{name}") | |
| if self.min_segment_ms < 1: | |
| raise ValueError(f"min_segment_ms must be >= 1, got {self.min_segment_ms}") | |
| if self.max_segment_ms < self.min_segment_ms: | |
| raise ValueError( | |
| f"max_segment_ms ({self.max_segment_ms}) must be >= " | |
| f"min_segment_ms ({self.min_segment_ms})" | |
| ) | |
| if self.min_cut_silence_ms < 1: | |
| raise ValueError(f"min_cut_silence_ms must be >= 1, got {self.min_cut_silence_ms}") | |
| if self.pad_ms < 0: | |
| raise ValueError(f"pad_ms must be >= 0, got {self.pad_ms}") | |
| if self.overlap_guard_ms < 0: | |
| raise ValueError(f"overlap_guard_ms must be >= 0, got {self.overlap_guard_ms}") | |
| def from_mapping(cls, mapping: Mapping[str, object]) -> SegmentConfig: | |
| """Build a config from a parsed YAML mapping. | |
| Raises: | |
| ValueError: If ``mapping`` is not a mapping, has unknown or missing keys, or a | |
| value is not an int (bools and floats are rejected) or violates its bound. | |
| """ | |
| if not isinstance(mapping, Mapping): | |
| raise ValueError(f"segment config must be a mapping, got {type(mapping).__name__}") | |
| keys = set(mapping) | |
| unknown = sorted(str(key) for key in keys - set(_CONFIG_FIELDS)) | |
| missing = sorted(set(_CONFIG_FIELDS) - keys) | |
| if unknown or missing: | |
| raise ValueError(f"segment config: unknown keys {unknown}, missing keys {missing}") | |
| values: dict[str, int] = {} | |
| for name in _CONFIG_FIELDS: | |
| value = mapping[name] | |
| if not isinstance(value, int) or isinstance(value, bool): | |
| raise ValueError(f"segment config: {name} must be an int (ms), got {value!r}") | |
| values[name] = value | |
| return cls(**values) | |
| _CONFIG_FIELDS: tuple[str, ...] = ( | |
| "min_segment_ms", | |
| "max_segment_ms", | |
| "min_cut_silence_ms", | |
| "pad_ms", | |
| "overlap_guard_ms", | |
| ) | |
| # -------------------------------------------------------------------------------------- | |
| # Interval algebra on normalised span lists (sorted, disjoint, non-touching) | |
| # -------------------------------------------------------------------------------------- | |
| def _normalize(spans: Iterable[_Span]) -> list[_Span]: | |
| """Sort and union spans; overlapping and touching spans are merged.""" | |
| out: list[_Span] = [] | |
| for start, end in sorted(spans): | |
| if out and start <= out[-1][1]: | |
| if end > out[-1][1]: | |
| out[-1] = (out[-1][0], end) | |
| else: | |
| out.append((start, end)) | |
| return out | |
| def _intersect(a: Sequence[_Span], b: Sequence[_Span]) -> list[_Span]: | |
| out: list[_Span] = [] | |
| i = j = 0 | |
| while i < len(a) and j < len(b): | |
| start = max(a[i][0], b[j][0]) | |
| end = min(a[i][1], b[j][1]) | |
| if start < end: | |
| out.append((start, end)) | |
| if a[i][1] < b[j][1]: | |
| i += 1 | |
| else: | |
| j += 1 | |
| return out | |
| def _subtract(a: Sequence[_Span], b: Sequence[_Span]) -> list[_Span]: | |
| """Parts of ``a`` not covered by ``b``.""" | |
| out: list[_Span] = [] | |
| j = 0 | |
| for start, end in a: | |
| cur = start | |
| while j < len(b) and b[j][1] <= cur: | |
| j += 1 | |
| k = j | |
| while k < len(b) and b[k][0] < end: | |
| if b[k][0] > cur: | |
| out.append((cur, b[k][0])) | |
| cur = max(cur, b[k][1]) | |
| k += 1 | |
| if cur >= end: | |
| break | |
| if cur < end: | |
| out.append((cur, end)) | |
| return out | |
| def _expand(spans: Iterable[_Span], by_ms: int, file_duration_ms: int) -> list[_Span]: | |
| return _normalize( | |
| (max(0, start - by_ms), min(file_duration_ms, end + by_ms)) for start, end in spans | |
| ) | |
| class _SpanIndex: | |
| """Binary-search queries over a normalised span list.""" | |
| def __init__(self, spans: Sequence[_Span]) -> None: | |
| self.spans: tuple[_Span, ...] = tuple(spans) | |
| self._starts = [start for start, _ in spans] | |
| self._ends = [end for _, end in spans] | |
| def intersects(self, start_ms: int, end_ms: int) -> bool: | |
| """True if any span shares at least one millisecond with ``[start_ms, end_ms)``.""" | |
| idx = bisect_right(self._ends, start_ms) # first span ending after start_ms | |
| return idx < len(self._starts) and self._starts[idx] < end_ms | |
| def containing(self, t_ms: int) -> _Span | None: | |
| """The span with ``start <= t_ms < end``, if any.""" | |
| idx = bisect_right(self._starts, t_ms) - 1 | |
| if idx >= 0 and t_ms < self._ends[idx]: | |
| return self.spans[idx] | |
| return None | |
| def last_end_at_or_before(self, t_ms: int) -> int | None: | |
| idx = bisect_right(self._ends, t_ms) - 1 | |
| return self._ends[idx] if idx >= 0 else None | |
| def first_start_at_or_after(self, t_ms: int) -> int | None: | |
| idx = bisect_left(self._starts, t_ms) | |
| return self._starts[idx] if idx < len(self._starts) else None | |
| # -------------------------------------------------------------------------------------- | |
| # Planning | |
| # -------------------------------------------------------------------------------------- | |
| class _Context: | |
| """Normalised inputs shared by all planning steps.""" | |
| file_duration_ms: int | |
| speech: _SpanIndex | |
| forbidden: _SpanIndex # overlap regions expanded by the guard | |
| others: dict[str, _SpanIndex] # per speaker: union of the *other* speakers' turns | |
| class _Chunk: | |
| start_ms: int | |
| end_ms: int | |
| speech_ms: int | |
| class _Candidate: | |
| speaker: str | |
| start_ms: int | |
| end_ms: int | |
| speech_ms: int | |
| def _spans_of(intervals: Sequence[Interval], what: str, file_duration_ms: int) -> list[_Span]: | |
| spans: list[_Span] = [] | |
| for item in intervals: | |
| if not isinstance(item, Interval): | |
| raise TypeError(f"{what} must contain Interval objects, got {type(item).__name__}") | |
| if item.file_end_ms > file_duration_ms: | |
| raise ValueError( | |
| f"{what}: interval {item.file_start_ms}..{item.file_end_ms} ms exceeds the " | |
| f"file duration {file_duration_ms} ms" | |
| ) | |
| spans.append((item.file_start_ms, item.file_end_ms)) | |
| return _normalize(spans) | |
| def _turns_by_speaker( | |
| turns: Sequence[SpeakerTurn], file_duration_ms: int | |
| ) -> dict[str, list[_Span]]: | |
| raw: dict[str, list[_Span]] = {} | |
| for turn in turns: | |
| if not isinstance(turn, SpeakerTurn): | |
| raise TypeError(f"turns must contain SpeakerTurn objects, got {type(turn).__name__}") | |
| if turn.file_end_ms > file_duration_ms: | |
| raise ValueError( | |
| f"turn of {turn.speaker!r}: {turn.file_start_ms}..{turn.file_end_ms} ms " | |
| f"exceeds the file duration {file_duration_ms} ms" | |
| ) | |
| raw.setdefault(turn.speaker, []).append((turn.file_start_ms, turn.file_end_ms)) | |
| return {speaker: _normalize(spans) for speaker, spans in sorted(raw.items())} | |
| def _classify_speech( | |
| ctx: _Context, speech: list[_Span], turns: dict[str, list[_Span]] | |
| ) -> tuple[dict[str, list[_Span]], list[RejectedSpan]]: | |
| """Step (a): split speech into per-speaker regions and rejected pieces. | |
| Every millisecond of ``speech`` lands in exactly one place: a speaker region, an | |
| ``overlap`` rejection (inside the forbidden zone, whoever speaks) or a ``no_speaker`` | |
| rejection (outside every turn). | |
| """ | |
| rejected: list[RejectedSpan] = [] | |
| forbidden = list(ctx.forbidden.spans) | |
| for start, end in _intersect(speech, forbidden): | |
| rejected.append(RejectedSpan(start, end, "overlap")) | |
| rest = _subtract(speech, forbidden) | |
| any_turn = _normalize(span for spans in turns.values() for span in spans) | |
| for start, end in _subtract(rest, any_turn): | |
| rejected.append(RejectedSpan(start, end, "no_speaker")) | |
| regions = {speaker: _intersect(rest, spans) for speaker, spans in turns.items()} | |
| return regions, rejected | |
| def _split_runs(regions: Sequence[_Span], speaker: str, ctx: _Context) -> list[list[_Span]]: | |
| """Split one speaker's regions into runs at *walls*. | |
| Between two consecutive regions the gap is a wall if anything but plain silence lies in | |
| it: other speech (e.g. a rejected piece), the forbidden zone, or another speaker's | |
| turn. Segments never span a wall, so a segment never contains foreign material. | |
| """ | |
| runs: list[list[_Span]] = [] | |
| others = ctx.others[speaker] | |
| for region in regions: | |
| if runs: | |
| gap_start = runs[-1][-1][1] | |
| gap_end = region[0] | |
| wall = ( | |
| ctx.speech.intersects(gap_start, gap_end) | |
| or ctx.forbidden.intersects(gap_start, gap_end) | |
| or others.intersects(gap_start, gap_end) | |
| ) | |
| if not wall: | |
| runs[-1].append(region) | |
| continue | |
| runs.append([region]) | |
| return runs | |
| def _merge_chunks(run: Sequence[_Span], cfg: SegmentConfig) -> list[_Chunk]: | |
| """Step (b): merge regions separated by silences shorter than ``min_cut_silence_ms``.""" | |
| chunks: list[_Chunk] = [] | |
| for start, end in run: | |
| if chunks and start - chunks[-1].end_ms < cfg.min_cut_silence_ms: | |
| last = chunks[-1] | |
| chunks[-1] = _Chunk(last.start_ms, end, last.speech_ms + (end - start)) | |
| else: | |
| chunks.append(_Chunk(start, end, end - start)) | |
| return chunks | |
| def _pack_run( | |
| chunks: Sequence[_Chunk], speaker: str, cfg: SegmentConfig | |
| ) -> tuple[list[_Candidate], list[RejectedSpan]]: | |
| """Steps (c) and (e): greedy packing, then the minimum-length check.""" | |
| candidates: list[_Candidate] = [] | |
| rejected: list[RejectedSpan] = [] | |
| i = 0 | |
| while i < len(chunks): | |
| first = chunks[i] | |
| if first.end_ms - first.start_ms > cfg.max_segment_ms: | |
| rejected.append( | |
| RejectedSpan(first.start_ms, first.end_ms, "too_long_without_pause", speaker) | |
| ) | |
| i += 1 | |
| continue | |
| j = i + 1 | |
| speech_ms = first.speech_ms | |
| while j < len(chunks) and chunks[j].end_ms - first.start_ms <= cfg.max_segment_ms: | |
| speech_ms += chunks[j].speech_ms | |
| j += 1 | |
| end_ms = chunks[j - 1].end_ms | |
| if end_ms - first.start_ms < cfg.min_segment_ms: | |
| rejected.append(RejectedSpan(first.start_ms, end_ms, "too_short", speaker)) | |
| else: | |
| candidates.append(_Candidate(speaker, first.start_ms, end_ms, speech_ms)) | |
| i = j | |
| return candidates, rejected | |
| def _fit_padding(pad_left_ms: int, pad_right_ms: int, room_ms: int) -> tuple[int, int]: | |
| """Shrink the two paddings so that their sum is at most ``room_ms`` (fairly).""" | |
| if pad_left_ms + pad_right_ms <= room_ms: | |
| return pad_left_ms, pad_right_ms | |
| half = room_ms // 2 | |
| if pad_left_ms <= half: | |
| return pad_left_ms, room_ms - pad_left_ms | |
| if pad_right_ms <= room_ms - half: | |
| return room_ms - pad_right_ms, pad_right_ms | |
| return half, room_ms - half | |
| def _left_limit_ms(start_ms: int, speaker: str, ctx: _Context) -> int: | |
| """Smallest start the padded segment may take (a segment starts inside speech).""" | |
| containing = ctx.speech.containing(start_ms) | |
| if containing is not None and containing[0] < start_ms: | |
| return start_ms # speech continues to the left: the cut is not in a silence | |
| limit = 0 | |
| previous_end = ctx.speech.last_end_at_or_before(start_ms) | |
| if previous_end is not None: | |
| limit = max(limit, previous_end + (start_ms - previous_end) // 2) | |
| for index in (ctx.forbidden, ctx.others[speaker]): | |
| wall = index.last_end_at_or_before(start_ms) | |
| if wall is not None: | |
| limit = max(limit, wall) | |
| return min(limit, start_ms) | |
| def _right_limit_ms(end_ms: int, speaker: str, ctx: _Context) -> int: | |
| """Largest end the padded segment may take.""" | |
| containing = ctx.speech.containing(end_ms - 1) | |
| if containing is not None and end_ms < containing[1]: | |
| return end_ms # speech continues to the right | |
| limit = ctx.file_duration_ms | |
| next_start = ctx.speech.first_start_at_or_after(end_ms) | |
| if next_start is not None: | |
| limit = min(limit, end_ms + (next_start - end_ms) // 2) | |
| for index in (ctx.forbidden, ctx.others[speaker]): | |
| wall = index.first_start_at_or_after(end_ms) | |
| if wall is not None: | |
| limit = min(limit, wall) | |
| return max(limit, end_ms) | |
| def _pad(candidate: _Candidate, ctx: _Context, cfg: SegmentConfig) -> PlannedSegment: | |
| """Step (d): pad both sides, then trim so the segment fits ``max_segment_ms``.""" | |
| pad_left = min( | |
| cfg.pad_ms, candidate.start_ms - _left_limit_ms(candidate.start_ms, candidate.speaker, ctx) | |
| ) | |
| pad_right = min( | |
| cfg.pad_ms, _right_limit_ms(candidate.end_ms, candidate.speaker, ctx) - candidate.end_ms | |
| ) | |
| room_ms = cfg.max_segment_ms - (candidate.end_ms - candidate.start_ms) | |
| pad_left, pad_right = _fit_padding(pad_left, pad_right, room_ms) | |
| return PlannedSegment( | |
| file_start_ms=candidate.start_ms - pad_left, | |
| file_end_ms=candidate.end_ms + pad_right, | |
| speaker=candidate.speaker, | |
| speech_ms=candidate.speech_ms, | |
| ) | |
| def plan_segments( | |
| file_duration_ms: int, | |
| speech: Sequence[Interval], | |
| turns: Sequence[SpeakerTurn], | |
| overlaps: Sequence[Interval], | |
| cfg: SegmentConfig, | |
| ) -> SegmentPlan: | |
| """Plan the segments of one source file (S3). | |
| All times are integer milliseconds relative to the start of the file. Inputs may be | |
| unsorted; overlapping or touching intervals of the same list are unioned. Wherever two | |
| different speakers' turns intersect, that region counts as overlapped speech too, even | |
| if ``overlaps`` does not list it. Speech that lies outside every turn is rejected with | |
| ``no_speaker``; speech inside an overlap region (expanded by ``cfg.overlap_guard_ms``) | |
| with ``overlap``. | |
| Guarantees (property-tested): | |
| * segments are inside ``[0, file_duration_ms]``, pairwise disjoint (they may touch), | |
| single-speaker, and ``cfg.min_segment_ms <= speech extent`` and | |
| ``duration_ms <= cfg.max_segment_ms``; | |
| * no segment intersects the expanded overlap regions or another speaker's turn; | |
| * every millisecond of speech lies in exactly one segment or one rejected span; | |
| * the result is deterministic and independent of input order. | |
| Note: | |
| A cut forced by a speaker change or by the overlap guard can fall inside speech | |
| (there is no silence to cut in); the padding on that side is then zero. Half-words | |
| at such edges are expected to be caught by the S4 / S5 filters. | |
| Args: | |
| file_duration_ms: Length of the source file; must be > 0. | |
| speech: VAD speech intervals. | |
| turns: Diarization turns (speaker label + interval). | |
| overlaps: Regions where two or more speakers talk. | |
| cfg: Segmentation thresholds. | |
| Returns: | |
| A :class:`SegmentPlan` with segments and rejected spans sorted by time. | |
| Raises: | |
| TypeError: If a list element is not an :class:`Interval` / :class:`SpeakerTurn`. | |
| ValueError: If ``file_duration_ms`` is not a positive int, or any interval lies outside | |
| ``[0, file_duration_ms]`` (negative and empty intervals are rejected when the | |
| :class:`Interval` / :class:`SpeakerTurn` is constructed). | |
| """ | |
| _check_int(file_duration_ms, "file_duration_ms") | |
| if file_duration_ms <= 0: | |
| raise ValueError(f"file_duration_ms must be > 0, got {file_duration_ms}") | |
| speech_spans = _spans_of(speech, "speech", file_duration_ms) | |
| turn_spans = _turns_by_speaker(turns, file_duration_ms) | |
| overlap_spans = _spans_of(overlaps, "overlaps", file_duration_ms) | |
| speakers = list(turn_spans) | |
| for index, first in enumerate(speakers): | |
| for second in speakers[index + 1 :]: | |
| overlap_spans.extend(_intersect(turn_spans[first], turn_spans[second])) | |
| forbidden = _expand(_normalize(overlap_spans), cfg.overlap_guard_ms, file_duration_ms) | |
| ctx = _Context( | |
| file_duration_ms=file_duration_ms, | |
| speech=_SpanIndex(speech_spans), | |
| forbidden=_SpanIndex(forbidden), | |
| others={ | |
| speaker: _SpanIndex( | |
| _normalize( | |
| span for other in speakers if other != speaker for span in turn_spans[other] | |
| ) | |
| ) | |
| for speaker in speakers | |
| }, | |
| ) | |
| regions, rejected = _classify_speech(ctx, speech_spans, turn_spans) | |
| candidates: list[_Candidate] = [] | |
| for speaker, speaker_regions in regions.items(): | |
| for run in _split_runs(speaker_regions, speaker, ctx): | |
| run_candidates, run_rejected = _pack_run(_merge_chunks(run, cfg), speaker, cfg) | |
| candidates.extend(run_candidates) | |
| rejected.extend(run_rejected) | |
| segments = sorted( | |
| (_pad(candidate, ctx, cfg) for candidate in candidates), | |
| key=lambda seg: (seg.file_start_ms, seg.file_end_ms, seg.speaker), | |
| ) | |
| rejected.sort(key=lambda span: (span.file_start_ms, span.file_end_ms, span.reason)) | |
| return SegmentPlan(segments=tuple(segments), rejected=tuple(rejected)) | |