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.

Ссылки

Комментарии (11)


  1. MonkAlex
    19.01.2026 06:46

    Как то можно проверить, что реализация достаточно хороша? FLAC lossless насколько я помню, значит ли это что одинаковые преобразования должны давать одинаковые результаты и можно сравнить результаты с другими энкодерами?

    UPD: лайк за статью, нравится читать такое


    1. formeo Автор
      19.01.2026 06:46

      Добрый день, спасибо за комментарий
      Вы правы FLAC это lossless, поэтому правильная реализация должна восстанавливать аудиоданные бит-в-бит. Получается если взять исходный WAV-файл, закодировать его в FLAC go-энкодером, а потом декодировать - результат должен быть полностью идентичен оригиналу

      Сейчас планирую добавить побайтовое сравнение:

      original.wav -> мой FLAC -> decoded.wav -> cmp original.wav decoded.wav

      И проверку через flac --test и совместимость с другими плеерами (VLC, Audacity и т.д.).

      Так что да — в ближайшее время сделаю полноценную валидацию и добавлю в Readme
      Спасибо за напоминание


  1. opusmode
    19.01.2026 06:46

    Автора не критикую, но всё же выражу скепсис - потратить время на то, чтобы написать свой урезанный и не факт, что функциональный вариант конвертера, ради экономии 75 мб (тут не очень понятно, о чём речь, видимо о каком-то из static build)

    Просто в alpine он установленный весит 600 кб, да и с зависимостями там мегабай 25-30 наберётся

    Никакого усложнения CI при этом не видно

    В общем, боли не очень понятны, решение мало что даёт, но отбирает поддерживаемое, обновляемое, проверенное решение, заменяя его достаточно спорным


    1. formeo Автор
      19.01.2026 06:46

      Добрый день, спасибо за комментарий, у меня было 2 цели
      1. у меня есть несколько утилит по звуку, и хотелось замкнуть зависимости на свой репозиторий
      2. академический интерес - написать свой конвертер, в процессе выяснилось, что для FLAC энкодера на го нет - было интересно свой попробовать написать


      1. opusmode
        19.01.2026 06:46

        Оба пункта волне легитимны и вы имеете на это полное право.

        Скепсис именно к формулировке болей


  1. yrub
    19.01.2026 06:46

    "Бенчмарки": так а почему в них нет FFmpeg? :)


    1. formeo Автор
      19.01.2026 06:46

      Добрый день, ух)
      По правде сказать - специально не сравнивал - разные задачи. FFmpeg быстрее, но тянет зависимости. Тут цель была - один бинарник, скачал и работает. Бенчмарки показывают что хватает с запасом.


  1. hard2018
    19.01.2026 06:46

    Я не был знаком со структурой FLAC, однако я вижу что вычисление CRC занимает значительную часть кода.
    Не пробовали сами реализовать подсчёт CRC? Его можно подсчитывать последовательно или табличным методом. Вплоть до того, что для каждого байта одной ассемблерной инструкцией. Учитывая применение для каждого чанка, в результате малейшей оптимизации, ускорение будет удивительным безо всякого распараллеливания.


    1. formeo Автор
      19.01.2026 06:46

      Добрый день, спасибо за коммент,
      Табличный метод уже используется - lookup table 256 байт для CRC-8 и CRC-16, один XOR + lookup на байт:
      crc8 = crc8Table[crc8 ^ byteVal]
      crc16 = (crc16 << 8) ^ crc16Table[(crc16>>8) ^ byteVal]

      По профайлеру основное время в Rice coding, а не в CRC. Но если найдутся кейсы где CRC станет узким местом - попробую slicing-by-4.


  1. xaoc80
    19.01.2026 06:46

    Ffmpeg можно собрать самому без зависимостей, которые вам не нужны. Скажем указать только нужные вам кодеки. Это делается при помощи configure перед сборкой. Я думаю раз в 10 можно объем зависимостей уменьшить и бонусом будет более новая версия ffmpeg.


    1. checkpoint
      19.01.2026 06:46

      ffmpeg - отличный швейцарский нож, но для всякого embedded уже не подходит. Сталкивался с этим, пришлось использовать отдельные библиотечки для каждого кодека (libhelix-mp3 например). Ну и длинный список зависимостей очень напрягается, выкинуть их все с помощью ./configure не получится - некоторые вросли глубоко своими корнями.