$ sudo teach IT
Модуль 5 · Графика и звук · Урок 5.3

Звуки и музыка

Вы научитесь создавать звуки кодом, записывать их в WAV-файлы и проигрывать эффекты и фоновую музыку в игре.

Теория + практика~35 минутСредний

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

Звук как список чисел

Звук в воздухе — это волна: воздух то сжимается, то разрежается. Микрофон превращает её в электрическое напряжение, которое тоже плавно растёт и падает. Но компьютер не умеет хранить «плавное», он умеет хранить числа. Поэтому он делает так же, как мультфильм делает с движением: много раз в секунду «фотографирует» громкость и записывает каждый замер числом. Получается длинный список чисел — и это и есть записанный звук.

Официально замер называется отсчётом (по-английски sample), а количество отсчётов в секунду — частотой дискретизации (sample rate). Мы возьмём 22050 отсчётов в секунду: для звуков игры этого хватает, а файлы получаются маленькими. Каждый отсчёт — целое число от -32768 до 32767 (16 бит, то есть два байта): минус — мембрана динамика отклонена в одну сторону, плюс — в другую, ноль — тишина. Один канал (моно) нам достаточно: два канала понадобились бы для стерео.

Простая арифметика: секунда звука — 22050 чисел по 2 байта, то есть около 44 килобайт. Звук в пол-секунды на выстрел весит совсем немного.

Чистый тон: функция tone_samples

Самый простой звук — ровный гудок, как у камертона. Представьте качели: они плавно уходят вперёд, возвращаются и уходят назад, и так без конца. Если замерять положение качелей много раз в секунду, получится волна, которую математика называет синусоидой. Чем быстрее качели, тем выше звук: количество качаний в секунду называется частотой и измеряется в герцах (Гц). Нота «ля» первой октавы — это 440 качаний в секунду, то есть 440 Гц.

В Python синус считает функция math.sin из модуля math, который мы уже подключали в logic.py. Она возвращает число от -1 до 1. Умножим его на громкость volume (от 0 до 1) и на 32767 — получим отсчёт в нужном диапазоне.

Python · kometa/make_assets.py
import math


def tone_samples(freq, duration, rate, volume):
    count = int(duration * rate)
    samples = []
    for i in range(count):
        t = i / rate
        value = math.sin(2 * math.pi * freq * t) * volume
        samples.append(int(value * 32767))
    return samples

Разбор по строкам

  • freq, duration, rate, volume — частота в герцах, длительность в секундах, отсчётов в секунду и громкость от 0 до 1.
  • count = int(duration * rate) — сколько всего отсчётов нужно: полсекунды при 22050 даёт 11025 чисел. int отбрасывает дробную часть.
  • for i in range(count) — по одному проходу на каждый отсчёт; i — его номер.
  • t = i / rate — момент времени в секундах, когда мы «фотографируем» звук.
  • math.sin(2 * math.pi * freq * t) — волна: за одну секунду она делает ровно freq полных качаний (math.pi — число π).
  • * volume — уменьшаем размах волны; при volume = 0 получается тишина.
  • int(value * 32767) — переводим число от -1 до 1 в целое отсчёта; append добавляет его в список.

Проверим без всякого pygame, прямо в Python:

Python · фрагмент
samples = tone_samples(440, 1, 22050, 1)
print(len(samples), samples[:5])
Вывод
22050 [0, 4097, 8130, 12036, 15752]

Секунда при 22050 отсчётах в секунду даёт ровно 22050 чисел, а первое число — ноль: синусоида стартует из тишины и плавно растёт.

Затухание, шум и ноты

Чистый тон, обрезанный в случайном месте, звучит как гудок с неприятным щелчком в конце: волна обрывается посреди качания. Настоящие звуки затихают постепенно, как звон колокола. Мы сделаем так же — умножим каждый отсчёт на число, которое плавно падает от 1 до 0. Это называется затуханием (fade out). Выстрел «пиу» — это короткий высокий тон, который быстро затухает.

