Внутренности ZXTune: быстрая эмуляция AY/YM
Другие статьи цикла:
В данной статье речь пойдет о способах оптимизации программной эмуляции всем известного музыкального сопроцессора. По сложившейся традиции, будет рассказ об общей идее с элементами псевдокода. Моя же реализация изложенного может быть найдена в исходных кодах проекта ZXTune, в результате работы над которым данное исследование и получилось.
Немного матчасти.
Аппаратная составляющая чипа относительно проста: три генератора тона, генератор шума, генератор огибающей, микшер и ЦАП. Значение уровня сигнала каждого канала, задаваемое для каждого канала индивидуальным регистром громкости или общим на всех уровнем огибающей, может модулироваться сигналом индивидуального генератора тона и/или сигналом общего генератора шума. Микшер управляет источником уровня и модуляторами.Три одинаковых делителя с разрядностью счетчика в 12 бит каждый служат генераторами тона. Делитель разрядностью 5 бит тактирует генератор шума- сдвиговый регистр с обратной связью. Делитель разрядностью 16 бит тактирует генератор огибающей, изменяющий свой уровень по одному из 16 алгоритмов (на самом деле, 9 — часть алгоритмов совпадают).
С помощью ЦАП уровень преобразуется в напряжение на выходе чипа по логарифмической зависимости.
Широко распространены две разновидности чипа- AY-3-8910 и YM2149F. Отличия между ними с точки зрения генерации звука состоят в параметрах ЦАП и удвоенном числе уровней огибающей у YM (32 против 16). Для упрощения эмуляции, используется 32 уровня, а 16 уровней получаются с помощью квантования.
Основы эмуляции.
Как таковая, эмуляция состоит из двух частей — обновление состояния (реакция на сигналы от тактового генератора) и синтез выходного сигнала.Обновление выполняется обычно с частотой от 1 до 3.5 МГц (в зависимости от модели эмулируемого компьютера), деленной на 8.
Синтез выполняется либо с частотой звука (от 8 до 96кГц), либо с частотой обновления (результат фильтруется и проходит изменение частоты до звуковой).
Алгоритм обновления выглядит следующим образом:
UpdateChip() : void
//обновление генераторов тона
ToneA.Update()
ToneB.Update()
ToneC.Update()
//а также шума и огибающей
Noise.Update()
Envelope.Update()
Т.е. просто обновление состояния генераторов в соответствии с параметрами, хранящимися в регистрах управления.
Поскольку все генераторы в основе содержат делители частоты, то общий их вид будет следующим:
Update() : void
//делитель частоты-
//персональный счетчик и период для каждого генератора
Counter++
if (Counter >= Period)
Counter = 0
//какие-то специфичные для генератора действия
UpdateLevel();
Алгоритм синтеза:
SynthesizeChip() : void
//получение внутреннего 5-битного уровня сигнала
//и преобразование во внешний (8/16/32 и т.п.) по таблице громкости
volA = VolumeTable[GetLevelA()]
volB = VolumeTable[GetLevelB()]
volC = VolumeTable[GetLevelC()]
//микширование в 2 стереоканала
left = mixerAL * volA + mixerBL * volB + mixerCL * volC
right = mixerAR * volA + mixerBR * volB + mixerCR * volC
Дальше выходные уровни поступают на ресемплер или напрямую на воспроизводящее устройство.
Функции получения уровня сигнала выполняют микширование в соответствии с режимами:
GetLevelX() : void
//модулирование тоном
if (Registers.HasToneX() and ToneX.IsLow())
return 0
//модулирование шумом
else if (Registers.HasNoiseX() and Noise.IsLow())
return 0
//выбор уровня- огибающая или громкость
else if (Registers.HasEnvelopeX())
return Envelope.GetLevel()
else
return Registers.GetLevelX()
Микрооптимизация для улучшения понимания алгоритма: выходной сигнал модулируется выходами генераторов тона и шума (если они включены для канала), поэтому выходной уровень будет нулевым, если выход соответствующего генератора имеет низкий уровень.
Оптимизируй это!
Пристальный взгляд с прищуром позволяет найти первую, очевидную, «жертву» для оптимизации- синтез. При каждом вызове происходит трехкратное вычисление 5-битного внутреннего уровня канала, который по таблице преобразуется во внешний уровень, а потом три канала преобразуются в два умножением на матрицу микшера. Слишком много действий. Два шага из трех можно вынести «за скобки»- если три 5-битых внутренних состояния преобразовать к одному 15-битному, то из таблицы можно будет сразу брать смикшированный результат. 32768 значений по 16+16 бит (128кб) — небольшая цена за отсутствие лишних доступов к таблице громкости и матрице микшера, а также нескольких умножений и сложений.Если при инициализации рассчитать табличку:
PrecalcTable() : void
for idx in 0..((1 << 15) - 1)
//из индекса получются внутренние уровни для каждого из 3 каналов
levelA = (idx >> 0) & 31
levelB = (idx >> 5) & 31
levelC = (idx >> 10) & 31
//преобразуются во внешние по таблице
volA = VolumeTable[levelA]
volB = VolumeTable[levelB]
volC = VolumeTable[levelC]
//микшируются
left = mixerAL * volA + mixerBL * volB + mixerCL * volC
right = mixerAR * volA + mixerBR * volB + mixerCR * volC
//упаковываются в значение из таблицы
Table[idx] = (right << 16) | left
то процедура синтеза превратится в
SynthesizeChip() : void
idx = GetLevelA() | (GetLevelB() << 5) | (GetLevelC() << 10)
level = Table[idx]
left = level & 0xffff
right = level >> 16
У данного метода есть один “недостаток” — при постоянном чтении управляющих параметров (таблица выходного сигнала и матрица микшера) их изменения моментально отражались на результате. Теперь же требуется пересчитывать таблицу при изменении любого из вышеуказанных параметров. На самом деле, это совсем не трудная задача, не говоря о ничтожной редкости изменения настроек по сравнению с их использованием.
Финальный штрих
Что еще можно ускорить без кардинальных переделок? Например, получение индекса в таблице:
SynthesizeChip() : void
level = Levels | (EnvelopeMask * Envelope.GetLevel())
noise = NoiseMask | Noise.GetLevel()
toneA = ToneMask | ToneA.GetLevel()
toneB = ToneMask | ToneB.GetLevel()
toneC = ToneMask | ToneC.GetLevel()
idx = level & toneA & toneB & toneC & noise
level = Table[idx]
left = level & 0xffff
right = level >> 16
Envelope.GetLevel() возвращает 5-битное значение, которое умножается на маску огибающей, в которой могут быть установлены 0, 5 и 10 биты. В этом случае в маске уровней Levels будут сброшены биты в диапазонах 0..4, 5..9 и 10..14 соответственно. Иначе там будут находиться значения громкости соответствующего канала. Noise.GetLevel() и ToneX.GetLevel() возвращают либо 0, либо 15 установленных бит, а NoiseMask и ToneMask содержат нули в вышеупомянутых диапазонах, если для соответствующего канала разрешен тон и шум соответственно. После этого остается лишь объединить все значения и получить сразу 15-битное значение состояния (индекса в таблице).
А еще быстрее?
Можно! Но приведенный ниже способ носит скорее ознакомительный характер, нежели руководство к действию. И используется в ZXTune в силу исторических причин- был придуман и успешно опробован до оптимизации, описанной выше.Как можно заметить, алгоритм обновления состояния копирует поведение реальной микросхемы. Только вот то, что в железе выполняется за один такт, в эмуляторе требует нескольких шагов: 5 обновлений счетчиков, 5 сравнений счетчика с пределом, от 0 до 5 обновлений состояний разной степени сложности. И все только для того чтобы отбросить большую часть результата на микшировании. А что если вычислять выходной уровень генераторов только если это действительно нужно?
Функция UpdateChip не изменилась, но функция обновления состояния генераторов сократилась до:
Update() : void
//просто обновляем внутреннее состояние
Counter++
Функция синтеза тоже претерпела некоторые изменения:
SynthesizeChip() : void
level = Levels
if (EnvelopeMask != 0)
//если есть огибающая хоть в одном канале, запрашиваем значение
level |= EnvelopeMask * Envelope.GetLevel()
noise = NoiseMask
if (NoiseMask != 0x7fff)
//если есть шум хоть в одном канале, запрашиваем значение
noise |= Noise.GetLevel()
//генераторы тона учитывают свою маску
toneA = ToneA.GetLevel(%11111 11111 00000)
toneB = ToneB.GetLevel(%11111 00000 11111)
toneC = ToneC.GetLevel(%00000 11111 11111)
idx = level & toneA & toneB & toneC & noise
level = Table[idx]
left = level & 0xffff
right = level >> 16
Самое интересное теперь кроется в функциях GetLevel() генераторов.
Генератор тона- обычный делитель частоты. Можно представить его как генератор меандра с периодом, равным удвоенному значению делителя:
ToneGenerator.GetLevel(low) : void
//если тон запрещен, возвращаем полную маску
if (Masked)
return 0x7fff
//приводим счетчик в диапазон меандра
Counter %= Period * 2
//если счетчик во второй половине- высокий уровень
if (Counter >= Period)
return 0x7fff
//иначе- низкий для текущего канала
else
return low
Как видно, можно совершенно бесплатно получить возможность менять скважность сигнала- достаточно сравнивать счетчик не с Period, а любым значением в диапазоне 0..Period*2. Хотя в железе этого нигде не реализовано, эффект получается интересный:)
Генератор шума- делитель частоты и сдвиговый регистр с обратной связью. И для получения текущего состояния необходимо знать число циклов обновления:
NoiseGenerator.GetLevel() : void
//считаем сколько циклов прошло с прошлого запроса
toUpdate = Counter / Period
Counter %= Period
//обновляем состояние
for cycles in 0..toUpdate
UpdateState()
if (IsLow())
return 0
else
return 0x7fff
Благодаря SAM style выяснилось, что генератор шума имеет период 131072, поэтому значение можно получить из таблицы:
NoiseGenerator.GetLevel() : void
//обновляем индекс в таблице на число циклов
Index += Counter / Period
Counter %= Period
return NoiseTable[Index & 0x1ffff]
Генератор огибающей- делитель частоты и машина состояний.
EnvelopeGenerator.GetLevel() : void
//обновлять состояние имеет смысл,
//если выходной уровень меняется- Decay=+1/-1
if (Decay != 0)
//такой же подсчет прошедшего числа циклов
toUpdate = Counter / Period
Counter %= Period
for cycles in 0..toUpdate
UpdateState()
return Level
Несколько замечаний:
— для некоторых аппаратных платформ операция взятия остатка по модулю может быть очень тяжелой, посему на практике применяется банальное вычитание с оптимизацией случаев, когда период является степенью двойки.
— при изменении периода делителя частоты также нужно обновлять состояние, в случае генератора тона еще и учесть позицию в меандре.
— наиболее сильный выигрыш в производительности получается при синтезе с частотой дискретизации звука. К сожалению, такой режим дает невысокое качество звука. Немного исправить ситуацию можно с помощью примитивного фильтра, усредняющего отсчеты. Для наилучшего качества необходимо синтезировать сигнал с тактовой частотой чипа использовать ФНЧ и ресемлер.
Как видно, код получается весьма сложный (потому и не рекомендуется:))
По результатам тестирования, этот способ дал от 15 до 50% прирост скорости в разных тестах.
Способ с таблицей гораздо более эффективный и дал прирост от 300 до 900%(!).
А что в мире?
Ниже представлен беглый анализ эмуляторов AY/YM в разных продуктах.Общими чертами считаются: обновление состояния и синтез звука с частотой чипа после делителя, процедурная генерация шума и огибающей, преобразование внутреннего уровня во внешний по одной таблице, микширование путем умножения матриц, арифметика целочисленная или с фиксированной запятой. Приведены отличающиеся особенности.
Музыкальные плееры
AY Emul. FIR фильтр 56 порядка (для стандартных настроек) с частотой среза 9200 Гц. Микширование результатов чтения из 6 таблиц (3 входных х 2 выходных канала) с оптимизацией случаев, когда канал промодулирован шумом и/или тоном. Отдельные процедуры генерации моно/стерео и 8/16 бит.AYFly. IIR фильтр 2 порядка с частотой среза 1/4 от звуковой. Микширование в числах с плавающей запятой. Для версии под S60 синтез выполняется с частотой звука, отсутствует фильтр, микшер жестко зашит в виде L=A+B/2, R=B/2+C.
ZXTune. Для режима с оптимизацией по качеству: IIR фильтр 2 порядка с частотой среза 9500 Гц. Для режима с оптимизацией по скорости работы: обновление состояния и синтез со звуковой частотой, простой FIR фильтр 2 порядка (усреднение). Табличная генерация шума. Табличное преобразование уровня с одновременным микшированием.
Game Music Emu. Табличная генерация огибающей. Тон с частотой выше 16кГц заменяется тишиной. Генератор шума не обновляется, если выключен. Микширование в моно с эффектом псевдостерео.
libayemu. Табличная генерация огибающей. Микширование с оптимизацией случаев, когда в канале отключены шум и тон.
ayumi. 8-кратная передискретизация, квадратичная интерполяция, затем децимирующий FIR фильтр 96 порядка (симметричное окно в 192 элемента). Возможен DC фильтр с усреднением по последним 1024 элементам. Микширование в числах с плавающей запятой.
Эмуляторы
Unreal Speccy. FIR фильтр 40 порядка (для стандартных настроек). 64-кратная передискретизация. Логика в исходнике весьма и весьма запутана, поэтому данные могут быть неточными.Xpeccy. Обновление состояния и синтез звука с частотой звука. Табличная генерация огибающей и шума. Микширование с оптимизацией случаев, когда в канале отключены шум и тон. Микшер жестко зашит в виде L=A+0.7*B, R=0.7*B + C с возможностью менять раскладку каналов.
ZXMAK.NET. Обновление состояния и синтез с частотой звука. Микширование результатов чтения из трех таблиц (по одной на входной канал).
MAME. Обновление состояния и синтез с частотой звука. Табличное преобразование уровня с одновременным микшированием.
34 комментария
Почему не 12, не 15, не 20? Я бы сам ответил на этот вопрос как 'чтоб меньше цпу жрало', если бы не такая же частота среза в ayemul (если верить твоим словам), ведь на пц явно нет смысла экономить cpu.
«Звук стал аккуратнее» — ты с реальным АУ сравнивал, или просто на свой вкус тюнил частоту среза? Если с реальным, то, например, по какой схеме он был включен?
Для примера, vsid (плеер .sid'ов из комплекта эмулятора C64 vice) имеет по дефолту частоту среза 21 кГц, и её можно менять. При работе жрёт примерно 20% ядра (на атлохе 3.5 Ггц).
Разницу ведь никто и не заметил бы, если б я об этом не рассказал. Так что эффект аудиофила налицо:)
— без интерполяции x1600
— с оптимизацией по скорости x1400
— с оптимизацией по качеству x320
Итого разница в 5 раз. Так что для слушания музыки через телефон хватает второго режима- все равно вокруг шумно, а батарейка гораздо экономнее расходуется.
По поводу z80stealth ничего сказать не могу- сорцов нет. Наверное, аффтар стесняеццо:)
Vitamin , Мощно!
А если серьезно, то ящитаю, что такие примочки- дело системы. Она в состоянии перехватить аудиопоток от любого приложения и обработать его как вздумается. В программе это делать мало того что бессмысленно, так еще и вредно.
Вопрос откуда возьмётся текстовый аналог psg предлагаю не обсуждать :)
P.S. В силу своей говнистости, я ожидал, что с проблемой синхронизации столкнется сам заказчик, клятвенно утверждавшего, что ничего больше не понадобится:) Но он до сих пор, похоже, ниасилил даже то что есть.
В другой работе — есть некоторые наработки прогрывания псг быстрее чем 50 раз в секунду.
В твоём туле это небольшое отклонение от нормы, кот. закроет оба случая.