Как я отказался от FFmpeg и написал FLAC энкодер за 500 строк на Go
- вторник, 20 января 2026 г. в 00:00:12
TL;DR: Надоело тащить 80 МБ FFmpeg ради конвертации аудио. Написал конвертер на чистом Go - один бинарник 5 МБ, без зависимостей, работает на любой платформе. Бонусом - реализовал FLAC энкодер с нуля, потому что готового pure Go решения не существовало.
У меня есть проект music_recognition — распознавание музыки через Shazam. Для работы нужно конвертировать аудио между форматами. Стандартное решение — FFmpeg.
Проблема: FFmpeg умеет все. Транскодировать 4K, стримить по RTMP, делать цветокоррекцию. Это как разворачивать Kubernetes, чтобы запустить один скрипт.
Конкретные боли:
80+ МБ на полную сборку
Нужно устанавливать отдельно или тащить в Docker
На CI/CD — дополнительная сложность
Кросс-компиляция — отдельная история
Решение: написать свой конвертер. Один бинарник - скачал и запустил.
Главное требование - один бинарник без зависимостей. Скачал, запустил, работает.
Go дает:
Статическая компиляция - результат не требует рантайма, библиотек, PATH
Кросс-компиляция из коробки -GOOS=windows go build и готово
Скорость на уровне C - нативный машинный код
Но был нюанс: большинство аудиобиблиотек на Go используют CGO — биндинги к C-библиотекам (libmp3lame, libvorbis, libFLAC). CGO ломает главное преимущество: для кросс-компиляции нужен кросс-компилятор C под каждую платформу. Прощай, простота.
Задача: найти pure Go библиотеки для всех форматов. Или написать самому.
WAV — простейший формат: заголовок + сырые PCM-данные. Библиотека go-audio/wav решает все:
decoder := wav.NewDecoder(file)
buf := &audio.IntBuffer{Data: make([]int, 4096)}
for {
n, err := decoder.PCMBuffer(buf)
if n == 0 { break }
// Обрабатываем buf.Data[:n]
}
hajimehoshi/go-mp3 - pure Go декодер от создателя игрового движка Ebiten. Проверен временем:
decoder, _ := mp3.NewDecoder(file)
pcmData, _ := io.ReadAll(decoder)
// pcmData — сырые сэмплы, little-endian stereo
LAME - стандарт MP3-кодирования - написан на C. Pure Go альтернатива: braheezy/shine-mp3 — порт библиотеки Shine от Xiph.org.
encoder := shine.NewEncoder(44100, 2) // sample rate, channels
encoder.Write(outputFile, samples)
Это не LAME, но для 90% задач достаточно. Файлы чуть больше при том же битрейте — но мы не за экстремальным сжатием гонимся.
jfreymuth/oggvorbis - отлично работает:
reader, _ := oggvorbis.NewReader(file)
buf := make([]float32, 8192)
for {
n, err := reader.Read(buf)
// buf[:n] — float32 сэмплы [-1.0, 1.0]
}
Pure Go энкодера Vorbis не существует. Vorbis - сложный кодек с психоакустической моделью. Портировать libvorbis на Go пока никто не взялся.
mewkiz/flac - полноценный декодер:
stream, _ := flac.New(file)
for {
frame, err := stream.ParseNext()
if err == io.EOF { break }
// frame.Subframes[channel].Samples
}
Pure Go энкодера FLAC не существовало. До этого момента.
Пришлось написать самому.
┌──────────────┐
│ "fLaC" (4B) │ Magic number
├──────────────┤
│ STREAMINFO │ Метаданные (34 байта)
├──────────────┤
│ FRAME 1 │ Сжатые аудио данные
├──────────────┤
│ FRAME 2 │
├──────────────┤
│ ... │
└──────────────┘
34 байта метаданных, упакованных по битам:
// Min/max block size (по 16 бит)
// Min/max frame size (по 24 бита)
// Sample rate (20 бит) + channels-1 (3 бита) + bps-1 (5 бит) + total samples (36 бит)
// MD5 сигнатура (128 бит)
packed := (sampleRate << 44) | (channels << 41) | (bps << 36) | totalSamples
MD5 считается от исходных PCM данных - для верификации после декодирования.
Каждый frame:
Header - sync code, размер блока, sample rate, channels, CRC-8
Subframes - по одному на канал, тут сжатие
Footer - CRC-16 всего frame
// Sync code (14 бит): 0x3FFE
bw.WriteBits(0x3FFE, 14)
// Reserved (1 бит)
bw.WriteBits(0, 1)
// Blocking strategy (1 бит): 0 = fixed block size
bw.WriteBits(0, 1)
FLAC поддерживает 4 типа предсказания:
VERBATIM - без сжатия, сырые сэмплы
CONSTANT - все сэмплы одинаковые (тишина)
FIXED - LPC с фиксированными коэффициентами
LPC - адаптивное линейное предсказание
Для первой версии реализовал FIXED - оптимальный баланс сложности и эффективности.
Вместо абсолютных значений храним разницу между предсказанным и реальным:
Порядок | Предсказание | Residual |
|---|---|---|
0 | 0 | sample[i] |
1 | предыдущий | sample[i] - sample[i-1] |
2 | линейная экстраполяция | sample[i] - 2*sample[i-1] + sample[i-2] |
Для гладкого сигнала (синусоида) порядок 2 дает residuals близкие к нулю - отлично сжимается!
func computeFixedResiduals(samples []int32, order int) []int32 {
residuals := make([]int32, len(samples)-order)
switch order {
case 1:
for i := 1; i < len(samples); i++ {
residuals[i-1] = samples[i] - samples[i-1]
}
case 2:
for i := 2; i < len(samples); i++ {
residuals[i-2] = samples[i] - 2*samples[i-1] + samples[i-2]
}
}
return residuals
}
Residuals - числа около нуля. Rice coding идеально подходит.
Идея: разбиваем число на две части:
Quotient (старшие биты) - унарный код: 5 → 111110
Remainder (младшие k бит) - обычный двоичный
func (bw *BitWriter) WriteSignedRice(value int32, k int) error {
// Zig-zag: 0→0, -1→1, 1→2, -2→3, 2→4...
var uval uint32
if value >= 0 {
uval = uint32(value) << 1
} else {
uval = (uint32(-value-1) << 1) | 1
}
q := uval >> k // Quotient
r := uval & ((1<<k) - 1) // Remainder
bw.WriteUnary(q) // q единиц + 0
bw.WriteBits(r, k) // k бит
return nil
}
Параметр k подбирается динамически для минимизации размера.
FLAC требует побитовой записи. Стандартный io.Writer работает с байтами:
type BitWriter struct {
w io.Writer
buf uint64 // Буфер накопления
bits int // Бит в буфере
}
func (b *BitWriter) WriteBits(value uint64, n int) error {
b.buf = (b.buf << n) | (value & ((1 << n) - 1))
b.bits += n
// Сбрасываем полные байты
for b.bits >= 8 {
b.bits -= 8
byteVal := byte(b.buf >> b.bits)
b.w.Write([]byte{byteVal})
}
return nil
}
Для каждого блока пробуем все порядки (0-4) и выбираем лучший:
func (e *Encoder) encodeSubframe(bw *BitWriter, samples []int32) error {
bestOrder := 0
bestSize := int64(1<<63 - 1)
for order := 0; order <= 4; order++ {
residuals := computeFixedResiduals(samples, order)
size := estimateRiceSize(residuals)
if size < bestSize {
bestSize = size
bestOrder = order
}
}
// Fallback на VERBATIM, если сжатие невыгодно
verbatimSize := int64(len(samples) * bitsPerSample)
if verbatimSize < bestSize {
return e.encodeVerbatimSubframe(bw, samples)
}
return e.encodeFixedSubframe(bw, samples, bestOrder)
}
Рабочий FLAC энкодер: ~500 строк чистого Go. Без CGO, без внешних зависимостей.
Тип контента | Сжатие | Комментарий |
|---|---|---|
Тишина | 95%+ | CONSTANT идеально |
Синусоида | 50-70% | FIXED order 2 |
Музыка | 30-50% | Зависит от сложности |
Шум | ~0% | Fallback на VERBATIM |
Это не уровень libFLAC (LPC даёт +10-20%), но для pure Go — более чем достойно.
Формат | Decode | Encode | Примечание |
|---|---|---|---|
WAV | ✅ pure Go | ✅ pure Go | Тривиально |
MP3 | ✅ pure Go | ✅ pure Go (shine) | Альтернатива LAME |
FLAC | ✅ pure Go | ✅ pure Go | FIXED prediction |
OGG | ✅ pure Go | ❌ | Требуется CGO |
WAV декодирование: ~1.2 мс/сек аудио
MP3 кодирование: ~24 мс/сек аудио
FLAC кодирование: ~15 мс/сек аудио
Всё быстрее реального времени — можно конвертировать на лету.
# Установка
go install github.com/formeo/go-audio-converter/cmd/audioconv@latest
# Конвертация
audioconv input.flac output.mp3
audioconv input.ogg output.flac
audioconv input.wav output.flac
# Вывод
Converting: input.ogg (ogg) -> output.flac (flac)
Done in 1.2s (4.2 MB)
Pure Go аудио — реальность. Декодировать можно все, кодировать — основные форматы.
Создание энкодера — не rocket science. Спецификация FLAC открыта. Сложнее всего — битовая арифметика и оптимизация.
FIXED prediction — отличный старт. LPC даст лучшее сжатие, но FIXED уже обеспечивает рабочее решение.
Один бинарник решает проблемы деплоя. Никаких "установите FFmpeg", "добавьте в PATH".
Иногда лучше написать самому. Если библиотеки нет — реализация может оказаться проще, чем кажется.
Этот конвертер — часть моей работы над аудио-инструментами. В следующей статье расскажу про очистку аудиокниг от шума с помощью AI — там MDX-Net, Roformer и много интересного про source separation.