Взрыв, наоборот, не имеет высоты: это шум. Шум — просто случайные числа вместо синусоиды, ведь именно так выглядит хаос. Случайные числа мы уже получали из модуля random. А чтобы сыграть мелодию, нужны ноты. Здесь помогает одна закономерность: каждые 12 полутонов вверх частота ровно удваивается. Если считать от ноты «ля» (440 Гц), то частота ноты на n полутонов выше равна 440 * 2 ** (n / 12). Знак ** — возведение в степень: 2 ** 3 равно 8.

Python · kometa/make_assets.py
def noise_samples(duration, rate, volume):
    count = int(duration * rate)
    samples = []
    for _ in range(count):
        value = random.uniform(-1, 1) * volume
        samples.append(int(value * 32767))
    return samples


def fade_out(samples):
    count = len(samples)
    result = []
    for i in range(count):
        k = 1 - i / count
        result.append(int(samples[i] * k))
    return result


def note_freq(semitones):
    return 440 * 2 ** (semitones / 12)

Разбор по строкам

  • random.uniform(-1, 1) — случайное дробное число от -1 до 1; в шапке файла нужен import random. Это тот же цикл, что в tone_samples, только без синуса.
  • for _ in range(count) — номер отсчёта здесь не нужен, поэтому переменную принято называть _.
  • k = 1 - i / count — множитель затухания: на первом отсчёте 1, на середине 0,5, к концу почти 0.
  • samples[i] * k — громкость отсчёта уменьшается; результат снова превращаем в целое через int.
  • 440 * 2 ** (semitones / 12) — при semitones = 0 получаем 440 Гц, при 12 — 880 Гц (октавой выше), при -12 — 220 Гц (октавой ниже), при 7 — около 659,26 Гц.

Запись WAV-файла: модуль wave

Список чисел в памяти ещё не звук в файле. Нужен формат, который знает и проигрыватель, и pygame. Самый простой — WAV: в начале файла короткий «паспорт» (сколько каналов, сколько байт на отсчёт, какая частота), а дальше те самые числа подряд. Читать и писать WAV умеет модуль wave из стандартной библиотеки Python — ставить ничего не надо.

Есть одна техническая деталь. wave принимает не список чисел, а байты. Превращает одно в другое тип array: он похож на список, но хранит числа одного фиксированного типа. Код типа "h" означает «знаковое целое в 16 бит» — ровно наш отсчёт. Метод tobytes() отдаёт его содержимое в виде байтов.

Python · kometa/make_assets.py
import wave
from array import array


def save_wav(path, samples, rate):
    with wave.open(str(path), "wb") as f:
        f.setnchannels(1)
        f.setsampwidth(2)
        f.setframerate(rate)
        f.writeframes(array("h", samples).tobytes())

Разбор по строкам

  • from array import array — берём из модуля array одноимённый тип.
  • with wave.open(str(path), "wb") as f: — открываем файл на запись ("wb": write, binary). Слово with означает «открой, выполни блок и сам закрой файл, даже если случится ошибка»; внутри блока файл зовётся f.
  • f.setnchannels(1) — один канал, моно.
  • f.setsampwidth(2) — два байта на отсчёт, то есть 16 бит.
  • f.setframerate(rate) — сколько отсчётов в секунду (у нас 22050).
  • f.writeframes(array("h", samples).tobytes()) — список превращается в 16-битный массив, затем в байты, и они записываются в файл.

Остаётся собрать четыре звука игры: «пиу» выстрела, шумный «бум» взрыва, мелодичный «дзинь» кристалла и фоновую мелодию. Мелодия — это просто тона разных нот, поставленные друг за другом: списки Python склеиваются знаком +.

Python · фрагмент
RATE = 22050

shot = fade_out(tone_samples(880, 0.15, RATE, 0.5))
boom = fade_out(noise_samples(0.5, RATE, 0.6))
pickup = tone_samples(660, 0.08, RATE, 0.4) + fade_out(tone_samples(990, 0.14, RATE, 0.4))

