Привет, Хабр! В прошлой части я рассказал, как автоматизировать простую нарезку YouTube-видео на Shorts, добавить туда текст и размытый фон. Сегодня займемся более комплексной задачей — генерацией вертикальных видео на основе записи с геймплеем и текстом. В тексте узнаете, как генерировать аудио с помощью библиотеки Bark и настроить анимацию ASCII-маскота. Подробнее — под катом.

Используйте навигацию, чтобы выбрать интересующий блок

Постановка задачи
Конвертация и кадрирование
Генерация аудио
Генерация маскота
Создание удобного UI
Итоги

Постановка задачи


Начнем с вводных данных. Моя главная цель — начать уделять меньше времени на продвижение игры. Для этого разработал небольшой проект по автоматизации монтажа видео для YouTube Shorts. Игра базируется на хакерской эстетике. Поэтому чтобы погрузить аудиторию в эту атмосферу, решил использовать роботизированный голос для озвучивания видео и ASCII-маскот, который говорит открывает рот в такт текста.


Внешний вид ASCII-маскота.

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

Резюмируем, что должен уметь конечный скрипт.

  1. Конвертировать и кадрировать горизонтальные видео в вертикальные.
  2. Генерировать аудио на основе текста, а также добавлять звуковые эффекты для роботизации голоса.
  3. Генерировать анимации маскота, привязанного к аудиофайлу.

В качестве дополнительного инструмента выбрал Python. В нем много TTS-решений (text-to-speech), которые помогают конвертировать текст в разговорную речь.

Конвертация и кадрирование


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

if [[ -z $3 ]]; then
  start_time="0"
fi

if [[ $no_console -ne 1 ]]; then
  console="[3:v]scale=1080*4:-1, crop=in_w:200:0:100, trim=start=$start_time, setpts=PTS-STARTPTS[console];\
    [mix][console]overlay=0:0[mix];"
else
  console=""
fi

ffmpeg -i $background -i $video -filter_complex \
  "[0:v]scale=1080:1920[bg]; \
  [1:v]scale=1080*2:-1, trim=start=$start_time, crop=in_w:in_h-300, setpts=PTS-STARTPTS[vid]; \
  [1:a]volume=0.1,atrim=start=$start_time[audio];\
  [bg][vid]overlay=(W-w)/2:0[mix];$console" \
  -map [mix] -map [audio] -r 60 output.mp4  -y

В скрипте можно настроить отображение консоли сверху. Если вам такая функция не нужна, используйте параметр $no_console, и она не будет отображаться.

Также можем подставлять в видео не только геймплей, но и другую запись. Пригодится, если нужно будет сделать информационный ролик без футажей игры.


Генерация аудио


У Python есть много TTS-библиотек, но большинство из них меня не устроили. Мне нужен был женский голос, а качественных решений, оказывается, было довольно мало.

Позже я нашел библиотеку Bark — мне понравилась поэтичность и точность названия. Она не просто роботизированно воспроизводит текст, но и добавляет паузы, вздохи, смешки и другие нюансы, которые оживляют голос. Я не проверял, умеет ли голос лаять, но не удивился, если бы и такая возможность была.

Поскольку проект комплексный, я разделил его на несколько файлов. У каждого была только одна задача. В результате у меня получилось упростить процесс написания и тестирования кода.

Создаем небольшой файл genwav.py для генерации нашей аудиодорожки. Стоит отметить, что библиотека адекватно работает только с аудио длительностью не более 13 секунд. Однако в документации написано, как эту проблему можно обойти. Разбиваем текст на отдельные предложения и склеиваем их в один файл. Вот что у меня получилось в итоге:

import os
import torch

os.environ["SUNO_ENABLE_MPS"] = "True"
torch.device("mps")

import nltk  
nltk.download('punkt')
import numpy as np

from bark.generation import (
    generate_text_semantic,
    preload_models,
)
from bark.api import semantic_to_waveform
from bark import generate_audio, SAMPLE_RATE

