После прохождения Atomic Heart у меня всё вертелось в голове, чего бы такого сделать по лору игры, чтобы было прикольно и несложно, а главное, более‑менее реально реализуемо в реале (например, я крайне сомневаюсь, что можно создать тех же пчёл в реале, как в игре, чтобы они аналогичным образом летали).
С выходом DLC 1 для игры мысль, что сделать, пришла сама собой.:) Гусь! Точнее, эксперимент с надетым на гуся нейроконнектором «Мысль» и натолкнул меня на создание схожего девайса (а на носу как раз был Хэллоуин на то�� момент, что только разжигало желание создать хотя бы прототип на коленке).
Ну, собственно, начал делать девайс на Black‑Pill (STM32F401CCU6), т. к. она была под рукой, и, в принципе, всё, что необходимо по интерфейсам, МК имел на борту. Сделать кастомную плату и в итоге сделать монолитный девайс менее чем за месяц я не успел бы, поэтому изначально было решено сделать всё максимально на готовых платах‑кубиках, просто упрятав девайс «за воротник». :D

Итак, вот сходу видос с частичным результатом работы девайса на Локи (имя котэ :) ):
Железо
Собственно, структурная схема девайса:
Всё предельно просто. :) Начинаем с мозга - МК, открываем куб и прикидываем, чё как раскидать по пинам:
Основная используемая периферия:
SPI — SPI1 на пинах PA4...7 для SPI Flash
I2C — I2C1 на пинах PB6, PB7 для работы с акселерометром
I2S — I2S3 на пинах PB3...5, PA15 для вывода звука
TIM PWM — TIM1 на пинах PA8...10 для статусного светодиода
SWD — на стандартных пинах PA13, PA4 для отладки и подключения консоли RTT
RCC — на стандартных пинах PH0, PH1 с внешним кварцем 16 МГц
Для хранения аудиофайлов я использовал первую попавшуюся плату типа такой для SPI Flash:

В качестве акселерометра я взял давно валявшуюся плату на дешёво�� ADXL345, выбирая по принципу «с чем я ущё не работал и что +\‑ километр подойдёт под задачу»:

Для вывода звука я захотел поковыряться с I2S и поэтому нашёл офигенный моно ЦАП с I2S входом и на выходе у него уже усилитель D класса — MAX98 357A:

Ну и светодиод... я решил изъевернуться и взял, что было под рукой — SMD RGB светодиод FC‑B1010RGBT‑HG:

Зато к нему очень удобно паять проволочки:

Результат:

Собственно, фотка примерно под конец сборки макета:

Локи уже подозревает что-то нехорошее:

Корпус
По причине того, что макет собирается на скорую руку, в итоге не получится готовый девайс такого вида:
Поэтому решено забить на точные габариты и плясать от динамика, который вместе со статусным светодиодом и поместим в корпус, спрятав всю остальную электронику под одежду (воротник) коту.


Получается, динамик с чёрной декоративной тряпочкой вклеивается сверху, сбоку просверливается отверстие под статусный светодиод и снизу всё заливаем клеем.
Выглядит примерно вот так (забыл сфотать итоговый результат, а сейчас уже разобрано всё):