melody = [0, 3, 7, 3, 0, 3, 7, 12, 10, 7, 5, 7, 3, 0, 3, 0]
music = []
for n in melody:
    note = tone_samples(note_freq(n), 0.25, RATE, 0.3)
    music = music + fade_out(note)

Разбор по строкам

  • shot — короткий (0,15 с) высокий тон в 880 Гц, затухающий до нуля: получается «пиу».
  • boom — полсекунды шума с затуханием: глухой «бум».
  • pickup — два тона подряд, ниже и выше (660 и 990 Гц): такой «дзинь» с подъёмом.
  • melody — список нот в полутонах от «ля»; каждая звучит 0,25 с и затухает, поэтому между нотами нет щелчков.
  • music = music + fade_out(note) — приклеиваем очередную ноту в конец мелодии. Всего 16 нот по 0,25 с — ровно 4 секунды, которые потом заиграют по кругу.

Громкость не больше 1

Отсчёт должен укладываться в 16 бит. Если задать volume = 1,5, то int(value * 32767) превысит 32767, и array откажется собирать массив: OverflowError: signed short integer is greater than maximum. Берите громкость от 0 до 1, а для запаса — 0,3–0,6.

Звуковые эффекты: pygame.mixer.Sound

Файлы готовы, пора их слушать. За звук в pygame отвечает модуль pygame.mixer (микшер: он смешивает несколько звуков в один поток для колонок). Его запускает уже знакомый вызов pygame.init(), поэтому отдельно ничего включать не нужно.

Короткие эффекты — это объекты pygame.mixer.Sound. Представьте кнопки на пульте: записанную фразу один раз загрузили в кнопку, а дальше можно нажимать сколько угодно. Так и тут: Sound(путь) читает файл целиком в память один раз, а метод play() запускает звук. Метод set_volume(0.4) задаёт громкость от 0 до 1.

Звуков у нас четыре, и каждому удобно дать имя. Для этого в Python есть словарь (dict). Это как телефонная книга: вы ищете не по номеру в списке, а по имени. Пара «имя: значение» называется ключ и значение. Словарь записывается в фигурных скобках, а значение достаётся по ключу в квадратных: sounds["shot"].

Python · kometa/sprites.py
def load_sound(name):
    return pygame.mixer.Sound(str(ASSETS / name))
Python · фрагмент
sounds = {
    "shot": load_sound("shot.wav"),
    "boom": load_sound("boom.wav"),
    "pickup": load_sound("pickup.wav"),
}
sounds["shot"].set_volume(0.4)
sounds["shot"].play()

Разбор по строкам

  • ASSETS / name — путь к файлу в папке assets, тот же приём, что у load_image; str(...) превращает путь в обычную строку.
  • pygame.mixer.Sound(...) — загружает WAV и возвращает объект, которым можно управлять.
  • sounds = { ... } — словарь: слева ключ-имя в кавычках, справа готовый объект Sound.
  • sounds["shot"].set_volume(0.4) — достаём звук по имени и делаем его потише: 40% громкости.
  • sounds["shot"].play() — проигрываем. Метод возвращает управляющий объект канала, но нам он не нужен.

Загружать один раз

load_sound вызывается до игрового цикла, а в цикле остаётся только play(). Загрузка файла с диска в каждом кадре — та же ошибка, что и загрузка кадров взрыва в каждом новом взрыве: игра начинает тормозить.

Фоновая музыка и клавиша M

Музыка длинная, и держать её целиком в памяти, как эффект, расточительно. Для неё в pygame есть отдельный канал — pygame.mixer.music: файл читается потоком по мере проигрывания, и одновременно может играть только одна мелодия. Работа с ней состоит из двух вызовов: load(путь) — выбрать файл, play(-1) — запустить. Число в скобках — сколько раз повторить после первого проигрывания; значение -1 означает «бесконечно». Громкость задаёт pygame.mixer.music.set_volume.

