Раздел 23 · Rust

APU и звук

senior~150 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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 звучит. Осталось собрать их в одну приставку и подать на вход реальную игру. Финал блока: сборка и запуск игры.

Домашка