Привет, Хабр!

Сейчас мы переживаем бум ИИ-сервисов, которые за небольшую плату могут реализовать любые ваши творческие фантазии без необходимости глубокого понимания технических принципов их работы. Но я из тех, кто любит «ковыряться под капотом», поэтому в качестве проекта «выходного дня» я решил реализовать сервис машинного закадрового перевода видео с помощью общедоступных моделей с локальным запуском. А что из этого вышло – читайте далее.

▨ С чего всё началось

Недавно компания Silero Speech выпустила пятую версию своих публичных моделей для синтеза речи. Их модели я часто использую в своих проектах, например, в pet-проекте голосового ассистента для управления умным домом. Мне захотелось опробовать эти новые модели, а чтобы тест не сводился к банальному синтезу речи из строки, я решил сделать что-то поинтереснее.

▨ Моя задумка

Как вы наверное уже поняли из названия статьи, было решено сделать машинный перевод и озвучку видео с помощью локальных ML-моделей. Ранее я уже делал проект телеграм-бота для перевода песен на русский язык с использованием ML-моделей. Если упрощенно, то мне нужно просто добавить к предыдущему проекту функцию синтеза речи и формирования аудио дорожки для озвучки видео.

Если сильно не углубляться в детали, то в нашем приложении будет выполняться несколько этапов:

  1. Загрузка видеофайла;

  2. Извлечение аудио из видео файла;

  3. Транскрибация (преобразование речи в текстовые данные);

  4. Перевод извлеченных текстовых данных;

  5. Синтез речи из перевода;

  6. Создание аудио файла из данных синтеза речи.

Загрузка видеофайла и извлечение аудио – это достаточно банальные вещи, и описывать их в статье нет смысла. Поэтому начнем с самого интересного, а именно, с пункта 3.

▨ Транскрибация

Также, как и в телеграм-боте, я буду использовать для транскрибации модель Whisper от OpenAI. Ниже приведен используемый в приложении код функции для транскрибации аудио исходной дорожки:

# 3. Транскрибирование с временными метками
def transcribe_with_timestamps(audio_path):
    print("Загружаем модель Whisper...")
    model = whisper.load_model("medium")  # или "small", "medium", "large", "base"

    print("Транскрибируем аудио...")
    result = model.transcribe(audio_path, word_timestamps=True, language='en')

    segments = []
    for segment in result['segments']:
        segments.append({
            'start': segment['start'],
            'end': segment['end'],
            'text': segment['text'].strip(),
            'words': segment.get('words', [])
        })

    print(f"Транскрибировано {len(segments)} сегментов")
    return segments

Данная функция возвращает нам кортеж с данными в формате JSON, который содержит временные метки сегмента и текстовые данные.

И чтобы это заработало, нужно установить следующие пакеты:

pip install torch openai-whisper

▨ Перевод

Функцию перевода сегментов, так же как и в предыдущем проекте, будем реализовывать с помощью модели Helsinki-NLP от Группы исследований языковых технологий Хельсинкского университета. Ниже представлен код переводчика, реализованного в отдельном классе:

# 4. Переводчик
class Translator:
    def __init__(self):
        print("Загружаем модель перевода...")
        self.model_name = "Helsinki-NLP/opus-mt-en-ru" # Используем модель EN - RU 
        self.tokenizer = MarianTokenizer.from_pretrained(self.model_name)
        self.model = MarianMTModel.from_pretrained(self.model_name).to(device)

    def translate_text(self, text):
        # Очищаем текст от лишних пробелов
        text = ' '.join(text.split())
        if not text or len(text.strip()) == 0:
            return ""

        inputs = self.tokenizer(text, return_tensors="pt", truncation=True, padding=True, max_length=512)
        inputs = {k: v.to(device) for k, v in inputs.items()}

        with torch.no_grad():
            outputs = self.model.generate(**inputs, max_length=512)

        translated = self.tokenizer.decode(outputs[0], skip_special_tokens=True)
        return translated

    def translate_segments(self, segments):
        print("Переводим сегменты...")
        translated_segments = []

        for i, segment in enumerate(segments):
            if not segment['text'].strip():
                continue

            print(f"Переводим сегмент {i+1}/{len(segments)}: {segment['text'][:50]}...")
            translated_text = self.translate_text(segment['text'])

            translated_segments.append({
                'start': segment['start'],
                'end': segment['end'],
                'original_text': segment['text'],
                'translated_text': translated_text,
                'duration': segment['end'] - segment['start']
            })

        return translated_segments