Последнее — выключатель звука. Игрок должен мочь заткнуть игру одной клавишей. Нам хватит переменной-флага muted, значение которой меняется на противоположное при каждом нажатии M, как show_boxes на клавише F1. Музыку при этом ставим на паузу методом pause() и возвращаем через unpause(), а эффекты просто не проигрываем. Это учебный вариант: в практикуме следующего урока выключатель переделаем на громкость (звук не ставится на паузу, а получает громкость 0), а расчёт громкости вынесем в logic.py.

Python · фрагмент
muted = False

pygame.mixer.music.load(str(ASSETS / "music.wav"))
pygame.mixer.music.set_volume(0.3)
pygame.mixer.music.play(-1)


def play_sound(name):
    if not muted:
        sounds[name].play()


# внутри цикла, в обработке событий:
if event.type == pygame.KEYDOWN and event.key == pygame.K_m:
    muted = not muted
    if muted:
        pygame.mixer.music.pause()
    else:
        pygame.mixer.music.unpause()

Разбор по строкам

  • muted = False — по умолчанию звук включён.
  • pygame.mixer.music.load(...) и play(-1) — загрузить мелодию и пустить её по кругу; set_volume(0.3) делает её тише эффектов, чтобы она не заглушала выстрелы.
  • play_sound(name) — общая обёртка: проигрывает звук по имени, только если звук не выключен. Читать muted внутри функции можно без особых слов.
  • muted = not muted — переключатель: True превращается в False и обратно.
  • pause() / unpause() — музыка замирает и продолжается с того же места.

Нет звуковой карты

Если на компьютере нет звукового устройства, pygame.init() молча пропустит микшер, а Sound(...) упадёт с pygame.error. Проверить, запущен ли микшер, можно вызовом pygame.mixer.get_init(): он вернёт None, если микшер не работает.

Практика: «Комета» зазвучала

Работаем в проекте kometa/ с тем, что осталось от прошлых уроков: у вас уже есть make_assets.py с картинками и кадрами взрыва, sprites.py с load_image и Explosion, и main.py, где взрывы вызываются функцией explode. Нужно добавить звук в три места по очереди и после каждого шага запускать проверку.

Шаг 1. Звуки в make_assets.py

Откройте make_assets.py. Первым делом замените блок импортов в начале файла: появляются math, random, wave и array.

Python · kometa/make_assets.py (заменить блок импортов)
import math
import random
import wave
from array import array
from pathlib import Path

import pygame

from logic import ship_points

Сразу после строк ASSETS = ... и EXPLOSION_COUNT = 6 добавьте частоту дискретизации:

Python · kometa/make_assets.py (после EXPLOSION_COUNT)
RATE = 22050

Пять функций из теории вставьте после функции save (перед строкой ASSETS.mkdir):

Python · kometa/make_assets.py (после функции save)
def tone_samples(freq, duration, rate, volume):
    count = int(duration * rate)
    samples = []
    for i in range(count):
        t = i / rate
        value = math.sin(2 * math.pi * freq * t) * volume
        samples.append(int(value * 32767))
    return samples


def noise_samples(duration, rate, volume):
    count = int(duration * rate)
    samples = []
    for _ in range(count):
        value = random.uniform(-1, 1) * volume
        samples.append(int(value * 32767))
    return samples


def fade_out(samples):
    count = len(samples)
    result = []
    for i in range(count):
        k = 1 - i / count
        result.append(int(samples[i] * k))
    return result


def note_freq(semitones):
    return 440 * 2 ** (semitones / 12)


def save_wav(path, samples, rate):
    with wave.open(str(path), "wb") as f:
        f.setnchannels(1)
        f.setsampwidth(2)
        f.setframerate(rate)
        f.writeframes(array("h", samples).tobytes())

Между циклом с кадрами взрыва и строкой print добавьте создание четырёх звуков:

Python · kometa/make_assets.py (перед строкой print)
shot = fade_out(tone_samples(880, 0.15, RATE, 0.5))
boom = fade_out(noise_samples(0.5, RATE, 0.6))
pickup = tone_samples(660, 0.08, RATE, 0.4) + fade_out(tone_samples(990, 0.14, RATE, 0.4))

