---
title: "Звук и музыка"
description: "audio.Context, WAV/OGG, громкость и зацикливание. Плееры эффектов и музыки, ссылки против GC, mute-тумблер и go:embed ассетов в играх на Go."
course: "Ebitengine: 2D-игры на Go"
url: "https://golangmaster.ru/courses/ebitengine/lessons/18-zvuk/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Ebitengine: 2D-игры на Go», урок 18 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/ebitengine/lessons/18-zvuk/*

Пока игра не умеет звук, она выглядит прототипом, каким бы красивым ни был арт: в Ebitengine за звук отвечает отдельный пакет audio, и он подчиняется обычным правилам Go. Мы соберём минимальный аудиокаркас: один audio.Context на игру, плеер для зацикленной музыки, пул плееров для коротких эффектов, громкость и mute-тумблер на клавишу, а заодно разберём типовую ловушку, из-за которой звук обрывается на середине.

У игры уже есть мир, камера, текст HUD и сцены, но она молчит, и молчание выдаёт в ней прототип. Прыжок без шороха, столкновение без удара, победа без фанфар — игрок считывает это как пустоту, даже если не может назвать причину. Ebitengine решает вопрос без сторонних библиотек: в поставке движка есть пакет audio, который даёт контекст вывода, плееры и декодеры форматов, и весь остальной звук — музыка, эффекты, интерфейсные щелчки — собирается из этих трёх деталей. Звук в играх несложен, пока вы понимаете два правила: контекст один, а ссылки на плееры живут столько, сколько играет звук. Нарушение первого рождает кашу из устройств вывода, нарушение второго — загадочно замолкающие эффекты. В этом уроке мы собираем аудиокаркас так, чтобы он пережил и долгую сессию, и итоговый проект.

### audio.Context: один контекст на игру

Аудиосистема начинается с audio.Context — объекта, который владеет устройством вывода и микширует все потоки игры. Контекст создаётся один раз за жизнь программы вызовом audio.NewContext(44100): аргумент — частота дискретизации в герцах, то есть сколько сэмплов в секунду потребляет выход. Дублей быть не должно, поэтому у контекста одно место рождения — конструктор Game; там, где контекст нужен без передачи через поля, готовый объект отдаёт audio.CurrentContext() — именно так поступают примеры самого движка. Частота выбирается один раз и навсегда: 44100 или 48000 герц, разницы на слух нет, зато все плееры контекста работают в одной шкале, и смешивать частоты вручную не придётся.

Второе, что важно понять: звук живёт вне игрового цикла. Update и Draw тикают шестьдесят раз в секунду, а аудиопоток потребляется в собственной ритмике движка: плеер сам читает данные из потока по мере проигрывания, и ваше дело — лишь не терять на него ссылки и подавать команды. Отсюда стиль аудиокода: короткие вызовы из игрового кода — играй, стоп, громкость, назад в начало, — и никакого покадрового обслуживания. Контекст, созданный в конструкторе, спокойно доживает до конца программы, а сцены и меню через него лишь меняют плееры. Полезное следствие — аудио не замедляет Update: код, играющий эффект, тратит микросекунды на поиск свободного плеера и запуск, само воспроизведение идёт параллельно кадрам, и даже сотня наложенных звуков не сдвигает ни один тик.

### Контекст и зацикленная музыка

```go
//go:embed assets/music.ogg
var musicOgg []byte

const (
        sampleRate     = 44100
        bytesPerSample = 8 // 2 канала * 4 байта float32
        introSeconds   = 4
)

type Game struct {
        audioCtx    *audio.Context
        music       *audio.Player // ссылка обязана жить в структуре
        musicVolume float64
}

func NewGame() (*Game, error) {
        g := &Game{musicVolume: 0.6}
        g.audioCtx = audio.NewContext(sampleRate) // один контекст на игру

        s, err := vorbis.DecodeF32(bytes.NewReader(musicOgg)) // Ogg -> PCM
        if err != nil {
                return nil, err
        }
        // интро звучит один раз, дальше трек крутится по кругу
        loop := audio.NewInfiniteLoopWithIntroF32(s,
                introSeconds*bytesPerSample*sampleRate,
                s.Length()-introSeconds*bytesPerSample*sampleRate)
        g.music, err = g.audioCtx.NewPlayerF32(loop)
        if err != nil {
                return nil, err
        }
        g.music.SetVolume(g.musicVolume) // громкость 0..1
        g.music.Play()
        return g, nil
}
```