Аудио-файлы
А будем мы их брать, конечно же, из игры :) Надо получить примерно такой набор файлов:
Собственно, для распаковки нам нужна лицензионная игра с DLC 1 на ПК и UnrealPakTool. Распаковываем pakchunk13-WindowsNoEditor.pak командой:
UnrealPakTool\UnrealPak.exe "%path-to-game%\AtomicHeart\Content\Paks\pakchunk13-WindowsNoEditor.pak" -Extract "D:\atomic unpack\unpacked"Далее потребуются утилиты:
ww2ogg - для преобразования wem (игровой формат звуковых файлов) в ogg
FFmpeg-Builds - для преобразования ogg в wav (скачивать ffmpeg-master-latest-win64-gpl.zip)
Преобразовываем все wem файлы в ogg следующей командой:
set folder=D:\atomic unpack\unpacked\WwiseAudio\Windows\Ru
for %%f in ("%folder%\*.wem") do ww2ogg.exe "%%f" --pcb packed_codebooks_aoTuV_603.binПосле чего я послушал все полученные ogg файлы, отобрал подходящие (например, явные фразы, указывающие что это гусь, я не стал брать, чтобы набор фраз был наиболее универсальным).
Далее перепаковываем полученные ogg в wav моно и 32 кГц (чтобы меньше весили, но качество голоса не слишком заметно упало, как, например, на 8 или 16 кГц) командой:
for %%f in ("*.ogg") do ffmpeg -hide_banner -loglevel error -i "%%f" -ab 128 -ar 32000 "%%f.wav"После чего я разбиваю файлы на группы по префиксу + номеру файла:

Всё, теперь это можно паковать в образ файловой системы и заливать на SPI Flash любым удобным способом.:)
Примечание: с распаковкой сильно помогла данная статья — ATOMIC HEART вытаскиваем ресурсы, музыку и звуки.
Файловая система
Была взята из open‑source проекта RTL00_WEB, берём из него файлы webfs.c \ webfs.h с небольшими модификациями, чтобы работать не напрямую с памятью МК \ SoC, а внешней флешкой, подключенной по SPI.
Сами файлы, как уже было сказанно выше, проименованны специальным образом, и вот для чего. Объявляем состояния, в которых может находиться нейроконнектор (кот):
typedef enum
{
NC_STATE_IDLE = 0,
NC_STATE_MOVE,
NC_STATE_ATTACK,
NC_STATE_FREE_FALL,
NC_STATE_ERROR = 0xff,
} nc_state_t;Далее объявляем структуру для каждого статуса с параметрами рандомизации, префикса в имени файла и количества файлов:
typedef struct
{
char prefix; // 'x'
uint8_t start; //0 ...
uint8_t end; /// n
uint8_t luck; // to play audio 0...100
uint32_t timeout; // ms to maybe play next file
} nc_state_audiofile_t;Ну и, собственно, теперь можно обьявить экземпляры для каждого статуса (последняя строка):
typedef struct
{
uint8_t playing_flag;
uint8_t special_flag;
uint32_t timeout;
uint32_t timeout_sp;
void *hi2s;
void *hi2c;
void *hspi;
void *cs_port;
uint16_t cs_pin;
wav_header_t wavh;
webfs_handle_t hfs;
nc_state_t state;
nc_state_audiofile_t audio[4]; // NC_STATE_IDLE ... NC_STATE_FREE_FALL
} nc_t;А инитится это следующим образом:
nc.audio[NC_STATE_IDLE].prefix = 'I';
nc.audio[NC_STATE_IDLE].start = 0;
nc.audio[NC_STATE_IDLE].end = 10;
nc.audio[NC_STATE_IDLE].luck = 30;
nc.audio[NC_STATE_IDLE].timeout = 50000;
nc.audio[NC_STATE_MOVE].prefix = 'M';
nc.audio[NC_STATE_MOVE].start = 0;
nc.audio[NC_STATE_MOVE].end = 19;
nc.audio[NC_STATE_MOVE].luck = 50;
nc.audio[NC_STATE_MOVE].timeout = 10000;Далее можно написать рандомизатор, который будет обращаться по текущему состоянию к нужной структуре и из неё читать, сколько доступно файлов для выбора, а также формировать случайно имя файла для воспроизведения.

