|
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-артефакты — дайте знать
-Источник
|