Контекст создаётся в единственном месте — конструкторе игры. Декодер vorbis.DecodeF32 превращает Ogg-файл в поток float32, длину которого Length() отдаёт в байтах: для стерео F32 один сэмпл весит восемь байт, поэтому длительность интро умножается на частоту и bytesPerSample. Обёртка NewInfiniteLoopWithIntroF32 делает поток бесконечным, NewPlayerF32 получает плеер, SetVolume задаёт громкость от нуля до единицы, Play запускает музыку до явной остановки.

**Вопрос:** Сколько объектов audio.Context должно быть в игре?

- ✅ Один: создаётся один раз и переиспользуется всеми плеерами
- По одному на сцену: у меню свой, у боя свой
- По одному на плеер: так изолируются громкости
- Ноль: контекст создаёт движок сам при RunGame

Контекст владеет аудиовыходом и микшером: второй экземпляр будет спорить за устройство, а громкости плееров разных контекстов не смешаются. Все плееры — и эффекты, и музыка — создаются из одного контекста его методами NewPlayerF32.

### Форматы: WAV, MP3 и Ogg

Пакет audio сырые файлы не ест: байты превращаются в PCM-сэмплы декодерами из подпакетов. Их три: wav.DecodeF32 для WAV, mp3.DecodeF32 для MP3 и vorbis.DecodeF32 для Ogg с кодеком Vorbis — этот список форматов и есть поддержка движка, всё сверх него придётся декодировать сторонними библиотеками и скармливать плееру готовые сэмплы. Выбор формата — компромисс размера и скорости: WAV не сжимается и декодируется мгновенно, поэтому он идеален для коротких эффектов, которые запускаются в ответ на нажатие; Ogg и MP3 сжимают в разы лучше, поэтому ими берут музыку и длинные вставки, где лишняя задержка на старте незаметна. Декодеры отдают поток F32 — тридцатидвухбитные плавающие сэмплы, единый формат для всех плееров контекста, и суффикс F32 в именах декодеров об этом честно говорит.

Файлы, из которых всё это собирается, попадают в игру не с диска в момент запуска, а из байтов в памяти: их встраивает в бинарь директива go:embed, о которой ниже отдельный разговор и которую активно использует сборка проекта из [урока про платформу](/courses/ebitengine/lessons/22-sborka-platformy/). Отдельный вопрос — права на сами файлы: лицензии ассетов разбирает [урок про ассеты](/courses/ebitengine/lessons/23-assety-i-lisenzii/), и аудио там стоит первым пунктом, потому что лицензия на трек способна запретить игру целиком.

### PlaySfx: пул плееров и mute-тумблер

```go
// в NewGame: декодируем эффект один раз и держим готовые байты
d, err := wav.DecodeF32(bytes.NewReader(jumpWav))
if err != nil {
        return nil, err
}
g.sfxBytes, err = io.ReadAll(d) // PCM в памяти: плеер собирается мгновенно

// PlaySfx играет эффект: свободный плеер переиспользуется,
// иначе заводится новый
func (g *Game) PlaySfx() {
        for _, p := range g.sfxPool {
                if !p.IsPlaying() {
                        p.Rewind() // назад в начало
                        p.Play()
                        return
                }
        }
        p, err := g.audioCtx.NewPlayerF32FromBytes(g.sfxBytes)
        if err != nil {
                return
        }
        g.sfxPool = append(g.sfxPool, p) // ссылка живёт в слайсе
        p.SetVolume(0.5)
        p.Play()
}

// в Update: mute по клавише M
if inpututil.IsKeyJustPressed(ebiten.KeyM) {
        g.muted = !g.muted
        if g.muted {
                g.music.SetVolume(0)
        } else {
                g.music.SetVolume(g.musicVolume)
        }
}
```

Байты эффекта декодируются один раз при загрузке: io.ReadAll вынимает из потока готовые PCM-сэмплы, и NewPlayerF32FromBytes потом собирает плеер за микросекунды. PlaySfx сначала ищет в пуле неиграющий плеер: Rewind возвращает его в начало, Play запускает заново. Свободных нет — создаётся новый и добавляется в слайс ссылок, поэтому эффекты накладываются друг на друга. Тумблер mute не удаляет звук, а ставит музыке громкость ноль: она продолжает играть, и снятие mute продолжит её с того же места.

### Звук → плеер → громкость