import sys
input = sys.argv[1] 

with open(input,'r') as i:
    text = i.read()

sentences = nltk.sent_tokenize(text)   

from transformers import AutoProcessor, BarkModel

processor = AutoProcessor.from_pretrained("suno/bark")
model = BarkModel.from_pretrained("suno/bark")

GEN_TEMP = 0.8
SPEAKER = "v2/en_speaker_9"

pieces = []
for sentence in sentences:
    semantic_tokens = generate_text_semantic(
        sentence,
        history_prompt=SPEAKER,
        temp=GEN_TEMP,
        min_eos_p=0.05,
    )

    audio_array = semantic_to_waveform(semantic_tokens, history_prompt=SPEAKER,)
    pieces += [audio_array]

result = np.concatenate(pieces)

import scipy

scipy.io.wavfile.write("temp/voice.wav", rate=int(SAMPLE_RATE), data=result)

Мне нужен был именно женский голос, поэтому я выбрал модель v2/en_speaker_9. Обратите внимание на частоту дискретизации, результирующей WAV-формат. Она равна 24 кГц. Это значение пригодится нам в будущем.

В самом начале я включил опцию использования MPS (Metal Performance Shaders), чтобы ускорить процесс генерации. Опция актуальна только для MacBook с процессорами Apple Silicon.

В результате генерации у аудио появились большие паузы между предложениями. Вырезать их я решил здесь же — не хотелось усложнять и без того большую цепочку скриптов. Учитывая, что у меня еще сырой поток не превращенных в WAV байтов, вырезать паузы можно еще быстрее.

// Генерация голоса

ZEROS_TO_REMOVE=10000
index_of_first_zero=-1
zero_counter=0

for i in range(len(result)):
    if abs(result[i]) < 0.005:
        if index_of_first_zero == -1:
            index_of_first_zero = i;
        zero_counter= zero_counter+1;  
        if zero_counter > ZEROS_TO_REMOVE:
            result[index_of_first_zero:i] = 0.;
            index_of_first_zero=i;
    else:
        zero_counter=0;
        index_of_first_zero=-1;

result=result[result!=0]

import scipy

scipy.io.wavfile.write("temp/voice.wav", rate=int(SAMPLE_RATE), data=result)

Аудио состоит из массива чисел от -1 до 1. Все значения меньше 0.005 по модулю достаточно тихие, чтобы считать их тишиной. Поэтому превращаю их в 0, после чего отфильтровываю.

Срабатывает изменение только в том случае, когда подобных значений 10 тысяч или больше. Это равносильно тишине чуть меньше 0,5 секунды (24 кГц равняется 24 000 значений в секунду). Конечно, алгоритм можно оптимизировать, но раз он справляется со своей задачей, то:


После — добавляем в существующий Shell-скрипт наше аудио. Но даже здесь все обходится не без нюансов. Подробнее о них рассказываю ниже.

vid_len=$(ffprobe -v error -select_streams v:0 -show_entries stream=duration -of default=noprint_wrappers=1:nokey=1 $video)
sound_len=$(ffprobe -v error -select_streams a:0 -show_entries stream=duration -of default=noprint_wrappers=1:nokey=1 $audio)
vid_len=$(echo $vid_len $start_time | awk '{print $1 - $2}')

duration=$(python -c "print(min($vid_len,$sound_len))")

if [[ $mute_video -ne 1 ]]; then
  vid_audio="[1:a]volume=0.1,atrim=start=$start_time[avid];\
  [audio][avid]amix=2[audio];"
else
  vid_audio=""
fi

ffmpeg -i $background -i $video -i $audio -filter_complex \
  "[0:v]scale=1080:1920[bg]; \
  [1:v]scale=1080*2:-1, trim=start=$start_time, crop=in_w:in_h-300, setpts=PTS-STARTPTS[vid]; \
  [2:a]volume=1.0,\
  tremolo=f=500:d=0.1,\
  chorus=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3,\
  rubberband=pitch=1.1\
  [audio];$vid_audio\
  [bg][vid]overlay=(W-w)/2:0[mix];$console" \
  -map [mix] -map [audio] -t $duration -r 60 output.mp4  -y

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

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

