-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathintonation.py
More file actions
196 lines (158 loc) · 10.9 KB
/
Copy pathintonation.py
File metadata and controls
196 lines (158 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
"""Вопрос по голосу: то, чего не может ни одна модель распознавания.
В русском языке вопрос без вопросительного слова отличается от утверждения
ТОЛЬКО интонацией. «Ты придёшь» и «Ты придёшь?» - это одни и те же слова в том
же порядке. Модель распознавания получает текст и ставит точку, потому что по
тексту вопроса не видно: его там нет, он в голосе.
Голос у нас есть - мы его записали. И выбрасываем сразу после распознавания.
Здесь мы его дослушиваем: у русского вопроса без вопросительного слова тон на
последнем ударном слоге резко идёт ВВЕРХ (интонационная конструкция ИК-3 по
Брызгуновой), а у утверждения - вниз. Это слышно и приборно измеримо.
Что мы НЕ делаем:
- не трогаем фразы, которые уже кончаются «?» - модель справилась сама;
- не трогаем фразы с вопросительным словом («кто», «где», «почему») - там
интонация другая (ИК-2, тон падает), и по ней вопрос не опознать;
- не трогаем «!» и многоточие - там человек выразился однозначно;
- при малейшем сомнении оставляем как есть. Лишний вопросительный знак
заметен и раздражает сильнее, чем недостающий.
Считается на numpy за миллисекунды: берём последние полсекунды, оцениваем
основной тон автокорреляцией по кадрам и смотрим наклон.
"""
import re
import numpy as np
SAMPLE_RATE = 16000
# Сколько секунд с конца слушаем. Подъём тона в ИК-3 укладывается в последний
# слог, но полсекунды дают опору для сравнения «до» и «после».
_TAIL_SEC = 0.55
_FRAME = 0.032 # 32 мс - тот же шаг, что у замера громкости
_HOP = 0.016
# Границы человеческого голоса. Ниже - гул и наводки, выше - шипящие.
_F0_MIN, _F0_MAX = 70.0, 320.0
# Насколько тон должен подняться, чтобы это был вопрос. В полутонах: голос
# меряется отношением частот, а не разностью, иначе бас и сопрано требовали бы
# разных порогов. Четыре полутона - это заметный подъём, примерно терция.
_RISE_SEMITONES = 4.0
# Сколько кадров обязаны быть звонкими, чтобы вообще судить. На шёпоте и на
# согласных основного тона нет, и решать там не по чему.
_MIN_VOICED = 5
# Вопросительные слова: при них интонация другая, и трогать нельзя.
_WH = re.compile(
r"\b(кто|что|чей|чья|чьё|чьи|где|куда|откуда|когда|почему|отчего|зачем|"
r"как|каков|какая|какое|какие|какой|сколько|насколько|разве|неужели|ли)\b",
re.IGNORECASE)
def _f0(frame: np.ndarray) -> float:
"""Основной тон кадра автокорреляцией. 0.0 - кадр незвонкий.
Автокорреляция, а не что-то умнее, намеренно: нам нужен не точный тон, а
его направление, и на это её хватает с запасом. Считается за микросекунды и
не тянет ни одной новой зависимости.
"""
frame = frame - frame.mean()
if frame.size < 64:
return 0.0
energy = float(np.sqrt(np.mean(frame * frame)))
if energy < 0.004: # тише этого - тишина или придыхание
return 0.0
corr = np.correlate(frame, frame, mode="full")[frame.size - 1:]
if corr[0] <= 0:
return 0.0
corr = corr / corr[0]
lo = int(SAMPLE_RATE / _F0_MAX)
hi = min(int(SAMPLE_RATE / _F0_MIN), corr.size - 1)
if hi <= lo:
return 0.0
window = corr[lo:hi]
peak = int(np.argmax(window)) + lo
# Слабый пик - это не тон, а случайное совпадение шума.
if corr[peak] < 0.3:
return 0.0
return SAMPLE_RATE / peak
def measure(audio: np.ndarray, sample_rate: int = SAMPLE_RATE) -> dict:
"""Замер подъёма тона к концу фразы - числами, а не «да/нет».
Возвращает всё, по чему принимается решение: звонких кадров, тон в начале
и в конце хвоста, подъём в полутонах, порог. Нужно, чтобы порог можно было
подстроить под конкретный голос и микрофон по журналу, а не на глаз: у
разных людей и разных гарнитур своя граница, и «поставь пониже» вслепую
ловит ложные вопросы, которых вся эта осторожность и избегает.
"""
if audio is None or audio.size < int(0.2 * sample_rate):
return {"voiced": 0, "reason": "коротко"}
tail = audio[-int(_TAIL_SEC * sample_rate):]
frame_n = int(_FRAME * sample_rate)
hop_n = int(_HOP * sample_rate)
pitches = []
for i in range(0, max(1, tail.size - frame_n), hop_n):
f = _f0(tail[i:i + frame_n])
if f > 0:
pitches.append(f)
if len(pitches) < _MIN_VOICED:
return {"voiced": len(pitches), "reason": "мало звонких"}
# Опора - первая половина хвоста, куда подъём ещё не дошёл. Вершина - самый
# конец, а не медиана второй половины: медиана попадает в СЕРЕДИНУ подъёма и
# занижает его вдвое. На проверке 120 -> 170 Гц это давало 3.0 полутона
# вместо настоящих 5.7, и вопрос не опознавался.
#
# Берём медиану последних четырёх кадров, а не последний: одна сорвавшаяся
# оценка тона не должна решать за всю фразу.
half = len(pitches) // 2
before = float(np.median(pitches[:half]))
after = float(np.median(pitches[-4:]))
if before <= 0 or after <= 0:
return {"voiced": len(pitches), "reason": "нулевой тон"}
semitones = 12.0 * np.log2(after / before)
return {"voiced": len(pitches), "before": round(before, 1),
"after": round(after, 1), "semitones": round(semitones, 2),
"threshold": _RISE_SEMITONES, "rises": semitones >= _RISE_SEMITONES}
def rises(audio: np.ndarray, sample_rate: int = SAMPLE_RATE) -> bool:
"""Идёт ли тон вверх к концу фразы. Обёртка над measure() ради тестов и
старых вызовов: им нужен только ответ, а не разбор."""
return bool(measure(audio, sample_rate).get("rises"))
def maybe_question(text: str, audio: np.ndarray,
sample_rate: int = SAMPLE_RATE) -> str:
"""Поставить «?» вместо точки, если голос спрашивал.
Возвращает текст как есть во всех сомнительных случаях. Это правило, а не
осторожность: цена ошибки несимметрична. Пропущенный вопросительный знак
человек допишет не задумываясь, а лишний - заметит, удивится и перестанет
доверять всей чистке.
"""
if not text:
return text
stripped = text.rstrip()
if not stripped.endswith("."):
_log_decision("не точка в конце", text)
return text # «?», «!», многоточие - не наше дело
if stripped.endswith("..."):
_log_decision("многоточие", text)
return text
# Смотрим только последнее предложение: вопросительное слово в середине
# длинной фразы («Я знаю, где он живёт.») к последнему предложению
# отношения не имеет.
last = re.split(r"(?<=[.!?])\s+", stripped)[-1]
if _WH.search(last):
_log_decision("вопросительное слово", text)
return text
m = measure(audio, sample_rate)
if not m.get("rises"):
_log_decision("тон не поднялся", text, m)
return text
_log_decision("ВОПРОС", text, m)
return stripped[:-1] + "?" + text[len(stripped):]
def _log_decision(why: str, text: str, m: dict | None = None) -> None:
"""Строка замера в журнал: почему поставили «?» или нет.
Нужна, чтобы подстроить порог под голос владельца по фактам, а не на глаз
(он пожаловался, что вопрос почти не ставится). Тихая: одна строка на
диктовку, только когда вопрос по голосу вообще включён. Импорт app лениво -
он импортирует нас, сверху вышло бы кольцо.
"""
try:
from app import log
tail = text.rstrip()[-40:]
if m and "semitones" in m:
log(f"интонация: {why} | подъём {m['semitones']} пт "
f"(порог {m['threshold']}) | {m['before']}->{m['after']} Гц, "
f"звонких {m['voiced']} | «...{tail}»")
elif m:
log(f"интонация: {why} | {m.get('reason', '')} "
f"звонких {m.get('voiced', 0)} | «...{tail}»")
else:
log(f"интонация: {why} | «...{tail}»")
except Exception: # noqa: BLE001 - замер не должен ронять диктовку
pass