nida-tts / src /nida /data /fusion.py
Bilal7Dev's picture
Nida v0.5 (research preview): weights, 16 voices, code, model card
c0bce59 verified
Raw History Blame Contribute Delete
34.1 kB
"""S7 — deterministic fusion of transcript, alignment and paralinguistic detections.
Turns one segment's aligned words (S5), event detections and per-word style scores (S6)
into tagged text following docs/02-tag-spec.md, or into a quarantine record with a
machine-readable reason. No LLM, no randomness: the same inputs always give the same
output. Rules (docs/03-data-pipeline.md S7, tag-spec §7):
1. Bands: a detection below ``low`` is ignored (and counted); at or above ``high`` it is
used; in the middle band ``[low, high)`` it drops the whole segment
(``ambiguous_event`` / ``ambiguous_style``) — unless every millisecond of it is
covered by confident detections of the same family (then the event is there anyway).
2. Families: ``laugh`` and ``laugh_long`` are one family, every other kind its own.
Confident detections of one family that overlap or are at most ``merge_gap_ms`` apart
are merged.
3. Speech overlap: a laugh/cry detection covering more than ``style_word_overlap`` of a
word's duration marks the word ``laughing`` / ``crying``. If its longest non-speech
part is at least ``min_residual_event_ms`` it is *also* placed as an event. Any other
detection whose speech overlap exceeds ``max_event_speech_overlap`` of its length
drops the segment (``event_overlaps_speech``: false positive or overlapped audio).
4. Placement: an event is placed at the word gap nearest the middle of its longest
non-speech part. A laugh becomes ``laugh_long`` if a detector said so or that placed
part lasts at least ``laugh_long_min_ms``. Placed events of different kinds whose
parts overlap by more than ``max_cross_kind_overlap`` of the shorter one drop the
segment (``conflicting_events``): taggers often fire related classes on one sound.
5. Spans: consecutive words marked with the same style form a span if they are at least
``min_span_words`` words or ``min_span_ms`` long; a shorter run drops the segment
(``short_style_run``: audible but untaggable). A word with two styles drops the
segment (``conflicting_styles``). Per-word score tracks of an ``ignored_styles``
style are discarded before any of this: the detector is known to be unreliable, so
it neither tags nor drops anything (unlike ``disabled_tags``, which drop the segment
whenever the detector fires at or above ``low``).
6. Pauses: measured on the longest part of an inter-word gap not covered by a confident
detection. Longer than ``pause_long_ms`` → ``pause_long``; at least ``pause_short_ms``
→ ``pause_short`` unless the previous word ends in punctuation. None at segment edges.
7. Text: words are NFC; standalone fillers are canonicalised to their lowercase canonical
form (tag-spec §4); words with digits drop the segment (``unnormalised_number``) until
number normalisation exists; ``[ ] < >`` in a word drops it
(``reserved_char_in_text``). The output is validated by round-tripping through
:func:`nida.text.tags.parse` (``invalid_output``).
"""
from __future__ import annotations
import math
import unicodedata
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from nida.data.types import EventDetection, QuarantineRecord, TimedWord, WordStyleScore
from nida.text.fillers import canonical_filler
from nida.text.tags import EVENTS, SPANS, Event, Item, Span, TagSyntaxError, Word, parse, render
from nida.text.turkish import nfc, tr_lower
__all__ = [
"EMOTION_LABELS",
"STAGE",
"STYLE_FROM_EVENT",
"Band",
"EmotionCondition",
"FusionConfig",
"FusionResult",
"TagRecord",
"emotion_condition",
"fuse_segment",
"normalise_word",
]
STAGE = "fusion"
#: Event families that, when they overlap words, mean the words carry a style (rule 3).
STYLE_FROM_EVENT: Mapping[str, str] = {"laugh": "laughing", "cry": "crying"}
#: Output classes of the emotion model (emotion2vec+); ``other``/``unknown`` map to null.
EMOTION_LABELS: frozenset[str] = frozenset(
{"angry", "disgusted", "fearful", "happy", "neutral", "other", "sad", "surprised", "unknown"}
)
_NULL_EMOTIONS = frozenset({"other", "unknown"})
_PAUSE_EVENTS = frozenset({"pause_short", "pause_long"})
_RESERVED_CHARS = frozenset("[]<>")
def _family(kind: str) -> str:
return "laugh" if kind in {"laugh", "laugh_long"} else kind
@dataclass(frozen=True)
class Band:
"""Confidence band: ``< low`` ignored, ``[low, high)`` ambiguous, ``>= high`` used."""
low: float
high: float
def __post_init__(self) -> None:
if not (0.0 <= self.low <= self.high <= 1.0):
raise ValueError(f"invalid band low={self.low} high={self.high}")
_INT_FIELDS = (
"min_span_words",
"min_span_ms",
"min_residual_event_ms",
"laugh_long_min_ms",
"merge_gap_ms",
"pause_short_ms",
"pause_long_ms",
)
_FRACTION_FIELDS = (
"style_word_overlap",
"max_event_speech_overlap",
"max_cross_kind_overlap",
"emotion_min_conf",
)
@dataclass(frozen=True)
class FusionConfig:
"""Fusion thresholds (configs/data/fusion.yaml). All fields are required."""
bands: Mapping[str, Band]
disabled_tags: frozenset[str]
ignored_styles: frozenset[str]
min_span_words: int
min_span_ms: int
style_word_overlap: float
max_event_speech_overlap: float
max_cross_kind_overlap: float
min_residual_event_ms: int
laugh_long_min_ms: int
merge_gap_ms: int
pause_short_ms: int
pause_long_ms: int
emotion_min_conf: float
def __post_init__(self) -> None:
required = (set(EVENTS) - _PAUSE_EVENTS) | set(SPANS)
if set(self.bands) != required:
missing = sorted(required - set(self.bands))
extra = sorted(set(self.bands) - required)
raise ValueError(f"bands must cover exactly the detectable tags: {missing=} {extra=}")
unknown = self.disabled_tags - (set(EVENTS) | set(SPANS))
if unknown:
raise ValueError(f"unknown disabled tags {sorted(unknown)}")
# Only styles that come as per-word score tracks can be ignored; laughing/crying
# come from events (STYLE_FROM_EVENT) and have no track to discard.
scored = set(SPANS) - set(STYLE_FROM_EVENT.values())
not_styles = self.ignored_styles - scored
if not_styles:
raise ValueError(
f"ignored_styles must be span styles with score tracks {sorted(scored)}, "
f"got {sorted(not_styles)}"
)
both = self.ignored_styles & self.disabled_tags
if both:
raise ValueError(f"tags both disabled and ignored: {sorted(both)}")
for name in _INT_FIELDS:
value = getattr(self, name)
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise ValueError(f"{name} must be a non-negative int, got {value!r}")
if self.min_span_words < 1:
raise ValueError("min_span_words must be >= 1")
if self.min_residual_event_ms < 1:
raise ValueError("min_residual_event_ms must be >= 1")
for name in _FRACTION_FIELDS:
value = getattr(self, name)
if isinstance(value, bool) or not isinstance(value, int | float):
raise ValueError(f"{name} must be a number, got {value!r}")
if not (math.isfinite(value) and 0.0 <= value <= 1.0):
raise ValueError(f"{name} must be in [0, 1], got {value!r}")
if not 0 < self.pause_short_ms <= self.pause_long_ms:
raise ValueError("need 0 < pause_short_ms <= pause_long_ms")
@classmethod
def from_mapping(cls, raw: Mapping[str, object]) -> FusionConfig:
"""Build from a parsed YAML mapping; unknown or missing keys are errors."""
expected = {f.name for f in cls.__dataclass_fields__.values()}
if set(raw) != expected:
raise ValueError(
f"fusion config keys: missing={sorted(expected - set(raw))} "
f"unknown={sorted(set(raw) - expected)}"
)
bands_raw = raw["bands"]
if not isinstance(bands_raw, Mapping):
raise ValueError("bands must be a mapping")
bands: dict[str, Band] = {}
for kind, band in bands_raw.items():
if not isinstance(band, Mapping) or set(band) != {"low", "high"}:
raise ValueError(f"band {kind!r} must be a mapping with exactly low/high")
low, high = band["low"], band["high"]
if not all(isinstance(v, int | float) and not isinstance(v, bool) for v in (low, high)):
raise ValueError(f"band {kind!r} values must be numbers")
bands[str(kind)] = Band(float(low), float(high))
disabled = raw["disabled_tags"]
if not isinstance(disabled, list) or not all(isinstance(t, str) for t in disabled):
raise ValueError("disabled_tags must be a list of tag ids")
ignored = raw["ignored_styles"]
if not isinstance(ignored, list) or not all(isinstance(t, str) for t in ignored):
raise ValueError("ignored_styles must be a list of style ids")
lists = {"bands", "disabled_tags", "ignored_styles"}
values = {k: v for k, v in raw.items() if k not in lists}
# The remaining values are typed `object` here; __post_init__ validates each one.
return cls(
bands=bands,
disabled_tags=frozenset(disabled),
ignored_styles=frozenset(ignored),
**values, # type: ignore[arg-type]
)
@dataclass(frozen=True)
class TagRecord:
"""Provenance of one emitted tag (tag-spec §7.3): ``{id, start, end, conf, source}``.
``start_ms``/``end_ms`` is what the tag stands for (the placed non-speech part of an
event, the words of a span, the silence of a pause); ``det_start_ms``/``det_end_ms``
is the merged detector interval it came from, so it can be checked against the
detector output.
"""
tag: str
start_ms: int
end_ms: int
conf: float
source: str
det_start_ms: int
det_end_ms: int
@dataclass(frozen=True)
class FusionResult:
"""Either ``items``/``text`` (accepted) or ``quarantine`` (dropped), never both."""
segment_id: str
items: tuple[Item, ...] | None
text: str | None
tags: tuple[TagRecord, ...]
quarantine: QuarantineRecord | None
ignored_detections: int = 0
#: The below-band detections themselves (``len == ignored_detections``), so a caller can
#: record what was heard but left untagged (ADR-0010: uncertain breaths keep the segment).
ignored_events: tuple[EventDetection, ...] = ()
def __post_init__(self) -> None:
accepted = self.items is not None and self.text is not None
dropped = self.items is None and self.text is None and not self.tags
if self.quarantine is None and not accepted:
raise ValueError("accepted result needs items and text")
if self.quarantine is not None and not dropped:
raise ValueError("quarantined result must not carry items, text or tags")
@property
def accepted(self) -> bool:
return self.quarantine is None
@dataclass(frozen=True)
class EmotionCondition:
emotion: str | None
intensity: float | None
@dataclass
class _Merged:
family: str
kinds: set[str]
start_ms: int
end_ms: int
conf: float
sources: set[str] = field(default_factory=set)
@property
def source(self) -> str:
return "+".join(sorted(self.sources))
@dataclass(frozen=True)
class _Placed:
gap: int
kind: str
start_ms: int
end_ms: int
event: _Merged
class _Drop(Exception):
"""Internal: the segment is quarantined with ``reason``."""
def __init__(self, reason: str, **details: object) -> None:
super().__init__(reason)
self.reason = reason
self.details = details
def _overlap_ms(a_start: int, a_end: int, b_start: int, b_end: int) -> int:
return max(0, min(a_end, b_end) - max(a_start, b_start))
def _union(intervals: Sequence[tuple[int, int]]) -> list[tuple[int, int]]:
out: list[tuple[int, int]] = []
for start, end in sorted(intervals):
if out and start <= out[-1][1]:
out[-1] = (out[-1][0], max(out[-1][1], end))
else:
out.append((start, end))
return out
def _validate_inputs(
words: Sequence[TimedWord],
duration_ms: int,
events: Sequence[EventDetection],
styles: Sequence[WordStyleScore],
) -> None:
if type(duration_ms) is not int or duration_ms <= 0:
raise ValueError(f"duration_ms must be a positive int, got {duration_ms!r}")
previous_end = 0
for word in words:
if word.start_ms < previous_end:
raise ValueError(f"words overlap or are unsorted at {word.text!r} ({word.start_ms} ms)")
if word.end_ms > duration_ms:
raise ValueError(f"word {word.text!r} ends after the segment ({word.end_ms} ms)")
previous_end = word.end_ms
for event in events:
if event.kind not in EVENTS or event.kind in _PAUSE_EVENTS:
raise ValueError(f"not a detectable event kind: {event.kind!r}")
if event.end_ms > duration_ms:
raise ValueError(f"event {event.kind!r} ends after the segment")
seen: set[str] = set()
for style in styles:
if style.style not in SPANS:
raise ValueError(f"unknown style {style.style!r}")
if style.style in seen:
raise ValueError(f"duplicate style scores for {style.style!r}")
seen.add(style.style)
if len(style.scores) != len(words):
raise ValueError(
f"style {style.style!r} has {len(style.scores)} scores for {len(words)} words"
)
def _split_by_band(
events: Sequence[EventDetection], cfg: FusionConfig
) -> tuple[list[EventDetection], list[EventDetection], list[EventDetection]]:
"""(confident, middle band, ignored = below ``low``)."""
confident: list[EventDetection] = []
middle: list[EventDetection] = []
ignored: list[EventDetection] = []
for event in events:
band = cfg.bands[event.kind]
if event.conf < band.low:
ignored.append(event)
elif event.conf < band.high:
middle.append(event)
else:
confident.append(event)
return confident, middle, ignored
def _merge_confident(
confident: Sequence[EventDetection], middle: Sequence[EventDetection], cfg: FusionConfig
) -> list[_Merged]:
"""Rules 1–2: ambiguity check against confident cover, then per-family merging."""
cover: dict[str, list[tuple[int, int]]] = {}
for event in confident:
cover.setdefault(_family(event.kind), []).append((event.start_ms, event.end_ms))
union = {family: _union(spans) for family, spans in cover.items()}
for event in sorted(middle, key=lambda e: (e.start_ms, e.end_ms, e.kind, e.source, e.conf)):
if event.kind in cfg.disabled_tags:
raise _Drop("disabled_tag", tag=event.kind, start_ms=event.start_ms, conf=event.conf)
covered = sum(
_overlap_ms(event.start_ms, event.end_ms, a, b)
for a, b in union.get(_family(event.kind), [])
)
if covered < event.duration_ms:
raise _Drop(
"ambiguous_event",
kind=event.kind,
conf=event.conf,
start_ms=event.start_ms,
end_ms=event.end_ms,
source=event.source,
)
for event in sorted(confident, key=lambda e: (e.start_ms, e.end_ms, e.kind, e.source)):
if event.kind in cfg.disabled_tags:
# Checked on the raw kind too: a disabled `laugh` must not survive by being
# relabelled `laugh_long` after merging.
raise _Drop("disabled_tag", tag=event.kind, start_ms=event.start_ms, conf=event.conf)
merged: list[_Merged] = []
ordered = sorted(
confident, key=lambda e: (_family(e.kind), e.start_ms, e.end_ms, e.kind, e.source)
)
for event in ordered:
family = _family(event.kind)
last = merged[-1] if merged else None
if (
last is not None
and last.family == family
and event.start_ms <= last.end_ms + cfg.merge_gap_ms
):
last.end_ms = max(last.end_ms, event.end_ms)
last.conf = max(last.conf, event.conf)
last.kinds.add(event.kind)
last.sources.add(event.source)
else:
merged.append(
_Merged(
family, {event.kind}, event.start_ms, event.end_ms, event.conf, {event.source}
)
)
return sorted(merged, key=lambda m: (m.start_ms, m.end_ms, m.family))
def _non_speech_pieces(
start_ms: int, end_ms: int, words: Sequence[TimedWord]
) -> list[tuple[int, int]]:
"""Parts of ``[start_ms, end_ms)`` not covered by any word (words are sorted, disjoint)."""
pieces: list[tuple[int, int]] = []
cursor = start_ms
for word in words:
if word.end_ms <= cursor:
continue
if word.start_ms >= end_ms:
break
if word.start_ms > cursor:
pieces.append((cursor, word.start_ms))
cursor = max(cursor, word.end_ms)
if cursor < end_ms:
pieces.append((cursor, end_ms))
return pieces
def _nearest_gap(time_ms: float, words: Sequence[TimedWord]) -> int:
"""Gap ``g`` lies before word ``g`` (``g == len(words)``: after the last word)."""
for index, word in enumerate(words):
if time_ms <= word.start_ms:
return index
if time_ms < word.end_ms:
# Inside a word (defensive: callers pass the middle of a non-speech piece, which
# never lies inside a word): the closer boundary wins; a tie goes after it.
return index if time_ms - word.start_ms < word.end_ms - time_ms else index + 1
return len(words)
def _event_kind(event: _Merged, placed_ms: int, cfg: FusionConfig) -> str:
if event.family != "laugh":
return event.family
if "laugh_long" in event.kinds or placed_ms >= cfg.laugh_long_min_ms:
return "laugh_long"
return "laugh"
def _place_events(
merged: Sequence[_Merged], words: Sequence[TimedWord], cfg: FusionConfig
) -> tuple[list[_Placed], list[tuple[str, list[int], _Merged]]]:
"""Rules 3–4. Returns (placed events, style evidence ``(style, word indices, event)``)."""
placed: list[_Placed] = []
style_events: list[tuple[str, list[int], _Merged]] = []
for event in merged:
length = event.end_ms - event.start_ms
style_id = STYLE_FROM_EVENT.get(event.family)
styled_words = [
i
for i, w in enumerate(words)
if style_id is not None
and _overlap_ms(event.start_ms, event.end_ms, w.start_ms, w.end_ms)
> cfg.style_word_overlap * w.duration_ms
]
pieces = _non_speech_pieces(event.start_ms, event.end_ms, words)
longest = max(pieces, key=lambda p: (p[1] - p[0], -p[0])) if pieces else None
longest_ms = longest[1] - longest[0] if longest is not None else 0
if styled_words:
assert style_id is not None
# The detection may also cover words it did not mark (overlap <= the style
# threshold); that speech is laughed/cried over untagged, so bound it like any
# other event's speech overlap.
unmarked_ms = sum(
_overlap_ms(event.start_ms, event.end_ms, w.start_ms, w.end_ms)
for i, w in enumerate(words)
if i not in styled_words
)
if unmarked_ms > cfg.max_event_speech_overlap * length:
raise _Drop(
"event_overlaps_speech",
kind=sorted(event.kinds),
start_ms=event.start_ms,
end_ms=event.end_ms,
speech_ms=unmarked_ms,
)
style_events.append((style_id, styled_words, event))
if longest_ms < cfg.min_residual_event_ms:
continue
else:
speech_ms = length - sum(b - a for a, b in pieces)
if longest is None or speech_ms > cfg.max_event_speech_overlap * length:
raise _Drop(
"event_overlaps_speech",
kind=sorted(event.kinds),
start_ms=event.start_ms,
end_ms=event.end_ms,
speech_ms=speech_ms,
)
assert longest is not None
kind = _event_kind(event, longest_ms, cfg)
if kind in cfg.disabled_tags:
raise _Drop("disabled_tag", tag=kind, start_ms=event.start_ms, conf=event.conf)
placed.append(
_Placed(_nearest_gap((longest[0] + longest[1]) / 2, words), kind, *longest, event)
)
for i, first in enumerate(placed):
for second in placed[i + 1 :]:
if first.kind == second.kind:
continue
shorter = min(first.end_ms - first.start_ms, second.end_ms - second.start_ms)
shared = _overlap_ms(first.start_ms, first.end_ms, second.start_ms, second.end_ms)
if shared > cfg.max_cross_kind_overlap * shorter:
raise _Drop(
"conflicting_events",
kinds=sorted({first.kind, second.kind}),
start_ms=max(first.start_ms, second.start_ms),
shared_ms=shared,
)
return placed, style_events
def _style_marks(
words: Sequence[TimedWord],
styles: Sequence[WordStyleScore],
style_events: Sequence[tuple[str, list[int], _Merged]],
cfg: FusionConfig,
) -> tuple[list[str | None], dict[int, tuple[float, str, int, int]]]:
"""Per-word style (or None) and, per marked word, its evidence
``(conf, source, det_start_ms, det_end_ms)`` (the word itself for acoustic scores)."""
marks: list[set[str]] = [set() for _ in words]
evidence: list[dict[str, tuple[float, str, int, int]]] = [{} for _ in words]
for style in sorted(styles, key=lambda s: s.style):
if style.style in cfg.ignored_styles:
continue
band = cfg.bands[style.style]
for index, score in enumerate(style.scores):
if score is None or score < band.low:
continue
if score < band.high:
raise _Drop(
"ambiguous_style",
style=style.style,
word_index=index,
word=words[index].text,
score=score,
source=style.source,
)
if style.style in cfg.disabled_tags:
raise _Drop("disabled_tag", tag=style.style, word_index=index, score=score)
marks[index].add(style.style)
word = words[index]
evidence[index][style.style] = (score, style.source, word.start_ms, word.end_ms)
for style_id, indices, event in style_events:
if style_id in cfg.disabled_tags:
raise _Drop("disabled_tag", tag=style_id, start_ms=event.start_ms, conf=event.conf)
for index in indices:
marks[index].add(style_id)
previous = evidence[index].get(style_id)
if previous is None or event.conf > previous[0]:
evidence[index][style_id] = (
event.conf,
event.source,
event.start_ms,
event.end_ms,
)
resolved: list[str | None] = []
for index, word_marks in enumerate(marks):
if len(word_marks) > 1:
raise _Drop(
"conflicting_styles",
word_index=index,
word=words[index].text,
styles=sorted(word_marks),
)
resolved.append(next(iter(word_marks)) if word_marks else None)
chosen = {
index: evidence[index][style_id]
for index, style_id in enumerate(resolved)
if style_id is not None
}
return resolved, chosen
def _style_runs(
words: Sequence[TimedWord], marks: Sequence[str | None], cfg: FusionConfig
) -> list[tuple[str, int, int]]:
"""Rule 5: maximal runs ``(style, first, last)`` of equally marked consecutive words."""
runs: list[tuple[str, int, int]] = []
index = 0
while index < len(marks):
style_id = marks[index]
if style_id is None:
index += 1
continue
last = index
while last + 1 < len(marks) and marks[last + 1] == style_id:
last += 1
count = last - index + 1
span_ms = words[last].end_ms - words[index].start_ms
if count < cfg.min_span_words and span_ms < cfg.min_span_ms:
raise _Drop(
"short_style_run",
style=style_id,
words=[w.text for w in words[index : last + 1]],
span_ms=span_ms,
)
runs.append((style_id, index, last))
index = last + 1
return runs
def _normalise_word(text: str) -> str:
"""Rule 7 for one word."""
text = nfc(text)
if any(ch in _RESERVED_CHARS for ch in text):
raise _Drop("reserved_char_in_text", word=text)
if any(unicodedata.category(ch).startswith("N") for ch in text): # 3, ², ½, Ⅳ
raise _Drop("unnormalised_number", word=text)
start, end = 0, len(text)
while start < end and unicodedata.category(text[start]).startswith("P"):
start += 1
while end > start and unicodedata.category(text[end - 1]).startswith("P"):
end -= 1
core = text[start:end]
if not core:
return text
canonical = canonical_filler(tr_lower(core))
return text if canonical is None else text[:start] + canonical + text[end:]
def normalise_word(text: str) -> str:
"""Rule 7 for one word, as :func:`fuse_segment` applies it to every aligned word.
Lets later stages (``timings``) compare the words of a stored tagged text with the
aligned words they came from.
Raises:
ValueError: if fusion would drop the segment for this word (reserved tag character
or a digit-like character), with fusion's reason in the message.
"""
try:
return _normalise_word(text)
except _Drop as drop:
raise ValueError(f"{drop.reason}: {drop.details}") from drop
def _ends_with_punctuation(text: str) -> bool:
return unicodedata.category(text[-1]).startswith("P")
def _pauses(
words: Sequence[TimedWord],
texts: Sequence[str],
merged: Sequence[_Merged],
cfg: FusionConfig,
) -> list[_Placed]:
"""Rule 6, on the longest part of each gap that no confident detection covers.
A short breath inside a 5 s silence must not hide the pause; the pause tag record
spans that uncovered part, and tags in one gap are later ordered by time.
"""
out: list[_Placed] = []
for gap in range(1, len(words)):
gap_start, gap_end = words[gap - 1].end_ms, words[gap].start_ms
covered = _union(
[
(max(gap_start, e.start_ms), min(gap_end, e.end_ms))
for e in merged
if _overlap_ms(gap_start, gap_end, e.start_ms, e.end_ms) > 0
]
)
free: list[tuple[int, int]] = []
cursor = gap_start
for a, b in covered:
if a > cursor:
free.append((cursor, a))
cursor = max(cursor, b)
if cursor < gap_end:
free.append((cursor, gap_end))
if not free:
continue
start, end = max(free, key=lambda p: (p[1] - p[0], -p[0]))
silence = end - start
kind: str | None = None
if silence > cfg.pause_long_ms:
kind = "pause_long"
elif silence >= cfg.pause_short_ms and not _ends_with_punctuation(texts[gap - 1]):
kind = "pause_short"
if kind is not None:
if kind in cfg.disabled_tags:
raise _Drop("disabled_tag", tag=kind, start_ms=start)
silence_event = _Merged(kind, {kind}, start, end, 1.0, {"alignment"})
out.append(_Placed(gap, kind, start, end, silence_event))
return out
def _build_items(
texts: Sequence[str],
events_by_gap: Mapping[int, Sequence[_Placed]],
runs: Sequence[tuple[str, int, int]],
) -> tuple[Item, ...]:
run_at = {first: (style_id, last) for style_id, first, last in runs}
items: list[Item] = []
index = 0
while True:
items.extend(Event(p.kind) for p in events_by_gap.get(index, ()))
if index == len(texts):
break
if index in run_at:
style_id, last = run_at[index]
children: list[Word | Event] = []
for inner in range(index, last + 1):
if inner > index:
children.extend(Event(p.kind) for p in events_by_gap.get(inner, ()))
children.append(Word(texts[inner]))
items.append(Span(style_id, tuple(children)))
index = last + 1
else:
items.append(Word(texts[index]))
index += 1
return tuple(items)
def fuse_segment(
segment_id: str,
words: Sequence[TimedWord],
duration_ms: int,
events: Sequence[EventDetection],
styles: Sequence[WordStyleScore],
cfg: FusionConfig,
) -> FusionResult:
"""Fuse one segment's annotations into tagged text (see module docstring).
Args:
segment_id: Content-addressed id (:func:`nida.data.types.segment_id`).
words: Aligned words, sorted and non-overlapping, segment-relative ms.
duration_ms: Segment length in ms.
events: Raw event detections (any confidence; bands are applied here).
styles: At most one per-word score track per span style.
cfg: Thresholds.
Returns:
A :class:`FusionResult`; when ``quarantine`` is set, ``items``/``text`` are None.
``ignored_detections`` counts below-band detections in both cases and
``ignored_events`` holds them.
Raises:
ValueError: on inconsistent inputs (a caller bug, not a data property).
"""
_validate_inputs(words, duration_ms, events, styles)
confident, middle, ignored = _split_by_band(events, cfg)
try:
if not words:
raise _Drop("no_words")
texts = [_normalise_word(w.text) for w in words]
merged = _merge_confident(confident, middle, cfg)
placed, style_events = _place_events(merged, words, cfg)
marks, evidence = _style_marks(words, styles, style_events, cfg)
runs = _style_runs(words, marks, cfg)
placed += _pauses(words, texts, merged, cfg)
events_by_gap: dict[int, list[_Placed]] = {}
tags: list[TagRecord] = []
for p in sorted(placed, key=lambda p: (p.start_ms, p.end_ms, p.kind)):
events_by_gap.setdefault(p.gap, []).append(p)
tags.append(
TagRecord(
p.kind,
p.start_ms,
p.end_ms,
p.event.conf,
p.event.source,
p.event.start_ms,
p.event.end_ms,
)
)
for style_id, first, last in runs:
run_evidence = [evidence[i] for i in range(first, last + 1)]
tags.append(
TagRecord(
style_id,
words[first].start_ms,
words[last].end_ms,
min(e[0] for e in run_evidence),
"+".join(sorted({e[1] for e in run_evidence})),
min(e[2] for e in run_evidence),
max(e[3] for e in run_evidence),
)
)
try:
items = _build_items(texts, events_by_gap, runs)
text = render(items)
if parse(text) != items:
raise _Drop("invalid_output", text=text, detail="round-trip mismatch")
except (TagSyntaxError, ValueError) as err:
raise _Drop("invalid_output", error=str(err)) from err
except _Drop as drop:
return FusionResult(
segment_id=segment_id,
items=None,
text=None,
tags=(),
quarantine=QuarantineRecord(segment_id, STAGE, drop.reason, dict(drop.details)),
ignored_detections=len(ignored),
ignored_events=tuple(ignored),
)
return FusionResult(
segment_id=segment_id,
items=items,
text=text,
tags=tuple(sorted(tags, key=lambda t: (t.start_ms, t.end_ms, t.tag))),
quarantine=None,
ignored_detections=len(ignored),
ignored_events=tuple(ignored),
)
def emotion_condition(scores: Mapping[str, float], cfg: FusionConfig) -> EmotionCondition:
"""Utterance-level emotion condition from emotion-model class probabilities.
The top class becomes the condition with its probability as intensity. ``other`` /
``unknown`` as top class, or a top probability below ``emotion_min_conf``, give a
null condition (not "neutral"; tag-spec §5, ADR-0003). Ties resolve to the
alphabetically first label so the result is deterministic.
Raises:
ValueError: unknown labels, empty scores, or probabilities outside [0, 1].
"""
if not scores:
raise ValueError("empty emotion scores")
unknown = set(scores) - EMOTION_LABELS
if unknown:
raise ValueError(f"unknown emotion labels {sorted(unknown)}")
for label, prob in scores.items():
if not (math.isfinite(prob) and 0.0 <= prob <= 1.0):
raise ValueError(f"emotion {label!r} probability {prob!r} not in [0, 1]")
label, prob = min(scores.items(), key=lambda kv: (-kv[1], kv[0]))
if label in _NULL_EMOTIONS or prob < cfg.emotion_min_conf:
return EmotionCondition(None, None)
return EmotionCondition(label, prob)