Руководство по задачам, возникающим при использовании речевой аналитики Яндекс 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 }
Все файлы подгрузились примерно за ночь. Стоимость обработки составила примерно рубль на один фрагмент. В проекте с аудиофайлами перед загрузкой необходимо создать тег, в котором ищем фразы поздравления с праздником. Обязательно перед загрузкой файлов. Новые и отредактированные теги на уже загруженные файлы не работают. Хотите новый тег создать и посмотреть, где он встречается — грузите и платите за расшифровку заново.
Установив фильтр на этот тег для высказываний оператора, получается список аудиозаписей и их ID, в которых есть поздравление с праздником.
Список ID для таких аудиозаписей можно скачать в CSV формате, По ID легко найти и поощрить операторов, которые при звонке клиента, проявляют инициативу и показывают уважение к клиенту.