Введение

Бывают дни, когда на работе делать нечего. А бывают дни, когда ты — программист и звукорежиссёр одновременно, и в голову приходит странная мысль: «А что, если взять аудио, превратить его в картинку-спектрограмму, сжать эту картинку как фотографию (JPEG, WebP, AVIF), а потом попробовать восстановить звук обратно? Как оно будет звучать?»

Спойлер: иногда — удивительно хорошо. Иногда — как из унитаза. Но всегда — интересно.

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

Идея

Спектрограмма — это визуальное представление звука: по горизонтали — время, по вертикали — частота, цвет — амплитуда. Если сохранить спектрограмму как картинку, а потом сжать её с потерями (как JPEG), то при восстановлении звука обратно мы получим... артефакты сжатия, но уже в аудио! Именно это я и хотел услышать.

Для стерео я использовал Mid/Side представление:

  • Зелёный канал (G) — Mid (моно-сумма левого и правого)
  • Синий канал (B) — Side (разница между левым и правым)
  • Красный канал (R) — не используется (пока)

Амплитуды логарифмируются в децибелы и маппятся в диапазон 0–255 (8 бит на канал). Частоты выше порога автоматически обрезаются для экономии места. Затем картинка сохраняется в нужном формате.

При декодировании фаза восстанавливается через алгоритм Гриффина-Лима (Griffin-Lim), потому что в спектрограмме мы храним только амплитуду, а фаза теряется.

Важное замечание о формате аудио

Прежде чем мы перейдём к деталям — один технический момент. Все восстановленные WAV-файлы я, разумеется, не выкладываю как есть. Во-первых, это было бы жестоко по отношению к серверу (70 секунд стерео 44.1/16 — это ~12 мегабайт на каждый тест, а тестов у нас 18). Во-вторых, это просто бессмысленно — WAV нужен только как промежуточный формат при обработке.

Все аудиопримеры, которые вы услышите, упакованы в Opus 128 kbps. Это современный, исключительно эффективный кодек, который на битрейте 128 kbps обеспечивает прозрачное качество — то есть WAV и Opus на этих настройках звучат абсолютно идентично для человеческого уха, но файл весит в 10 раз меньше. Так что вы не теряете ровным счётом ничего в качестве прослушивания, а сервер скажет вам спасибо.

Для интересующихся: Opus — это open-source кодек от IETF (RFC 6716), используемый в YouTube, WhatsApp, Discord и WebRTC. На битрейте 128 kbps для стерео он работает в гибридном режиме: нижние частоты кодируются линейным SILK-кодеком, верхние — MDCT на основе CELT. Проще говоря — это лучшее, что есть в lossy audio на сегодня.

Архитектура проекта

Проект состоит из нескольких модулей:

config.py          — пресеты FFT и дефолтные настройки
encoder.py         — аудио → изображение
decoder.py         — изображение → аудио
phase_generator.py — алгоритм Гриффина-Лима для восстановления фазы
transforms.py      — Mid/Side ↔ Left/Right преобразования
utils.py           — утилиты (JSON, размеры файлов, очистка)
main.py            — одиночный прогон пайплайна
test_runner.py     — батч-тестирование форматов сжатия

Основной пайплайн (main.py)

Полный цикл выглядит так:

def full_pipeline(config: dict):
    """Полный цикл: аудио -> изображение -> аудио (с генерацией фазы)."""
    preset = PRESETS[config["active_preset"]]
    n_fft = preset["N_FFT"]
    hop_length = preset["HOP_LENGTH"]

    # Шаг 1: MP3 -> WAV
    wav_temp = Path(data_dir) / "temp_stereo.wav"
    mp3_to_wav(config["mp3_file"], str(wav_temp))

    # Шаг 2: WAV -> изображение
    image_path = str(Path(data_dir) / f"spectrogram.{ext}")
    metadata, _ = audio_to_image(wav_temp, image_path, n_fft, hop_length, config)

    # Шаг 3: изображение -> WAV
    recovered_path = str(Path(data_dir) / "recovered.wav")
    audio_recovered = image_to_audio(image_path, recovered_path, metadata)

    return audio_recovered

Кодирование в изображение (encoder.py)

Ключевой фрагмент — преобразование аудио в RGB-картинку:

def audio_to_image(wav_path, image_path, n_fft, hop_length, config):
    y, sr = librosa.load(wav_path, sr=44100, mono=False)

    # Mid/Side преобразование
    mid = (y[0] + y[1]) * 0.5
    side = (y[0] - y[1]) * 0.5

    # STFT
    D_mid = librosa.stft(mid, n_fft=n_fft, hop_length=hop_length, window='hann')
    D_side = librosa.stft(side, n_fft=n_fft, hop_length=hop_length, window='hann')

    # В децибелы и в 0..255
    mag_mid_db = librosa.amplitude_to_db(np.abs(D_mid), ref=np.max)
    mag_mid_norm = np.clip((mag_mid_db - mag_min) / (-mag_min) * 255, 0, 255).astype(np.uint8)

    # RGB: G=Mid, B=Side, R=0
    rgb = np.zeros((n_freqs, n_frames, 3), dtype=np.uint8)
    rgb[:, :, 1] = mag_mid_norm   # Зелёный = Mid
    rgb[:, :, 2] = mag_side_norm  # Синий = Side

    img = Image.fromarray(np.flipud(rgb), 'RGB')
    # ... сохранение через PIL с параметрами качества

Автоматический срез высоких частот — экономим место, отбрасывая то, что всё равно не слышно:

def _find_high_cut_auto(mag_mid_db, mag_side_db, freqs, threshold_db=-80, freq_min=8000):
    mean_mag = np.maximum(np.mean(mag_mid_db, axis=1), np.mean(mag_side_db, axis=1))
    # Сглаживание и поиск первого стабильного падения ниже порога
    below_threshold = mean_mag_smooth < effective_threshold
    for i in range(min_idx, len(freqs) - 5):
        if np.all(below_threshold[i:i+5]):
            return freqs[i], i

Декодирование и восстановление фазы (decoder.py + phase_generator.py)

Самая сложная часть — восстановление утерянной фазы:

def image_to_audio(image_path, output_wav_path, metadata):
    img = Image.open(image_path).convert('RGB')
    arr = np.array(img, dtype=np.float32)

    # Достаём Mid и Side из зелёного и синего каналов
    mag_mid_norm = arr[:, :, 1] / 255.0  # Зелёный
    mag_side_norm = arr[:, :, 2] / 255.0  # Синий

    # Обратно из dB в амплитуду
    mag_mid = librosa.db_to_amplitude(mag_mid_db, ref=ref_mid)

    # Генерация фазы через Griffin-Lim (fast, parallel)
    phase_mid, phase_side = griffin_lim_stereo_parallel(
        mag_mid, mag_side, n_fft, hop_length,
        iterations=5000, mode='fast'
    )

    # Восстановление комплексного спектра и обратное STFT
    D_mid = mag_mid * np.exp(1j * phase_mid)
    y_mid = librosa.istft(D_mid, hop_length=hop_length, window='hann')

    # Mid/Side -> Left/Right
    left = mid + side
    right = mid - side

    return np.stack([left, right], axis=1)

Алгоритм Гриффина-Лима (fast-версия с memory layout оптимизациями):

def griffin_lim_fast(magnitude, n_fft, hop_length, iterations=50, ...):
    rng = np.random.RandomState(random_seed)
    angles = rng.uniform(-np.pi, np.pi, magnitude.shape).astype(np.float32)

    for i in range(iterations):
        # Собираем комплексный спектр с текущей фазой
        stft_matrix = magnitude * np.exp(1j * angles)
        # ISTFT -> STFT для получения новой оценки фазы
        y = librosa.istft(stft_matrix, hop_length=hop_length)
        D_new = librosa.stft(y, n_fft=n_fft, hop_length=hop_length)
        angles = np.angle(D_new)

        # Early stopping
        if improvement < early_stop_threshold:
            patience_counter += 1
            if patience_counter >= early_stop_patience:
                return best_angles

    return angles

Тестирование форматов сжатия (test_runner.py)

Я написал автотестер, который для каждого формата и уровня качества:

  • Конвертирует MP3 → WAV
  • Кодирует в изображение
  • Декодирует обратно в WAV
  • Считает размер файла и время

Результаты сохраняются в отдельные папки с отчётами. Вот конфигурации тестов:

TEST_CONFIGS = {
    "png_max":       {"output_format": "png",   "output_quality": 9,   "output_lossless": True},
    "jpeg_q100":     {"output_format": "jpeg",  "output_quality": 100},
    "jpeg_q75":      {"output_format": "jpeg",  "output_quality": 75},
    "jpeg_q50":      {"output_format": "jpeg",  "output_quality": 50},
    "jpeg_q25":      {"output_format": "jpeg",  "output_quality": 25},
    "jpeg_q5":       {"output_format": "jpeg",  "output_quality": 5},
    "webp_q100":     {"output_format": "webp",  "output_quality": 100},
    # ... и так далее для WebP и AVIF
}

Результаты тестов

Важное замечание: SNR в этих тестах не показателен, потому что восстановленный сигнал сдвинут по фазе относительно оригинала — пики могут не совпадать, хотя звучит всё приемлемо. Поэтому оценивать качество лучше на слух (аудиопримеры приложены к статье для каждого теста).

Тест Размер (MB) Время кодирования (сек) Время декодирования (сек) Файлы
PNG (lossless) 8.47 1.9 299.4 spectrogram.png / recovered.opus
WebP lossless 6.62 13.2 294.2 spectrogram.webp / recovered.opus
WebP q100 3.16 2.8 185.5 spectrogram.webp / recovered.opus
WebP q75 0.66 1.7 170.0 spectrogram.webp / recovered.opus
WebP q50 0.35 1.4 164.3 spectrogram.webp / recovered.opus
WebP q25 0.16 1.2 162.5 spectrogram.webp / recovered.opus
WebP q5 0.05 1.0 176.7 spectrogram.webp / recovered.opus
JPEG q100 4.57 1.1 205.7 spectrogram.jpg / recovered.opus
JPEG q75 0.76 0.5 158.9 spectrogram.jpg / recovered.opus
JPEG q50 0.45 0.5 139.3 spectrogram.jpg / recovered.opus
JPEG q25 0.22 0.5 162.3 spectrogram.jpg / recovered.opus
JPEG q5 0.04 0.5 203.5 spectrogram.jpg / recovered.opus
AVIF lossless 4.06 21.1 194.3 spectrogram.avif / recovered.opus
AVIF q100 4.06 20.9 188.1 spectrogram.avif / recovered.opus
AVIF q75 1.27 40.4 164.0 spectrogram.avif / recovered.opus
AVIF q50 0.28 30.8 157.9 spectrogram.avif / recovered.opus
AVIF q25 0.02 11.6 171.1 spectrogram.avif / recovered.opus
AVIF q5 0.004 5.1 176.1 spectrogram.avif / recovered.opus

Исходный MP3: 44.4 MB (264 сек, обработанный фрагмент — 70 сек)
Размер восстановленного WAV (70 сек стерео 44.1/16): ~12.1 MB
Аудиопримеры выложены в Opus 128 kbps: ~1.1 MB каждый

Анализ

PNG и lossless-форматы. PNG (8.47 MB) и WebP lossless (6.62 MB) — самые большие, но это честное сжатие без потерь. Интересно, что WebP lossless сжал лучше PNG — примерно на 22%. При этом PNG кодируется быстрее всех — 1.9 секунды против 13.2 у WebP lossless.

Lossy-сжатие: экстремальные значения. Самый маленький файл — AVIF q5 (4 KB!). На 70 секунд стерео-аудио! WebP q5 выдал 51 KB, JPEG q5 — 43 KB. Это сжатие в ~1000 раз относительно WAV и в ~250 раз относительно MP3 исходного качества. Четыре килобайта на минуту с лишним звука — с ума сойти.

Скорость кодирования. JPEG — абсолютный чемпион: 0.5 секунды на любое качество. AVIF, наоборот, самый медленный — до 40 секунд на высоком качестве. Оно и понятно: AVIF использует значительно более сложный кодек (внутри — AV1), который делает намного больше вычислений для достижения такой плотности сжатия.

Скорость декодирования. Время декодирования почти одинаковое (~160–200 сек), так как основное время съедает алгоритм Гриффина-Лима (до 5000 итераций), а не чтение картинки. JPEG чуть медленнее из-за характерных блочных артефактов — алгоритму требуется больше итераций, чтобы «сгладить» их.

Интересные наблюдения:

