golang

Как я отказался от FFmpeg и написал FLAC энкодер за 500 строк на Go

  • вторник, 20 января 2026 г. в 00:00:12
https://habr.com/ru/articles/985442/

TL;DR: Надоело тащить 80 МБ FFmpeg ради конвертации аудио. Написал конвертер на чистом Go - один бинарник 5 МБ, без зависимостей, работает на любой платформе. Бонусом - реализовал FLAC энкодер с нуля, потому что готового pure Go решения не существовало.

Зачем это все

У меня есть проект music_recognition — распознавание музыки через Shazam. Для работы нужно конвертировать аудио между форматами. Стандартное решение — FFmpeg.

Проблема: FFmpeg умеет все. Транскодировать 4K, стримить по RTMP, делать цветокоррекцию. Это как разворачивать Kubernetes, чтобы запустить один скрипт.

Конкретные боли:

  • 80+ МБ на полную сборку

  • Нужно устанавливать отдельно или тащить в Docker

  • На CI/CD — дополнительная сложность

  • Кросс-компиляция — отдельная история

Решение: написать свой конвертер. Один бинарник - скачал и запустил.

Почему Go

Главное требование - один бинарник без зависимостей. Скачал, запустил, работает.

Go дает:

  • Статическая компиляция - результат не требует рантайма, библиотек, PATH

  • Кросс-компиляция из коробки -GOOS=windows go build и готово

  • Скорость на уровне C - нативный машинный код

Но был нюанс: большинство аудиобиблиотек на Go используют CGO — биндинги к C-библиотекам (libmp3lame, libvorbis, libFLAC). CGO ломает главное преимущество: для кросс-компиляции нужен кросс-компилятор C под каждую платформу. Прощай, простота.

Задача: найти pure Go библиотеки для всех форматов. Или написать самому.

Обзор pure Go решений

WAV — тривиально

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]
}

MP3 — декодирование

hajimehoshi/go-mp3 - pure Go декодер от создателя игрового движка Ebiten. Проверен временем:

decoder, _ := mp3.NewDecoder(file)
pcmData, _ := io.ReadAll(decoder)
// pcmData — сырые сэмплы, little-endian stereo

MP3 — кодирование

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% задач достаточно. Файлы чуть больше при том же битрейте — но мы не за экстремальным сжатием гонимся.

OGG/Vorbis — декодирование

jfreymuth/oggvorbis - отлично работает:

reader, _ := oggvorbis.NewReader(file)
buf := make([]float32, 8192)
for {
    n, err := reader.Read(buf)
    // buf[:n] — float32 сэмплы [-1.0, 1.0]
}

OGG/Vorbis — кодирование (проблема)

Pure Go энкодера Vorbis не существует. Vorbis - сложный кодек с психоакустической моделью. Портировать libvorbis на Go пока никто не взялся.

FLAC — декодирование

mewkiz/flac - полноценный декодер:

stream, _ := flac.New(file)
for {
    frame, err := stream.ParseNext()
    if err == io.EOF { break }
    // frame.Subframes[channel].Samples
}

FLAC — кодирование (вызов принят!)

Pure Go энкодера FLAC не существовало. До этого момента.

Пришлось написать самому.

Пишем FLAC энкодер с нуля

Структура файла

┌──────────────┐
│ "fLaC" (4B)  │  Magic number
├──────────────┤
│ STREAMINFO   │  Метаданные (34 байта)
├──────────────┤
│ FRAME 1      │  Сжатые аудио данные
├──────────────┤
│ FRAME 2      │
├──────────────┤
│ ...          │
└──────────────┘

STREAMINFO — паспорт файла

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 — где происходит магия

Каждый 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)

Subframe — типы сжатия

FLAC поддерживает 4 типа предсказания:

  • VERBATIM - без сжатия, сырые сэмплы

  • CONSTANT - все сэмплы одинаковые (тишина)

  • FIXED - LPC с фиксированными коэффициентами

  • LPC - адаптивное линейное предсказание

Для первой версии реализовал FIXED - оптимальный баланс сложности и эффективности.

FIXED prediction — идея

Вместо абсолютных значений храним разницу между предсказанным и реальным:

Порядок

Предсказание

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
}

Rice coding — сжатие 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 подбирается динамически для минимизации размера.

BitWriter — побитовая запись

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

Бенчмарки (M1 Mac)

  • 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)

Выводы

  1. Pure Go аудио — реальность. Декодировать можно все, кодировать — основные форматы.

  2. Создание энкодера — не rocket science. Спецификация FLAC открыта. Сложнее всего — битовая арифметика и оптимизация.

  3. FIXED prediction — отличный старт. LPC даст лучшее сжатие, но FIXED уже обеспечивает рабочее решение.

  4. Один бинарник решает проблемы деплоя. Никаких "установите FFmpeg", "добавьте в PATH".

  5. Иногда лучше написать самому. Если библиотеки нет — реализация может оказаться проще, чем кажется.

Что дальше

Этот конвертер — часть моей работы над аудио-инструментами. В следующей статье расскажу про очистку аудиокниг от шума с помощью AI — там MDX-Net, Roformer и много интересного про source separation.

Ссылки