Да, я искал библиотеку с реалистичным голосом, чтобы потом его роботизировать.


По сути этого могло быть достаточно, но хотелось чего-то эдакого, поэтому приступил к добавлению анимированного маскота.

Генерация маскота


Для начала уточню, из чего состоит анимация. Подобную логику я реализовывал в игре, но без анализа аудиофайла.

  • IDLE-анимация. Классическая история для видеоигр, в которых персонаж двигается вниз-вверх.
  • Моргание ASCII-маскота.
  • Открывание рта в такт сгенерированному голосу.

С первыми двумя пунктами в целом понятно: их можно реализовать в рамках вышеописанного скрипта. А для последнего потребуется немного подготовительной работы. Добавим в новый Python-скрипт файл gensylltim.py для рассчитывания тайминга слогов, который поможет понять, когда маскоту нужно открыть рот.

import sys
audio = sys.argv[1] 

import numpy as np
from scipy.io.wavfile import read
import wave

frame_rate = 24000

a = read(audio)
arr = np.array(a[1],dtype=float)

import scipy.signal
indexes, _ = scipy.signal.find_peaks(arr, height=0.05, distance=frame_rate/4)

result=map(lambda it: it/frame_rate, indexes)

print(list(result))

Скрипт ищет пиковые значения в массиве байтов WAV-файла, используя библиотеку SciPy. После — возвращает результат в потоке stdout. Далее буду «ловить» его с помощью утилиты grep в Shell-скрипте.

Здесь пригодилось значение частоты дискретизации — при поиске пиковых значений в нашем массиве громкостей, а также при конвертации этих значений в тайминги.

Значения height=0.05 и distance=frame_rate/4 были подобраны путем научного тыка. В случае использования модели отличной от моей, значения могут отличаться.

Теперь перейдем к генерации состояний для нашего маскота. Анимация будет состоять из 10 FPS, IDLE-анимация — из 6 FPS на каждое состояние (12 кадров в итоге), моргание — 33 FPS (три кадра на закрытые глаза и 30 на открытые). За счет такого рассинхрона анимация выглядит живее, при этом никак не влияет на реализацию. В конце первого скрипта добавляю фрагмент кода:

loop_time=$(echo "$vid_len * 10" | bc -l)
loop_time=${loop_time%.*}

function get_states()
{
  local i=0
  states=()
  local timer=0
  local current_state=0
  while [[ $i -le $loop_time ]]; do
    states+=("$current_state")
    ((timer = timer + 1))
    if [[ -z $2 ]]; then
      if [[ $timer -ge $1 ]]; then
        timer=0
        current_state=$((current_state ^= 1));
      fi
    else
      if ([ $current_state == 0 ] && [ $timer -ge $1 ]) || ([ $current_state == 1 ] && [ $timer -ge $2 ]); then
        timer=0
        current_state=$((current_state ^= 1));
      fi
    fi
    ((i = i + 1))
  done
}

readonly TOP_TIME=6
get_states $TOP_TIME

top_states=("${states[@]}")

readonly CE_TIME=3
readonly OE_TIME=30
get_states $OE_TIME $CE_TIME

eyes_states=("${states[@]}")

syll_timings=($(python gensylltim.py $2 | tr -d '[],'))

Здесь я генерирую массивы состояний для каждого кадра и подтягиваю тайминги слогов, используя ранее написанный скрипт. Код выходит уже не самый красивый и лаконичный, но не переживайте — самое страшное будет дальше:

toc_states="between(t,-1,-1)"
boc_states="between(t,-1,-1)"
tcc_states="between(t,-1,-1)"
bcc_states="between(t,-1,-1)"
tco_states="between(t,-1,-1)"
bco_states="between(t,-1,-1)"
too_states="between(t,-1,-1)"
boo_states="between(t,-1,-1)"

current_time=0

for i in ${!top_states[@]}; do
  prev_time_sec=$(echo $current_time | awk '{printf "%.2f", $1 / 10}')
  ((current_time = current_time + 1))
  current_time_sec=$(echo $current_time | awk '{printf "%.2f", $1 / 10}')
  between="+between(t,$prev_time_sec,$current_time_sec)"

  syll_condition=0
    if [[ ${#syll_timings} -ne 0 ]]; then
      syll_condition=$(echo "scale=2; ${syll_timings[0]} <= ($current_time_sec + 0.1)" | bc)
      if [[ $syll_condition -eq 1 ]]; then
        syll_timings=("${syll_timings[@]:1}")
      fi
	fi

  if [[ ${top_states[$i]} -eq 0 ]]; then
    if [[ ${eyes_states[$i]} -eq 0 ]]; then
      if [[ $syll_condition -eq 1 ]]; then
        too_states+=$between  
      else
        toc_states+=$between
      fi
    else
      if [[ $syll_condition -eq 1 ]]; then
        tco_states+=$between
      else
        tcc_states+=$between
      fi
    fi
  else
    if [[ ${eyes_states[$i]} -eq 0 ]]; then
      if [[ $syll_condition -eq 1 ]]; then
        boo_states+=$between
      else
        boc_states+=$between
      fi
    else 
      if [[ $syll_condition -eq 1 ]]; then
        bco_states+=$between
      else
        bcc_states+=$between
      fi
    fi
  fi
done

Сниппет генерирует строки between, которые будут говорить FFMPEG, когда и какую картинку показывать. Сейчас выглядит он не самым оптимальным образом, поскольку опция работает для каждой 0,1 секунды, когда появляется изображение. Но на перформансе это не отражается, поэтому в оптимизации алгоритма смысла не вижу.

Наконец, внедряем это в FFMPEG.

tcc="loda/Loda,Default,Top,Eclose,MClose.png"
tco="loda/Loda,Default,Top,Eclose,MOpen.png"
toc="loda/Loda,Default,Top,Eopen,MClose.png"
too="loda/Loda,Default,Top,Eopen,MOpen.png"
bcc="loda/Loda,Default,Bottom,Eclose,MClose.png"
bco="loda/Loda,Default,Bottom,Eclose,MOpen.png"
boc="loda/Loda,Default,Bottom,Eopen,MClose.png"
boo="loda/Loda,Default,Bottom,Eopen,MOpen.png"

ffmpeg $quiet -i "temp/output.mp4" \
  -i $tcc -i $tco -i $toc -i $too \
  -i $bcc -i $bco -i $boc -i $boo \
  -filter_complex \
  "[1:v]scale=-1:800[tcc];\
  [2:v]scale=-1:800[tco];\
  [3:v]scale=-1:800[toc];\
  [4:v]scale=-1:800[too];\
  [5:v]scale=-1:800[bcc];\
  [6:v]scale=-1:800[bco];\
  [7:v]scale=-1:800[boc];\
  [8:v]scale=-1:800[boo];\
  [0:v][tcc]overlay=(W-w)/2:(H-h):enable='$tcc_states'[temp];\
  [temp][tco]overlay=(W-w)/2:(H-h):enable='$tco_states'[temp];\
  [temp][toc]overlay=(W-w)/2:(H-h):enable='$toc_states'[temp];\
  [temp][too]overlay=(W-w)/2:(H-h):enable='$too_states'[temp];\
  [temp][bcc]overlay=(W-w)/2:(H-h):enable='$bcc_states'[temp];\
  [temp][bco]overlay=(W-w)/2:(H-h):enable='$bco_states'[temp];\
  [temp][boc]overlay=(W-w)/2:(H-h):enable='$boc_states'[temp];\
  [temp][boo]overlay=(W-w)/2:(H-h):enable='$boo_states'[res];\
  [0:a]volume=1.0[audio];"\
  -map [res] -map [audio] -r 60 "fin.mp4"

Закидываем на вход ранее сгенерированное видео, а также картинки для каждого состояния. Накладываем их в нижнюю часть экрана и с помощью параметра enable проставляем моменты, в которых будем показывать нужные. Готово!

Результат.

Создание удобного UI


Сейчас у нас есть несколько скриптов. Чтобы упростить их использование, я написал еще один:

while getopts ':v:s:t:mc' flag; do
 case $flag in
   v)
     vid=$OPTARG
   ;;
   s)   
     st=$OPTARG
   ;;
   t)
     text=$OPTARG
   ;;
   m)
     mute=1
   ;;
   c)
     console=1
   ;;
   \?)
     continue
   ;;
 esac