WebP q100 (3.16 MB) и AVIF q100 (4.06 MB) дают заметно больший размер, чем JPEG q75 (0.76 MB), но звучат... по-разному — - JPEG добавляет характерный «звон», а WebP и AVIF артефачат более «гладко»
- AVIF lossless и AVIF q100 дали одинаковый размер (4.06 MB) — видимо, на этих данных кодер решил, что q100 эквивалентен lossless
- WebP q5 показал парадоксально неплохое звучание при 51 KB — для голосовых записок или подкастов может быть интересно
- JPEG q5 (43 KB) звучит откровенно плохо, но слова разобрать можно — блочная структура JPEG даёт характерное «квакающее» эхо на высоких

Как это звучит?

К статье приложены файлы spectrogram. и recovered.opus* для каждого теста (напоминаю: аудио в Opus 128 kbps, потому что WAV бессмысленно гонять через интернет). Вот некоторые субъективные впечатления:

  • PNG/WebP lossless: как оригинал, но с характерной «шероховатостью» от Гриффина-Лима — лёгкий фазовый шум, к которому быстро привыкаешь. Это baseline, лучше чего уже не сделать без сохранения фазы.
  • JPEG q75: удивительно достойно, лёгкое «звенящее» послезвучие на высоких, но музыка остаётся музыкой
  • JPEG q50: заметное «жужжание» и потеря деталей в верхах, середина ещё терпима
  • JPEG q5: звук как из консервной банки, переданный по факсу, но слова разобрать можно — характерный блочный артефакт 8×8 пикселей превращается в ритмичный треск на высоких
  • WebP q5: на удивление чище, чем JPEG на тех же битрейтах — WebP артефачит более «гладко», без резких блочных границ
  • AVIF q5 (4 KB!): шум, треск, артефакты — но факт, что это вообще работает, поражает. Звук отдалённо напоминает оригинал, как будто слушаешь через трубу в ветреную погоду

Как повторить самому

  • Клонируйте исходники (ссылка в конце статьи)
  • Установите зависимости:
pip install numpy librosa soundfile Pillow pydub scipy
  • Для AVIF поддержки может понадобиться:
pip install pillow-avif-plugin
# или
pip install pillow-heif
  • Положите MP3-файл в папку проекта и назовите track.mp3 (или измените путь в config.py)
  • Запустите одиночный тест:
python main.py

Результаты появятся в папке data/: спектрограмма и восстановленный WAV.

  • Запустите батч-тестирование всех форматов:
python test_runner.py

Создаст папку test_results_[timestamp]/ с отдельными подпапками для каждого теста. В каждой — spectrogram, recovered.wav, metadata.json и report.txt. Общий сводный отчёт будет в summary_report.txt.

  • Настройте пресеты и качество в config.py — там всё задокументировано:
PRESETS = {
    "75p_n4096": {"N_FFT": 4096, "HOP_LENGTH": 1024},  # 75% overlap
    "87p_n4096": {"N_FFT": 4096, "HOP_LENGTH": 512},   # 87.5% overlap
    # ...
}

DEFAULT_CONFIG = {
    "mp3_file": "track.mp3",
    "trim_start": 60.0,      # Начало фрагмента в секундах
    "trim_end": 130.0,       # Конец (0 = до конца)
    "phase_generate_iterations": 5000,
    "griffin_lim_mode": "fast",
    # ...
}

Заключение

Этот эксперимент показал, что современные форматы сжатия изображений, особенно AVIF и WebP, могут фантастически эффективно упаковывать спектрограммы. Мы говорим о сжатии в сотни и тысячи раз — 70 секунд стерео-аудио в 4 килобайтах. Да, с артефактами, но само то, что это возможно — впечатляет.

Практического смысла в этом, конечно, маловато (MP3 и Opus справляются с аудиосжатием куда лучше, потому что заточены именно под особенности человеческого слуха). Но как эксперимент на стыке двух областей — аудио и изображений — это было чертовски интересно. И теперь я знаю, как звучит JPEG.

К статье приложены:

  • Исходники проекта (config.py, encoder.py, decoder.py, phase_generator.py, transforms.py, utils.py, main.py, test_runner.py)
  • Для каждого теста: spectrogram.* + recovered.opus (128 kbps) + metadata.json + report.txt
  • Сводный отчёт summary_report.txt

Все исходники распространяются свободно — делайте что хотите. Если натренируете нейросеть «слышать» JPEG-артефакты — дайте знать :)

скачать проект