Рандомайзер
Да-да.... несмотря на название и лор игры, никакими полимерами и даже просто нейросетями тут не пахнет. :(

Я не пограмист и не нейронщик... поэтому быдлокодим в лоб банальный рандомайзер для проигрывания случайного файла из текущего состояния:
void nc_RandomPlay()
{
static char filename[10];
static uint8_t s;
uint8_t luck = rand() % 101;
SysView_LogInfo("[nc] Luck: %u (Threshold: %u)\r\n", luck, nc.audio[nc.state].luck);
if (luck <= nc.audio[nc.state].luck)
{
for (;;)
{
uint8_t n = rand() % (nc.audio[nc.state].end + 1);
if (s != n)
{
s = n;
break;
}
}
if (s < nc.audio[nc.state].start)
{
s = nc.audio[nc.state].start;
}
snprintf(&filename[0], 10, "%c%u.wav", nc.audio[nc.state].prefix, s);
//SysView_LogInfo("[nc] Generated filename: %s\r\n", filename);
nc_PlayFile(&filename[0]);
}
}Который вызывается из вечного цикла в зависимости от текущего состояния через прописанные для этого состояния промежутки времени:
void nc_Routine()
{
led_Routine();
if ((LL_GPIO_IsInputPinSet(GPIOB, LL_GPIO_PIN_8)) && (nc.playing_flag == 0))
{
adxl345_GetIntSource(&adxl);
if (((adxl.int_src & ADXL345_INT_EN_BIT_DOUBLE_TAP) > 0) &&
SysTick_GetCurrentTick() - nc.timeout_sp > 5000)
{
nc_TapSpecialPlay();
nc.state = NC_STATE_ATTACK;
nc.timeout_sp = SysTick_GetCurrentTick();
nc_UpdateLedStatus();
}
else if ((adxl.int_src & ADXL345_INT_EN_BIT_ACTIVITY) > 0)
{
nc.state = NC_STATE_MOVE;
nc_UpdateLedStatus();
}
else if ((adxl.int_src & ADXL345_INT_EN_BIT_INACTIVITY) > 0)
{
nc.state = NC_STATE_IDLE;
nc_UpdateLedStatus();
}
SysView_LogInfo("\r\n[nc] Set State: %u\r\n", nc.state);
SysView_LogInfo("[nc] Int Status: 0x%x\r\n", adxl.int_src);
}
switch (nc.state)
{
case NC_STATE_IDLE:
case NC_STATE_MOVE:
if (nc.playing_flag == 0)
{
if (SysTick_GetCurrentTick() - nc.timeout > nc.audio[nc.state].timeout)
{
nc.timeout = SysTick_GetCurrentTick();
nc_RandomPlay();
}
}
break;
case NC_STATE_ATTACK:
case NC_STATE_FREE_FALL:
if (nc.playing_flag == 0)
{
nc.state = NC_STATE_MOVE;
nc_UpdateLedStatus();
SysView_LogInfo("\r\n[nc] Set State: %u\r\n", nc.state);
nc.timeout = SysTick_GetCurrentTick();
}
break;
default: // NC_STATE_ERROR
break;
}
}Увы, тут ничего прямо уж интересного и сложного нет.
Проигрывание аудио
С этим особо сложностей тоже не возникло, просто читаем из FS файл кратно выбранному буферу (в моём случае PLAYBACK_BUF_SIZE = 1024 байт), после чего копируем данные из полученного массива в буфер для I2S DMA 2 раза (в wav файле у нас mono, а для I2S в STM32 нужно stereo, поэтому полуслово данных необходимо копировать 2 раза) и выводим следующим образом:
void nc_PlayFile(char *name)
{
nc.hfs = webfs_Open(name);
if (nc.hfs != WEBFS_INVALID_HANDLE)
{
SysView_LogInfo("[wav] File %d name: %s (size %u)\r\n", nc.hfs, name, webfs_GetBytesRem(nc.hfs));
uint8_t filedata[78];
webfs_GetArray(nc.hfs, &filedata[0], 78);
if (wav_parser_ParseHeader(&filedata[0], &nc.wavh) == 0x00)
{
SysView_LogInfo("[wav] Type %u Sample rate: %u Bits per sample: %u Channels: %u Size: %u Data Offset: %u\r\n",
nc.wavh.type, nc.wavh.sample_rate, nc.wavh.bits_per_sample, nc.wavh.channels, nc.wavh.size, nc.wavh.data_offset);
}
// wavh.size в байтах (2 на полуслово), а в playback_Start надо передавать размер в полусловах, но каналов 2 (!)
playback_Start(read_buf, nc.wavh.size, 0);
nc.playing_flag = 1;
nc_UpdateLedStatus();
}
}Для автоматического проигрывания файла, начатого в nc_PlayFile, определяем буфер и callback:
static uint16_t buf[PLAYBACK_BUF_SIZE];
static void read_buf(uint16_t *dest, uint32_t count)
{
// count в полусловах (2 канала)
// webfs_GetArray запрашиваем в байтах
// и в итоге один канал с флешки дублируем на 2
//SysView_LogInfo("[wav] req %u\r\n", count);
webfs_GetArray(nc.hfs, (uint8_t *)&buf[0], count);
for (uint32_t i = 0; i < count / 2; i++)
{
dest[i * 2 + 0] = buf[i];
dest[i * 2 + 1] = buf[i];
}
}Инитим микро-библиотеку, представляющую собой простую надстройку над I2S HAL:
playback_Init(nc.hi2s);
playback_SetStop_Callback(&nc_FileDone);И, собственно, самописная библиотека для проигрывания.
playback.h
#ifndef _PLAYBACK_H_
#define _PLAYBACK_H_
#include "main.h"
#include <string.h>
#define PLAYBACK_BUF_SIZE 1024 // 256 // 4096
typedef void (*fill_audio_buf_cb_t)(uint16_t *dest, uint32_t count);
void playback_Init(void *hi2s);
int playback_Start(fill_audio_buf_cb_t cb, const uint32_t len, uint8_t loop_audio);
void playback_Stop(void);
void playback_Pause(void);
void playback_Resume(void);
void playback_SetStop_Callback(void (*callback)(void));
void playback_I2S_Tx_Cplt_Callback(void);
void playback_I2S_Tx_HalfCplt_Callback(void);
#endif // _PLAYBACK_H_playback.c
#include "playback.h"
#include "stm32f4xx_hal.h"
static fill_audio_buf_cb_t fill_audio_buf_cb;
static I2S_HandleTypeDef *i2s;
static uint8_t loop;
static uint32_t offset;
static uint32_t total_len;
static void (*on_playback_stopped)(void);
static uint16_t audio_bufs[2][PLAYBACK_BUF_SIZE];
void playback_Init(void *hi2s)
{
i2s = (I2S_HandleTypeDef *) hi2s;
}
static void update_audio_buf(uint8_t buf_number)
{
uint16_t *dest = audio_bufs[buf_number];
size_t len = total_len - offset;
if (!fill_audio_buf_cb)
{
return;
}
if (len > PLAYBACK_BUF_SIZE)
{
len = PLAYBACK_BUF_SIZE;
}
fill_audio_buf_cb(dest, len);
offset += len;
}
void playback_Stop(void)
{
HAL_I2S_DMAStop(i2s);
}
void playback_Pause(void)
{
HAL_I2S_DMAPause(i2s);
}
void playback_Resume(void)
{
HAL_I2S_DMAResume(i2s);
}
int playback_Start(fill_audio_buf_cb_t cb, const uint32_t len, uint8_t loop_audio)
{
total_len = len;
offset = 0;
fill_audio_buf_cb = cb;
update_audio_buf(0);
loop = loop_audio;
size_t count = len;
if (count > PLAYBACK_BUF_SIZE)
{
count = PLAYBACK_BUF_SIZE;
}
return HAL_I2S_Transmit_DMA(i2s, (uint16_t *)&audio_bufs[0], count * 2);
}
void playback_SetStop_Callback(void (*callback)(void))
{
on_playback_stopped = callback;
}
void playback_I2S_Tx_Cplt_Callback(void)
{
if (offset >= total_len)
{
if (loop)
{
offset = 0;
}
else
{
playback_Stop();
if (on_playback_stopped)
{
on_playback_stopped();
}
}
}
else
{
update_audio_buf(1);
}
}
void playback_I2S_Tx_HalfCplt_Callback(void)
{
if (loop || offset < total_len)
{
update_audio_buf(0);
}
}
Да, ещё момент, простой парсер wav-заголовков.
wav_parser.h :
#ifndef _WAV_PARSER_H_
#define _WAV_PARSER_H_
#include <stdint.h>
#define WAV_PARSER_HEADER_LEN 44
typedef struct
{
uint8_t type;
uint16_t sample_rate;
uint8_t bits_per_sample;
uint8_t channels;
uint32_t size;
uint32_t data_offset;
} wav_header_t;
int wav_parser_ParseHeader(const uint8_t *header, wav_header_t *format);
#endif // _WAV_PARSER_H_
wav_parser.с :
#include "wav_parser.h"
#include <string.h>
int wav_parser_ParseHeader(const uint8_t *header, wav_header_t *format)
{
const char *p = (char *)header;
if (strncmp(p, "RIFF", 4) != 0) // 0
{
return 1;
}
p += 8;
if (strncmp(p, "WAVE", 4) != 0) // 8
{
return 1;
}
p += 4;
if (strncmp(p, "fmt", 3) != 0) // 12
{
return 1;
}
p += 8;
format->type = p[0] | (p[1] << 8); // 20
p += 2;
format->channels = p[0] | (p[1] << 8); // 22
p += 2;
format->sample_rate = 0; // 24
for (uint8_t i = 0; i < 4; i++)
{
format->sample_rate |= p[i] << (8 * i);
}
p += 10;
format->bits_per_sample = p[0] | (p[1] << 8); // 34
p += 2;
for (uint8_t i = 0; i < 10; i++) // 10 chunks Max
{
if (strncmp(p, "data", 4) != 0)
{
p += 4;
uint32_t chunk_len = (p[0] | (p[1] << 8) | (p[2] << 16) | (p[3] << 24));
p += (4 + chunk_len);
}
else
{
p += 4;
format->size = (p[0] | (p[1] << 8) | (p[2] << 16) | (p[3] << 24));
//format->data_offset = (uint32_t)*p;
format->data_offset = 78;//+= 4; // TODO: какой то баг, для файла получаю 90, а должно быть 78
break;
}
}
return 0;
}
Инициализация I2S и DMA выглядит следующим образом:
void SysInit_DMA(void)
{
LL_AHB1_GRP1_EnableClock(LL_AHB1_GRP1_PERIPH_DMA1);
hdma_spi3_tx.Instance = DMA1_Stream5;
hdma_spi3_tx.Init.Channel = DMA_CHANNEL_0;
hdma_spi3_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;
hdma_spi3_tx.Init.PeriphInc = DMA_PINC_DISABLE;
hdma_spi3_tx.Init.MemInc = DMA_MINC_ENABLE;
hdma_spi3_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD;
hdma_spi3_tx.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD;
hdma_spi3_tx.Init.Mode = DMA_CIRCULAR;
hdma_spi3_tx.Init.Priority = DMA_PRIORITY_HIGH;
hdma_spi3_tx.Init.FIFOMode = DMA_FIFOMODE_DISABLE; // DMA_FIFOMODE_ENABLE
hdma_spi3_tx.Init.FIFOThreshold = DMA_FIFO_THRESHOLD_FULL;
hdma_spi3_tx.Init.MemBurst = DMA_MBURST_SINGLE;
hdma_spi3_tx.Init.PeriphBurst = DMA_PBURST_SINGLE;
HAL_DMA_Init(&hdma_spi3_tx);
__HAL_LINKDMA(&hi2s3, hdmatx, hdma_spi3_tx);
//NVIC_SetPriority(SPI3_IRQn, 0);
//NVIC_EnableIRQ(SPI3_IRQn);
NVIC_SetPriority(DMA1_Stream5_IRQn, 0);
NVIC_EnableIRQ(DMA1_Stream5_IRQn);
}
void SysInit_I2S(void)
{
LL_APB1_GRP1_EnableClock(LL_APB1_GRP1_PERIPH_SPI3);
// I2S3: SD (PB5), extSD (PB4), CK (PB3), WS (PA15)
LL_GPIO_SetPinMode(GPIOB, LL_GPIO_PIN_5, LL_GPIO_MODE_ALTERNATE);
LL_GPIO_SetPinMode(GPIOB, LL_GPIO_PIN_3, LL_GPIO_MODE_ALTERNATE);
LL_GPIO_SetPinMode(GPIOA, LL_GPIO_PIN_15, LL_GPIO_MODE_ALTERNATE);
LL_GPIO_SetAFPin_0_7(GPIOB, LL_GPIO_PIN_5, LL_GPIO_AF_6);
LL_GPIO_SetAFPin_0_7(GPIOB, LL_GPIO_PIN_3, LL_GPIO_AF_6);
LL_GPIO_SetAFPin_8_15(GPIOA, LL_GPIO_PIN_15, LL_GPIO_AF_6);
hi2s3.Instance = SPI3;
hi2s3.Init.Mode = I2S_MODE_MASTER_TX;
hi2s3.Init.Standard = I2S_STANDARD_PHILIPS;
hi2s3.Init.DataFormat = I2S_DATAFORMAT_16B;
hi2s3.Init.MCLKOutput = I2S_MCLKOUTPUT_DISABLE;
hi2s3.Init.AudioFreq = I2S_AUDIOFREQ_32K;
hi2s3.Init.CPOL = I2S_CPOL_LOW;
hi2s3.Init.ClockSource = I2S_CLOCK_PLL;
hi2s3.Init.FullDuplexMode = I2S_FULLDUPLEXMODE_DISABLE;
HAL_I2S_Init(&hi2s3);
}Прерывания:
void DMA1_Stream5_IRQHandler(void)
{
HAL_DMA_IRQHandler(&hdma_spi3_tx);
}
void HAL_I2S_TxCpltCallback(I2S_HandleTypeDef * i2s)
{
playback_I2S_Tx_Cplt_Callback();
}
void HAL_I2S_TxHalfCpltCallback(I2S_HandleTypeDef *hi2s)
{
playback_I2S_Tx_HalfCplt_Callback();
}Вывод
Получилось увлекательно и, блин, угарно, когда ты играешься с котом и он тебя кроет матом иногда очень в тему кидает фразы:D
Был как‑то такой момент, я отлаживал работу специальной фразы (что по двойному тапу по коту из начала видео) и для теста нацепил девайс на кота, который валялся на пледике рядом. Немного «потестировав», я отошёл обратно к компу и залип в написание прошивки, и так удачно совпало, что рандомизатор довольно долгое время в Idle состоянии ничего не выводил и я забыл о включенном девайсе.:) Сижу я значит, сижу и тут:
— БУ! Испугался?!
Я аж вздрогнул XD
Саму статью я думал написать сильно раньше (с Хэллоуина так‑то уже немало времени прошло), но навалилось работы и потом мне просто было лень. Зато до Нового года я сделал следующую, нормальную версию железа нейроконнектора:

Успел пока только проверить основные узлы и, главное, работу SWD поверх Type-C с самодельным адаптером, выглядит это как то так:

Адаптер автоматически определяет подключение Type‑C кабеля, умеет определять его ориентацию и автоматически перекидывает SWDIO\SWCLK линии в соответствии с подключением. Главный косяк только в том, что тут не хватает одной линии для Reset, и если на целевом МК отключен отладочный интерфейс, то его так не прошить и никак не сбросить... можно, конечно, нарушить спецификации USB и задействовать линии CC1\2, но я так делать не стал.
В общем это, когда проверю плату полноценно и перенесу прошивку нейроконнектора для этой версии, то думаю тогда и сделать репу на гите, да статейку о нём и адаптере.;)
Спасибо, что прочитали! Надеюсь, было интересно хоть немного.