done

if [[ -z $vid ]]; then
  echo "You need to pass video using -v flag, you can also set text using -t and start time using -s"
  exit
fi

set -e

dir="$HOME/tools/TikTok"
source $dir/.env/bin/activate
mkdir -p $dir/temp

if [[ "$text" != "" ]]; then
  python $dir/genwav.py $text

  open $dir/temp/voice.wav
  read -r -p "Do you like the result? [y/N] " response
  if ! [[ "$response" =~ ^([yY])$ ]]; then
    exit
  fi
fi

sh $dir/genvid.sh $vid $dir/temp/voice.wav $st $console $mute

deactivate

open .

В нем указываю входные параметры с помощью флагов:

  • v — видео,
  • t — файл с текстом (если его опустить, скрипт начнет использовать ранее сгенерированный звук),
  • s — начало видео (опционально),
  • m — заглушить оригинальное видео (опционально),
  • с — спрятать консоль (опционально).

После генерации система начинает автоматически проигрывать аудио и, если результат устраивает, то продолжает свою работу. В конце открывается папка со сгенерированным видео, чтобы можно было его загрузить в соц. сети.

Итоги


Несмотря на то, что удалось значительно ускорить процесс создания вертикальных видео, требуется некоторое время на генерацию аудио. Однако это намного быстрее, чем создавать, обрезать и настраивать видео руками. Запускаешь генерацию и занимаешься своими делами — мечта инженера!

Также хочу отметить пользу работы над проектом лично для меня. Наконец, я более или менее разобрался с Bash, продвинулся в понимании работы FFMPEG, а также познакомился с библиотекой Bark, которая в будущем мне может пригодится.

Надеюсь, вам тоже был интересен мой опыт. Буду рад вашему мнению в комментариях.

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


  1. EugeneH
    14.05.2024 10:16
    +1

    В Bark еще можно генерировать/клонировать голоса. Очень легко сделать голос по своему вкусу вместо стандартного встроенного.

    Плюс, существует фреймворк Bark Infinity с автоматической нарезкой длинного текста и другими плюшками.

    Мне не хватало только возможности стримить аудио с низкой задержкой (барк хоть и работает быстрее, чем в реальном времени, но приходится ждать пока будет готов кусок в 11-13 секунд). Поэтому переключился на Coqui TTSv2, там стриминг из коробки работает.


    1. fellow_pablo Автор
      14.05.2024 10:16
      +1

      Спасибо!

      Может есть ещё рекомендации по клонированию голоса + переводу на другой язык? Очень интересно подобное решение (видел платные сервисы только, хотя глубокий ресерч ещё не производил).


      1. EugeneH
        14.05.2024 10:16
        +1

        Ничего локально запускаемого, чтобы было на уровне HeyGen не знаю.

        Такая же беда с генерацией музыки.


        1. fellow_pablo Автор
          14.05.2024 10:16
          +1

          Ну значит подождём ещё полгодика)

          Рано или поздно что-нибудь достойное окажется в опенсорсе.