Продовження серії про AI Gateway і Катю. Частина про те, як звичайний звук перетворюється на картинку, чому модель два тижні була “сліпою” і який один рядок коду все виправив.
Зміст
- Чому не готове рішення
- Що таке wake word і як це працює в голові
- Звук — це числа. Багато чисел
- Мел-спектрограма: перетворюємо звук на картинку
- Нейромережа, епохи, батчі — пояснення без формул
- Збираємо датасет: мікрофон і терпіння
- Тренуємо модель — і вона не вчиться
- Один рядок який все виправив
- Живий результат
- Що далі
1. Чому не готове рішення
У попередній статті про Катю я згадав wake word як “наступний крок” і навіть написав назви готових інструментів — openWakeWord, Picovoice Porcupine. Логічно було б просто взяти одне з них і рухатись далі.
Але є нюанс.
Porcupine від Picovoice — платний для комерційного використання, і кастомне слово коштує грошей. openWakeWord — безкоштовний і непоганий, але він базується на великій pre-trained моделі (~20MB) яку потрібно запустити на Luckfox Pico з RV1106. А там лише 256MB RAM і специфічний NPU який “їсть” тільки RKNN-формат. Портувати туди чужу архітектуру — окремий квест.
Тому я вирішив: зроблю свою модель. Маленьку, просту, зрозумілу. Таку яка влізе в 50KB і запуститься на будь-якому залізі.
Спойлер: це зайняло значно більше часу ніж я думав. І головна проблема виявилась не в даних і не в архітектурі — а в одному рядку коду який я написав неправильно з самого початку.
2. Що таке wake word і як це працює в голові
Перш ніж писати код — розберемось що ми взагалі хочемо зробити.
Wake word (“слово пробудження”) — це те слово після якого пристрій починає вас слухати. “Окей Гугл”, “Алекса”, “Сірі” — всі вони працюють однаково: мікрофон слухає постійно, але пристрій “прокидається” тільки коли чує своє слово.
Технічно це задача бінарної класифікації. Є два класи:
- positive — прозвучало слово “Катя”
- negative — все інше: тиша, фонові звуки, інші слова
Модель повинна слухати потік аудіо і для кожного короткого вікна (1 секунда) відповідати: “Катя” чи “не Катя”.
Звучить просто. Але є одна проблема: нейромережі не вміють працювати зі звуком напряму. Вони вміють працювати з числами і — особливо добре — з картинками. Тому перший крок — перетворити звук на картинку.
3. Звук — це числа. Багато чисел
Коли ви говорите в мікрофон, він вимірює коливання повітря тисячі разів на секунду і записує їх як числа. Стандарт для аудіо в ML — 16,000 вимірювань на секунду (16kHz).
Тобто одна секунда вашого голосу — це масив з 16,000 чисел від -1.0 до 1.0.
|
1 2 |
[0.001, -0.003, 0.012, -0.008, 0.024, 0.031, -0.015, ...] # 16,000 таких чисел для кожної секунди |
Можна спробувати подати ці 16,000 чисел напряму в нейромережу. Але є проблема: однакове слово сказане тихо і голосно дасть зовсім різні числа, хоча звучить однаково. Сказане повільно і швидко — теж різні числа. Модель буде навчатись не на слові, а на гучності і темпі конкретного запису.
Потрібне краще представлення. Таке де “Катя” тихим і голосним голосом виглядає схоже.
4. Мел-спектрограма: перетворюємо звук на картинку
Рішення підказує те, як чує людське вухо.
Наше вухо не аналізує кожне коливання окремо. Воно розкладає звук на частоти — як призма розкладає світло на кольори. Низький бас — одна частота, високий голос — інша. І ми чуємо не окремі коливання, а цей “спектр”.
Спектрограма — це саме така картинка. По горизонталі — час, по вертикалі — частота, яскравість — гучність на цій частоті в цей момент.
Мел-спектрограма — це спектрограма де вісь частот стиснута так само як її сприймає людське вухо. Ми краще чуємо різницю між 100Hz і 200Hz ніж між 5000Hz і 5100Hz — хоча різниця та сама 100Hz. Мел-шкала враховує це.
В результаті одна секунда аудіо перетворюється на картинку 64×64 пікселі:
|
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 |
import librosa import numpy as np def wav_to_spectrogram(wav_path): # Завантажуємо аудіо audio, sr = librosa.load(wav_path, sr=16000, duration=1.0) # Рахуємо мел-спектрограму mel = librosa.feature.melspectrogram( y=audio, sr=16000, n_mels=64, # 64 частотних смуги по вертикалі n_fft=512, # вікно аналізу ~32ms hop_length=160 # крок 10ms → ~100 кадрів по горизонталі ) # Переводимо в децибели (логарифмічна шкала — як чує вухо) mel_db = librosa.power_to_db(mel, ref=np.max) # Стискаємо до 64×64 mel_resized = tf.image.resize(mel_db[..., np.newaxis], [64, 64]).numpy()[..., 0] # Нормалізуємо до [0, 1] mel_norm = (mel_resized - mel_resized.min()) / (mel_resized.max() - mel_resized.min()) return mel_norm # картинка 64×64 float32 |
Ось як виглядає “Катя” як картинка:
|
1 2 3 4 |
Тиша зліва → різкий сплеск енергії → затухання [░░░░░░░▓▓▓▓▓▓░░░░░░] ← висока частота (приголосна "К") [░░░░░▓▓▓▓▓▓▓▓▓░░░░░] ← середня частота (голосна "а") [░░░░▓▓▓▓▓▓▓▓▓▓░░░░░] ← низька частота (резонанс) |
А ось “один”:
|
1 |
Повільніший початок, інша форма сплеску, інший розподіл по частотах |
Тепер у нас є картинки. А з картинками нейромережі вміють працювати дуже добре.
5. Нейромережа, епохи, батчі — пояснення без формул
Якщо ви ніколи не тренували нейромережу — ось найпростіше пояснення.
Нейромережа — це функція з мільйонами параметрів (вагами). На вході — картинка 64×64, на виході — одне число від 0 до 1 (“наскільки це схоже на Катю”). Спочатку всі ваги випадкові і модель відповідає навмання.
Тренування — це процес підбору правильних ваг. Ми показуємо моделі картинку і кажемо “правильна відповідь — 1 (це Катя)” або “правильна відповідь — 0 (це не Катя)”. Модель дивиться наскільки її відповідь відрізняється від правильної (це називається loss) і трохи підкручує ваги щоб наступного разу відповісти краще.
Батч — скільки картинок показуємо моделі за один раз перш ніж підкрутити ваги. Якщо батч = 32, то модель дивиться на 32 картинки, рахує середню помилку і робить один крок оновлення. Більший батч — стабільніше навчання, але більше потреба в пам’яті.
Епоха — один повний прохід по всьому датасету. Якщо у вас 1000 картинок і батч 32, то одна епоха = ~31 крок оновлення ваг. Зазвичай тренують 50-100 епох.
Adam — алгоритм який вирішує наскільки сильно підкручувати ваги на кожному кроці. Він “розумний”: якщо вага весь час рухається в одному напрямку — прискорює, якщо стрибає туди-сюди — гальмує.
AUC (Area Under Curve) — метрика якості моделі від 0.5 до 1.0. 0.5 — модель відповідає навмання (як монетка). 1.0 — ідеальна модель. 0.85+ — добре для wake word.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
model.compile( optimizer=tf.keras.optimizers.Adam(3e-4), # learning rate — розмір кроку loss="binary_crossentropy", # функція помилки для 0/1 задач metrics=["accuracy", tf.keras.metrics.AUC(name="auc")] ) history = model.fit( train_dataset, validation_data=val_dataset, epochs=60, # 60 проходів по датасету batch_size=32, # 32 картинки за раз class_weight={0: 0.65, 1: 2.1} # компенсація дисбалансу класів ) |
6. Збираємо датасет: мікрофон і терпіння
Для навчання потрібні приклади. Два класи:
- Positive: записи слова “Катя” — ціль 300+
- Negative: все інше — ціль 600+
Баланс 1:2 або 1:3 — нормально. Якщо негативних буде 10:1 — модель навчиться просто завжди казати “не Катя” і матиме 90% accuracy нічого не навчившись.
Найважливіше при записі — різноманіття:
|
1 2 3 4 5 6 7 |
python record.py --mode positive # Говори "Катя" по-різному: # - тихо і голосно # - питально ("Катя?") і стверджувально ("Катя!") # - здалеку і близько до мікрофону # - повільно і швидко # - вранці і ввечері (голос змінюється) |
Для негативних — не тільки тиша і фоновий шум. Модель повинна знати слова які звучать схоже:
|
1 2 3 |
python record.py --mode negative # Обов'язково: Саша, Маша, Даша — фонетично найближчі # Також: один, два, три, книга, стіл, вода — звичайні слова |
Чому це важливо? Якщо всі ваші негативні — це тиша і шум вулиці, а позитивні — живий голос, модель навчиться не “Катя” розпізнавати, а “живий голос = Катя”. Це називається shortcut learning — модель знайшла обхідний шлях замість реального патерну.
Перевірка датасету перед тренуванням:
|
1 2 3 4 5 |
python check_dataset.py # Покаже: # - кількість тихих/порожніх записів (їх треба видалити) # - баланс класів # - проблеми з sample rate |
7. Тренуємо модель, а вона не вчиться
Ось де починається найцікавіше.
Я зібрав датасет, написав модель, запустив тренування. І отримав таке:
|
1 2 3 4 5 |
Epoch 1: train_auc=0.487 val_auc=0.529 val_accuracy=0.840 Epoch 2: train_auc=0.464 val_auc=0.538 val_accuracy=0.840 Epoch 3: train_auc=0.517 val_auc=0.538 val_accuracy=0.840 ... Epoch 10: train_auc=0.481 val_auc=0.552 val_accuracy=0.840 |
val_accuracy=0.840 — константа на кожній епосі. train_auc=0.487 — це майже як монетка (0.5 = повна випадковість).
Що це означає: модель навчилась казати “не Катя” на все. У датасеті 84% негативних прикладів — от вона і видає 84% accuracy нічому не навчившись. Це класична пастка незбалансованого датасету.
Два тижні я шукав причину. Перевірив все:
- ✓ Дані валідні, спектрограми різні на вигляд
- ✓
class_weightпередається правильно ({0: 0.65, 1: 2.1}) - ✓ Shuffle buffer виправлений (
buffer_size=2000) - ✓ Learning rate підняв (
Adam(1e-3)) - ✓ Баланс датасету перевірений
Нічого не допомагало. train_auc залишався ~0.50.
Тоді я зробив тест: запустив найпростішу лінійну модель (Dense без згорток) на тих самих даних:
|
1 2 3 |
Epoch 1: AUC=0.524 Epoch 2: AUC=0.540 Epoch 3: AUC=0.600 ← покращується! |
Лінійна модель вчиться. CNN — ні. Проблема в архітектурі CNN, не в даних.
8. Один рядок який все виправив
Ось архітектура моделі:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
def build_model(): inputs = tf.keras.Input(shape=(64, 64, 1)) x = Conv2D(16, 3, padding="same", activation="relu")(inputs) x = MaxPooling2D(2)(x) # 64→32 x = Dropout(0.2)(x) x = Conv2D(32, 3, padding="same", activation="relu")(x) x = MaxPooling2D(2)(x) # 32→16 x = Dropout(0.2)(x) x = Conv2D(64, 3, padding="same", activation="relu")(x) x = MaxPooling2D(2)(x) # 16→8 x = Dropout(0.3)(x) x = GlobalAveragePooling2D()(x) # ← ОСЬ ПРОБЛЕМА x = Dense(32, activation="relu")(x) x = Dropout(0.4)(x) output = Dense(1, activation="sigmoid")(x) |
Бачите GlobalAveragePooling2D? Це шар який бере feature map після згорток і усереднює всі позиції. Тобто якщо в картинці є яскравий сплеск в одному куті і тиша скрізь — він поверне середнє значення по всій картинці.
А “Катя” на спектрограмі — це саме такий локальний сплеск. Характерний патерн в конкретному місці по часу і частоті. GAP цей сплеск “розмазував” по всьому feature map і градієнт зникав.
Заміна одного слова:
|
1 2 3 4 5 |
# Було: x = GlobalAveragePooling2D()(x) # Стало: x = GlobalMaxPooling2D()(x) # бере МАКСИМУМ, а не середнє |
GlobalMaxPooling2D питає: “де найсильніша активація?” — і знаходить саме той сплеск.
Результат після заміни:
|
1 2 3 |
Epoch 1: train_auc=0.600 ← одразу рушило! Epoch 10: train_auc=0.770 Epoch 76: val_auc=0.862 ← фінальний результат |
З 0.55 до 0.86 — зміна одного слова в коді.
9. Живий результат
Це був не один запуск — а три ітерації, кожна з яких вирішувала конкретну проблему.
Ітерація 1: після GlobalMaxPooling
|
1 2 3 4 5 6 |
python inference.py --model models/wake_word_*/best.keras --threshold 0.32 [23:39:01] КАТЯ [██████░░░░░░░░░░░░░░] 0.33 ✓ [23:39:05] КАТЯ [██████░░░░░░░░░░░░░░] 0.33 ✓ ─ [█████░░░░░░░░░░░░░░░] 0.28 ← "один" — не спрацьовує ─ [████░░░░░░░░░░░░░░░░] 0.22 ← "стіл" — не спрацьовує |
Один, два, три, стіл, підлога, кавун — не тригерять. Але “Саша”, “Маша”, “Плаття” дають ~0.33. Фонетично схожі — закінчення -аша/-атя/-ття має подібну структуру на спектрограмі.
Рішення: записати їх як hard negatives і перетренувати.
Ітерація 2: після hard negatives + два мікрофони
Записав позитивні і негативні на два різні мікрофони. Модель більше не може вивчити “цей мікрофон = Катя”.
|
1 2 |
val_AUC: 0.9815 val_Accuracy: 95.7% ← було 85.1% |
Живий тест на першому мікрофоні:
|
1 2 3 4 5 |
[07:15:35] КАТЯ [█████████████░░░░░░░] 0.66 ✓ [07:16:16] КАТЯ [████████████████░░░░] 0.84 ✓ [07:17:23] КАТЯ [██████████████████░░] 0.91 ✓ ─ [░░░░░░░░░░░░░░░░░░░░] 0.03 ← фон ─ [░░░░░░░░░░░░░░░░░░░░] 0.02 ← фон |
Але з’явилась нова проблема: стукіт клавіатури дає 0.72-0.83. Ритмічний стукіт створює на спектрограмі короткі вертикальні сплески — точно така ж форма як приголосна “К” на початку “Каті”.
Рішення: записати стукіт клавіатури і музику як негативні, і “Катю” з музикою на фоні як позитивні.
Ітерація 3: фінальна
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
val_AUC: 0.9869 val_Accuracy: 95.7% val_Loss: 0.1611 Best epoch: 74/80 python inference.py --threshold 0.45 [07:53:16] КАТЯ [██████████████████░░] 0.92 ✓ [07:53:19] КАТЯ [██████████████░░░░░░] 0.75 ✓ [07:53:22] КАТЯ [██████████████████░░] 0.90 ✓ [07:53:30] КАТЯ [██████████████████░░] 0.95 ✓ ─ [░░░░░░░░░░░░░░░░░░░░] 0.03 ← фон ─ [░░░░░░░░░░░░░░░░░░░░] 0.04 ← клавіатура |
“Катя” впевнено — 0.58-0.95 залежно від відстані до мікрофону. Фон і клавіатура — 0.02-0.05. Зазор достатній для будь-якого порогу від 0.4 до 0.5.
Підсумок трьох ітерацій:
| Run | Val AUC | Val Accuracy | Що вирішили |
|---|---|---|---|
| GlobalAvgPool | 0.55 | 82% | нічого, модель не вчилась |
| GlobalMaxPool | 0.86 | 85% | архітектурний баг |
| + hard negatives, 2 мікрофони | 0.98 | 95.7% | Саша/Маша/shortcut |
| + клавіатура, музика | 0.98 | 95.7% | false positive на стукіт |
10. Що далі
Модель (~50KB INT8) готова до деплою на Luckfox Pico через RKNN-конвертацію. Але це вже інша стаття.

Що я виніс з цього проекту:
Два тижні я думав що проблема в даних — додавав, прибирав, балансував. Виявилось що проблема в одному архітектурному рішенні яке я взяв “за замовчуванням” навіть не думаючи.
GlobalAveragePooling — стандартний вибір для більшості задач класифікації. Але wake word — це не “яке це зображення загалом”, а “чи є в цьому зображенні конкретний локальний патерн”. Для другої задачі потрібен GlobalMaxPooling.
Деталь яка не очевидна поки не зіткнешся.
Попередня стаття: Як я будував AI Gateway: Катя, компаньйонка 18+
Теги: TensorFlow, wake word, mel spectrogram, CNN, TFLite, embedded ML, Luckfox Pico