Руководство по задачам, возникающим при использовании речевой аналитики Яндекс SpeechSense (Часть 2)

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

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

Задача 1

Необходимо перекодировать все аудиозаписи в формат wave. В документации вы найдете, что можете использовать 4 аудиоформата, но пример в инструкции только для формата wave. У вас 2 варианта, первый — выучить наизусть весь git репозиторий с API Яндекса и переписать пример из инструкции и второй — приводить все аудиозаписи всех разговоров в формат wave. Я выбрал второй вариант, кодек ffmpeg и простой скрипт recode.bat решил проблему. Берем любые аудиозаписи любого из указанных форматов и перекодируем их в .wav вот так:

@echo off
set converter="c:/ffmpeg-4.4-essentials_build/bin/ffmpeg.exe"
set outfolder="wave"
mkdir %outfolder%
for %%f in (*.wma,*.mp3,*.mp4,*.ogg,*.wav,*.m4a,*.aac) do %converter% -i "%%f"  %outfolder%/"%%~nf.wav"

Скрипт берет все аудиофайлы из папки, в которой лежит и в папку /wave/ складывает перекодированные в формат wavе файлы с тем же именем и расширением .wav.

Задача 2

Для подгрузки аудиозаписи в SpeechSense желательно иметь файл с метаданными аудиозаписи. Инструкция предлагает 2 варианта, создать .json файл к каждому аудио самостоятельно, либо, при отсутствии файла, использовать всегда вот такой формат:

now = datetime.datetime.now().isoformat()
metadata = {
         'operator_name': 'Operator',
         'operator_id': '1111',
         'client_name': 'Client',
         'client_id': '2222',
         'date': str(now),
         'date_from': '2023-09-13T17:30:00.000',
         'date_to': '2023-09-13T17:31:00.000',
         'direction_outgoing': 'true',
      }

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

В первом случае в имени файла был только ID звонка.

import os
import json
from datetime import datetime

# Папка с файлами в формате wave из предыдущего скрипта.
wave_folder = 'c:\\speech_sense\\upload_data\\calls\\wave\\'

# Получение текущей даты в формате YYYY-MM-DDTHH:MM:SS.SSS. 
# Яндекс требует все даты именно в таком формате, пришлось повозиться, чтобы
# отформатировать время корректно. При любых отклонениях от этого формата  
# загрузка аудио в SpeechSense заканчивается ошибкой.
current_date = datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]

# Проходим по всем файлам в папке
for filename in os.listdir(wave_folder):
    if filename.endswith('.wav'):
        # Создание шаблона JSON для каждого файла
        data = {
            "operator_name": os.path.splitext(filename)[0],
            "operator_id": os.path.splitext(filename)[0],
            "client_name": "Client",
            "client_id": "1",
            "date": current_date,
            "direction_outgoing": "true",
            "language": "ru"
        }
        # Для имени и ID оператора используем ID из названия файла.
        # По этому ID можно будет узнать, в каком файле найдены поздравления.
        # Имя JSON файла
        json_filename = f"{os.path.splitext(filename)[0]}.json"

        # Запись данных в JSON файл. Обязательно указывать кодировку windows-1251
        # без параметра или в UTF-8 SpeechSense преобразует все русские буквы в кракозябры.
        with open(os.path.join(wave_folder, json_filename), 'w', encoding='windows-1251') as json_file:
            json.dump(data, json_file, ensure_ascii=False, indent=4)

print("JSON файлы успешно созданы.")

Во втором случае в имени файла была информация о дате и времени звонка, контактном телефоне клиента и ID звонка. Шаблон имени файла был такой «YYYYMMDD-HHMM-toXXXXXXXXXX-ID.wav». Решение выглядит вот так

import os
import json
from datetime import datetime

# Папка с файлами
wave_folder = 'c:\\speech_sense\\upload_data\\calls\\wave\\'