| Звук | Плеер | Громкость |
| --- | --- | --- |
| Шаги и прыжок героя | один плеер в Game, Rewind и Play | 0.7–0.9: герой слышен всегда |
| Выстрелы и попадания | пул из PlaySfx, звуки поверх друг друга | 0.4–0.6: частое звучит тише |
| Музыка уровня | один долгий плеер с бесконечным лупом | 0.3–0.5: фон не перекрикивает бой |
| UI-клики | переиспользуемый плеер из пула | 0.3: короткий и ненавязчивый |
| Смерть и победа | отдельный плеер без зацикливания | 1.0: событие раз за партию |
| Mute-тумблер | плееры не трогает, меняет SetVolume | 0 у музыки, эффекты по вкусу |

### Что услышит игрок при двух выстрелах в один тик?

```go
// пул пуст, ни один эффект не играет
PlaySfx() // -> NewPlayerF32FromBytes, играет плеер p1
// второй выстрел в тот же тик:
PlaySfx()
```

**Что выведет программа?**

- Второй выстрел заменит первый: p1 затихнет
- ✅ Второй сыграет поверх: пул найдёт свободный плеер или заведёт новый
- Второй выпадет: один эффект на тик — лимит движка
- Ошибка: один и тот же эффект нельзя играть дважды

PlaySfx обходит пул и не находит свободного плеера — первый ещё играет. Тогда создаётся новый плеер из тех же байтов, и два звука идут одновременно: микшер контекста складывает потоки. Так работают очереди выстрелов; без пула на каждое нажатие плодились бы плееры, которые уже некуда деть.

### Громкость: SetVolume и тумблер mute

Громкость плеера — это SetVolume с числом от нуля до единицы: единица означает исходную громкость файла, ноль — тишину. Значения выше единицы усиления не обещают, а общий ползунок в настройках — это не новый механизм, а умножение базовых громкостей плееров на коэффициент. Ухо слышит громкость логарифмически: разница между 0.1 и 0.2 огромна, между 0.8 и 0.9 почти незаметна, поэтому линейный ползунок ощущается сломанным — первые шаги глухие, последние три оглушают. Практическое правило простое: музыку держать заметно тише эффектов, иначе она съедает выстрелы, шаги и всё, на чём строится отклик игры.

Mute — не отдельный тайник громкости, а запомненное значение: тумблер сохраняет текущую громкость в поле структуры, ставит ноль, а при снятии возвращает сохранённое. Место для muted и уровней громкости — [игровое состояние](/courses/ebitengine/lessons/16-igrovoe-sostoyanie/): настройки звука живут рядом со счётом и паузой и обязаны переживать переходы сцен. Резкий SetVolume на границе смерти режет ухо, поэтому громкость, которую меняет сама игра, лучше подводить к цели за короткий таймер в Update — плавное затухание звучит профессиональнее щелчка.

**Вопрос:** Эффект играл три секунды и оборвался на середине. Его плеер создаётся локальной переменной и нигде не сохраняется. Что произошло?

- Плеер упёрся в лимит одновременных звуков движка
- ✅ Сборщик мусора собрал плеер без ссылок — звук остановился
- Контекст сбросил приоритет у коротких эффектов
- Файл эффекта не успел дочитаться с диска

Плеер — обычный Go-объект: если на него нет ни одной ссылки, GC вправе собрать его вместе с воспроизведением. Пул эффектов и поле music в структуре — не стиль, а необходимость: ссылка на плеер обязана жить, пока играет звук.

> **Живое место: звук обрывается без причины.** Симптом коварный: на коротких тестах всё играет, а в долгой сессии эффекты начинают замолкать в случайных местах. Виноват сборщик мусора: локальный плеер из функции звучал ровно до того тика, когда GC до него добрался. В отладчике ловушка невидима — никто не паникует, звук просто заканчивается раньше файла. Дисциплина одна: каждый плеер, который обязан доиграть до конца, живёт в поле структуры или в слайсе пула, как music и sfxPool выше. Потеряли последнюю ссылку — считайте, эффект уже не сыграет, и никакой флаг не предупредит.

### Эффекты и музыка: разные жизни плееров

Короткий эффект и музыкальный трек — два разных режима работы плеера, и смешивать их — типовая ошибка. Эффект живёт секунды: его данные держат готовыми байтами в памяти, плеер берётся из пула или заводится на лету, и после Play ему не нужно никакого ухода. Музыка живёт всю сцену: это один долгий плеер, заведённый на входе в игру или уровень, с бесконечным лупом из NewInfiniteLoopWithIntroF32, которым управляют только пауза и громкость. Заводить новый плеер на каждый кадр музыки — прямой путь к исчерпанию ресурсов, а переиспользовать музыкальный плеер для эффектов — получить Rewind посреди трека вместо прыжка.

