APU и звук
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
APU и звук
Третья и последняя машина блока живёт прямо внутри CPU 2A03. APU это пять звуковых каналов, у каждого свой генератор: два прямоугольных, треугольный, шум и сэмплер DMC. Вместе они дают весь узнаваемый звук эпохи. Урок про то, как из счётчиков и таблиц рождается тон, и как довести этот тон без щелчков до колонок твоего компьютера.
Пять каналов
- Pulse 1 и 2: прямоугольная волна с выбором скважности, огибающей громкости и блоком sweep для плавного скольжения тона.
- Triangle: треугольная волна, басовая линия, без регулировки громкости.
- Noise: псевдослучайный шум через сдвиговый регистр, ударные и шипение.
- DMC: проигрывает дельта-кодированные сэмплы прямо из памяти картриджа.
APU устроен как набор счётчиков. Канал держит период (делитель частоты), а высота тона это частота кварца, делённая на период. Меняешь период, меняешь ноту. Сама структура APU это пять каналов плюс дирижёр (frame counter) и пара таблиц для микшера.
Мы соберём модуль src/apu/ снизу вверх: сначала два общих узла, на которые опираются каналы (счётчик длины и огибающая), потом сами каналы по одному, потом дирижёр кадра, и в самом конце mod.rs, который связывает всё в единый звуковой сопроцессор с нелинейным микшером и ресемплингом.
Добавь модуль
Открой src/lib.rs и подключи новый модуль рядом с уже готовыми CPU, PPU и картриджем:
pub mod apu;
Дальше создай папку src/apu/. Внутри будет mod.rs и по файлу на каждый канал и помощник: length.rs, envelope.rs, pulse.rs, triangle.rs, noise.rs, dmc.rs, frame_counter.rs. Пойдём по порядку зависимостей.
Счётчик длины
Первый общий узел: length counter. Это таймер длительности ноты, но считается он не в тактах процессора, а в кадрах frame counter. Пока счётчик больше нуля, канал звучит; на половинном такте кадра он уменьшается, и на нуле канал замолкает. Флаг halt замораживает счётчик, чтобы нота тянулась бесконечно.
Создай файл src/apu/length.rs:
//! Length counter: общий узел для всех каналов кроме DMC.
//!
//! Урок 47. Это таймер длительности ноты, выраженный не в тактах, а в кадрах
//! frame counter. Пока счётчик больше нуля, канал звучит; на половинном такте
//! кадра (half frame) счётчик уменьшается, и когда он дойдёт до нуля, канал
//! замолкает. Флаг halt (он же loop у envelope) замораживает счётчик, чтобы нота
//! тянулась бесконечно.
//!
//! Старшие 5 бит регистра длины это индекс в таблице ниже, а не само значение.
//! Так аппаратура экономила место: 32 музыкально удобные длительности вместо
//! линейной шкалы.
/// Таблица перезагрузки length counter (NESdev, индекс это биты 7..3 регистра).
const LENGTH_TABLE: [u8; 32] = [
10, 254, 20, 2, 40, 4, 80, 6, 160, 8, 60, 10, 14, 12, 26, 14, 12, 16, 24, 18, 48, 20, 96, 22,
192, 24, 72, 26, 16, 28, 32, 30,
];
/// Счётчик длительности ноты.
#[derive(Default)]
pub struct LengthCounter {
/// Текущее значение. Ноль означает молчание.
value: u8,
/// Флаг halt (он же loop у envelope). Замораживает счётчик.
halt: bool,
/// Включён ли канал через `$4015`. При выключении счётчик принудительно
/// обнуляется и не даёт себя перезагружать.
enabled: bool,
}
impl LengthCounter {
/// Установить флаг halt (берётся из регистра управления канала).
pub fn set_halt(&mut self, halt: bool) {
self.halt = halt;
}
/// Включить или выключить канал (запись в `$4015`). Выключение глушит ноту.
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
if !enabled {
self.value = 0;
}
}
/// Перезагрузить счётчик по старшим битам регистра длины. Если канал
/// выключен, запись игнорируется.
pub fn load(&mut self, register_value: u8) {
if self.enabled {
let index = (register_value >> 3) as usize;
self.value = LENGTH_TABLE[index];
}
}
/// Полутактовый шаг кадра: уменьшить счётчик, если он не заморожен.
pub fn clock(&mut self) {
if self.value > 0 && !self.halt {
self.value -= 1;
}
}
/// Звучит ли канал прямо сейчас (счётчик не дошёл до нуля).
pub fn active(&self) -> bool {
self.value > 0
}
}
Обрати внимание: старшие пять бит регистра длины это индекс в LENGTH_TABLE, а не само значение. Так чип за пять бит давал 32 музыкально удобные длительности. И флаг halt тут общий с флагом loop огибающей, его выставляет регистр управления канала.
Огибающая
Огибающая это второй общий узел: генератор громкости для pulse и noise. Она либо отдаёт постоянную громкость, либо плавно затухающую пилу от 15 к нулю. Без неё каждую ноту пришлось бы вести с CPU вручную. Это та же идея периодического тика, что таймеры из урока про MMIO, только она генерирует звук.
Создай файл src/apu/envelope.rs:
//! Envelope generator: общий узел громкости для pulse и noise.
//!
//! Урок 47. Envelope это либо постоянная громкость, либо плавно затухающая
//! пила от 15 к 0. Выбор делает флаг constant volume в регистре управления
//! канала. Когда громкость не постоянная, envelope каждую четверть кадра
//! (quarter frame) уменьшает внутренний счётчик decay, а флаг loop позволяет
//! ему перезапуститься с 15, давая повторяющееся затухание.
//!
//! Скорость затухания и уровень постоянной громкости это одно и то же поле
//! регистра (нижние 4 бита): аппаратура переиспользовала его в двух смыслах.
/// Генератор огибающей громкости.
#[derive(Default)]
pub struct Envelope {
/// Флаг постоянной громкости. Если стоит, на выход идёт `volume` напрямую.
constant: bool,
/// Флаг loop (он же halt у length counter). Перезапускает decay по кругу.
loop_flag: bool,
/// Нижние 4 бита регистра: либо период затухания, либо постоянная громкость.
volume: u8,
/// Флаг старта: на следующем такте envelope перезагрузит decay и делитель.
start: bool,
/// Делитель: считает такты до очередного шага decay.
divider: u8,
/// Текущий уровень затухающей пилы (15..0).
decay: u8,
}
impl Envelope {
/// Применить регистр управления (`$4000`, `$4004`, `$400C`).
/// Биты: 5 это loop, 4 это constant volume, 3..0 это volume/period.
pub fn write_control(&mut self, value: u8) {
self.loop_flag = value & 0x20 != 0;
self.constant = value & 0x10 != 0;
self.volume = value & 0x0F;
}
/// Запись регистра длины канала взводит флаг старта envelope.
pub fn restart(&mut self) {
self.start = true;
}
/// Четвертьтактовый шаг кадра: либо стартуем заново, либо двигаем decay.
pub fn clock(&mut self) {
if self.start {
self.start = false;
self.decay = 15;
self.divider = self.volume;
return;
}
if self.divider == 0 {
self.divider = self.volume;
if self.decay > 0 {
self.decay -= 1;
} else if self.loop_flag {
self.decay = 15;
}
} else {
self.divider -= 1;
}
}
/// Текущая громкость на выходе канала (0..15).
pub fn output(&self) -> u8 {
if self.constant {
self.volume
} else {
self.decay
}
}
}
Тонкость в одном поле: нижние четыре бита регистра управления это и скорость затухания, и уровень постоянной громкости одновременно. Что именно, решает флаг constant. Тактируется огибающая четвертьтактовыми импульсами кадра.
Прямоугольный канал
Pulse это прямоугольник с одной из четырёх скважностей (duty). Сама форма волны это таблица: единица значит высокий уровень в восьмишаговой последовательности. Высоту тона задаёт 11-битный период таймера, громкость это огибающая, а sweep плавно сдвигает период вверх или вниз, рисуя глиссандо.
Создай файл src/apu/pulse.rs:
//! Pulse channel: прямоугольная волна с duty, envelope и sweep.
//!
//! Урок 47. Два таких канала дают основные голоса NES. Форма волны это
//! прямоугольник с одной из четырёх скважностей (duty): 12.5, 25, 50 и 75
//! процентов. Высоту тона задаёт 11-битный период таймера; громкость это
//! envelope; sweep плавно сдвигает период вверх или вниз, рисуя глиссандо.
//!
//! Тонкость sweep: при вычитании period два канала ведут себя по-разному.
//! Pulse1 использует one's complement (вычитает на единицу больше), pulse2
//! two's complement. Из-за этого pulse1 не может опуститься так же низко, как
//! pulse2. Это не наша ошибка, это поведение реального чипа, и тесты на него
//! опираются.
use super::envelope::Envelope;
use super::length::LengthCounter;
/// Таблицы duty: единица это высокий уровень в восьмишаговой последовательности.
const DUTY_TABLE: [[u8; 8]; 4] = [
[0, 1, 0, 0, 0, 0, 0, 0], // 12.5%
[0, 1, 1, 0, 0, 0, 0, 0], // 25%
[0, 1, 1, 1, 1, 0, 0, 0], // 50%
[1, 0, 0, 1, 1, 1, 1, 1], // 75% (инверсия 25%)
];
/// Один прямоугольный канал. `is_pulse1` выбирает вариант negate у sweep.
pub struct Pulse {
is_pulse1: bool,
envelope: Envelope,
length: LengthCounter,
/// Индекс строки в `DUTY_TABLE` (0..3).
duty: u8,
/// Позиция внутри восьмишаговой последовательности duty.
duty_step: u8,
/// 11-битный период перезагрузки таймера.
timer_period: u16,
/// Текущее значение таймера, считает вниз до нуля.
timer: u16,
// --- sweep ---
sweep_enabled: bool,
sweep_period: u8,
sweep_negate: bool,
sweep_shift: u8,
sweep_reload: bool,
sweep_divider: u8,
}
impl Pulse {
/// Создать канал. `is_pulse1 = true` для канала на `$4000`, иначе `$4004`.
pub fn new(is_pulse1: bool) -> Self {
Pulse {
is_pulse1,
envelope: Envelope::default(),
length: LengthCounter::default(),
duty: 0,
duty_step: 0,
timer_period: 0,
timer: 0,
sweep_enabled: false,
sweep_period: 0,
sweep_negate: false,
sweep_shift: 0,
sweep_reload: false,
sweep_divider: 0,
}
}
/// Регистр `$4000` / `$4004`: duty, loop/halt, constant volume, громкость.
pub fn write_control(&mut self, value: u8) {
self.duty = value >> 6;
self.length.set_halt(value & 0x20 != 0);
self.envelope.write_control(value);
}
/// Регистр `$4001` / `$4005`: настройка sweep.
pub fn write_sweep(&mut self, value: u8) {
self.sweep_enabled = value & 0x80 != 0;
self.sweep_period = (value >> 4) & 0x07;
self.sweep_negate = value & 0x08 != 0;
self.sweep_shift = value & 0x07;
self.sweep_reload = true;
}
/// Регистр `$4002` / `$4006`: младшие 8 бит периода таймера.
pub fn write_timer_low(&mut self, value: u8) {
self.timer_period = (self.timer_period & 0x0700) | value as u16;
}
/// Регистр `$4003` / `$4007`: старшие 3 бита периода, перезагрузка длины,
/// сброс фазы duty и старт envelope.
pub fn write_timer_high(&mut self, value: u8) {
self.timer_period = (self.timer_period & 0x00FF) | (((value & 0x07) as u16) << 8);
self.length.load(value);
self.duty_step = 0;
self.envelope.restart();
}
/// Включить или выключить канал из `$4015`.
pub fn set_enabled(&mut self, enabled: bool) {
self.length.set_enabled(enabled);
}
/// Звучит ли length counter (для бита статуса `$4015`).
pub fn length_active(&self) -> bool {
self.length.active()
}
/// Такт таймера канала. Pulse тикает раз в два такта процессора, об этом
/// заботится вызывающий код в `mod.rs`. На нуле таймер перезагружается и
/// двигает позицию duty по кругу.
pub fn clock_timer(&mut self) {
if self.timer == 0 {
self.timer = self.timer_period;
self.duty_step = (self.duty_step + 1) & 7;
} else {
self.timer -= 1;
}
}
/// Четвертьтактовый шаг кадра двигает envelope.
pub fn clock_quarter_frame(&mut self) {
self.envelope.clock();
}
/// Полутактовый шаг кадра двигает length counter и sweep.
pub fn clock_half_frame(&mut self) {
self.length.clock();
self.clock_sweep();
}
/// Вычислить целевой период sweep с учётом negate (one's или two's
/// complement). Это и есть та самая разница между pulse1 и pulse2.
fn sweep_target(&self) -> u16 {
let change = self.timer_period >> self.sweep_shift;
if self.sweep_negate {
if self.is_pulse1 {
// one's complement: вычитаем на единицу больше.
self.timer_period.wrapping_sub(change).wrapping_sub(1)
} else {
// two's complement: обычное вычитание.
self.timer_period.wrapping_sub(change)
}
} else {
self.timer_period.wrapping_add(change)
}
}
/// Канал заглушается sweep, если текущий или целевой период вне допустимого
/// диапазона (меньше 8 или больше `0x7FF`).
fn sweep_muted(&self) -> bool {
self.timer_period < 8 || self.sweep_target() > 0x7FF
}
/// Полутактовый шаг sweep: при готовности делителя сдвигаем период.
fn clock_sweep(&mut self) {
if self.sweep_divider == 0
&& self.sweep_enabled
&& self.sweep_shift > 0
&& !self.sweep_muted()
{
self.timer_period = self.sweep_target();
}
if self.sweep_divider == 0 || self.sweep_reload {
self.sweep_divider = self.sweep_period;
self.sweep_reload = false;
} else {
self.sweep_divider -= 1;
}
}
/// Текущий отсчёт канала (0..15). Ноль означает молчание: канал молчит при
/// слишком коротком или длинном периоде, при нулевой длине или на низком
/// уровне duty.
pub fn output(&self) -> u8 {
if self.timer_period < 8 || self.timer_period > 0x7FF {
return 0;
}
if !self.length.active() {
return 0;
}
if DUTY_TABLE[self.duty as usize][self.duty_step as usize] == 0 {
return 0;
}
self.envelope.output()
}
}
Таймер канала на нуле перезагружается и двигает позицию в DUTY_TABLE по кругу. Выход собирается из трёх условий: канал молчит, если период вне диапазона (< 8 или > 0x7FF), если длина ноты вышла, или если текущий шаг duty низкий. Иначе на выход идёт громкость огибающей.
Отдельная тонкость в sweep_target: pulse1 при отрицательном сдвиге вычитает на единицу больше (one’s complement), а pulse2 вычитает обычно (two’s complement). Из-за этого pulse1 не может опуститься так же низко, как pulse2. Это не баг, а поведение реального чипа, и тесты на него опираются.
Высота тона выходит из периода по формуле частота = 1789773 / (16 * (period + 1)). Покрути виджет: меняй скважность и период, смотри, как форма волны и частота меняются вместе, и как короткий период (высокий тон) глушит канал.
Треугольный канал
Triangle даёт мягкий басовый голос. Форма волны это 32 ступени: счёт 15 вниз до 0 и обратно от 0 вверх до 15. Громкость у triangle не регулируется (огибающей тут нет), есть только включено или выключено. И таймер triangle тикает на каждом такте процессора, а не через один, как у pulse, поэтому он берёт верхние октавы.
Создай файл src/apu/triangle.rs:
//! Triangle channel: ступенчатая треугольная волна.
//!
//! Урок 47. Этот канал даёт мягкий басовый голос. Форма волны это 32 ступени,
//! считающие 15 вниз до 0 и обратно 0 вверх до 15. Громкость у triangle не
//! регулируется (envelope тут нет), есть только включено или выключено, поэтому
//! на слух это ровный треугольник.
//!
//! У triangle два сторожа: length counter (как у всех) и linear counter, его
//! личный тонкий таймер на четвертьтактах кадра. Канал звучит, только когда оба
//! счётчика ненулевые. И важная деталь: таймер triangle тикает на каждом такте
//! процессора, а не через один, как у pulse, поэтому он берёт верхние октавы.
use super::length::LengthCounter;
/// 32-шаговая треугольная последовательность (15..0, затем 0..15).
const TRIANGLE_SEQUENCE: [u8; 32] = [
15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
13, 14, 15,
];
/// Треугольный канал.
#[derive(Default)]
pub struct Triangle {
length: LengthCounter,
/// 11-битный период таймера.
timer_period: u16,
/// Текущее значение таймера.
timer: u16,
/// Позиция в 32-шаговой последовательности.
sequence_step: u8,
// --- linear counter ---
/// Флаг control (он же halt length counter).
linear_control: bool,
/// Значение перезагрузки linear counter.
linear_reload_value: u8,
/// Текущее значение linear counter.
linear_value: u8,
/// Флаг перезагрузки, взводится записью в `$400B`.
linear_reload: bool,
}
impl Triangle {
/// Регистр `$4008`: флаг control/halt и значение перезагрузки linear counter.
pub fn write_control(&mut self, value: u8) {
self.linear_control = value & 0x80 != 0;
self.linear_reload_value = value & 0x7F;
self.length.set_halt(self.linear_control);
}
/// Регистр `$400A`: младшие 8 бит периода таймера.
pub fn write_timer_low(&mut self, value: u8) {
self.timer_period = (self.timer_period & 0x0700) | value as u16;
}
/// Регистр `$400B`: старшие 3 бита периода, перезагрузка длины и взвод
/// флага перезагрузки linear counter.
pub fn write_timer_high(&mut self, value: u8) {
self.timer_period = (self.timer_period & 0x00FF) | (((value & 0x07) as u16) << 8);
self.length.load(value);
self.linear_reload = true;
}
/// Включить или выключить канал из `$4015`.
pub fn set_enabled(&mut self, enabled: bool) {
self.length.set_enabled(enabled);
}
/// Звучит ли length counter (для бита статуса `$4015`).
pub fn length_active(&self) -> bool {
self.length.active()
}
/// Такт таймера: тикает на каждом такте процессора. На нуле перезагружается
/// и двигает последовательность, но только если оба счётчика живы, иначе
/// волна замирает на текущей ступени (так чип не щёлкает при выключении).
pub fn clock_timer(&mut self) {
if self.timer == 0 {
self.timer = self.timer_period;
if self.length.active() && self.linear_value > 0 {
self.sequence_step = (self.sequence_step + 1) & 31;
}
} else {
self.timer -= 1;
}
}
/// Четвертьтактовый шаг кадра двигает linear counter.
pub fn clock_quarter_frame(&mut self) {
if self.linear_reload {
self.linear_value = self.linear_reload_value;
} else if self.linear_value > 0 {
self.linear_value -= 1;
}
if !self.linear_control {
self.linear_reload = false;
}
}
/// Полутактовый шаг кадра двигает length counter.
pub fn clock_half_frame(&mut self) {
self.length.clock();
}
/// Текущий отсчёт канала (0..15). При мёртвых счётчиках канал молчит. На
/// очень коротких периодах (фактически ультразвук) приглушаем волну, чтобы
/// не было резкого писка: реальный чип на таких частотах отдаёт почти
/// постоянный уровень.
pub fn output(&self) -> u8 {
if !self.length.active() || self.linear_value == 0 {
// Замираем на текущей ступени, без щелчка.
return TRIANGLE_SEQUENCE[self.sequence_step as usize];
}
if self.timer_period < 2 {
// Ультразвук: возвращаем середину, фактически глушим.
return 7;
}
TRIANGLE_SEQUENCE[self.sequence_step as usize]
}
}
У triangle два сторожа: уже знакомый length counter и личный linear counter на четвертьтактах кадра. Канал двигает последовательность только когда оба ненулевые, иначе волна замирает на текущей ступени (так чип не щёлкает при выключении).
Шумовой канал
Noise даёт ударные и взрывы. Источник случайности это LFSR (linear feedback shift register) на 15 бит: на каждый такт он сдвигается, а новый старший бит получается как XOR двух его битов (tap). Какие биты берём в XOR, решает режим: обычный режим это биты 0 и 1, режим 2 это биты 0 и 6. Режим 2 даёт более металлический, почти тональный звук.
Создай файл src/apu/noise.rs:
//! Noise channel: псевдослучайный шум на сдвиговом регистре.
//!
//! Урок 47. Этот канал даёт ударные и взрывы. Источник случайности это LFSR
//! (linear feedback shift register) на 15 бит: на каждый такт он сдвигается, а
//! новый старший бит получается как XOR двух его битов (tap). Какие именно биты
//! берём в XOR, решает режим: в обычном режиме это биты 0 и 1, в режиме 2 это
//! биты 0 и 6. Режим 2 даёт более металлический, почти тональный звук.
//!
//! Период берётся из таблицы (как у length, тут готовые музыкальные значения).
//! Громкость задаёт envelope, длительность это length counter.
use super::envelope::Envelope;
use super::length::LengthCounter;
/// Таблица периодов noise для NTSC (NESdev), индекс это нижние 4 бита `$400E`.
const NOISE_PERIODS: [u16; 16] = [
4, 8, 16, 32, 64, 96, 128, 160, 202, 254, 380, 508, 762, 1016, 2034, 4068,
];
/// Шумовой канал.
pub struct Noise {
envelope: Envelope,
length: LengthCounter,
/// Режим периода: false это tap по биту 1, true это tap по биту 6.
mode: bool,
timer_period: u16,
timer: u16,
/// 15-битный сдвиговый регистр. Стартует с 1, иначе залип бы в нуле навсегда.
shift: u16,
}
impl Default for Noise {
fn default() -> Self {
Noise {
envelope: Envelope::default(),
length: LengthCounter::default(),
mode: false,
timer_period: NOISE_PERIODS[0],
timer: NOISE_PERIODS[0],
// LFSR обязан стартовать ненулевым, единица это канонический сид.
shift: 1,
}
}
}
impl Noise {
/// Регистр `$400C`: loop/halt, constant volume и громкость envelope.
pub fn write_control(&mut self, value: u8) {
self.length.set_halt(value & 0x20 != 0);
self.envelope.write_control(value);
}
/// Регистр `$400E`: бит 7 это режим периода, нижние 4 бита это индекс
/// в таблице периодов.
pub fn write_mode_period(&mut self, value: u8) {
self.mode = value & 0x80 != 0;
self.timer_period = NOISE_PERIODS[(value & 0x0F) as usize];
}
/// Регистр `$400F`: перезагрузка длины и старт envelope.
pub fn write_length(&mut self, value: u8) {
self.length.load(value);
self.envelope.restart();
}
/// Включить или выключить канал из `$4015`.
pub fn set_enabled(&mut self, enabled: bool) {
self.length.set_enabled(enabled);
}
/// Звучит ли length counter (для бита статуса `$4015`).
pub fn length_active(&self) -> bool {
self.length.active()
}
/// Такт таймера: тикает раз в два такта процессора (как pulse). На нуле
/// сдвигает LFSR и перезагружается. Новый бит обратной связи это XOR бита 0
/// и либо бита 1 (обычный режим), либо бита 6 (режим 2).
pub fn clock_timer(&mut self) {
if self.timer == 0 {
self.timer = self.timer_period;
let other_bit = if self.mode {
(self.shift >> 6) & 1
} else {
(self.shift >> 1) & 1
};
let feedback = (self.shift & 1) ^ other_bit;
self.shift >>= 1;
self.shift |= feedback << 14;
} else {
self.timer -= 1;
}
}
/// Четвертьтактовый шаг кадра двигает envelope.
pub fn clock_quarter_frame(&mut self) {
self.envelope.clock();
}
/// Полутактовый шаг кадра двигает length counter.
pub fn clock_half_frame(&mut self) {
self.length.clock();
}
/// Текущий отсчёт канала (0..15). Канал молчит, если бит 0 LFSR равен 1 или
/// length counter дошёл до нуля.
pub fn output(&self) -> u8 {
if self.shift & 1 == 1 || !self.length.active() {
return 0;
}
self.envelope.output()
}
}
Ключевая деталь в Default: сдвиговый регистр стартует с единицы, не с нуля. Нулевой LFSR залип бы в нуле навсегда (XOR нулей это ноль), и шум бы не звучал. Период noise берётся из таблицы готовых музыкальных значений, как и у length counter.
Канал DMC
DMC (delta modulation channel) проигрывает заранее записанный звук из памяти картриджа: голоса, барабаны. Идея дельта-кодирования простая: у канала есть 7-битный выходной уровень, и поток бит говорит ему только «прибавь два» или «убавь два».
Создай файл src/apu/dmc.rs:
//! DMC channel: воспроизведение дельта-кодированных сэмплов.
//!
//! Урок 47. DMC (delta modulation channel) проигрывает заранее записанный звук
//! из памяти картриджа: голоса, барабаны. Идея дельта-кодирования простая. У
//! канала есть 7-битный выходной уровень, и поток бит говорит ему только
//! «прибавь два» или «убавь два». Так из одного бита на сэмпл получается грубая,
//! но узнаваемая запись.
//!
//! Сознательное упрощение учебного эмулятора. Настоящий DMC по таймеру читает
//! байты сэмплов прямо с шины процессора (это даже крадёт у CPU такты). У нас в
//! этом модуле нет доступа к шине, и тянуть его сюда мы не хотим: это разрушило
//! бы слоистую архитектуру. Поэтому мы честно реализуем регистры, таймер скорости
//! и флаг IRQ при завершении, но подачу новых байтов сэмплов из памяти оставляем
//! заглушкой: когда счётчик оставшихся байтов доходит до нуля, мы либо
//! зацикливаемся, либо поднимаем IRQ, ровно как настоящий чип на конце сэмпла.
//! Сам выходной уровень при этом меняется только программно (через `$4011`), а не
//! из потока памяти. Для учебных целей этого достаточно: вся логика тактирования,
//! length и прерываний видна, а доступа к шине не требуется.
/// Таблица периодов скорости DMC для NTSC (NESdev), индекс это нижние 4 бита
/// регистра `$4010`. Значения в тактах процессора между шагами.
const DMC_RATES: [u16; 16] = [
428, 380, 340, 320, 286, 254, 226, 214, 190, 160, 142, 128, 106, 84, 72, 54,
];
/// Канал дельта-модуляции.
#[derive(Default)]
pub struct Dmc {
/// Флаг IRQ по завершении сэмпла (бит 7 `$4010`).
irq_enabled: bool,
/// Флаг зацикливания сэмпла (бит 6 `$4010`).
loop_flag: bool,
/// Период таймера скорости (в тактах процессора).
timer_period: u16,
/// Текущее значение таймера.
timer: u16,
/// 7-битный выходной уровень (0..127). Задаётся `$4011` и заглушкой потока.
output_level: u8,
/// Стартовая длина сэмпла в байтах (из `$4013`, формула `len * 16 + 1`).
sample_length: u16,
/// Сколько байт сэмпла осталось проиграть.
bytes_remaining: u16,
/// Включён ли канал (бит 4 `$4015`).
enabled: bool,
/// Поднятый флаг прерывания (читается в `$4015`, держит линию IRQ).
interrupt: bool,
}
impl Dmc {
/// Создать канал в исходном состоянии.
pub fn new() -> Self {
Dmc {
timer_period: DMC_RATES[0],
timer: DMC_RATES[0],
..Dmc::default()
}
}
/// Регистр `$4010`: IRQ-флаг, loop и индекс скорости.
pub fn write_control(&mut self, value: u8) {
self.irq_enabled = value & 0x80 != 0;
self.loop_flag = value & 0x40 != 0;
self.timer_period = DMC_RATES[(value & 0x0F) as usize];
// Снятие IRQ-флага гасит уже поднятое прерывание (поведение чипа).
if !self.irq_enabled {
self.interrupt = false;
}
}
/// Регистр `$4011`: прямая загрузка 7-битного выходного уровня.
pub fn write_direct_load(&mut self, value: u8) {
self.output_level = value & 0x7F;
}
/// Регистр `$4012`: стартовый адрес сэмпла. В учебном упрощении адрес нам не
/// нужен (мы не читаем память), но регистр принимаем, чтобы запись не терялась.
pub fn write_sample_address(&mut self, _value: u8) {
// Сознательная заглушка: чтения с шины нет, адрес не используется.
}
/// Регистр `$4013`: длина сэмпла в байтах по формуле `value * 16 + 1`.
pub fn write_sample_length(&mut self, value: u8) {
self.sample_length = (value as u16) * 16 + 1;
}
/// Включить или выключить канал из `$4015`. Включение перезапускает сэмпл,
/// если он не играет; выключение обнуляет остаток. Запись всегда снимает IRQ.
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
self.interrupt = false;
if !enabled {
self.bytes_remaining = 0;
} else if self.bytes_remaining == 0 {
self.bytes_remaining = self.sample_length;
}
}
/// Играет ли сэмпл прямо сейчас (для бита статуса `$4015`).
pub fn active(&self) -> bool {
self.bytes_remaining > 0
}
/// Запрашивает ли DMC прерывание (для линии IRQ и статуса `$4015`).
pub fn irq_pending(&self) -> bool {
self.interrupt
}
/// Такт таймера скорости. На нуле «проигрывается» один байт сэмпла. Реальный
/// чип тут читал бы байт из памяти и крутил выходной уровень по битам; в
/// учебной заглушке мы просто уменьшаем счётчик оставшихся байтов и на конце
/// сэмпла либо зацикливаемся, либо поднимаем IRQ.
pub fn clock_timer(&mut self) {
if self.timer == 0 {
self.timer = self.timer_period;
self.step_sample();
} else {
self.timer -= 1;
}
}
/// Шаг проигрывания одного байта сэмпла (заглушка потока памяти).
fn step_sample(&mut self) {
if self.bytes_remaining == 0 {
return;
}
self.bytes_remaining -= 1;
if self.bytes_remaining == 0 {
if self.loop_flag {
self.bytes_remaining = self.sample_length;
} else if self.irq_enabled {
self.interrupt = true;
}
}
}
/// Текущий 7-битный выходной уровень канала (0..127).
pub fn output(&self) -> u8 {
self.output_level
}
}
Тут стоит сознательное упрощение учебного эмулятора. Настоящий DMC по таймеру читает байты сэмплов прямо с шины процессора, иногда даже крадёт у CPU такты. У нас в этом модуле нет доступа к шине, и тянуть его сюда мы не хотим: это разрушило бы слоистую архитектуру. Поэтому мы честно реализуем регистры, таймер скорости и флаг IRQ при завершении, но подачу новых байтов из памяти оставляем заглушкой. Выходной уровень меняется только программно через $4011. Для учебных целей этого хватает: вся логика тактирования, длины и прерываний видна.
Дирижёр кадра
Frame counter сам не знает про звук, он только раздаёт импульсы по расписанию в тактах процессора. Каналы реагируют на флаги: quarter frame двигает огибающие и linear counter, half frame дополнительно двигает length counter и sweep. У счётчика два режима: 4-step с frame IRQ в конце цикла и 5-step без IRQ, но с более длинным циклом.
Создай файл src/apu/frame_counter.rs:
//! Frame counter: дирижёр APU на регистре `$4017`.
//!
//! Урок 47. Сами каналы не знают, когда двигать envelope или length: им нужен
//! общий метроном. Это frame counter. Он отсчитывает такты процессора и в
//! заранее заданные моменты выдаёт два вида импульсов:
//!
//! - quarter frame (четверть кадра): двигает envelope у pulse/noise и linear
//! counter у triangle;
//! - half frame (половина кадра): дополнительно двигает length counter и sweep.
//!
//! У счётчика два режима. В режиме 4-step он за кадр выдаёт четыре quarter и два
//! half такта, а в конце поднимает frame IRQ (если бит 6 `$4017` не маскирует
//! прерывание). В режиме 5-step тактов больше, цикл длиннее, и IRQ не бывает
//! вовсе. Запись в `$4017` сбрасывает счётчик, а в режиме 5-step ещё и сразу
//! выдаёт одинарный quarter плюс half такт.
//!
//! Интервалы заданы в тактах процессора по NESdev (NTSC). Мы считаем по фронту
//! целого такта; полутактовые тонкости реального чипа для учебного эмулятора
//! опущены, на слух и на тесты это не влияет.
/// Что frame counter просит сделать на этом такте. Каналы реагируют на флаги.
#[derive(Default, Clone, Copy)]
pub struct FrameClock {
/// Двигать envelope и linear counter.
pub quarter: bool,
/// Двигать length counter и sweep.
pub half: bool,
}
/// Дирижёр APU. По умолчанию режим 4-step, без маски IRQ, счётчик с нуля.
#[derive(Default)]
pub struct FrameCounter {
/// false это режим 4-step, true это режим 5-step (бит 7 `$4017`).
five_step: bool,
/// Маска прерывания (бит 6 `$4017`): если стоит, frame IRQ не поднимается.
irq_inhibit: bool,
/// Счётчик тактов процессора от начала цикла.
cycle: u32,
/// Поднятый флаг frame IRQ (сбрасывается чтением `$4015`).
interrupt: bool,
}
impl FrameCounter {
/// Запись в `$4017`. Биты: 7 это режим, 6 это маска IRQ. Сброс счётчика; в
/// режиме 5-step немедленный quarter и half такт.
pub fn write(&mut self, value: u8) -> FrameClock {
self.five_step = value & 0x80 != 0;
self.irq_inhibit = value & 0x40 != 0;
if self.irq_inhibit {
self.interrupt = false;
}
self.cycle = 0;
if self.five_step {
FrameClock {
quarter: true,
half: true,
}
} else {
FrameClock::default()
}
}
/// Поднят ли frame IRQ прямо сейчас (для линии IRQ и статуса `$4015`).
pub fn irq_pending(&self) -> bool {
self.interrupt
}
/// Снять флаг frame IRQ (чтение `$4015` его сбрасывает).
pub fn clear_irq(&mut self) {
self.interrupt = false;
}
/// Такт процессора. Возвращает, какие импульсы выдать каналам на этом такте.
pub fn clock(&mut self) -> FrameClock {
self.cycle += 1;
if self.five_step {
self.clock_five_step()
} else {
self.clock_four_step()
}
}
/// Режим 4-step: quarter на 7457, 14913, 22371, 29829; half на 14913 и
/// 29829; frame IRQ на 29829 (если не замаскирован). Цикл это 29830 тактов.
fn clock_four_step(&mut self) -> FrameClock {
let mut clock = FrameClock::default();
match self.cycle {
7457 => clock.quarter = true,
14913 => {
clock.quarter = true;
clock.half = true;
}
22371 => clock.quarter = true,
29829 => {
clock.quarter = true;
clock.half = true;
if !self.irq_inhibit {
self.interrupt = true;
}
}
29830 => self.cycle = 0,
_ => {}
}
clock
}
/// Режим 5-step: quarter на 7457, 14913, 22371, 37281; half на 14913 и
/// 37281; IRQ не поднимается. Цикл это 37282 такта.
fn clock_five_step(&mut self) -> FrameClock {
let mut clock = FrameClock::default();
match self.cycle {
7457 => clock.quarter = true,
14913 => {
clock.quarter = true;
clock.half = true;
}
22371 => clock.quarter = true,
37281 => {
clock.quarter = true;
clock.half = true;
}
37282 => self.cycle = 0,
_ => {}
}
clock
}
}
Эти точные числа (7457, 14913, 22371, 29829) и есть «тайминг звука». Если их сдвинуть, тон поплывёт и затухания собьются. Frame counter сам не звучит, он только раздаёт FrameClock каналам, а уже они решают, что с этими импульсами делать.
Сборка APU и ресемплинг
Остался mod.rs: он подключает все файлы, держит пять каналов и дирижёра, и связывает их в единый сопроцессор. APU тикает на частоте процессора, примерно 1.79 МГц, а звуковая карта хочет 44.1 или 48 кГц. Между ними пропасть, и через неё надо перекинуть мост. На каждый системный такт каналы выдают число, микшер складывает их, а ресемплер набирает такты и при переполнении кладёт один готовый сэмпл хоста.
Создай файл src/apu/mod.rs:
//! APU: звуковая часть процессора 2A03.
//!
//! Урок 47. APU (audio processing unit) живёт прямо внутри чипа 2A03 рядом с
//! ядром 6502. У него пять голосов: два прямоугольных канала (pulse), один
//! треугольный (triangle), один шумовой (noise) и канал дельта-сэмплов (DMC).
//! Программа управляет ими через регистры `$4000..$4017`, а общий метроном
//! (frame counter на `$4017`) дирижирует огибающими и длительностями нот.
//!
//! Тактирование. APU работает на частоте процессора (NTSC примерно 1.789773
//! MHz). На каждый такт процессора мы двигаем frame counter, каналы и таймеры,
//! считаем мгновенный отсчёт микшера и решаем, не пора ли положить готовый
//! сэмпл в выходной буфер с частотой хоста (например 44100 Гц).
//!
//! Тонкость частоты. Triangle и DMC тикают на каждом такте процессора, а pulse
//! и noise через один (их таймер работает на половинной частоте). Поэтому у нас
//! есть флаг чётности `odd_cycle`.
//!
//! Микширование нелинейное. Уши не складывают громкости линейно, да и сам чип
//! смешивает каналы через резисторную сетку. Мы берём формулы микса из NESdev:
//! отдельно группа pulse, отдельно группа triangle/noise/DMC, и складываем. На
//! выходе моно-сигнал примерно в диапазоне 0.0..1.0.
mod dmc;
mod envelope;
mod frame_counter;
mod length;
mod noise;
mod pulse;
mod triangle;
use dmc::Dmc;
use frame_counter::FrameCounter;
use noise::Noise;
use pulse::Pulse;
use triangle::Triangle;
/// Тактовая частота процессора NTSC в герцах.
const CPU_CLOCK_NTSC: f32 = 1_789_773.0;
/// Звуковой сопроцессор NES.
pub struct Apu {
pulse1: Pulse,
pulse2: Pulse,
triangle: Triangle,
noise: Noise,
dmc: Dmc,
frame_counter: FrameCounter,
/// Чётность такта процессора: pulse и noise тикают только по нечётным.
odd_cycle: bool,
// --- ресемплинг ---
/// Сколько тактов процессора приходится на один выходной сэмпл хоста.
cycles_per_sample: f32,
/// Дробный аккумулятор тактов до следующего сэмпла.
sample_accumulator: f32,
/// Готовые моно-сэмплы, ждущие, когда их заберёт хост.
samples: Vec<f32>,
// --- предрасчитанные таблицы микшера ---
/// Таблица выхода группы pulse по сумме отсчётов (0..30).
pulse_table: [f32; 31],
/// Таблица выхода группы triangle/noise/DMC по линейному индексу.
tnd_table: [f32; 203],
}
impl Apu {
/// Создать APU. `sample_rate` это частота вывода хоста (например 44100.0).
pub fn new(sample_rate: f32) -> Self {
Apu {
pulse1: Pulse::new(true),
pulse2: Pulse::new(false),
triangle: Triangle::default(),
noise: Noise::default(),
dmc: Dmc::new(),
frame_counter: FrameCounter::default(),
odd_cycle: false,
cycles_per_sample: CPU_CLOCK_NTSC / sample_rate,
sample_accumulator: 0.0,
samples: Vec::new(),
pulse_table: build_pulse_table(),
tnd_table: build_tnd_table(),
}
}
/// Запись регистра APU. Маршрутизируем `$4000..=$4013`, `$4015` и `$4017`.
pub fn write_register(&mut self, addr: u16, value: u8) {
match addr {
0x4000 => self.pulse1.write_control(value),
0x4001 => self.pulse1.write_sweep(value),
0x4002 => self.pulse1.write_timer_low(value),
0x4003 => self.pulse1.write_timer_high(value),
0x4004 => self.pulse2.write_control(value),
0x4005 => self.pulse2.write_sweep(value),
0x4006 => self.pulse2.write_timer_low(value),
0x4007 => self.pulse2.write_timer_high(value),
0x4008 => self.triangle.write_control(value),
0x400A => self.triangle.write_timer_low(value),
0x400B => self.triangle.write_timer_high(value),
0x400C => self.noise.write_control(value),
0x400E => self.noise.write_mode_period(value),
0x400F => self.noise.write_length(value),
0x4010 => self.dmc.write_control(value),
0x4011 => self.dmc.write_direct_load(value),
0x4012 => self.dmc.write_sample_address(value),
0x4013 => self.dmc.write_sample_length(value),
0x4015 => self.write_status(value),
0x4017 => self.write_frame_counter(value),
// Прочие адреса в окне APU (например неиспользуемый $4009, $400D)
// молча игнорируем, как и реальный чип.
_ => {}
}
}
/// Запись `$4015`: включение и выключение каналов по битам 0..4.
fn write_status(&mut self, value: u8) {
self.pulse1.set_enabled(value & 0x01 != 0);
self.pulse2.set_enabled(value & 0x02 != 0);
self.triangle.set_enabled(value & 0x04 != 0);
self.noise.set_enabled(value & 0x08 != 0);
self.dmc.set_enabled(value & 0x10 != 0);
}
/// Запись `$4017`: настройка frame counter с возможным немедленным тактом.
fn write_frame_counter(&mut self, value: u8) {
let clock = self.frame_counter.write(value);
self.apply_frame_clock(clock);
}
/// Чтение статуса `$4015`. Биты 0..4 это активность length-счётчиков (и
/// сэмпла DMC), бит 6 это frame IRQ, бит 7 это DMC IRQ. Чтение сбрасывает
/// frame IRQ (но не DMC IRQ).
pub fn read_status(&mut self) -> u8 {
let mut status = 0u8;
if self.pulse1.length_active() {
status |= 0x01;
}
if self.pulse2.length_active() {
status |= 0x02;
}
if self.triangle.length_active() {
status |= 0x04;
}
if self.noise.length_active() {
status |= 0x08;
}
if self.dmc.active() {
status |= 0x10;
}
if self.frame_counter.irq_pending() {
status |= 0x40;
}
if self.dmc.irq_pending() {
status |= 0x80;
}
// Чтение статуса сбрасывает именно frame IRQ.
self.frame_counter.clear_irq();
status
}
/// Продвинуть APU ровно на один такт процессора.
pub fn tick(&mut self) {
// 1. Метроном кадра двигает огибающие и длительности.
let clock = self.frame_counter.clock();
self.apply_frame_clock(clock);
// 2. Таймеры каналов. Triangle и DMC на каждом такте, pulse и noise
// через один (по нечётным тактам).
self.triangle.clock_timer();
self.dmc.clock_timer();
if self.odd_cycle {
self.pulse1.clock_timer();
self.pulse2.clock_timer();
self.noise.clock_timer();
}
self.odd_cycle = !self.odd_cycle;
// 3. Ресемплинг: набираем такты и при переполнении пишем сэмпл хоста.
self.sample_accumulator += 1.0;
if self.sample_accumulator >= self.cycles_per_sample {
self.sample_accumulator -= self.cycles_per_sample;
let sample = self.mix();
self.samples.push(sample);
}
}
/// Раздать импульсы кадра всем каналам.
fn apply_frame_clock(&mut self, clock: frame_counter::FrameClock) {
if clock.quarter {
self.pulse1.clock_quarter_frame();
self.pulse2.clock_quarter_frame();
self.triangle.clock_quarter_frame();
self.noise.clock_quarter_frame();
}
if clock.half {
self.pulse1.clock_half_frame();
self.pulse2.clock_half_frame();
self.triangle.clock_half_frame();
self.noise.clock_half_frame();
}
}
/// Запрашивает ли APU прерывание IRQ (frame counter или DMC).
pub fn irq_pending(&self) -> bool {
self.frame_counter.irq_pending() || self.dmc.irq_pending()
}
/// Забрать накопленные сэмплы (моно, f32 примерно 0.0..1.0) и очистить буфер.
pub fn take_samples(&mut self) -> Vec<f32> {
std::mem::take(&mut self.samples)
}
/// Перенастроить частоту вывода под устройство хоста (CoreAudio часто 48000, а
/// не 44100). Сбрасывает накопитель ресемплинга и буфер сэмплов.
pub fn set_sample_rate(&mut self, sample_rate: f32) {
self.cycles_per_sample = CPU_CLOCK_NTSC / sample_rate;
self.sample_accumulator = 0.0;
self.samples.clear();
}
/// Нелинейный микс пяти каналов в один моно-отсчёт по таблицам NESdev.
fn mix(&self) -> f32 {
let p1 = self.pulse1.output() as usize;
let p2 = self.pulse2.output() as usize;
let t = self.triangle.output() as usize;
let n = self.noise.output() as usize;
let d = self.dmc.output() as usize;
let pulse_out = self.pulse_table[p1 + p2];
// Индекс группы tnd по той же формуле комбинации, что и в таблице.
let tnd_out = self.tnd_table[3 * t + 2 * n + d];
pulse_out + tnd_out
}
}
impl Default for Apu {
fn default() -> Self {
Apu::new(44100.0)
}
}
/// Предрасчёт таблицы группы pulse: индекс это сумма отсчётов двух каналов
/// (0..30). Формула NESdev: `95.88 / (8128 / (p1 + p2) + 100)`.
fn build_pulse_table() -> [f32; 31] {
let mut table = [0.0f32; 31];
for (sum, slot) in table.iter_mut().enumerate() {
*slot = if sum == 0 {
0.0
} else {
95.88 / (8128.0 / sum as f32 + 100.0)
};
}
table
}
/// Предрасчёт таблицы группы triangle/noise/DMC. Индекс это линейная комбинация
/// `3*t + 2*n + d` (максимум `3*15 + 2*15 + 127 = 202`). NESdev даёт точную
/// нелинейную формулу `159.79 / (1 / (t/8227 + n/12241 + d/22638) + 100)`, но
/// для неё и для готовой целочисленной таблицы существует удобное линейное
/// приближение `163.67 / (24329 / index + 100)`, где `index = 3*t + 2*n + d`.
/// Именно его мы и кэшируем: на слух и на наши тесты разница незаметна, а индекс
/// получается целым и без щелчков по краям.
fn build_tnd_table() -> [f32; 203] {
let mut table = [0.0f32; 203];
for (index, slot) in table.iter_mut().enumerate() {
*slot = if index == 0 {
0.0
} else {
163.67 / (24329.0 / index as f32 + 100.0)
};
}
table
}
Разбери tick по шагам. Сначала метроном кадра двигает огибающие и длительности. Потом таймеры каналов: triangle и DMC на каждом такте, а pulse и noise через один (по нечётным тактам, за это отвечает odd_cycle). И наконец ресемплинг: набираем такты в дробный аккумулятор, и при переполнении кладём один готовый сэмпл хоста.
Микширование не линейное: уши не складывают громкости в лоб, да и сам чип смешивает каналы через резисторную сетку. Поэтому выход считается по двум предрасчитанным таблицам из NESdev, отдельно группа pulse, отдельно triangle/noise/DMC. Сами таблицы строятся в build_pulse_table и build_tnd_table один раз при создании APU.
В эталоне поверх этого потока живёт вывод на устройство через cpal, а в браузере тот же поток сэмплов уходит в Web Audio. Один и тот же код, две поверхности вывода, ровно как мы соберём в финале блока.
Проверка
Создай файл tests/apu.rs:
//! Интеграционные тесты APU (урок 47).
//!
//! Проверяем то, что поддаётся детерминированной проверке без реального звука:
//! поведение length counter, глушение через `$4015`, генерацию и сброс frame
//! IRQ, объём накопленных сэмплов при ресемплинге и живучесть LFSR шума.
use our_nes::apu::Apu;
/// Удобный помощник: продвинуть APU на `n` тактов процессора.
fn tick_many(apu: &mut Apu, n: u32) {
for _ in 0..n {
apu.tick();
}
}
#[test]
fn length_counter_glushit_kanal_pri_dostizhenii_nulya() {
// Включаем pulse1 через $4015, задаём короткую длину и нормальный период.
let mut apu = Apu::new(44100.0);
apu.write_register(0x4015, 0x01); // включить pulse1
apu.write_register(0x4000, 0x10); // constant volume, halt снят (бит 5 = 0)
apu.write_register(0x4002, 0x00); // период low
apu.write_register(0x4003, 0x08); // период high + перезагрузка длины
// Сразу после загрузки канал должен числиться активным.
assert_ne!(
apu.read_status() & 0x01,
0,
"сразу после загрузки длины канал жив"
);
// Length counter уменьшается на половинных тактах кадра. Перезагрузим его:
// старшие 5 бит регистра это индекс в таблице длин.
apu.write_register(0x4003, 0b1100_0000); // перезагрузка длины
assert_ne!(
apu.read_status() & 0x01,
0,
"после перезагрузки канал снова жив"
);
// Гоним много тактов. Каждый кадр 4-step даёт два half-такта, а длина из
// таблицы это десятки шагов, поэтому берём с большим запасом по кадрам.
tick_many(&mut apu, 30_000 * 200);
assert_eq!(
apu.read_status() & 0x01,
0,
"после долгой работы length counter должен обнулиться и заглушить канал"
);
}
#[test]
fn zapis_4015_nol_glushit_vse_kanaly() {
let mut apu = Apu::new(44100.0);
// Включаем все четыре канала с length и задаём длины.
apu.write_register(0x4015, 0x0F);
apu.write_register(0x4003, 0x08); // pulse1 length
apu.write_register(0x4007, 0x08); // pulse2 length
apu.write_register(0x400B, 0x08); // triangle length
apu.write_register(0x400F, 0x08); // noise length
let status = apu.read_status();
assert_eq!(status & 0x0F, 0x0F, "все четыре канала должны быть активны");
// Выключаем всё.
apu.write_register(0x4015, 0x00);
let status = apu.read_status();
assert_eq!(
status & 0x0F,
0x00,
"запись $4015=0 должна заглушить все каналы"
);
}
#[test]
fn frame_irq_vystavlyaetsya_v_4step_i_sbrasyvaetsya_chteniem() {
let mut apu = Apu::new(44100.0);
// Режим 4-step без маски IRQ (бит 6 = 0, бит 7 = 0).
apu.write_register(0x4017, 0x00);
assert!(
!apu.irq_pending(),
"сразу после сброса frame IRQ ещё не поднят"
);
// За один полный цикл 4-step (около 29830 тактов) IRQ должен подняться.
tick_many(&mut apu, 29_830);
assert!(
apu.irq_pending(),
"в 4-step режиме frame IRQ обязан подняться к концу цикла"
);
// Чтение $4015 сбрасывает frame IRQ.
let status = apu.read_status();
assert_ne!(
status & 0x40,
0,
"бит 6 статуса отражает поднятый frame IRQ"
);
assert!(!apu.irq_pending(), "чтение $4015 должно сбросить frame IRQ");
}
#[test]
fn frame_irq_zamaskirovan_bitom_6() {
let mut apu = Apu::new(44100.0);
// Режим 4-step с маской IRQ (бит 6 = 1).
apu.write_register(0x4017, 0x40);
tick_many(&mut apu, 60_000);
assert!(
!apu.irq_pending(),
"при выставленном бите 6 frame IRQ не должен подниматься"
);
}
#[test]
fn pyat_step_ne_dayot_frame_irq() {
let mut apu = Apu::new(44100.0);
// Режим 5-step (бит 7 = 1).
apu.write_register(0x4017, 0x80);
tick_many(&mut apu, 100_000);
assert!(
!apu.irq_pending(),
"режим 5-step вообще не генерирует frame IRQ"
);
}
#[test]
fn take_samples_dayot_primerno_pravilnyy_obyom() {
let sample_rate = 44_100.0f32;
let cpu_clock = 1_789_773.0f32;
let mut apu = Apu::new(sample_rate);
// Прогоним столько тактов, сколько процессор делает примерно за один кадр,
// и проверим, что число сэмплов близко к N * sample_rate / cpu_clock.
let cpu_cycles = 1_789_773u32; // ровно секунда тактов процессора
tick_many(&mut apu, cpu_cycles);
let samples = apu.take_samples();
let expected = (cpu_cycles as f32) * sample_rate / cpu_clock;
let lower = (expected * 0.99) as usize;
let upper = (expected * 1.01) as usize;
assert!(
samples.len() >= lower && samples.len() <= upper,
"за {cpu_cycles} тактов ждём около {expected} сэмплов, получили {}",
samples.len()
);
// После take буфер должен опустеть.
assert!(
apu.take_samples().is_empty(),
"повторный take должен вернуть пустой буфер"
);
}
#[test]
fn samply_v_razumnom_diapazone() {
let mut apu = Apu::new(44_100.0);
// Заведём pulse1 и noise, чтобы микшер давал ненулевой сигнал.
apu.write_register(0x4015, 0x0F);
apu.write_register(0x4000, 0x3F); // pulse1 громкость 15, halt
apu.write_register(0x4002, 0x40);
apu.write_register(0x4003, 0x08);
apu.write_register(0x400C, 0x3F); // noise громкость 15, halt
apu.write_register(0x400E, 0x00);
apu.write_register(0x400F, 0x08);
tick_many(&mut apu, 200_000);
let samples = apu.take_samples();
assert!(!samples.is_empty(), "должны накопиться сэмплы");
for s in &samples {
assert!(
s.is_finite() && *s >= 0.0 && *s < 1.0,
"каждый сэмпл обязан быть конечным и в диапазоне 0.0..1.0, получили {s}"
);
}
}
#[test]
fn noise_lfsr_ne_zalipaet_v_nule() {
let mut apu = Apu::new(44_100.0);
// Включаем noise с самым коротким периодом, чтобы LFSR сдвигался часто.
apu.write_register(0x4015, 0x08);
apu.write_register(0x400C, 0x3F); // громкость 15, halt (чтобы не замолк)
apu.write_register(0x400E, 0x00); // режим обычный, период минимальный
apu.write_register(0x400F, 0x08); // длина
// Если бы LFSR залип в нуле, шум всегда выдавал бы один и тот же отсчёт и
// сэмплы были бы идентичны. Собираем сэмплы и проверяем, что есть разброс.
tick_many(&mut apu, 300_000);
let samples = apu.take_samples();
assert!(
samples.len() > 10,
"нужно достаточно сэмплов для проверки разброса"
);
let first = samples[0];
let varies = samples.iter().any(|s| (s - first).abs() > 1e-6);
assert!(
varies,
"LFSR не должен залипать: ожидаем разброс отсчётов шума"
);
}
#[test]
fn po_umolchaniyu_chastota_44100() {
// Default обязан давать ту же раскладку ресемплинга, что и new(44100.0).
let mut a = Apu::default();
let mut b = Apu::new(44_100.0);
tick_many(&mut a, 100_000);
tick_many(&mut b, 100_000);
assert_eq!(
a.take_samples().len(),
b.take_samples().len(),
"Default должен совпадать с new(44100.0) по числу сэмплов"
);
}
Запусти:
cargo test --test apu
Тесты бьют по всем граням сборки. Первые два про length counter: что нота сама замолкает на нуле счётчика и что запись $4015 = 0 глушит все каналы разом. Три теста про frame IRQ проверяют тайминг дирижёра: в режиме 4-step прерывание поднимается к концу цикла и сбрасывается чтением статуса, бит 6 его маскирует, а режим 5-step не даёт IRQ вообще. Дальше идут тесты ресемплера: за секунду тактов процессора накапливается примерно нужное число сэмплов (с допуском один процент), и Default совпадает с new(44100.0). Тест на диапазон убеждается, что каждый сэмпл конечный и лежит в 0.0..1.0, то есть микшер не выходит за пределы. И последний проверяет, что LFSR шума не залипает в нуле: отсчёты должны иметь разброс.
Дальше
Все три машины готовы: CPU считает, PPU рисует, APU звучит. Осталось собрать их в одну приставку и подать на вход реальную игру. Финал блока: сборка и запуск игры.