melody = [0, 3, 7, 3, 0, 3, 7, 12, 10, 7, 5, 7, 3, 0, 3, 0]
music = []
for n in melody:
    note = tone_samples(note_freq(n), 0.25, RATE, 0.3)
    music = music + fade_out(note)

save_wav(ASSETS / "shot.wav", shot, RATE)
save_wav(ASSETS / "boom.wav", boom, RATE)
save_wav(ASSETS / "pickup.wav", pickup, RATE)
save_wav(ASSETS / "music.wav", music, RATE)
Python · kometa/make_assets.py (заменить строку print)
print("Картинки и звуки сохранены в", ASSETS)

Запустите скрипт ещё раз — картинки перезапишутся теми же, а рядом появятся звуки:

Терминал
python make_assets.py
ls assets
Вывод
Картинки и звуки сохранены в /Users/anna/kometa/assets
boom.wav       explosion_1.png  explosion_5.png  pickup.wav
crystal.png    explosion_2.png  laser.png        ship.png
explosion_0.png  explosion_3.png  meteor.png     shot.wav
explosion_4.png  music.wav

На Windows вместо ls assets наберите dir assets. Звук можно проверить на слух: откройте assets/shot.wav двойным щелчком — должно прозвучать короткое «пиу». Размер music.wav около 172 килобайт: 4 секунды по 44 килобайта в секунду.

Шаг 2. load_sound в sprites.py

В sprites.py добавьте функцию сразу после load_image:

Python · kometa/sprites.py (после функции load_image)
def load_sound(name):
    return pygame.mixer.Sound(str(ASSETS / name))

Шаг 3. Звуки и музыка в main.py

Замените строку импорта из sprites, чтобы добраться до ASSETS и load_sound:

Python · kometa/main.py (заменить строку импорта из sprites)
from sprites import ASSETS, Crystal, Explosion, Laser, Meteor, Player, load_frames, load_sound

После строки explosion_frames = load_frames(...) добавьте словарь звуков, музыку и функцию play_sound:

Python · kometa/main.py (после explosion_frames)
sounds = {
    "shot": load_sound("shot.wav"),
    "boom": load_sound("boom.wav"),
    "pickup": load_sound("pickup.wav"),
}
sounds["shot"].set_volume(0.4)
muted = False

pygame.mixer.music.load(str(ASSETS / "music.wav"))
pygame.mixer.music.set_volume(0.3)
pygame.mixer.music.play(-1)


def play_sound(name):
    if not muted:
        sounds[name].play()

Функция explode теперь не только показывает взрыв, но и играет «бум». Вызывается она и при попадании лазера, и при ударе в корабль, поэтому дописывать звук отдельно в обоих местах не придётся:

Python · kometa/main.py (заменить функцию explode)
def explode(center):
    all_sprites.add(Explosion(center, explosion_frames))
    play_sound("boom")

В обработку событий добавьте ветку для клавиши M:

Python · kometa/main.py (заменить обработку событий в цикле)
while running:
    # ... начало цикла без изменений ...
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                running = False
            elif event.key == pygame.K_F1:
                show_boxes = not show_boxes
            elif event.key == pygame.K_m:
                muted = not muted
                if muted:
                    pygame.mixer.music.pause()
                else:
                    pygame.mixer.music.unpause()
    # ... дальше цикл без изменений ...

Пауза музыки и проверка muted в play_sound — временное решение: в следующем уроке оба места заменит громкость, поэтому не привязывайтесь к ним. Теперь выстрел и кристалл получают свои звуки:

Python · kometa/main.py (блок выстрела в цикле)
while running:
    # ... события и клавиши без изменений ...
    shot_cooldown.update(dt)
    if keys[pygame.K_SPACE] and shot_cooldown.ready():
        nose_x, nose_y = player.rect.midtop
        laser = Laser(nose_x, nose_y)
        all_sprites.add(laser)
        lasers.add(laser)
        shot_cooldown.start()
        play_sound("shot")
    # ... дальше цикл без изменений ...
