Files
talkscore-asr/app/acoustics.py
T
Vladimir BryzgalovandClaude Opus 5 ba68785c81 Частота голоса как признак для разметки ролей
Громкость и спектр у собеседников в одной комнате похожи - на этом
разделение по голосам и провалилось. Основная частота зависит от
говорящего, а не от его места за столом.

Замер на восьми записях: смешанные пары дают разрыв 68-93 Гц, однополые
11-33 Гц. Промежуточных случаев нет, поэтому порог в 40 Гц надёжен.
Признак работает на двух записях из восьми - там, где клиент мужчина,
а менеджер женщина.

В acoustics добавлено pitch_hz, в stats - pitch_separates с медианами
групп. Подсказку в промпт стоит давать только при pitch_separates=true:
в однополых парах разница в пределах шума и собьёт разметку.

Сравнение с эталоном уточнено: пометки расшифровщика вида *неразборчиво*
и латиница в названиях категорий больше не считаются ошибками.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:03:32 +05:00

123 lines
6.1 KiB
Python

"""Акустические признаки реплики.
На записи с одним микрофоном на столе участники сидят на разном расстоянии,
и это слышно: дальний голос глуше и тише. Тембровые модели такую разницу
почти не улавливают, поэтому признаки считаются отдельно и отдаются наружу -
по ним LLM может уточнить, кто где, когда разделение по голосу ненадёжно.
"""
import numpy as np
__all__ = ["segment_pitch", "pitch_split", "segment_acoustics", "separation_quality"]
SAMPLE_RATE = 16000
# Диапазон человеческого голоса с запасом: женский обычно 165-255 Гц,
# мужской 85-155 Гц.
F0_MIN, F0_MAX = 70.0, 320.0
F0_FRAME_SEC = 0.04
F0_HOP_SEC = 0.02
# Ниже этой корреляции кадр считаем шумом, а не голосом.
F0_VOICED = 0.35
# Разрыв между группами, ниже которого голоса считаем неразличимыми.
# На восьми записях: смешанные пары дают 68-93 Гц, однополые 11-33 Гц.
PITCH_SPLIT_HZ = 40.0
def frame_pitch(frame: np.ndarray) -> float | None:
"""Основная частота кадра по автокорреляции.
Голос периодичен, и период виден как пик автокорреляции. Пик ниже порога
означает, что периодичности нет: шум, шелест, пауза.
"""
frame = frame - frame.mean()
if float(np.dot(frame, frame)) < 1e-6:
return None
corr = np.correlate(frame, frame, mode="full")[len(frame) - 1:]
corr /= corr[0] + 1e-12
lo = int(SAMPLE_RATE / F0_MAX)
hi = min(int(SAMPLE_RATE / F0_MIN), len(corr) - 1)
if hi <= lo:
return None
peak = int(np.argmax(corr[lo:hi])) + lo
return SAMPLE_RATE / peak if corr[peak] >= F0_VOICED else None
def segment_pitch(samples: np.ndarray) -> float | None:
"""Частота голоса на отрезке: медиана по озвученным кадрам.
Громкость и спектр у собеседников в одной комнате похожи - на этом
разделение по голосам и провалилось. Частота зависит от говорящего,
а не от его места за столом, поэтому разделяет там, где остальное нет.
"""
size = int(F0_FRAME_SEC * SAMPLE_RATE)
hop = int(F0_HOP_SEC * SAMPLE_RATE)
values = [f for start in range(0, max(0, len(samples) - size), hop)
if (f := frame_pitch(samples[start:start + size])) is not None]
return round(float(np.median(values)), 1) if len(values) >= 3 else None
def pitch_split(turns: list[dict]) -> dict:
"""Расходятся ли голоса по частоте настолько, чтобы на это опираться.
Признак работает только в парах разного пола. В однополых парах разрыв
в пределах шума, и подсказывать по нему нельзя - собьёт.
"""
values = sorted(t["acoustics"]["pitch_hz"] for t in turns
if (t.get("acoustics") or {}).get("pitch_hz"))
if len(values) < 8:
return {"pitch_separates": False}
middle = len(values) // 2
low = float(np.median(values[:middle]))
high = float(np.median(values[middle:]))
return {
"pitch_separates": bool(high - low >= PITCH_SPLIT_HZ),
"pitch_low_hz": round(low, 1),
"pitch_high_hz": round(high, 1),
}
def segment_acoustics(samples: np.ndarray) -> dict:
"""Считает признаки, связанные с расстоянием до микрофона."""
if len(samples) < SAMPLE_RATE // 4:
return {}
rms = float(np.sqrt((samples ** 2).mean()) + 1e-9)
spectrum = np.abs(np.fft.rfft(samples * np.hanning(len(samples))))
freqs = np.fft.rfftfreq(len(samples), 1 / SAMPLE_RATE)
total = float(spectrum.sum()) + 1e-9
return {
# Громкость: ближний к микрофону обычно громче
"loudness_db": round(20 * float(np.log10(rms)), 1),
# Доля высоких частот: у дальнего голоса верх съедается расстоянием
"hf_ratio": round(float(spectrum[(freqs > 3000) & (freqs < 7000)].sum()) / total, 3),
# Центр тяжести спектра, тот же смысл другим числом
"centroid_hz": round(float((spectrum * freqs).sum()) / total),
# Единственный признак, не зависящий от места за столом.
"pitch_hz": segment_pitch(samples),
}
def separation_quality(embeddings: np.ndarray, labels: np.ndarray) -> float:
"""Насколько уверенно голоса разошлись по кластерам.
Единица - участники различаются чётко, около нуля - модель фактически
не видит разницы и разметка по говорящим случайна. На записях с одним
микрофоном в комнате значение обычно низкое.
"""
if len(embeddings) < 4 or len(set(labels.tolist())) < 2:
return 0.0
norm = embeddings / (np.linalg.norm(embeddings, axis=1, keepdims=True) + 1e-9)
distances = 1 - norm @ norm.T
scores = []
for i in range(len(norm)):
own = distances[i][(labels == labels[i])]
other = distances[i][(labels != labels[i])]
own = own[own > 0]
if len(own) and len(other):
a, b = float(own.mean()), float(other.mean())
scores.append((b - a) / max(a, b))
return round(float(np.mean(scores)), 3) if scores else 0.0