- PVSM.RU - https://www.pvsm.ru -

Однако же за это время PIS-OS прирос кучей всего, навроде поддержки ещё одного типа экранов, системы меню, а также и функцией будильника — посему понадобились и более мелодичные рингтоны, чем просто пиликание одним тоном.
В процессе выяснилось, что пьезоэлемент был припаян к той ноге МК, на которой ЦАП отсутствует. Впрочем, если бы я хотел будильник, который звучит как mp3 — просто пользовался бы мобильником, так что самое время вспоминать наследие демосцены и делать самый настоящий однобитный драйвер звука!
Конечно, можно музыку делать и при помощи однотонального бипера. Для этого хорошо подходит функция ledcWriteTone [1] из фреймворка Arduino на ESP32. Эта функция просто рассчитывает параметры встроенного ШИМ-контроллера так, чтобы на выходе получилась заданная частота — но полифонию таким образом не получить.
Долгое время в прошивке был одноканальный секвенсор [2], прибитый гвоздями к биперу на базе этой самой функции [3], а мелодии напоминали музыку, играемую на PC Speaker.
NB: Здесь и далее звук вставлен в виде записей на SoundCloud, а если он у вас не грузится — продублирован ссылками на файл.
DJ Brisk & Trixxy — Eye Opener [4] на бипере: потому что будильник тоже в своём роде открывашка для глаз :-)
Чтобы понять, как работает однобитный звук, нужно представить, как работает звук обычный.
Например, у нас есть простейший синтезатор, в котором несколько генераторов тонов («голосов», если пользоваться синтезаторной терминологией). Для того, чтобы превратить несколько отдельных тонов в один, они просто суммируются:
Открытием для меня стал тот факт, что для однобитного звука принцип ничуть не отличается! Мы имеем несколько генераторов прямоугольных импульсов и просто их суммируем. Так как разрешение у нас всего лишь 1 бит, то и сложение можно использовать логическое — то есть, двоичное «ИЛИ». В таком контексте оно будет работать просто как сложение с насыщением, т.е. «клипирование» нам даётся автоматически.
Если подумать, то это отчасти кажется очевидным — генератор с меньшей длиной волны, т.е. с более высоким тоном, будет слышно в промежутках, когда генератор более низкого тона «молчит», выдавая логический 0. Эдакое сверхбыстрое арпеджио :-)
Дальше же срабатывает инерционность последующих стадий системы — от физической инерции динамика, уха, и воздуха между ними; и до банальной инерции восприятия. принимает от уха эти интермодуляции, которые получаются в результате такого грубого смешения сигналов, и «по привычке» считает звучащее за отдельные тона аккорда.
В итоге задача сводится к тому, чтобы дрыгать ногой микроконтроллера равномерно выдавая единицы либо нули с заданной точностью, чтобы получить что-то похожее на график ниже.
Казалось бы, элементарно решается через связку digitalWrite() и delayMicroseconds() на обычной ардуине. Но увы, когда микроконтроллеру нужно заниматься чем-то ещё более полезным — считать время, например, или погоду показывать — такой подход уже не канает.
Можно, конечно, заморочиться с прерываниями, и дёргать ногой по таймеру. Но тут обнаружилось решение попроще...
ESP32, на базе которого я сделал часы, имеет аппаратный трансивер I²S с DMA. Конечно, встроенный I²S-ЦАП там тоже есть, но по уже озвученным причинам пользоваться им мы не будем. Однако аппаратный драйвер шины можно подцепить на любые пины — в том числе и на тот, на который у меня повешена пищалка.
Сам же протокол I²S чем-то напоминает SPI — линия тактового сигнала плюс линия данных, и вдогонку линия чётности для стерео:
Раз оно заточено на звук, то и стабильность выдачи данных должна быть шикарной, а наличие DMA означает, что мы можем просто записать кусок данных в буфер и на какое-то время забыть про эту задачу вообще — до тех пор, пока буфер не опустошится. Процессор при этом может делать что угодно, а трансивер I²S будет сам читать данные из оперативной памяти по мере необходимости.
Для непрерывности же достаточно иметь два-три таких буфера, и пока один «выдрыгивается», мы заполняем второй, сразу же отдаём назад и ждём прерывания о том, что первый закончился. Заполняем уже его, и далее по кругу. Впрочем, об этой логике нам даже думать не нужно — в стандартной библиотеке ESP32 это всё уже реализовано до нас.
Поэтому начнём с того, что инициализируем драйвер трансивера на нужном нам пине. Многие настройки подбирались экспериментально, так что часть из них может выглядеть нелогично — но логично выглядящие по документации фактически на моей плате не заработали:
void WaveOut::init_I2S(gpio_num_t pin) {
if(WaveOut::i2sInited) return;
i2s_config_t i2s_config = {
.mode = I2S_MODE_MASTER | I2S_MODE_TX, // <- работа на передачу
.sample_rate = SAMPLE_RATE, // <- экспериментально подобрано равным 22050
.bits_per_sample = I2S_BITS_PER_SAMPLE_8BIT, // <- 8 бит на сэмпл, для простоты работы с буферами
.channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT,
.communication_format = I2S_COMM_FORMAT_STAND_MSB, // <- передаём данные начиная с наибольшего бита, слева направо
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL2,
.dma_buf_count = DMA_NUM_BUF, // <- сколько буферов для очереди создавать, мне хватило трёх
.dma_buf_len = DMA_BUF_LEN, // <- длина одного буфера, мне хватило 512 байт
.use_apll = true, // <- использует APLL для точности тайминга, в конкретно этом случае не похоже, что влияет на что-то
.bits_per_chan = I2S_BITS_PER_CHAN_DEFAULT // <- битов на канал столько же, сколько на сэмпл, т.е. 8 бит
};
// Неиспользуемые пины не указываем, однако инициализировать этот код нужно первым,
// т.к. на ESP32 состояние pinmux в этот момент всё равно испортится
i2s_pin_config_t pincfg = {
.mck_io_num = I2S_PIN_NO_CHANGE,
.bck_io_num = I2S_PIN_NO_CHANGE,
.ws_io_num = I2S_PIN_NO_CHANGE,
.data_out_num = pin,
.data_in_num = I2S_PIN_NO_CHANGE,
};
i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
i2s_set_pin(I2S_NUM, &pincfg);
// Создаём таску, которая будет подготавливать буферы для вывода
xTaskCreate(
task,
"WaveOut",
4096,
nullptr,
pisosTASK_PRIORITY_WAVEOUT, // <- (configMAX_PRIORITIES - 1)
&hTask
);
WaveOut::i2sInited = true;
}
Сама таска подготовки буферов тоже будет достаточно простой — запрашиваем у каждого зарегистрированного в системе источника отрендерить сколько-то битов. При этом для оптимизации по памяти и скорости положим, что каждый источник должен сам присуммироваться к переданному ему буферу, вместо того, чтобы подставлять ему чистый буфер и смешивать их воедино в самой таске.
void WaveOut::task(void*) {
// Сюда рендерит источник
static uint8_t chunk[RENDER_CHUNK_SIZE+1] = { 0x0 };
// Нулевые байты на случай если ничего не нарендерили, чтобы не греть процессор отдачей буферов нулевой длины
static const uint8_t null[RENDER_CHUNK_SIZE+1] = { 0 };
// Сколько записали в DMA буфер, не используется — см. ниже
static size_t out_size = 0;
while(1) {
size_t total = 0;
for(int i = 0; i < CHANNEL_COUNT; i++) {
// Просим генератор отрендерить кусок звука в буфер
size_t generated_bytes = callback[i](chunk, RENDER_CHUNK_SIZE);
// Если текущий генератор сделал больше семплов, чем прошлый, то выводим все
if(generated_bytes > total) total = generated_bytes;
}
if(total > 0) {
// Записываем в DMA-буфер, блокируя поток до конца записи — именно поэтому out_size нам особо и не нужен
i2s_write(I2S_NUM, chunk, total, &out_size, portMAX_DELAY);
// Очищаем буфер перед следующей итерацией
memset(chunk, 0, RENDER_CHUNK_SIZE+1);
} else {
// Ничего не было отрендерено, записываем полный буфер нулей
i2s_write(I2S_NUM, null, RENDER_CHUNK_SIZE, &out_size, portMAX_DELAY);
}
taskYIELD(); // <- передаём управление следующей по приоритету задаче
}
}
Теперь у нас есть способ вывести поток однобитных сэмплов на пин микроконтроллера, однако же нужно их сгенерировать, чтобы услышать что-то осмысленное.
Самым простым будет сгенерировать прямоугольные импульсы. Для этого нужно определить, по сколько единичных и нулевых битов нужно выдавать. Их количество можно посчитать как отношение итогового битрейта к частоте:
В моём случае, при настройке трансивера I²S на 44100 Гц (медленнее почему-то не заводится), 8 бит на канал, стерео, битрейт получился:
Тогда, например, для тона «ля» в 440 Гц, длина импульса у нас получится:
То есть на выход нужно будет отправить сначала 1604 бита в состоянии лог. 1, потом 1604 бита в состоянии лог. 0, и так до посинения — а на выходе будет меандр в приблизительно 440 Гц. Попробуем сгенерировать прямоугольную волну, просто заполняя нужное количество битов через уже известное нам логическое «ИЛИ»:
size_t SquareGenerator::generate_samples(void* buffer, size_t length, uint32_t want_samples_) {
if(!active || wavelength == 0) return 0; // <- генератор выключен
// Считаем, сколько бит держать в состоянии лог. 1
// с учётом скважности, где скважность 2 соответствует меандру (50%)
int bits_high = wavelength / abs(duty);
if(duty < 0) bits_high = wavelength - bits_high;
uint8_t* buff = (uint8_t*) buffer;
// Если не было указано, сколько бит сгенерировать, заполняем весь буфер
uint32_t want_samples = want_samples_ == 0 ? (length * 8) : std::min(want_samples_, length * 8);
size_t idx = 0;
int bit = 7;
// Цикл по битам буфера
for(int s = 0; s < want_samples; s++) {
bool state = (phase < bits_high); // <- текущее состояние бита
idx = s / 8; // <- индекс байта, в котором находится текущий бит
bit = 8 - (s % 8); // <- индекс бита внутри байта, при нумерации слева направо (MSB = 0, LSB = 7)
if(state) {
buff[idx] |= (1 << bit); // <- если бит нужно "включить", то так и делаем
}
phase = (phase + 1) % wavelength; // <- обновляем текущую фазу генератора
}
return idx + 1; // <- возвращаем количество сгенерированного с округлением до байта
}
Захотелось также иметь возможность генерировать шумовую дорожку в качестве простейшего ритм-инструмента. После многих экспериментов остановился на генераторе шума из знакомого уже нам AY-3-8910 [6], который я, ничтоже сумняшеся, передрал из MAME [7]. Не очень сильно понимаю, как он работает, поэтому просто приведу его код как есть. Звучит, по крайней мере, весьма похоже на тот, что на спектруме был :-)
size_t NoiseGenerator::generate_samples(void* buffer, size_t length, uint32_t want_samples_) {
if(!active || wavelength == 0) return 0;
uint8_t* buff = (uint8_t*) buffer;
uint32_t want_samples = want_samples_ == 0 ? (length * 8) : std::min(want_samples_, length * 8);
size_t idx = 0;
int bit = 7;
for(int s = 0; s < want_samples; s++) {
idx = s / 8;
bit = 8 - (s % 8);
if(state && (rng & 1) > 0) {
buff[idx] |= (1 << bit);
}
phase = (phase + 1) % wavelength;
if(phase == 0) {
state ^= 1;
if (state) {
rng ^= (((rng & 1) ^ ((rng >> 3) & 1)) << 17);
rng >>= 1;
}
}
}
return idx + 1;
}
Во времена, когда такой звук был актуален, высшим пилотажем считалось заставить компьютер говорить человеческим голосом, или играть звук настоящего инструмента. Ну а чем мы хуже!
Для экономии места мы даже можем не хранить сырые PCM отсчёты, а сделать эдакое RLE [8]-сжатие: сначала число единичных битов, потом число нулевых, потом опять единичных, и т.д.
На питоне был наговнокожен конвертер, реализующий пороговый фильтр, а затем сохраняющий данные именно в таком виде. Так как он настолько простой, что даже не проверяет, является ли подсунутое ему файлом WAV-формата с параметрами «8 бит, моно, 8 кГц», то прячу его под спойлер — если вам нужно решить такую же задачу, лучше напишите что-то своё :-)
#-*- coding: utf-8 -*-
import sys
MARGIN = 4 # Запас гистерезиса от медианного значения в файле
fname = sys.argv[1] # Путь ко входному файлу в формате WAV/8kHz/8bit
sname = sys.argv[2] # Название итоговой переменной с данными
oname = sys.argv[3] if len(sys.argv) >= 4 else None # Путь к выходному файлу WAV/8kHz/8bit для предпрослушивания
sdata = open(fname, 'rb').read()
outf = None
if oname is not None:
outf = open(oname, 'wb')
outf.write(sdata[:0x28])
# Пропускаем заголовок по фиксированному смещению
sdata = sdata[0x28::]
i = 0
min = 999
max = 0
sts = 0xFF
last_sts = 0xFF
rle_buf = [0]
def median(data):
x = list(data)
x.sort()
mid = len(x) // 2
return (x[mid] + x[~mid]) / 2.0
# Находим медианное значение в файле и по нему определяем пороги для лог. 1 и лог. 0
med = median(sdata)
print("Median", med)
HIGH = med + MARGIN
LO = med - MARGIN
while i < len(sdata):
curSample = sdata[i]
if curSample >= HIGH:
sts = 255
elif curSample <= LO:
sts = 1
if curSample < min and curSample > 0:
min = curSample
if curSample > max and curSample > 0:
max = curSample
if outf is not None:
outf.write(bytes([sts]))
if sts != last_sts: # Если бит поменялся, добавляем новое значение в массив
rle_buf.append(0)
last_sts = sts
if rle_buf[-1] == 255:
rle_buf.append(0) # Если байт в массиве переполнился, а бит всё ещё не менялся, добавляем
rle_buf.append(0) # запись о нуле бит противоположной полярности
rle_buf[-1] += 1
i += 1
print(f"static const uint8_t {sname}_rle_data[] = {{" + str(rle_buf)[1::][:-1:] + "};")
print(f"static const rle_sample_t {sname} = {{ .sample_rate = 8000, .root_frequency = 524 /* C5 */, .rle_data = {sname}_rle_data, .length = {len(rle_buf)} }};")
Затем аналогичным образом воспроизводим этот сэмпл, примешивая в буфер. При этом следует не забыть растянуть сэмпл до того битрейта, с которым мы буфер выводим. Понижение тональности делается путём «растяжения» битов, повышение, соответственно, «сжатием».
Конкретно за алгоритм изменения тональности не ручаюсь — его проверять не доводилось. Но один к одному этот генератор сэмплы играет корректно.
size_t Sampler::generate_samples(void * buffer, size_t length, uint32_t want_samples_) {
if(!active || waveform == nullptr || waveform->length == 0 || waveform->rle_data == nullptr)
return 0;
uint8_t* buff = (uint8_t*) buffer;
uint32_t want_samples = want_samples_ == 0 ? (length * 8) : std::min(want_samples_, length * 8);
size_t idx = 0;
int bit = 7;
for(int s = 0; s < want_samples; s++) {
idx = s / 8;
bit = 8 - (s % 8);
if(state) {
buff[idx] |= (1 << bit);
}
if(stretch_factor == 1 || (s > 0 && (s % stretch_factor) == 0)) {
if(skip_factor > remaining_samples) {
playhead = (playhead + std::max(skip_factor / 8, 1)) % waveform->length;
remaining_samples = waveform->rle_data[playhead] - ((skip_factor % 8) - remaining_samples);
state ^= 1;
} else {
remaining_samples -= skip_factor;
}
if(remaining_samples == 0) {
playhead = (playhead + 1) % waveform->length;
remaining_samples = waveform->rle_data[playhead];
state ^= 1;
}
}
}
return idx + 1;
}
После этого осталось дело за малым — иметь возможность управлять этими генераторами во времени. Для этого понадобится написать простейший секвенсор. Сначала определим типы данных для задания мелодий:
typedef enum melody_item_type {
FREQ_SET, // <- Установить частоту генератора, или 0 = заткнуть его
DUTY_SET, // <- Установить скважность генератора
DELAY, // <- Задержка в миллисекундах
LOOP_POINT_SET, // <- Установить точку зацикливания мелодии
SAMPLE_LOAD, // <- Загрузить сэмпл в сэмплер
MAX_INVALID
} melody_item_type_t;
typedef struct melody_item {
const melody_item_type_t command : 4; // <- Команда
const uint8_t channel : 4; // <- Канал
const int argument1; // <- Аргумент для команды
} melody_item_t;
typedef struct melody_sequence {
const melody_item_t * array; // <- Данные мелодии
size_t count; // <- Количество записей в массиве данных мелодии
} melody_sequence_t;
Сам секвенсор же будет генерировать биты для вывода, запрашивая поочерёдно у каждого из своих «голосов» примешать сигнал в буфер.
size_t NewSequencer::fill_buffer(void* buffer, size_t length) {
if(!is_running) return 0;
// Если задержка закончилась, обработать следующие команды в треке до следующей задержки
if(remaining_delay_samples == 0) process_steps_until_delay();
size_t generated = 0;
uint32_t want_samples = std::min(length * 8, (size_t) remaining_delay_samples);
// Рендерим в буфер все каналы поочерёдно
for(int i = 0; i < CHANNELS; i++) {
size_t cur = voices[i]->generate_samples(buffer, length, want_samples);
if(cur > generated) generated = cur;
}
// Уменьшить счётчик текущей задержки на число битов, которое мы сгенерировали
remaining_delay_samples -= want_samples;
return generated;
}
void NewSequencer::process_steps_until_delay() {
if(!is_running) return;
// Дошли до конца трека
if(pointer >= sequence->count) {
// Если играем бесконечно или нужны ещё повторения
if(repetitions == -1 || repetitions > 0) {
// Уменьшаем счётчик повторов и переходим на точку зацикливания
if(repetitions > 0) repetitions--;
pointer = loop_point;
process_steps_until_delay();
return;
} else if(repetitions == 0) {
// Заканчиваем играть мелодию
stop_sequence();
return;
}
}
const melody_item_t * cur_line = &sequence->array[pointer];
switch(cur_line->command) {
case FREQ_SET:
voices[cur_line->channel]->set_parameter(ToneGenerator::Parameter::PARAMETER_FREQUENCY, cur_line->argument1);
break;
case DUTY_SET:
voices[cur_line->channel]->set_parameter(ToneGenerator::Parameter::PARAMETER_DUTY, cur_line->argument1);
break;
case LOOP_POINT_SET:
loop_point = pointer + 1;
break;
case DELAY:
// Считаем задержку в битах, умножая число миллисекунд на число битов за миллисекунду
remaining_delay_samples = cur_line->argument1 * WaveOut::BAUD_RATE / 1000;
break;
case SAMPLE_LOAD:
voices[cur_line->channel]->set_parameter(ToneGenerator::Parameter::PARAMETER_SAMPLE_ADDR, cur_line->argument1);
break;
default:
break;
}
pointer++;
if(cur_line->command != DELAY) {
// Если ещё не дошли до задержки, обрабатываем следующую команду
process_steps_until_delay();
}
}
И напоследок, для упрощения написания мелодий, пишем конвертер из MIDI в секвенции. Конечно, итоговый результат порой требует доработки напильником, но всё же такой инструмент сильно помогает. Для чтения файлов я использовал библиотеку MIDO [9], а для получения частоты по MIDI-нотам — freq-note-converter [10].
#!/usr/bin/env python3
from sys import argv
from mido import MidiFile
import freq_note_converter
mid = MidiFile(argv[1])
name = argv[2]
ended = False
class Event():
def __init__(self, kind, chan, arg):
self.kind = kind
self.chan = chan
self.arg = arg
def __str__(self):
return f" {{{self.kind}, {str(self.chan)}, {str(int(self.arg))}}},"
class Comment():
def __init__(self, s):
self.content = s
self.kind = "REM"
def __str__(self):
return f" /* {self.content} */"
evts = []
# Находит предыдущую команду отключения звука на канале, если с тех пор не было ни одной команды задержки
def prev_note_off_event(chan):
for i in range(1,len(evts)+1):
e = evts[-i]
if e.kind == "FREQ_SET" and e.arg == 0 and e.chan == chan:
return e
elif e.kind == "DELAY":
return None
return None
for msg in mid:
# Если нужна задержка, вставляем соответствующую команду
if msg.time > 0.005:
evts.append(Event("DELAY", 0, msg.time * 1000))
# Событие нажатия или отпускания ноты
if msg.type == "note_on" or msg.type == "note_off":
if msg.type == "note_on" and msg.velocity > 0:
# Событие нажатия ноты (note_on с усилием больше 0, если усилие = 0, то это то же самое, что и note_off)
existing_evt = prev_note_off_event(msg.channel)
if existing_evt is not None:
# Если с прошлого отключения звука на этом канале не было ни одной задержки, то записываем частоту в то событие
existing_evt.arg = freq_note_converter.from_note_index(msg.note).freq
else:
# Иначе создаём новое событие установки частоты
evts.append(Event("FREQ_SET", msg.channel, freq_note_converter.from_note_index(msg.note).freq))
else:
# Создаём событие установки частоты = 0, т.е. отключение звука
evts.append(Event("FREQ_SET", msg.channel, 0))
elif msg.type == "end_of_track":
if ended:
raise Exception("WTF, already ended")
ended = True
elif msg.type == "marker":
# Добавляем комментарий
evts.append(Comment(msg.text))
if msg.text == "LOOP":
# Если комментарий LOOP, устанавливаем в этом месте точку зацикливания трека
evts.append(Event("LOOP_POINT_SET", 0, 0))
elif msg.type == "control_change":
if msg.control == 2:
# Control Change #2 используем для изменения скважности генератора
evts.append(Event("DUTY_SET", msg.channel, msg.value))
print("static const melody_item_t "+name+"_data[] = {")
for e in evts:
print(str(e))
print("};")
print("const melody_sequence_t "+name+" = MELODY_OF("+name+"_data);")
Так как на звание нового Тима Фоллина [11] я (пока :-) не претендую, то просто понатыкал что-то по мелочи на синтезаторе и раскидал в уже привычном редакторе Sekaiju [12]:

Важно следить, чтобы было не больше одной ноты на канал, так как каждый генератор у нас генерирует только один тон. Возможно, когда-то потом я это автоматизирую в том же скрипте конвертера, но не сейчас :-)
После добавления всех этих мелодий в прошивку заливаем её в часы и слушаем результат:
→ MP3 ← [13]
Артефакт из той эпохи, когда в ряды опенингов из аниме и просто попсы могла пробиться трансовая композиция, звучащая как что-то трекерное с демосцены. Оригинал [14], MIDI [15], код [16]
→ MP3 ← [17]
Здесь трек уже никак не клеится без голосовых вставок, которые есть в оригинале — для этого и был добавлен сэмплер. Оригинал [18], MIDI [19], код [20]
→ MP3 ← [21]
Это был первый трек, подобранный для нового секвенсора. Как по мне, «дроп» в переходе от биперной партии к многоканальной звучит шикарно. Оригинал [22], MIDI [23], код [24]
→ MP3 ← [25]
Заевший ещё во времена МУЗ-ТВ и прочего тем, кто постарше, а младшему поколению уже как мелодия из вирусного видео «Скибиди-туалет [26]», откуда и пошло название для статьи %) Оригинал [27], MIDI [28], код [29]
→ MP3 ← [30]
Даже если полноценный кавер и не делать, добавление просто второго канала на интервале в одну октаву придаёт биперу интересный тембр.
Вот таким совершенно нехитрым образом можно генерировать биперный звук на ESP32, практически не занимая процессор, для тех случаев, когда полноценного ЦАПа слишком много, а ledcWriteTone — слишком мало.
Посмотреть код можно на гитхабе [31], конкретно аудио в src/sound [32]. Со временем хотелось бы пооптимизировать всё это дело и как-то ещё упростить написание мелодий. Ну а почитать прочие ворклоги работы над этим бешеным будильником, среди прочих скитаний и туевой хучи фоток еды и Мику вы можете у меня в телеграм-канале [33] :-)
Также отдельное спасибо @NightRadio [34] за наводки и помощь в написании движка, сам бы я вряд ли додумался, что это можно сделать так просто.
Автор: vladkorotnev
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/fizika/394648
Ссылки в тексте:
[1] ledcWriteTone: https://makeabilitylab.github.io/physcomp/esp32/tone.html#example-circuit
[2] одноканальный секвенсор: https://github.com/vladkorotnev/plasma-clock/blob/e8cb14c4b693d758467791d50da2331b509a7227/src/sound/sequencer.cpp#L72-L105
[3] биперу на базе этой самой функции: https://github.com/vladkorotnev/plasma-clock/blob/e8cb14c4b693d758467791d50da2331b509a7227/src/sound/beeper.cpp#L58-L64
[4] DJ Brisk & Trixxy — Eye Opener: https://github.com/vladkorotnev/plasma-clock/raw/develop/docs/rec/eye_opener.mp3
[5] Мозг: http://www.braintools.ru
[6] AY-3-8910: https://habr.com/ru/articles/725752/
[7] MAME: https://github.com/mamedev/mame/blob/master/src/devices/sound/ay8910.cpp
[8] RLE: https://ru.wikipedia.org/wiki/%D0%9A%D0%BE%D0%B4%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D0%B5_%D0%B4%D0%BB%D0%B8%D0%BD_%D1%81%D0%B5%D1%80%D0%B8%D0%B9
[9] MIDO: https://mido.readthedocs.io/en/stable/
[10] freq-note-converter: https://pypi.org/project/freq-note-converter/
[11] Тима Фоллина: https://en.wikipedia.org/wiki/Tim_Follin
[12] Sekaiju: https://openmidiproject.opal.ne.jp/Sekaiju_en.html
[13] → MP3 ←: https://github.com/vladkorotnev/plasma-clock/raw/develop/docs/rec/re_sublimity.mp3
[14] Оригинал: https://youtu.be/QXDwb2rueYM?t=65
[15] MIDI: https://github.com/vladkorotnev/plasma-clock/blob/develop/helper/chimes/resublimity.mid
[16] код: https://github.com/vladkorotnev/plasma-clock/blob/develop/src/sound/melodies.cpp#L7755-L9070
[17] → MP3 ←: https://github.com/vladkorotnev/plasma-clock/raw/develop/docs/rec/kamippoina.mp3
[18] Оригинал: https://www.youtube.com/watch?v=EHBFKhLUVig
[19] MIDI: https://github.com/vladkorotnev/plasma-clock/blob/develop/helper/chimes/kamippoina.mid
[20] код: https://github.com/vladkorotnev/plasma-clock/blob/develop/src/sound/melodies.cpp#L5510-L7752
[21] → MP3 ←: https://github.com/vladkorotnev/plasma-clock/raw/develop/docs/rec/ark.mp3
[22] Оригинал: https://www.youtube.com/watch?app=desktop&v=cB7eevDk1s0
[23] MIDI: https://github.com/vladkorotnev/plasma-clock/blob/develop/helper/chimes/ark.mid
[24] код: https://github.com/vladkorotnev/plasma-clock/blob/develop/src/sound/melodies.cpp#L4352-L4751
[25] → MP3 ←: https://github.com/vladkorotnev/plasma-clock/raw/develop/docs/rec/skibidi.mp3
[26] Скибиди-туалет: https://youtu.be/6dMjCa0nqK0
[27] Оригинал: https://youtube.com/watch?v=RgoiSJ23cSc
[28] MIDI: https://github.com/vladkorotnev/plasma-clock/blob/develop/helper/chimes/skibidi_toilet.mid
[29] код: https://github.com/vladkorotnev/plasma-clock/blob/develop/src/sound/melodies.cpp#L4753-L5507
[30] → MP3 ←: https://github.com/vladkorotnev/plasma-clock/raw/develop/docs/rec/arise.mp3
[31] на гитхабе: https://github.com/vladkorotnev/plasma-clock/tree/develop
[32] src/sound: https://github.com/vladkorotnev/plasma-clock/tree/develop/src/sound
[33] у меня в телеграм-канале: https://t.me/sapporolife
[34] @NightRadio: https://www.pvsm.ru/users/nightradio
[35] Нужно больше ламповых табло!!! Запускаем дисплей от пейджера NJE-105: https://habr.com/ru/companies/timeweb/articles/821311/
[36] Сложно о простом. Сеансовый уровень (L5), представительный (L6) уровень и прикладной (L7) уровень: https://habr.com/ru/companies/timeweb/articles/830308/
[37] Zynq 7000. Загрузка Embedded Linux на SoC через JTAG с помощью XSCT: https://habr.com/ru/companies/timeweb/articles/835912/
Нажмите здесь для печати.