Python · kometa/main.py (блок кристаллов в цикле)
while running:
    # ... начало цикла без изменений ...
    caught = pygame.sprite.spritecollide(player, crystals, True, pygame.sprite.collide_circle)
    score += CRYSTAL_SCORE * len(caught)
    if caught:
        play_sound("pickup")
    # ... дальше цикл без изменений ...

Запуск

Терминал
python main.py

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

Как получилось

Звука нет совсем? Сначала проверьте, что системная громкость не на нуле, и запустите python -c "import pygame; pygame.init(); print(pygame.mixer.get_init())": вместо None должен появиться кортеж с частотой. Дальше смотрите раздел «Частые ошибки».

Проверьте себя

  • Удерживайте Пробел: выстрелы звучат равномерно, примерно четыре раза в секунду, а не сплошным гулом?
  • Метеор, в который попал лазер, и метеор, который врезался в корабль, взрываются со звуком?
  • Клавиша M выключает и музыку, и выстрелы, а при втором нажатии всё возвращается?
  • Если в звуке выстрела поставить громкость 1.5, скрипт упадёт с OverflowError? А если вернуть 0.5, он снова проходит?

Частые ошибки

OverflowError: signed short integer is greater than maximum

Что видно: при запуске make_assets.py падает save_wav. Почему: отсчёт вышел за 16 бит: громкость больше 1, либо склеенные или сложенные звуки дали число больше 32767. Как исправить: держите volume от 0 до 1, а звуки складывайте только по очереди, одним за другим.

pygame.error: mixer not initialized

Что видно: игра падает на строке с pygame.mixer.Sound(...) или music.load. Почему: микшер не запущен: Sound создаётся раньше pygame.init(), либо на компьютере нет звукового устройства. Как исправить: загружайте звуки только после pygame.init(); проверьте pygame.mixer.get_init(): если там None, проверьте настройки звука в системе.

FileNotFoundError: ... shot.wav

Что видно: файл звука не находится. Почему: скрипт make_assets.py ещё не запускали после добавления звуков, либо путь собран не через ASSETS. Как исправить: запустите python make_assets.py ещё раз и убедитесь, что в папке assets появились четыре .wav; пути всегда строим как ASSETS / name.

Выстрел «трещит» или звучит каждый кадр

Что видно: при зажатом Пробеле выстрелы сливаются в гул, а при взрыве звук идёт без остановки. Почему: play() вызывается в каждом кадре, а не в момент события: при выстреле — после проверки перезарядки, при попадании — один раз. Как исправить: ставьте play_sound("shot") рядом с созданием лазера, а play_sound("boom") — там, где создаётся взрыв.

Клавиша M выключает эффекты, но музыка играет

Что видно: звук пропал только частично. Почему: переключили muted, но забыли про музыку: она идёт отдельным каналом. Как исправить: вместе со сменой флага вызывайте pygame.mixer.music.pause() или unpause(). (В практикуме следующего урока это место займёт громкость.)

Что важно запомнить

  • Цифровой звук — список целых чисел (отсчётов) от -32768 до 32767; у нас 22050 отсчётов в секунду, один канал.
  • Высота тона — частота в герцах. Чистый тон — синусоида: math.sin(2 * math.pi * freq * t); громкость — множитель от 0 до 1.
  • Затухание (fade_out) убирает щелчок в конце, шум (random.uniform) даёт взрыв, а 440 * 2 ** (n / 12) — частоту ноты на n полутонов от «ля».
  • WAV пишет модуль wave: setnchannels(1), setsampwidth(2), setframerate(rate), writeframes(array("h", samples).tobytes()).
  • Короткие эффекты — pygame.mixer.Sound: грузим один раз, храним в словаре sounds, в цикле только play().
  • Музыка — pygame.mixer.music: load, play(-1) для бесконечного повтора, pause и unpause для клавиши M (в практикуме заменим их на громкость).

Дальше — практикум: соберём картинки, взрывы и звуки в три полных файла, а выключатель звука переделаем на громкость.

На следующий урок