Как звучит JPEG? Или что будет, если сжать спектрограмму как фотографию

Страницы:  1

Ответить
 

Professor Seleznov


Введение
Бывают дни, когда на работе делать нечего. А бывают дни, когда ты — программист и звукорежиссёр одновременно, и в голову приходит странная мысль: «А что, если взять аудио, превратить его в картинку-спектрограмму, сжать эту картинку как фотографию (JPEG, WebP, AVIF), а потом попробовать восстановить звук обратно? Как оно будет звучать?»
Спойлер: иногда — удивительно хорошо. Иногда — как из унитаза. Но всегда — интересно.
В этой статье я расскажу, как реализовал весь этот пайплайн, покажу код, проведу батч-тесты разных форматов и уровней качества, и, конечно, дам послушать результаты. Все исходники прилагаются, и вы сможете повторить эксперимент сами.
Идея
Спектрограмма — это визуальное представление звука: по горизонтали — время, по вертикали — частота, цвет — амплитуда. Если сохранить спектрограмму как картинку, а потом сжать её с потерями (как JPEG), то при восстановлении звука обратно мы получим… артефакты сжатия, но уже в аудио! Именно это я и хотел услышать.
Для стерео я использовал Mid/Side представление:
  • Зелёный канал (G) — Mid (моно-сумма левого и правого)
  • Синий канал (B) — Side (разница между левым и правым)
  • Красный канал ® — не используется (пока)
Амплитуды логарифмируются в децибелы и маппятся в диапазон 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
Декодирование и восстановление фазы (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.mp 3 (или измените путь в 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.
К статье приложены: Все исходники распространяются свободно — делайте что хотите. Если натренируете нейросеть «слышать» JPEG-артефакты — дайте знать -Источник
 
Loading...
Error