Для использования переводчика используется следующая конструкция:

    translator = Translator()  #  Инициализируем класс переводчика
    translated_segments = translator.translate_segments(segments)  

Метод translator.translate_segments(segments) возвращает данные в формате JSON с временными метками (start, end), оригинальным текстом, переводом и длиной сегмента.

И чтобы это работало, нам нужны трансформеры:

 pip install transformers

▨ Синтез речи (закадровый перевод)

Пожалуй, это самый интересный раздел данной статьй. Как я уже говорил ранее, в проекте будем пытаться использовать пятую версию модели синтеза речи от Silero Speech. Для русского языка актуальны две модели v5_cis_base и v5_ ru. Ниже приведен код для предварительного теста ML-моделей синтеза речи:

# Пробный тест синтеза речи
from silero import silero_tts
import sounddevice as sd

# Инициализация устройств
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Используется устройство: {device}")

model_id = 'v5_cis_base'
model, example_text = silero_tts(language='ru',
                                 speaker=model_id)
model.to(device)

sample_rate = 48000
speaker = 'ru_alexandr'
'''
доступные голоса для модели v5_cis_base

ru_aigul, ru_albina, ru_alexandr, ru_alfia, ru_alfia2, ru_bogdan, ru_dmitriy, 
ru_ekaterina, ru_vika, ru_gamat, ru_igor, ru_karina, ru_kejilgan, ru_kermen,
ru_marat, ru_miyau, ru_nurgul, ru_oksana, ru_onaoy, ru_ramilia, ru_roman,
ru_safarhuja, ru_saida, ru_sibday, ru_zara, ru_zhadyra, ru_zhazira,
ru_zinaida, ru_eduard

доступные голоса для модели v5_ ru
aidar, baya, kseniya, xenia
'''

example_text = 'бродить с дождём под окнами твоими.'

audio = model.apply_tts(text=example_text,
                        speaker=speaker,
                        sample_rate=sample_rate)

sd.play(audio, sample_rate)

Несмотря на то, что я возлагал большие надежды на модель v5_cis_base (из-за разнообразия голосов), в итоге я остановился на стандартной модели v5_ ru, так как качество синтеза речи здесь гораздо лучше, чем у v5_cis_base. Ниже вы можете оценить качество синтеза данных моделей:

Выбор очевиден, в проекте мы будем использовать синтез речи с голосом Xenia и модель v5_ru.

Еще одним недостатком публичных ML-моделей от Silero является то, что они не умеют озвучивать цифры, если они указаны в виде числа, а Whisper в результатах транскрибации часто выводит числа вместо слов. Данное ограничение можно снять с помощью Python пакета num2words и метода проверки строки на наличие чисел с последующим преобразованием в текст. Забегая вперед, скажу, что данный метод также реализован и в этом проекте.

Синтез – синтезом, но нам нужно еще решить одну проблему. Дело в том, что если мы просто озвучим с помощью синтеза речи то, что нам натранскрибировал Whisper, то мы не уложимся во временные интервалы исходного аудио, а закадровый перевод будет звучать так не гармонично, что пользователь в ужасе закроет наше видео.

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

И так как модель синтеза речи возвращает нам данные с типом numpy.ndarray c dtype float32, то мы можем проводить манипуляции с этим массивом данных, используя мощную библиотеку Librosa для обработки аудио. Учитывая все выше описанные проблемы, был реализован класс для синтеза речи, код которого вы можете видеть ниже:

код класса Silero TTS
# 5.  Синтез речи для перевода
class SileroTTSWithTiming:
    def __init__(self):
        print("Загружаем Silero TTS...")
        self.model, _ = silero_tts(language='ru', speaker='v5_ru')
        self.model.to(device)
        self.sample_rate = 48000
        print("Готов")

    def preprocess_text(self, text: str) -> str:
        """Предобработка текста с использованием num2words"""

        def convert_numbers(match):
            """Конвертирует числа в слова с учетом контекста"""
            num_str = match.group()

            try:
                # Пробуем преобразовать в число
                num = float(num_str) if '.' in num_str else int(num_str)

                # ОСОБЫЙ СЛУЧАЙ: ГОДА (4-значные числа)
                if len(num_str) == 4 and 1000 <= num <= 2099:
                    # Года произносим по цифрам: 2024 → "два ноль два четыре"
                    digits = {
                        '0': 'ноль', '1': 'один', '2': 'два', '3': 'три',
                        '4': 'четыре', '5': 'пять', '6': 'шесть', '7': 'семь',
                        '8': 'восемь', '9': 'девять'
                    }
                    return ' '.join(digits[digit] for digit in num_str)

                # ОСОБЫЙ СЛУЧАЙ: НОМЕРА ТЕЛЕФОНОВ, КОДЫ (6-12 цифр)
                if len(num_str) in [6, 7, 8, 10, 11, 12]:
                    # Произносим по цифрам
                    digits = {
                        '0': 'ноль', '1': 'один', '2': 'два', '3': 'три',
                        '4': 'четыре', '5': 'пять', '6': 'шесть', '7': 'семь',
                        '8': 'восемь', '9': 'девять'
                    }
                    return ' '.join(digits[digit] for digit in num_str)

                # ОБЫЧНЫЕ ЧИСЛА: используем num2words
                # Для дробных чисел
                if '.' in num_str:
                    whole_part = num2words_lib(int(num), lang='ru')
                    decimal_part = ' '.join(digits.get(d, d) for d in num_str.split('.')[1])
                    return f"{whole_part} целых {decimal_part}"

                # Для целых чисел
                return num2words_lib(num, lang='ru')

            except (ValueError, OverflowError):
                # Если не удалось преобразовать, возвращаем как есть
                return num_str

        # Шаг 1: Преобразуем год (особый случай)
        text = re.sub(r'\b(19|20)\d{2}\b', convert_numbers, text)

        # Шаг 2: Преобразуем номера телефонов (6-12 цифр подряд)
        text = re.sub(r'\b\d{6,12}\b', convert_numbers, text)

        # Шаг 3: Преобразуем обычные числа
        text = re.sub(r'\b\d+(?:\.\d+)?\b', convert_numbers, text)

        # Шаг 4: Обработка специальных символов и математических операций
        replacements = {
            '%': ' процент',
            '$': ' доллар',
            '€': ' евро',
            '₽': ' рубль',
            '£': ' фунт',
            '¥': ' иена',
            '+': ' плюс',
            '*': ' умножить на',
            '÷': ' разделить на',
            '=': ' равно',
            '/': ' на',
            '>': ' больше',
            '<': ' меньше'
        }

        for char, replacement in replacements.items():
            text = text.replace(char, replacement)

        # Шаг 5: Обработка процентов после чисел
        text = re.sub(r'(\d+)\s*процент', r'\1 процент', text)

        # Шаг 6: Обработка валют после чисел
        text = re.sub(r'(\d+)\s*(доллар|евро|рубль|фунт|иена)', r'\1 \2', text)

        # Шаг 7: Очистка лишних символов
        text = re.sub(r'[«»"“”„
()]', '', text)

        # Шаг 8: Удаление лишних пробелов
        text = re.sub(r'\s+', ' ', text).strip()

        return text

    def high_quality_speed_up(self, audio: np.ndarray, speed_factor: float) -> np.ndarray:
        """Качественное УСКОРЕНИЕ (только ускорение, без замедления!)"""
        if abs(speed_factor - 1.0) < 0.01 or speed_factor < 1.0:
            return audio  # Не замедляем!

        # Ограничиваем максимальное ускорение для сохранения качества
        if speed_factor > 2.0:
            print(f"  Внимание: большое ускорение {speed_factor:.2f}x, ограничиваем до 2.0x")
            speed_factor = 2.0

        # Параметры для лучшего качества
        n_fft = 2048
        hop_length = n_fft // 4

        try:
            # STFT преобразование
            stft = librosa.stft(audio, n_fft=n_fft, hop_length=hop_length, window='hann')

            # Phase vocoder для ускорения
            stft_stretched = librosa.phase_vocoder(stft, rate=speed_factor, hop_length=hop_length)

            # Обратное преобразование
            audio_stretched = librosa.istft(stft_stretched, hop_length=hop_length, window='hann')

            return audio_stretched.astype(np.float32)

        except Exception as e:
            print(f"  Ошибка при ускорении: {e}")
            # Резервный метод - обрезка
            target_samples = int(len(audio) / speed_factor)
            return audio[:target_samples]

    def apply_smooth_fade(self, audio: np.ndarray, fade_in_ms: int = 5, fade_out_ms: int = 10) -> np.ndarray:
        """Применяет плавное затухание для предотвращения щелчков"""
        if len(audio) < 100:  # Слишком короткий для фейдов
            return audio

        # Fade-in в начале
        fade_in_samples = int(fade_in_ms * self.sample_rate / 1000)
        if fade_in_samples > 0 and len(audio) > fade_in_samples:
            fade_in = np.linspace(0, 1, fade_in_samples)
            audio[:fade_in_samples] *= fade_in

        # Fade-out в конце
        fade_out_samples = int(fade_out_ms * self.sample_rate / 1000)
        if fade_out_samples > 0 and len(audio) > fade_out_samples:
            fade_out = np.linspace(1, 0, fade_out_samples)
            audio[-fade_out_samples:] *= fade_out

        return audio

    def trim_excessive_silence(self, audio: np.ndarray, threshold_db: float = -30) -> np.ndarray:
        """Убирает слишком длинную тишину в начале и конце"""
        if len(audio) == 0:
            return audio

        # Преобразуем в dB
        audio_db = librosa.amplitude_to_db(np.abs(audio), ref=np.max)

        # Находим ненулевые участки (где громкость выше порога)
        above_threshold = audio_db > threshold_db

        if not np.any(above_threshold):
            return audio[:int(0.1 * self.sample_rate)]  # Возвращаем короткую тишину

        # Находим начало и конец речи
        speech_start = np.argmax(above_threshold)
        speech_end = len(audio) - np.argmax(above_threshold[::-1])

        # Оставляем небольшие паузы (50мс) в начале и конце
        padding = int(0.05 * self.sample_rate)
        start = max(0, speech_start - padding)
        end = min(len(audio), speech_end + padding)

        return audio[start:end]

    def generate_audio_segment(self, text: str, target_duration: float) -> np.ndarray:
        # Записываем оригинальный текст для логирования
        original_text = text

        # Предобработка текста
        text = self.preprocess_text(text.strip())

        if not text or len(text) < 2:
            print(f"  Пустой текст, возвращаем тишину")
            return np.zeros(int(target_duration * self.sample_rate), dtype=np.float32)

        # Логирование преобразования
        print(f"  Оригинал: {original_text[:80]}...")
        print(f"  Обработан: {text[:80]}...")

        # Ограничение длины текста для стабильности
        if len(text) > 350:
            print(f"  Текст слишком длинный ({len(text)} символов), обрезаем")
            text = text[:340] + "..."

        try:
            # Генерация аудио
            with torch.no_grad():
                wav = self.model.apply_tts(
                    text=text,
                    speaker='xenia',
                    sample_rate=self.sample_rate,
                    put_accent=True,
                    put_yo=True
                )

            audio = wav.cpu().numpy().squeeze()

            # Убираем лишнюю тишину по краям
            audio = self.trim_excessive_silence(audio)

            # Нормализация
            if np.max(np.abs(audio)) > 0:
                audio = audio / np.max(np.abs(audio)) * 0.95

            # Текущая длительность
            current_duration = len(audio) / self.sample_rate
            print(f"  Длительность сгенерированного: {current_duration:.2f}s")
            print(f"  Целевая длительность: {target_duration:.2f}s")

            # РЕШАЕМ: что делать с длительностью?
            if current_duration > target_duration:
                # Случай 1: русский текст ДЛИННЕЕ - УСКОРЯЕМ
                speed_factor = current_duration / target_duration
                print(f"  Русский текст длиннее, ускоряем в {speed_factor:.2f} раз")
                audio = self.high_quality_speed_up(audio, speed_factor)
                current_duration = len(audio) / self.sample_rate
                print(f"  Длительность после ускорения: {current_duration:.2f}s")

            # Финальная коррекция длины
            target_samples = int(target_duration * self.sample_rate)

            if len(audio) > target_samples:
                # Если после ускорения все еще длиннее - обрезаем с fade-out
                excess_samples = len(audio) - target_samples
                print(f"  После ускорения все еще длиннее, обрезаем {excess_samples} сэмплов")

                # Применяем fade-out перед обрезкой
                fade_out_samples = min(100, excess_samples)
                if fade_out_samples > 0:
                    audio[target_samples - fade_out_samples:target_samples] *= np.linspace(1, 0, fade_out_samples)

                audio = audio[:target_samples]

            elif len(audio) < target_samples:
                # Случай 2: русский текст КОРОЧЕ - ПРОСТО ДОБАВЛЯЕМ ТИШИНУ
                silence_samples = target_samples - len(audio)
                silence_duration = silence_samples / self.sample_rate
                print(f"  Русский текст короче, добавляем {silence_duration:.2f}s тишины")

                # Просто дополняем тишиной в конце
                audio = np.pad(audio, (0, silence_samples), mode='constant')

            else:
                # Идеально подошло по времени
                print(f"  Идеальная длительность! ±0.01s")

            # Применяем плавные переходы
            audio = self.apply_smooth_fade(audio, fade_in_ms=5, fade_out_ms=10)

            # Финальная проверка
            final_duration = len(audio) / self.sample_rate
            duration_diff = abs(final_duration - target_duration)

            if duration_diff > 0.01:
                print(f"  Внимание: итоговая длительность отличается на {duration_diff:.3f}s")
            else:
                print(f"  Итоговая длительность: {final_duration:.2f}s (цель: {target_duration:.2f}s)")

            return audio.astype(np.float32)

        except Exception as e:
            print(f"  Ошибка при генерации аудио: {e}")
            return np.zeros(int(target_duration * self.sample_rate), dtype=np.float32)

