Глава 2. Учимся резать и склеивать звук

О чём эта глава

В предыдущей главе мы смотрели на звук и изучали его свойства. Мы были как учёные, которые исследуют образец под микроскопом. Теперь пришло время стать ремесленниками. Мы возьмём аудиофайлы в руки и начнём с ними работать: отрезать лишнее, соединять нужное, переставлять куски местами. Всё то, что обычно делается мышкой в программах вроде Audacity, мы будем делать кодом. Почему кодом, а не мышкой? Потому что код можно запустить повторно. Потому что код не устаёт и не ошибается от усталости. Потому что код может обработать сто файлов за время, которое вы тратите на ручную обработку одного. В этой главе мы напишем инструменты, которые превратят монтаж аудио из утомительной рутины в быстрое и приятное занятие. Мы научимся вырезать фрагменты из аудио, склеивать несколько файлов в один, добавлять паузы нужной длины, выравнивать громкость разных фрагментов и собирать полноценный эпизод подкаста из заготовок. К концу главы у вас будет скрипт, который делает всю черновую работу по монтажу за вас.

Как резать аудио в коде

Начнём с самого простого действия: отрезать кусок от начала или конца записи. Представьте, что вы записали подкаст. Вы начали запись, проверили микрофон, сказали «раз-раз, меня слышно?», потом выпили воды, прокашлялись и только через три минуты начали говорить по делу. Эти три минуты не должны попасть в финальную версию. Более того, в конце записи вы, скорее всего, скажете что-то вроде «Всё, выключаю запись. Ой, она ещё пишет? Ну всё, пока». Эти последние десять секунд тоже нужно убрать. В графическом редакторе вы бы выделили ненужный фрагмент мышкой и нажали Delete. В коде мы сделаем то же самое, только вместо мышки у нас будут индексы массива.

Вспомним, что наш аудиофайл — это массив чисел y. Каждое число имеет свой порядковый номер, или индекс. Первое число имеет индекс ноль, второе — индекс один, сотое — индекс девяносто девять. В Python мы можем взять любую часть массива, указав начальный и конечный индекс в квадратных скобках через двоеточие. Это называется срез. Если мы хотим убрать первые три минуты, нам нужно выбросить все числа с нулевого индекса до индекса, соответствующего трём минутам. А этот индекс мы вычисляем умножением: три минуты — это сто восемьдесят секунд, умножаем на частоту дискретизации 22050, получаем около четырёх миллионов. Значит, нам нужны все числа начиная с индекса четыре миллиона и до конца массива.

Вот как это выглядит в коде:

python

import librosa

import soundfile as sf


y, sr = librosa.load('long_record.wav')


# Сколько секунд отрезать от начала

cut_seconds = 180

cut_samples = cut_seconds * sr


# Берём всё, начиная с cut_samples и до конца

y_trimmed = y[cut_samples:]


sf.write('record_trimmed.wav', y_trimmed, sr)

print(f"Было: {len(y) / sr:.1f} сек")

print(f"Стало: {len(y_trimmed) / sr:.1f} сек")

Запись y[cut_samples:] — это и есть срез. Квадратные скобки говорят Python, что мы хотим взять часть массива. Число до двоеточия — начальный индекс. Двоеточие без числа после него означает «и до самого конца». Всё, что было до индекса cut_samples, игнорируется. Обратите внимание: мы не удаляем данные из исходного массива y. Мы создаём новый массив y_trimmed, который содержит только нужную нам часть. Исходный массив остаётся нетронутым, и мы всегда можем к нему вернуться.

Теперь обратная задача: обрезать конец. Это делается похоже, только мы указываем конечный индекс, а начальный оставляем пустым:

python

# Сколько секунд отрезать от конца

cut_end_seconds = 10

cut_end_samples = len(y) - (cut_end_seconds * sr)


# Берём всё от начала до cut_end_samples

y_trimmed = y[:cut_end_samples]

