Картинки у «Кометы» уже настоящие, взрыв играет по кадрам, но игра пока немая: лазер стреляет беззвучно, метеор рассыпается в тишине. Скачивать звуки мы не будем — как и картинки, создадим их кодом. Для этого придётся понять, что вообще такое звук внутри компьютера, а потом научить 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 — получим отсчёт в нужном диапазоне.
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:
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.
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() отдаёт его содержимое в виде байтов.
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 склеиваются знаком +.
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"].
def load_sound(name):
return pygame.mixer.Sound(str(ASSETS / name))
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.
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.
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 добавьте частоту дискретизации:
RATE = 22050
Пять функций из теории вставьте после функции save (перед строкой ASSETS.mkdir):
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 добавьте создание четырёх звуков:
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)
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:
def load_sound(name):
return pygame.mixer.Sound(str(ASSETS / name))
Шаг 3. Звуки и музыка в main.py
Замените строку импорта из sprites, чтобы добраться до ASSETS и load_sound:
from sprites import ASSETS, Crystal, Explosion, Laser, Meteor, Player, load_frames, load_sound
После строки explosion_frames = load_frames(...) добавьте словарь звуков, музыку и функцию play_sound:
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 теперь не только показывает взрыв, но и играет «бум». Вызывается она и при попадании лазера, и при ударе в корабль, поэтому дописывать звук отдельно в обоих местах не придётся:
def explode(center):
all_sprites.add(Explosion(center, explosion_frames))
play_sound("boom")
В обработку событий добавьте ветку для клавиши M:
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 — временное решение: в следующем уроке оба места заменит громкость, поэтому не привязывайтесь к ним. Теперь выстрел и кристалл получают свои звуки:
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")
# ... дальше цикл без изменений ...
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 (в практикуме заменим их на громкость).
Дальше — практикум: соберём картинки, взрывы и звуки в три полных файла, а выключатель звука переделаем на громкость.