И чтобы это всё заработано, установим следующие библиотеки:

pip install -q silero num2words librosa

Далее собираем аудиофайл нашего закадрового перевода, используя следующую функцию:

# 6. Создание финального аудио
def create_final_audio(translated_segments, tts, original_audio_duration):
    print("Создаем финальное аудио...")

    # Создаем массив для финального аудио
    final_audio = np.zeros(int(original_audio_duration * tts.sample_rate), dtype=np.float32)
    processed_segments = 0

    for i, segment in enumerate(translated_segments):
        print(f"Синтезируем сегмент {i+1}/{len(translated_segments)}: '{segment['translated_text'][:50]}...'")

        try:
            synthesized_audio = tts.generate_audio_segment(
                segment['translated_text'],
                segment['duration']
            )

            # Проверяем что синтезированное аудио валидно
            if synthesized_audio is None or len(synthesized_audio) == 0:
                print(f"Пустое аудио для сегмента {i+1}, пропускаем")
                continue

            # Рассчитываем позицию в финальном аудио
            start_sample = int(segment['start'] * tts.sample_rate)
            end_sample = start_sample + len(synthesized_audio)

            # Проверяем границы
            if start_sample >= len(final_audio):
                print(f"Сегмент {i+1} выходит за границы, пропускаем")
                continue

            if end_sample > len(final_audio):
                print(f"Сегмент {i+1} выходит за границы, обрезаем")
                synthesized_audio = synthesized_audio[:len(final_audio) - start_sample]
                end_sample = len(final_audio)

            # Добавляем в финальное аудио
            final_audio[start_sample:end_sample] = synthesized_audio
            processed_segments += 1

        except Exception as e:
            print(f"Ошибка при обработке сегмента {i+1}: {e}")
            continue

    print(f"Успешно обработано {processed_segments}/{len(translated_segments)} сегментов")
    return final_audio