Здесь срез y[:cut_end_samples] означает «от начала до индекса cut_end_samples, не включая его». Мы вычисляем этот индекс как общую длину массива минус количество измерений в десяти секундах. Таким образом мы отрезаем ровно последние десять секунд.

Если нужно обрезать и начало, и конец одновременно, оба среза можно объединить в одну строчку:

python

y_trimmed = y[180 * sr : len(y) - 10 * sr]

Читается это так: «Возьми массив y от индекса, соответствующего ста восьмидесяти секундам, до индекса, который находится за десять секунд до конца». Всё просто и понятно.

Склеиваем несколько файлов

С разрезанием разобрались. Теперь обратная операция — склеивание. Предположим, у вас есть три файла: музыкальное вступление, записанный голос и музыкальная концовка. Их нужно объединить в один файл, который пойдёт на публикацию. В видеоредакторе это делается перетаскиванием файлов на дорожку. В коде это делает функция np.concatenate из библиотеки numpy. Она берёт несколько массивов и соединяет их в один длинный массив, как будто склеивает ленту из кусков.

Вот базовый пример:

python

import librosa

import numpy as np

import soundfile as sf


# Загружаем три файла

intro, sr1 = librosa.load('intro.wav')

voice, sr2 = librosa.load('voice.wav')

outro, sr3 = librosa.load('outro.wav')


# Склеиваем

final = np.concatenate([intro, voice, outro])


# Сохраняем

sf.write('final.wav', final, sr1)

Однако здесь есть подводный камень: все три файла должны иметь одинаковую частоту дискретизации. Если вступление записано с частотой 44100, голос — с 22050, а концовка — с 48000, склеенный файл будет звучать неправильно. На стыках скорость воспроизведения будет меняться, и голос может стать то быстрее, то медленнее. Поэтому перед склеиванием нужно убедиться, что все файлы имеют одинаковую частоту, и если нет — привести их к единому стандарту.

Вот улучшенная версия скрипта с проверкой частоты:

python

import librosa

import numpy as np

import soundfile as sf


# Загружаем и проверяем частоту

target_sr = 22050


intro, sr_i = librosa.load('intro.wav', sr=target_sr)

voice, sr_v = librosa.load('voice.wav', sr=target_sr)

outro, sr_o = librosa.load('outro.wav', sr=target_sr)


print(f"Интро: {len(intro) / target_sr:.1f} сек")

print(f"Голос: {len(voice) / target_sr:.1f} сек")

print(f"Аутро: {len(outro) / target_sr:.1f} сек")


# Склеиваем

final = np.concatenate([intro, voice, outro])

print(f"Итого: {len(final) / target_sr:.1f} сек")


sf.write('episode.wav', final, target_sr)

Параметр sr=target_sr в функции librosa.load говорит библиотеке: «Загрузи файл и пересчитай его так, как будто он был записан с частотой target_sr». Если исходный файл был записан с другой частотой, librosa автоматически подгонит его под нужную. Это очень удобно и экономит нам кучу ручной работы.

Добавляем паузы между фрагментами

Когда вы склеиваете вступление и голос вплотную, они могут звучать неестественно. Обычно между музыкальной заставкой и началом речи нужна небольшая пауза — полсекунды или секунда, чтобы слушатель переключился с музыки на голос. В коде пауза — это просто массив из нулей. Ноль в аудио означает абсолютную тишину. Чтобы создать секунду тишины, нам нужно создать массив из нулей длиной в частоту дискретизации. При частоте 22050 это будет 22050 нулей.

python

# Создаём паузу в одну секунду

silence_sec = 1.0

silence = np.zeros(int(target_sr * silence_sec))


# Склеиваем с паузами

final = np.concatenate([intro, silence, voice, silence, outro])

Функция np.zeros(n) создаёт массив из n нулей. Мы передаём ей количество сэмплов, которое равно длительности паузы в секундах, умноженной на частоту дискретизации. Этот массив нулей можно вставлять между любыми фрагментами, и на слух это будет восприниматься как естественная пауза. Длину паузы можно менять по своему вкусу. Для энергичного контента паузы делают короче — четверть секунды или даже меньше. Для спокойного, вдумчивого — длиннее, до полутора секунд.