Правило звучит так: сколько независимых источников у события — столько и плееров. Прыжок героя играет через один плеер, который Rewind-ится на новом прыжке: старый звук обрывается, и это даже правильно — очередь прыжков не должна звучать хором. Выстрелы очереди накладываются — значит, нужен пул. Музыка одна, и второй плеер для неё заводится только при смене трека. Ровно так устроен пример flappy из поставки движка: два поля jumpPlayer и hitPlayer, на каждое событие — Rewind и Play, и никакой очереди плееров. Пауза музыки — тоже отдельная работа: уходя в меню, плеер ставят на Pause, возвращаясь в бой — на Play, и трек продолжается ровно с того же места, где игрок его покинул. Если музыка должна смениться, поле music просто заменяется новым плеером: старую ссылку никто не держит, и сборщик забирает плеер вместе с отзвучавшим треком.

### Правила звука одним списком

- audio.Context создаётся один раз на игру: плееры берутся только из него
- Ссылка на плеер живёт в поле структуры или в пуле — иначе GC остановит звук
- Декодеры движка — WAV, MP3 и Ogg: эффекты в WAV, музыка в сжатых форматах
- Эффекты декодируются один раз в байты, плееры собираются из NewPlayerF32FromBytes
- Короткие звуки — пул плееров с Rewind и Play: наложение звуков бесплатно
- Музыка — один долгий плеер с бесконечным лупом и паузой на выходе в меню
- Громкость — SetVolume от нуля до единицы, музыка тише эффектов
- Mute хранит прошлое значение в состоянии игры и возвращает его при снятии

### Соберите запуск музыки

Восстановите два вызова: создание контекста и плеера.

```go
g.audioCtx = audio.___(sampleRate) // один на игру
...
g.music, err = g.audioCtx.___(loop)
g.music.SetVolume(0.6)
g.music.Play()
```

Подсказка: Контекст даёт пакет audio, плеер — сам контекст: оба имени начинаются с New.

Правильные ответы в пропусках: `NewContext`, `NewPlayerF32`

> **Про песочницу: пул без звука.** Сам звук требует настоящего аудиовыхода и проверяется в локальном Go, но логика пула — чистые данные: слайс ссылок, флаг IsPlaying, поиск свободного плеера. Тематическая программа в песочнице сайта гоняет модель пула: печатает, какой плеер взят под эффект, сколько ссылок живёт и не потерялась ли ни одна — те же инварианты, которые проверяет ваш будущий PlaySfx.

### Тик PlaySfx

Соберите поиск свободного плеера в пуле эффектов.

Правильный порядок строк:

1. `func (g *Game) PlaySfx() {`
2. `for _, p := range g.sfxPool {`
3. `if !p.IsPlaying() {`
4. `p.Rewind() // назад в начало`
5. `p.Play()`
6. `return // свободный найден`
7. `} // конец if`
8. `} // конец for`
9. `} // конец PlaySfx`

### go:embed: звук внутри бинаря

Все примеры выше читали звук из байтов в памяти не случайно: файлы проекта встраиваются в бинарь директивой go:embed, которая прилинковывает содержимое папки assets к программе ещё на компиляции. Для звука это три выигрыша сразу: файлы не теряются при переносе бинаря, загрузка мгновенна — декодер ест байты из памяти через bytes.NewReader, — а сборка под другие платформы не тащит за собой папку ассетов. Платить приходится размером исполняемого файла: несжатый WAV растолстеет в разы, поэтому для музыки разумнее Ogg, а WAV оставить коротким эффектам, где секунды сжатия не решают.

Встраивание не отменяет порядок в проекте: подпапки music и sfx внутри assets, осмысленные имена файлов, единая точка загрузки в конструкторе — всё это переживёт момент, когда ассетов станет сотня. Директива работает только с файлами своего пакета, поэтому байты звука объявляют в одном файле ассетов, а остальные пакеты получают готовые переменные. И помните про права: встроенный в бинарь трек с чужой лицензией остаётся чужим даже внутри исполняемого файла, и проверка лицензий — часть релиза, а не дело после него.

