项目文件夹

文件
2026-07-13 13:39:38 +08:00

348 行
13 KiB
Python

from __future__ import annotations
import asyncio
import contextlib
from dataclasses import dataclass
import numpy as np
from livekit import rtc
from .. import utils
from ..language import LanguageCode
from ..log import logger
from ..types import DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, APIConnectOptions, NotGivenOr
from ..utils.audio import AudioByteStream
from .stt import STT, RecognizeStream, SpeechData, SpeechEvent, SpeechEventType
class MultiSpeakerAdapter(STT):
def __init__(
self,
*,
stt: STT,
detect_primary_speaker: bool = True,
suppress_background_speaker: bool = False,
primary_detection_options: NotGivenOr[PrimarySpeakerDetectionOptions] = NOT_GIVEN,
primary_format: str = "{text}",
background_format: str = "{text}",
):
"""MultiSpeakerAdapter is an adapter that allows to detect and suppress background speakers.
It needs STT with diarization capability and works for a single audio track.
Args:
stt (STT): STT instance to wrap
detect_primary_speaker (bool, optional): Whether to detect primary speaker. Defaults to True.
suppress_background_speaker (bool, optional): Whether to suppress background speaker. Defaults to False.
primary_detection_options (NotGivenOr[PrimarySpeakerDetectionOptions], optional): Primary speaker detection options.
If not provided, the default options will be used.
primary_format (str, optional): Format for primary speaker.
Supports {text} and {speaker_id} placeholders. Defaults to "{text}".
background_format (str, optional): Format for background speaker.
Supports {text} and {speaker_id} placeholders. Defaults to "{text}".
Raises:
ValueError: If the STT does not support diarization.
"""
if not stt.capabilities.diarization:
raise ValueError("MultiSpeakerAdapter needs STT with diarization capability")
super().__init__(capabilities=stt.capabilities)
self._stt = stt
self._detect_primary = detect_primary_speaker
self._suppress_background = suppress_background_speaker
self._opt = primary_detection_options or PrimarySpeakerDetectionOptions()
self._primary_format = primary_format
self._background_format = background_format
async def _recognize_impl(
self,
buffer: utils.AudioBuffer,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SpeechEvent:
return await self._stt.recognize(buffer, language=language, conn_options=conn_options)
def stream(
self,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> RecognizeStream:
return MultiSpeakerAdapterWrapper(
stt=self, wrapped_stt=self._stt, language=language, conn_options=conn_options
)
class MultiSpeakerAdapterWrapper(RecognizeStream):
def __init__(
self,
stt: MultiSpeakerAdapter,
*,
wrapped_stt: STT,
language: NotGivenOr[str],
conn_options: APIConnectOptions,
):
super().__init__(stt=stt, conn_options=conn_options)
self._wrapped_stt = wrapped_stt
self._language = language
self._detector = _PrimarySpeakerDetector(
detect_primary_speaker=stt._detect_primary,
suppress_background_speaker=stt._suppress_background,
primary_detection_options=stt._opt,
primary_format=stt._primary_format,
background_format=stt._background_format,
)
async def _run(self) -> None:
async def _forward_input(stream: RecognizeStream) -> None:
async for frame in self._input_ch:
if isinstance(frame, rtc.AudioFrame):
stream.push_frame(frame)
self._detector.push_audio(frame)
elif isinstance(frame, self._FlushSentinel):
stream.flush()
with contextlib.suppress(RuntimeError):
stream.end_input()
async def _forward_output(stream: RecognizeStream) -> None:
async for ev in stream:
updated_ev = self._detector.on_stt_event(ev)
if updated_ev is not None:
self._event_ch.send_nowait(updated_ev)
elif ev.type == SpeechEventType.FINAL_TRANSCRIPT:
# send an empty final transcript to clear the interim results
self._event_ch.send_nowait(
SpeechEvent(
type=SpeechEventType.FINAL_TRANSCRIPT,
alternatives=[SpeechData(language=LanguageCode(""), text="")],
)
)
stream = self._wrapped_stt.stream(language=self._language, conn_options=self._conn_options)
tasks = [
asyncio.create_task(
_forward_input(stream), name="DiarizationAdapterWrapper.forward_input"
),
asyncio.create_task(
_forward_output(stream), name="DiarizationAdapterWrapper.forward_output"
),
]
try:
await asyncio.gather(*tasks)
finally:
await utils.aio.cancel_and_wait(*tasks)
await stream.aclose()
@dataclass
class PrimarySpeakerDetectionOptions:
"""Configuration for primary speaker detection"""
frame_size_ms: int = 100
"""Frame size for RMS computation"""
rms_buffer_duration: float = 120.0
"""How long to keep RMS data"""
min_rms_samples: int = 3
"""Minimum RMS samples needed for a speech event"""
rms_smoothing_factor: float = 0.5
"""Smoothing factor for RMS for a speaker, rms = rms * factor + new_rms * (1 - factor)"""
# switching primary speaker
threshold_multiplier: float = 1.3
"""Candidate's RMS needs to be louder than current primary's RMS by this multiplier"""
decay_to_equal_time: float = 60
"""Time to decay from switch_threshold_multiplier to 1.0 (equal levels)"""
threshold_min_multiplier: float = 0.5
"""Minimum threshold multiplier (candidate can be min_multiplier quieter)"""
class _PrimarySpeakerDetector:
@dataclass
class SpeakerData:
speaker_id: str
last_activity_time: float = 0.0
rms: float = 0.0
def __init__(
self,
*,
detect_primary_speaker: bool = True,
suppress_background_speaker: bool = False,
primary_detection_options: NotGivenOr[PrimarySpeakerDetectionOptions] = NOT_GIVEN,
primary_format: str = "{text}",
background_format: str = "{text}",
):
"""Primary speaker detector. It detects the primary speaker based on RMS,
formats the primary and background speakers separately, or suppresses the background speaker.
Args:
detect_primary_speaker (bool, optional): Whether to detect primary speaker. Defaults to True.
suppress_background_speaker (bool, optional): Whether to suppress background speaker. Defaults to False.
primary_detection_options (PrimaryDetectionOptions, optional): Primary speaker detection options.
primary_format (str, optional): Format for primary speaker.
Supports {text} and {speaker_id} placeholders. Defaults to "{text}".
background_format (str, optional): Format for background speaker.
Supports {text} and {speaker_id} placeholders. Defaults to "{text}".
"""
self._primary_format = primary_format
self._background_format = background_format
self._detect_primary = detect_primary_speaker
self._suppress_background = suppress_background_speaker
self._opt = primary_detection_options or PrimarySpeakerDetectionOptions()
if self._suppress_background and not self._detect_primary:
logger.warning(
"Suppressing background speaker is not supported when `detect_primary_speaker` is False"
)
self._suppress_background = False
self._pushed_duration: float = 0.0
self._primary_speaker: str | None = None
self._speaker_data: dict[str, _PrimarySpeakerDetector.SpeakerData] = {}
self._bstream: AudioByteStream | None = None
self._rms_buffer: list[float] = []
self._frame_size = self._opt.frame_size_ms / 1000
self._max_rms_size = int(self._opt.rms_buffer_duration / self._frame_size)
def push_audio(self, frame: rtc.AudioFrame) -> None:
if not self._detect_primary:
self._pushed_duration += frame.duration
return
if not self._bstream:
sample_per_channel = int(frame.sample_rate * self._frame_size)
self._bstream = AudioByteStream(
sample_rate=frame.sample_rate,
num_channels=frame.num_channels,
samples_per_channel=sample_per_channel,
)
self._frame_size = sample_per_channel / frame.sample_rate # accurate frame size
for f in self._bstream.push(frame.data):
rms = self._compute_rms(f)
self._rms_buffer.append(rms)
self._pushed_duration += f.duration
if len(self._rms_buffer) > self._max_rms_size:
self._rms_buffer = self._rms_buffer[-self._max_rms_size :]
def on_stt_event(self, ev: SpeechEvent) -> SpeechEvent | None:
if not ev.alternatives:
return ev
sd = ev.alternatives[0]
if ev.type == SpeechEventType.FINAL_TRANSCRIPT:
self._update_primary_speaker(sd)
if sd.speaker_id is None or self._primary_speaker is None:
return ev
sd.is_primary_speaker = sd.speaker_id == self._primary_speaker
# format the transcript
if sd.is_primary_speaker:
sd.text = self._primary_format.format(text=sd.text, speaker_id=sd.speaker_id)
else:
if self._suppress_background:
return None
sd.text = self._background_format.format(text=sd.text, speaker_id=sd.speaker_id)
return ev
def _compute_rms(self, frame: rtc.AudioFrame) -> float:
audio_data = np.frombuffer(frame.data, dtype=np.int16)
if len(audio_data) == 0:
return 0.0
rms = np.sqrt(np.mean(audio_data.astype(np.float32) ** 2))
return float(rms)
def _get_rms_for_timerange(self, start_time: float, end_time: float) -> float | None:
if not self._rms_buffer:
return None
start = int((self._pushed_duration - start_time) / self._frame_size)
end = int((self._pushed_duration - end_time) / self._frame_size)
start = len(self._rms_buffer) - start - 1
end = len(self._rms_buffer) - end
if end < 0 or start >= len(self._rms_buffer):
return None
start = max(start, 0)
if end - start < self._opt.min_rms_samples:
return None
return float(np.median(self._rms_buffer[start:end]))
def _update_primary_speaker(self, sd: SpeechData) -> None:
if sd.speaker_id is None or not self._detect_primary:
self._primary_speaker = None
return
rms = self._get_rms_for_timerange(sd.start_time, sd.end_time)
if rms is None:
return
# update speaker data
speaker_id = sd.speaker_id
if data := self._speaker_data.get(speaker_id):
data.last_activity_time = sd.end_time
data.rms = data.rms * self._opt.rms_smoothing_factor + rms * (
1 - self._opt.rms_smoothing_factor
)
else:
self._speaker_data[speaker_id] = _PrimarySpeakerDetector.SpeakerData(
speaker_id=speaker_id,
last_activity_time=sd.end_time,
rms=rms,
)
if self._primary_speaker == speaker_id:
return
# compare the new speaker's RMS to the primary's RMS, switch primary if:
# 1. it's the first speaker
# 2. the new speaker's RMS is significantly louder than the primary's RMS
if (
self._primary_speaker is None
or (primary := self._speaker_data.get(self._primary_speaker)) is None
):
self._primary_speaker = speaker_id
logger.debug("set first primary speaker", extra={"speaker_id": speaker_id, "rms": rms})
return
silence_duration = self._pushed_duration - primary.last_activity_time
# decay the threshold multiplier over time in case the primary speaker is silent for a long time
if self._opt.threshold_multiplier > 1.0:
decay_rate = (self._opt.threshold_multiplier - 1.0) / self._opt.decay_to_equal_time
else:
decay_rate = 0.0
multiplier = max(
self._opt.threshold_multiplier - (decay_rate * silence_duration),
self._opt.threshold_min_multiplier,
)
rms_threshold = primary.rms * multiplier
extra = {
"speaker_id": speaker_id,
"rms": rms,
"rms_threshold": rms_threshold,
"silence_duration": silence_duration,
"multiplier": multiplier,
}
if rms > rms_threshold:
self._primary_speaker = speaker_id
logger.debug("primary speaker switched", extra=extra)
else:
logger.debug("primary speaker unchanged", extra=extra)