for filename in os.listdir(wave_folder):
    if filename.endswith('.wav'):
        # разбиваем имя файла по разделителю "-"
        data = os.path.splitext(filename)[0]
        parts = data.split("-")
        # склеиваем первую и вторую часть имени, где остаются дата и время звонка.
        date_str = parts[0] + parts[1]
        # приводим к необходимому для SpeechSense формату даты
        date_object = datetime.strptime(date_str, "%Y%m%d%H%M")
        formatted_date = date_object.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
        
        data = {
            "operator_name": "Operator",
            "operator_id": parts[3],
            "client_name": "Respondent",
            "client_id": parts[2],
            "date": formatted_date,
            "direction_outgoing": "true",
            "language": "ru"
        }

        # пишем JSON файл, не забыв указать кодировку.
        json_filename = f"{os.path.splitext(filename)[0]}.json"
        
        with open(os.path.join(wave_folder, json_filename), 'w', encoding='windows-1251') as json_file:
            json.dump(data, json_file, ensure_ascii=False, indent=4)

print("JSON файлы успешно созданы.")

Я понимаю, что тут найдутся специалисты, которые справедливо упрекнут, что нарушен принцип DRY, код повторяется и при желании не сложно написать одну функцию, которая будет извлекать метаданные из имен файла любого формата. Да, всё верно, рефакторинг кода уже запланирован на будущее. А тут изначально и планировались и проект по документации должен хранить в именах файлов только ID. Реальность оказалась сложнее. Вторая часть задачи возникла совершенно внезапно, когда весь код уже был написан.

Задача 3

Внезапно оказалось, что грузить и анализировать все аудиозаписи полностью очень дорого, такого бюджета у проекта не было. Быстро прослушав какие-то части и прогнав несколько сотен через бесплатные или десктопные STT решения, я обнаружил, что поздравления встречаются только в конце аудиофайла. Появилась идея отрезать только последние 30 секунд и проанализировать их. Установив в python модуль ffmpeg и прописав в windows PATH путь к кодеку удалось решить задачу вот так:

import os
import ffmpeg
from pydub import AudioSegment

mp3_folder = 'c:\\speech_sense\\upload_data\\calls\\'
wave_folder = 'c:\\speech_sense\\upload_data\\calls\\wave\\'
finish_fragment_folder = 'c:\\speech_sense\\upload_data\\calls\\wave\\finish_fragment\\'

def convert_mp3_to_wav(mp3_path, wav_path):
    """
    Конвертируем MP3 в WAV используя модуль и установленный кодек ffmpeg.
    """
    try:
        ffmpeg.input(mp3_path).output(wav_path).run()
        print(f"Converted {mp3_path} to {wav_path}")
    except Exception as e:
        print(f"Error converting {mp3_path} to WAV: {e}")

def extract_last_30_seconds(wav_path, output_path):
    """
    Извлекаем последние 30 секунд в WAV файле.
    """
    try:
        audio = AudioSegment.from_wav(wav_path)
        last_30_seconds = audio[-30000:]  # оставляем последние 30000 мс (30 секунд)
        last_30_seconds.export(output_path, format="wav")
        print(f"Extracted last 30 seconds to {output_path}")
    except Exception as e:
        print(f"Error extracting last 30 seconds from {wav_path}: {e}")

if __name__ == "__main__":

    # перебираем все файлы и конвертируем.
    for filename in os.listdir(mp3_folder):
        if filename.endswith('.mp3'):
            mp3_file = os.path.join(mp3_folder, filename)
            wave_filename = f"{os.path.splitext(filename)[0]}.wav"
            wav_file = os.path.join(wave_folder, wave_filename)
            finish_fragment_filename = f"{os.path.splitext(filename)[0]}_last_30_seconds.wav"
            output_last_30_seconds = os.path.join(finish_fragment_folder, finish_fragment_filename)
    
            # Если Вам, уважаемый читатель, кажется, что решение Задачи 1 не подходит,
            # Просто сотрите символ комментария со стороки внизу.
            # Функция convert_mp3_to_wav() решает задачу 1 средствами python
            # convert_mp3_to_wav(mp3_file, wav_file)
            
            extract_last_30_seconds(wav_file, output_last_30_seconds)

На calls\wave\finish_fragment\ повторно пришлось прогнать скрипт из Задачи 2 и получить метаданные уже по обрезанным фрагментам.

Задача 4

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

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

Код решения я всё равно выложу, возможно кто-то увидит, объяснит и поправит ошибки. Буду благодарен.

