Слушаем ZX Spectrum музыку с MIDI-плеера

Как-то раз долгие годы назад у меня умер Спектрум. Уже и не вспомню, что с ним случилось, но возиться с ремонтом желания не было, ибо на замену давно хотелось новенький ZX Evolution.

Все более-менее полезные и выглядящие целыми детали были сняты, в том числе и музыкальный сопроцессор YM2149F. И как раз в нужный момент попалась статья @Z80A о сборке плеера на базе Arduino.

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

Как сказал препод по схемотехнике, изъяв прибор во время испытаний на задних рядах: «ЦАП у вас тут, конечно, эрзац, да и сборка не сильно лучше... Но начало хорошее!»

Как сказал препод по схемотехнике, изъяв прибор во время испытаний на задних рядах: «ЦАП у вас тут, конечно, эрзац, да и сборка не сильно лучше… Но начало хорошее!»

С тех пор проект законченным назвать было сложно — усилитель для наушников был безжалостно выброшен, т.к. из-за неправильного включения слишком сильно шумел, да и устройство получилось не особо компактным, чтобы носить его с собой. До корпуса и кнопок переключения дорожек руки тоже не дошли. Так и валялась плата с кучей проводов на стойке с аудиотехникой, подключенная в линейный выход — на случай, если захочется послушать спектрумное поппури.

Однако, недавно мне на барахолке попалась вот такая вундервафля — Casio FD-1. Спереди — кнопки воспроизведения, справа — дисковод, сзади — MIDI-выход.

f61cbb2eb161f84f8d1a64ae6e8ee2d4.jpg

Как только я её увидел, в голове сразу что-то щёлкнуло: дисковод есть — можно сборники составлять, кнопки выбора трека тоже есть, да и пианино мигающее на панели прикольным было бы. До кучи, можно использовать его и с другими MIDI-синтезаторами, которые у меня есть.

С некоторым трудом плата AY-плеера была выкопана из барахла родственниками и отправлена в мою сторону. К счастью, доехала как будто бы целой —, а попробуй пойми, если она и изначально вся кривая :-)

a7538436880bf4f0711488c2a6c67b52.jpg

Роадмап проекта был намечен максимально коротким, чтобы не откладывать в долгий ящик — придумываем протокол, пишем новую прошивку, поверх навесом на макетке наворачиваем MIDI-интерфейс, всё это в коробочку и в стойку.

Зачем? Чтобы что?

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

Эдакая извращённая аудиофилия, эволюционирующая в разумную форму жизни, ну и плюс шанс руки занять вечерами.

Главное не забыть перед прослушиванием саундтрека из Lyra II чип пивом натереть, а межблочники из компрессора продуть, чтобы звук воздушным стал

Главное не забыть перед прослушиванием саундтрека из Lyra II чип пивом натереть, а межблочники из компрессора продуть, чтобы звук воздушным стал

Протокол

Чтобы играть музыку по нотам, нужно бы поддерживать различные MIDI-события типа Note On/Note Off, однако в случае с AY это несколько сложно — ведь для создания красивых тембров трекеры манипулируют регистрами несколько раз в секунду. То есть это надо писать по сути свой драйвер с тембрами, таблицей нот, о которых по сей день возникают холивары, и всё вот это вот.

Так как в этом проекте мне хотелось чисто слушать готовую музыку из демок и игр, то можем ограничиться двумя действиями — выставлением тактовой частоты и записью в регистр музыкального сопроцессора. Посылать их будем через System Exclusive (SysEx) — сообщения, являющиеся по сути сырым потоком данных на MIDI-шине.

Тут нам попадается первый подводный камень — в MIDI все байты, не являющиеся статусом (т.е. теми же Note On/Off и иже с ними) должны быть с нулевым старшим битом.

Для начала, посмотрим в даташит на музыкальный сопроцессор и убедимся, что 4 бита нам хватит на номер регистра:

Порты A/B можно потом приспособить под что-нибудь ещё, или вообще перехватывать их до записи в чип и использовать для настройки прошивки :-)

Порты A/B можно потом приспособить под что-нибудь ещё, или вообще перехватывать их до записи в чип и использовать для настройки прошивки :-)

Команды у нас пока что тоже две, но возьмём для них два бита, чтобы с запасом. Остался один бит — в него можно как раз запихать старший бит значения регистра. По итогу вписываем команду записи регистра всего в два байта:

MSB

6

5

4

3

2

1

LSB

0

Запрещён

№ команды WRITE PAIR (0b10)

№ регистра

MSB значения

1

Остальные биты значения

То есть, например, если мы хотим записать в регистр #1 значение #FC, то пакет будет выглядеть так:

F0 : маркер начала SysEx-сообщения
A7 : маркер, что это пакет нашего формата. В стандарте MIDI указан как зарезервированный.
43 : (0b10 << 5) | (0x1 << 1) | (0xFC >> 7) 
       ^             ^            ^— значение регистра (старший бит)
       |             +— номер регистра
       +— константа команды WRITE_PAIR
7C : оставшиеся биты от значения регистра (0xFC & 0x7F)
F7 : маркер конца SysEx-сообщения

Очень удачно на такой формат первого байта ложится и команда выставления частоты тактовки сопроцессора:

MSB

6

5

4

3

2

1

LSB

0

Запрещён

№ команды SET CLOCK (0b11)

MSB байта №1

MSB байта №2

MSB байта №3

MSB байта №4

Признак ACB-Stereo

1

Первый байт частоты в UInt32-LE без MSB

2

Второй байт частоты в UInt32-LE без MSB

3

Третий байт частоты в UInt32-LE без MSB

4

Четвёртый байт частоты в UInt32-LE без MSB

В свободный бит я добавил флаг выбора альтернативной стерео-раскладки, так как в моей коллекции есть композиции как в ABC, так и в ACB-стерео. Так как каналы у сопроцессора абсолютно идентичны, то менять их местами можно будет программно.

Прошивка

Как там в фидошных времён песне-то было? «Свыклись с мощной машиной, отвыкли от всякого риска» — вот и я взял и с лёгкой руки наваял всё поначалу на FreeRTOS с мьютексами и приоритетами. Совершенно при этом не подумав, что в 2048 байтах оперативной памяти у лежащей в основе платформы ATMEGA328P такое будет вертеться как слон в посудной лавке:-)

Не, ну смотрится-то красиво-модно-молодёжно! Лишь лёгкий недостаток — не работает %)

Не, ну смотрится-то красиво-модно-молодёжно! Лишь лёгкий недостаток — не работает %)

Вдоволь насмотревшись на быстро мигающий светодиод при stack overflow и медленно мигающий при out of memory, решил всё сделать попроще, и та портянка со скрина выше стала более лаконичной:

void status_regi_notify(uint8_t regi, uint8_t valu) {
    if(regi > 0xF) return;
    register_dump[regi] = valu;
}

uint8_t status_regi_get_blocking(uint8_t regi) {
    uint8_t val =  register_dump[regi];
    return val;
}

Рисуем остаток совы Пишем остальной код, заливаем — работает! Делаем от балды MIDI-файл, который зацикленно играет арпеджио из трёх нот, выводим с компа — работает! почти… Лагает безбожно с частотой обновления экрана.

Готовим салат «Асинхронный» — вам потребуются: помиогурдоры, цымайон, ез.

Библиотека для работы с MIDI была написана весьма сносно, а вот для работы с дисплеем — в лучших традициях Ардуины: «Есть библиотеки, чтобы сделать что угодно, но не больше одной вещи за раз»

Выяснилось, что библиотека LiquidCrystal_I2C, которую я изначально взял для управления экраном, содержит кучу задержек, оставшихся от её предка с параллельным интерфейсом. Но даже после того, как они все были убраны, дисплей стал инициализироваться нестабильно, но тормоза никуда не ушли.

>разбираем родную библиотеку Wire на части
>внутри провода задержки

d48f16ccc8e78f11f8276d8ad1d0810d.png

Поиски привели к библиотеке AsyncI2CMaster от cskarai, которая позволяет асинхронно управлять I2C-шиной. Однако, если просто заменить Wire на неё, сама библиотека LiquidCrystal_I2c будет забивать очередь весьма быстро, да и никаких средств контроля таймингов в ней нет, а надеяться на медленность своего кода — подход не особо хороший :-)

Поэтому, делаем свою эрзац-кооперативную многозадачность поверх имеющегося кода.

Реализация простая — добавляем функцию, которая в цикле дёргается из основной программы, рядом с функцией опроса MIDI-интерфейса. Она проверяет флаг «занято» и если он не выставлен, то берёт следующий элемент очереди. Формат элементов очереди делаем максимально компактным, чтобы даже при перерисовке всего экрана не закончилась память:

Операция

u8

SEND (посыл байта) либо WAIT (задержка более чем)

Атрибуты

u16

Минимальное время задержки, мкс

либо

u8

Байт для отправки в дисплей

u8

Режим отправки (доп. битовая маска от библиотеки LiquidCrystal)

Если из очереди был вытащен элемент типа WAIT, то функция выставляет флаг «занято» и записывает текущее системное время в переменную, а при последующих дёрганиях проверяет, не превысила ли разница запрошенное время задержки.

Если же там оказался элемент типа SEND, то она запускает следующий конечный автомат, который дёргается из основной программы аналогичным образом.

Сейчас

Делаем

Дальше

WILL_SET_BUS

Записываем полубайт в I2C-регистр дисплея

DID_SET_BUS

DID_SET_BUS

Записываем то же самое значение, но со включенным битом EN, что говорит дисплею считать его с шины

DID_EN_HIGH

DID_EN_HIGH

Сохраняем значение системного таймера

WAIT_EN_LOW

WAIT_EN_LOW

Если с момента сохранения системного таймера прошло больше 2 мкс, отключаем бит EN и отправляем значение в I2C-регистр

DID_EN_LOW

DID_EN_LOW

I2C-регистр обработал наш запрос, сохраняем таймер опять

WAIT_SETTLE

WAIT_SETTLE

При условии, что прошло больше 50 мкс (время, нужное дисплею на обработку команды целиком):

* Если закончили отправлять первый полубайт, и флаг «идёт инициализация дисплея» выключен, то поменять местами половины байта в очереди и перейти снова к WILL_SET_BUS

* В противном случае отключить флаг ожидания, чтобы на следующем «дёрге» основная функция могла продолжить обрабатывать очередь

Пробуем — вуаля, всё шустро и быстро! До кучи выяснилось, что конкретно моему дисплею и шаг WILL_SET_BUS не особо нужен, и разгон шины I2C со 100 кГц до 850 кГц он переживает спокойно (выше — уже нет :-)

Скорее всего, словами это описание понять сложно, поэтому вот тут можно посмотреть код.

Конвертер

Работающий плеер — это, конечно, хорошо, но какой с него толк, если для него нет музыки?

Поэтому был написан модуль, которому скармливаешь попарно записи в регистры AY, а он взамен выдаёт в описанном нами протоколе SysEx’ы, и до кучи Note On/Off на 4 канале, чтобы на панели FD-1 мигали клавиши пианино.

Поверх него были собраны две программы для, собственно, конвертирования — из формата PSG, записанных через ZXTune, и из формата VGM.

Тут появляется ещё один подводный камень — в MIDI тайминги задаются двумя значениями: tempo и timebase;, а события расставляются по третьему, из них вычисляемому — по тикам.

Связаны они между собой уравнением: tick (ms) = \frac{60000}{BPM \times TB}

В связи с особенностью прошивки Casio FD-1, в нашем случае BPM не может быть больше чем 255.

Для PSG нам нужны задержки с дискретностью в 20 мс и 80 мс, поэтому для tick = 10ms нам подойдёт 120 BPM при Timebase = 50: \frac{60000}{50 \times 120} = 10

VGM — более сложный случай, т.к. в нём используется частота дискретизации 44100 Гц, то есть нам нужен тик в \frac{1000}{44100} = 0.02267 мс.
Экспериментально по методу Подгониана были выбраны Timebase = 16000 и BPM = 165, так как инструментарий, которым я пользовался для отладки MIDI-файлов, не очень любит файлы с большими Timebase: \frac{60000}{165\times16000} = 0.0227 мс

И скорости света мало

Всё это время я отлаживал воспроизведение через связку loopMIDI + Hairless MIDI-Serial по штатному USB-порту ардуины. Теперь пришло время подключить её по-нормальному, старым добрым DIN5-кабелем.

По стандарту MIDI вход должен быть гальванически развязан через оптопару, срабатывающую при токе через диод в 5 мА. Ничтоже сумняшеся я вытащил из загашников TLP621 и какую-то подобранную в гугл-картинках схему:

Выглядит просто, значит должно работать! Только Rd на 1кОм заменить.

Выглядит просто, значит должно работать! Только Rd на 1кОм заменить.

На коленке собираем:

b414bc5ca20229b3e7e776372091e0f9.jpg

Втыкаем в комп, проверяем — работает! Да как же так, вот прям с первого раза?

Как раз тут и началась магия. С Yamaha MU50 в роли интерфейса для компа — всё приходит и играется замечательно. С FD-1 напрямую, или даже через ямаховский THRU-порт — сплошной шум да мусор, вешающий контроллер. (Вот и прошивку пофаззили заодно) Значит, пора доставать осциллограф, и любоваться:

Сверху — вход оптопары, снизу — выход

Сверху — вход оптопары, снизу — выход

Задержку между фронтами считаем по клеточкам, как в школе. Посередине верхнего графика на входе импульс шириной около 32 мкс, как раз на скорости в 31250 бод одна единичка получается.

А вот под ним из оптопары вылетает нечто длиной в 10–15 мкс. Как это прочитается UART’ом? Да как повезёт, так и прочитается — поэтому вместо данных и получаем на входе мусор.

Так как в последнем радиомагазине в городе даже резисторы и конденсаторы уже не всегда в наличии, пользуемся читом «звонок другу» и на выходных получаем в руки новенькую 6N138. Она имеет существенно большее быстродействие по сравнению с TLP621. Помимо этого, на выходе у неё составной транзистор, который сам по себе хоть медленнее обычного, но на корпус этой оптопары выведена в том числе и база его выходного транзистора:

869f5418a297e256039ea24e156e24ce.png

Подтянув её через 10кОм к земле, мы можем ещё сильнее улучшить быстродействие оптопары.

b9e1c5c41858d03e38fef860a7a29aae.png

К счастью, после этого задержка между фронтами входного и выходного сигнала упала более чем в два раза, и всё идеально заработало.

Сыграем в ящик

Раз уж проект заканчивается спустя столько лет, то захотелось собрать первое в жизни полноценное устройство.

Не коробочку, из которой торчат провода. Не корпус от заводского девайса, из которого вываливаются модули на термосоплях. А именно полноценную, пусть даже кривую и косую, но вещь в себе.

Для начала был куплен корпус из гнутого листового алюминия:

Пока ещё даже красивый

Пока ещё даже красивый

Процесс сверления и пиления не фиксировал, поэтому сразу итоговый результат:

261e3a58fd48f44ad0d06c36414e961a.jpg

Ужасно? Да это вы его ещё в лицо не видели!

4b996e3207c1bfaac1ccd9a58dbad253.jpg

Поговорка про «семь раз отмерь, один раз отрежь», оказывается, подразумевает, что нужно ещё подумать, где измерять %)

Поначалу затупил и начал пилить отверстие по внешнему размеру корпуса дисплея. Ну, а потом понял, как его закрепить так, чтобы не торчал, но было уже слишком поздно.

Подписи к разъёмам напечатал на принтере этикеток, чтобы через пару месяцев/лет не гадать, что это за дичь и куда она втыкается.

А отверстия под разъёмы, в силу отсутствия крупных свёрл, пришлось долбить напильником

А отверстия под разъёмы, в силу отсутствия крупных свёрл, пришлось долбить напильником

На экран вывел все отвечающие за звук регистры, визуализатор громкостей, значки активного обмена каналов (ACB → ABC) и приёма валидных данных.

Сама ЖК-панель не успевает переключаться вслед за приёмом разогнанных нами данных

Сама ЖК-панель не успевает переключаться вслед за приёмом разогнанных нами данных

Демо!

Talk is cheap, show me the code

Естественно, всё это было выложено на гитхаб, если кто-то захочет зачем-то такое повторить :-)

В планах, по порядку убывания вероятности:

  • Поменять ни за чем не успевающий ЖК-экран на вакуумно-люминисцентный

  • … и прикрыть дырень под него полупрозрачным тёмным стеклом :-)

  • Развести и заказать новую плату, попутно впилить ещё один сопроцессор и поддержку TurboSound

  • Полноценный синтезатор (Note On, Note Off, вот это вот всё)

  • Backend для ZXTune для вывода в MIDI-порт в этом формате?

Ну, а пока что как-то так! Подписывайтесь, ставьте лайки, вот это вот всё, или как там сейчас принято, и спасибо за внимание.

И главное, помните — лучше уродливое решение, которое выполняет вашу задачу, чем идеально красивое, но так и не собранное:-)

© Habrahabr.ru