Выравниваем громкость фрагментов

Ещё одна важная задача при склеивании — выравнивание громкости. Музыкальная заставка часто бывает записана громче, чем голос. Если их склеить без обработки, слушателю придётся убавлять громкость на музыке и прибавлять на голосе. Это раздражает. Профессиональный монтаж подразумевает, что все части звучат на одном уровне.

Выравнивать громкость мы будем через пиковую нормализацию — метод, который мы кратко упоминали в Главе 1, а подробно разберём в Главе 3. Сейчас используем упрощённый вариант:

python

def normalize_volume(y, target_peak=0.9):

"""Подгоняет пиковую громкость к заданному уровню."""

current_peak = np.max(np.abs(y))

if current_peak == 0:

return y

gain = target_peak / current_peak

return y * gain


# Нормализуем все части

intro = normalize_volume(intro, 0.9)

voice = normalize_volume(voice, 0.9)

outro = normalize_volume(outro, 0.9)

После такого выравнивания все три части будут иметь одинаковый пиковый уровень, и слушателю не придётся тянуться к регулятору громкости.

Собираем готовый эпизод подкаста

Теперь давайте соберём всё вместе в один большой скрипт, который принимает набор файлов и выдаёт готовый смонтированный эпизод. Этот скрипт можно будет использовать для каждого выпуска подкаста, меняя только имена файлов.

python

import librosa

import numpy as np

import soundfile as sf


def build_podcast(intro_file, voice_file, outro_file, output_file,

trim_start=180, trim_end=10,

silence_between=1.0,

target_sr=22050,

target_peak=0.9):

"""

Собирает эпизод подкаста из трёх файлов.

trim_start - сколько секунд отрезать от начала голосовой дорожки

trim_end - сколько секунд отрезать от конца голосовой дорожки

silence_between - длина паузы между частями в секундах

"""

print("=== Сборка эпизода ===")


# Загружаем

print("Загружаю файлы...")

intro, _ = librosa.load(intro_file, sr=target_sr)

voice, _ = librosa.load(voice_file, sr=target_sr)

outro, _ = librosa.load(outro_file, sr=target_sr)


# Обрезаем голос

start_sample = int(trim_start * target_sr)

end_sample = len(voice) - int(trim_end * target_sr)

voice = voice[start_sample:end_sample]

print(f"Голос после обрезки: {len(voice) / target_sr:.1f} сек")


# Нормализуем громкость

for arr in [intro, voice, outro]:

peak = np.max(np.abs(arr))

if peak > 0:

arr *= target_peak / peak


# Создаём паузу

silence = np.zeros(int(target_sr * silence_between))


# Склеиваем

final = np.concatenate([intro, silence, voice, silence, outro])

print(f"Финальная длина: {len(final) / target_sr:.1f} сек")


# Сохраняем

sf.write(output_file, final, target_sr)

print(f"Сохранено: {output_file}")

print("Готово!")


# Запускаем

build_podcast(

intro_file='intro.wav',

voice_file='raw_voice.wav',

outro_file='outro.wav',

output_file='episode_ready.wav',

trim_start=180,

trim_end=10,

silence_between=1.0

)

Запустите этот скрипт, указав свои файлы. Если у вас нет готовых джинглов для интро и аутро, не беда — можете пока использовать любые музыкальные фрагменты или даже тишину. Главное — понять процесс. Когда появятся настоящие джинглы, вы просто подставите их в скрипт, и всё будет работать.

Пакетная обработка: монтируем много файлов сразу

Одно из главных преимуществ кода перед ручным монтажом — возможность обрабатывать много файлов одной командой. Предположим, у вас в папке лежит десять записей, и каждую нужно обрезать с начала и с конца, а потом сохранить результат в другую папку. В Audacity это заняло бы час. В Python это делается одним скриптом и занимает минуту.

python

import os

import librosa

import soundfile as sf

Загрузка...