import os
import shutil
import glob

# список directories и переменную directories_list python отказывался создавать
directories = ['/path/to/dir1', '/path/to/dir2', '/path/to/dir3']
directories_list = '/path/to/dir3'
destination_dir = 'c:\\speech_sense\\upload_data\\calls\\'
        
os.makedirs(destination_dir, exist_ok=True)

def copy_mp3_files(directories, destination):
    for directory in directories:
        
        mp3_files = glob.glob(os.path.join(directory, '*.mp3'))
        
        for mp3 in mp3_files:
            
            shutil.copy(mp3, destination)


copy_mp3_files(directories, destination_dir)

Задача 5

Начальные условия задачи были такие:

  • У меня есть 25000 готовых к загрузке аудиофайлов.

  • К каждому аудиофайлу созданы метаданные в .json с таким-же названием.

  • Примера с пакетной загрузкой в документации Яндекса нет.

  • Грузить файлы я умею только по одному.

  • Если прописать команду загрузки файла (см. первую часть статьи) в файл PowerShell .ps1 и запустить — ничего, кроме сообщения, что команду выполнить нельзя, не происходит.

Проблему с запуском файлов .ps1 я решил вот так: (https://ru.stackoverflow.com/questions/935212/powershell-выполнение-сценариев-отключено-в-этой-системе)

Открываем терминал с правами администратора
Пишем и запускаем: Set-ExecutionPolicy RemoteSigned
На вопрос отвечаем: A (Да для всех)

Далее я создал каждому аудиофайлу свой файл .ps1 для загрузки в SpeechSense.

import os

wave_directory = 'c:\\speech_sense\\upload_data\\calls\\wave\\finish_fragment\\'
output_directory = 'c:\\speech_sense\\upload_data\\calls\\powershell\\'
# Constants for connection ID and key
CONNECTION_ID = 'ID подключения' # Замените на свой
KEY = 'API_KEY'  # Замените на свой

def generate_powershell_script(audio_filename):
    # Определяем имя файла для метаданных в .json взяв исходное имя файла без расширения
    base_name = os.path.splitext(audio_filename)[0]
    json_filename = f"{base_name}.json"

    # пишем команду PowerShell которая нужна для загрузки
    # обратите внимание, где стоят кавычки "". Без них .ps1 ничего не запускал
    cmd = f'py upload_grpc.py --audio-path "{wave_directory}\\{audio_filename}" --meta-path "{wave_directory}\\{json_filename}" --connection-id {CONNECTION_ID} --key {KEY}'
    
    # Имя скрипта PowerShell
    ps_script_filename = f"{base_name}.ps1"
    
    # Пишем команду в файл PowerShell
    with open(os.path.join(output_directory, ps_script_filename), 'w') as ps_script_file:
        ps_script_file.write(cmd)   
    
    print(f"Generated {ps_script_filename}")

def main():
    # Проверяем, существует ли основная директория
    if not os.path.isdir(wave_directory):
        print(f"The directory {wave_directory} does not exist.")
        return
    
    # Формируем скрипт PowerShell для каждого файла .wav в директории
    for filename in os.listdir(wave_directory):
        if filename.endswith('.wav'):
            generate_powershell_script(filename)

if __name__ == '__main__':
    main()

Задача 6

Запустить все файлы в папке оказалось легко средствами PowerShell. Как я уже упоминал ранее, запускаем его от имени администратора в папке, где лежит output от задачи 5. У меня это c:\speech_sense\upload_data\calls\powershell\

Set-ExecutionPolicy RemoteSigned

Get-ChildItem -Path . -Filter "*.ps1" | ForEach-Object { powershell -File $_.FullName }

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

4a0b9beaf4585a2e26ebe99681c903db.png

Установив фильтр на этот тег для высказываний оператора, получается список аудиозаписей и их ID, в которых есть поздравление с праздником.

8903406ef201d73b5e398a14f7196990.png

Список ID для таких аудиозаписей можно скачать в CSV формате, По ID легко найти и поощрить операторов, которые при звонке клиента, проявляют инициативу и показывают уважение к клиенту.

© Habrahabr.ru