Игра научилась звучать: один контекст держит аудиовыход, музыка крутится бесконечным лупом, эффекты снимаются с пула, а громкость и mute подчиняются состоянию игры. Главная привычка урока — владение ссылками: плеер, который должен доиграть, обязан жить в структуре, и проверяется это не тестом, а долгой сессией. Вторая привычка — разделение ролей: короткие звуки и долгая музыка не делят ни плееров, ни логику, и именно поэтому микс не рассыпается при десятке одновременных событий. Дальше курс возвращается к картинке: в следующем уроке HUD и индикаторы соберутся из векторных примитивов без единой картинки, а следом — производительность, где выяснится, сколько стоит каждый такой кадр. Домашнее задание прикрутит звук к вашему проекту, и первый озвученный прыжок изменит ощущение от игры сильнее, чем любая тень.

### Термины урока

- **audio.Context** — Владелец аудиовыхода и микшера. Создаётся один раз на игру через audio.NewContext(44100); готовый контекст отдаёт audio.CurrentContext().
- **audio.Player** — Плеер одного потока: Play, Pause, Rewind, IsPlaying, Position и SetVolume. Создаётся из контекста методом NewPlayerF32 или NewPlayerF32FromBytes.
- **Форматы аудио** — WAV, MP3 и Ogg (Vorbis): декодеры wav, mp3 и vorbis с суффиксом F32 отдают плавающие сэмплы для плееров.
- **Ловушка GC** — Плеер без ссылок собирается сборщиком мусора вместе со звуком. Ссылка обязана жить в поле структуры или в пуле, пока звук играет.
- **NewInfiniteLoopWithIntroF32** — Обёртка потока: интро звучит один раз, остальное крутится бесконечно. Длины задаются в байтах: секунды умножить на частоту и на восемь.
- **Пул эффектов** — Слайс плееров: PlaySfx берёт свободный через Rewind и Play или заводит новый. Эффекты накладываются, ссылки не теряются.

## Коротко: что запомнить

- audio.Context — один на игру: частота 44100, создание в конструкторе, доступ через CurrentContext.
- Форматы движка — WAV, MP3 и Ogg; декодеры F32 отдают поток сэмплов для NewPlayerF32.
- Ссылка на плеер живёт в структуре или пуле: потеряли — GC остановит звук.
- Эффекты: готовые байты, пул плееров, Rewind и Play — наложение звуков бесплатно.
- Музыка: один долгий плеер с бесконечным лупом, громкость ниже эффектов.
- SetVolume — число от нуля до единицы; mute хранит прошлое значение в состоянии игры.
- Звук встраивается go:embed и едет внутри бинаря на все платформы.

## Домашнее задание

Подключите звук к проекту: заведите папку assets с двумя файлами — коротким WAV для прыжка и любым Ogg для фоновой музыки. В конструкторе Game создайте audio.NewContext(44100), декодируйте WAV в байты через wav.DecodeF32 и io.ReadAll, а музыку заверните в NewInfiniteLoopWithIntroF32 и запустите одним плеером. Реализуйте PlaySfx с пулом: свободный плеер переигрывается через Rewind, новый — добавляется в слайс ссылок. Повесьте mute на клавишу M с сохранением прошлой громкости и проверьте живое место: запустите игру минут на пять — если звук обрывается, ищите плеер без ссылок. Сбалансируйте микс: музыка тише эффектов примерно на треть. Затем повесьте отдельный звук на столкновения из урока про коллизии и убедитесь, что десять быстрых прыжков подряд не плодят плееры до бесконечности — пул обязан выйти на плато.

## Вопросы и ответы по уроку

### Почему 44100 герц, а не 48000, как в примерах движка?

Обе частоты — стандарты качественного звука: 44100 пришла из аудио компакт-дисков, 48000 — из видеопроизводства. Разницы на слух между ними нет, а декодеры движка приводят файлы к шкале контекста, поэтому важнее не выбрать идеально, а создать контекст один раз и не держать два в одной программе.

### Можно ли генерировать звук без файлов, например писк?

Да: плееру не важно, откуда поток. Реализуйте собственный тип с чтением сэмплов — синус по формуле — и отдайте его NewPlayerF32; примеры sinewave и pcm в поставке движка делают ровно это. Для ретро-эффектов генерация дешевле файлов, и бинарь от неё не толстеет.

### Эффектов десятки, и пул растёт. Что делать?

Ограничьте размер пула и при переполнении либо игнорируйте новый вызов, либо забирайте самый старый плеер. На слух очередь из десяти наложенных выстрелов превращается в кашу: три-четыре одновременных звука одного эффекта — практический потолок, дальше игра только шумит.