А для запуска синтеза речи и сохранения аудио файла с нашим переводом, используется следующий код:

    # Синтезируем речь
    tts = SileroTTSWithTiming()
    final_audio = create_final_audio(translated_segments, tts, original_duration)

    # Сохраняем результат
    output_path = 'translated_audio.wav'
    sf.write(output_path, final_audio, tts.sample_rate

▨ Итоги и результаты

Несмотря на то, что проект был реализован буквально на коленке и за пару часов, результат меня вполне устраивает. В качестве первого подопытного было выбрано видео с уроком английского языка – результат его обработки вы можете увидеть ниже:

А следом и тв-шоу с участием Тома Холланда – это буквально стресс-тест для скрипта, учитывая скорость разговора:

И еще один тестовый перевод новостей про SpaceX и новую версию Starship. Голос kseniya, модель Whisper'а – Large:

Все эксперименты с моделями я выполнял в Google Colab. Ссылка на ноутбук будет размещена ниже, чтобы вы тоже могли поэкспериментировать. Хочу уточнить, что результатом работы скрипта является аудиофайл с переводом. Его необходимо добавить в оригинальное видео с помощью любого приложения для видеомонтажа.

Конечно, можно было реализовать добавление аудиодорожки с переводом и в сам скрипт, но это уже дополнительные фичи, которые не планировались в рамках «проекта выходного дня».

На этом, пожалуй, можно закончить статью. Спасибо за внимание! И по традиции: если у вас есть вопросы, пожелания, осуждение или замечания – добро пожаловать в комментарии! Всем добра и интересных проектов!

▨ PS PS:

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

Скриншот приложения
Скриншот приложения
Скриншот приложения
Скриншот приложения

Интерфейс интуитивно понятный, где можно поиграться с настройками моделей. А еще я поменял модель проводчика с Helsinki-NLP на модель от Цукерберга и его компании (которую нельзя упоминать в России), назовем её книгалиц/nllb :). Ниже представлена демонстрация перевода с турецкого языка:

Ссылки к статье:

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