Всем привет. Меня зовут Игорь Филиппов и я веб-разработчик. Вы, вероятнее всего, знаете, как прочно ChatGPT закрепился в медийном пространстве. Ежедневно выходят сотни статей и видео на эту тему, предлагая разнообразные варианты применения.
Также регулярно выпускаются новые инструменты, использующие нейронные сети, которые пытаются сделать нашу жизнь лучше. Должен сказать, я до сих пор очень впечатлен всеми этими AI штуковинами и постоянно размышляю, как по максимуму применить их в моей обычной рутине.
Я активно использую Telegram для ежедневной коммуникации: начиная от друзей/родственников, заканчивая бесчисленным количеством рабочих чатов по проектам. Вы же все знаете эту шутку, про “больше чатов богу чатов”, да? А что, если в этих группах еще и много любителей голосовых сообщений? Быстро ухватить суть обсуждения без прослушивания каждого точно не получится. Поэтому появилась мысль, как можно оптимизировать время и эффективно решить эту задачу.
Кто-то может сказать, что подписка Telegram Premium как раз имеет такой функционал - распознавание любого аудио сообщения по клику на него. Но лично у меня было много претензий к нему, особенно к скорости. Давайте представим ситуацию: вы открываете чат, в нем 50+ новых сообщений и половина из них - голосовые по паре минут. У вас нет возможности быстро проскролить чат и влиться в контекст обсуждения, вам придется прокликивать каждое голосовое и ждать (бывает, очень долго) пока Telegram клиент отработает запрос.
Сначала мне в голову пришла идея создать бота, который автоматически под каждым сообщением оставляет свой реплай с полной расшифровкой аудио. Но в процессе разработки я подумал, что можно дополнительно проинтегрировать бота с ChatGPT - для получения краткого пересказа самого сообщения. Тем более, к тому моменту, когда я делал бота, Open AI только выпустила доступ к API.
Мне было очень интересно попробовать, руки зачесались, и я приступил к реализации. Своей разработкой решил поделиться с вами, вдруг, вам будет интересно или полезно.
Задача #1 – Получить расшифровку аудио
Конечно же, я решил по максимуму использовать существующие решения. Выбор пал на speech-to-text сервис от Яндекса. У меня уже был опыт работы с Yandex SpeechKit, и в первую очередь я решил использовать именно его.
Первая проблема, с которой я столкнулся – Yandex SpeechKit не поддерживает ogg формат, в котором Telegram отдает аудио. Вторая – Yandex SpeechKit обрабатывает аудио длительностью не больше 30 секунд.
Ок, это решаемо, благодаря прекрасному инструменту, настоящему швейцарскому ножу для работы с аудио/видео - ffmpeg.
Тем более, помимо конвертирования, он без проблем справится и с нарезанием аудио на фрагменты.
Вот пример вызова ffmpeg из терминала:
ffmpeg -i /path/to/origin/file -f segment -segment_time 30 -c copy \"%03d.ogg\"Кстати, в качестве источника ffmpeg без проблем принимает и любой url из интернета, не только локальные файлы.
Но в последствии пришлось отказаться от Яндекса. Из-за того, что мы режем фрагменты ровно по 30 секунд, то с очень большой вероятностью попадаем в середину слова, что вызывает некоторые дыры или ошибки в итоговом склеенном тексте.
Следующий выбор пал на модель whisper от все того же OpenAI. На мой взгляд, этот сервис работает значительно лучше. Во-первых, нет ограничения на длительность файла, только на размер - не больше 25 мб (а этого с головой хватит даже для очень больших голосовых). Во-вторых, нет необходимости передавать исходный язык - whisper автоматически его определяет и отдает результат строкой. Тестировались: английский, турецкий, украинский, русский, испанский. Качество распознавания очень хорошее и, на мой субъективный взгляд, лучше, чем у аналогичного сервиса от Яндекс. В-третьих, whisper понимает, когда человек делает паузы, задает вопросы, восклицает, поэтому в ответ он отдает размеченный текст с пунктуацией. Благодаря этому визуально текст выглядит значительно приятнее. И наконец, последний аргумент в пользу whisper - цена. Он кратно дешевле.
Так как бота я писал на php, соответственно и примеры код-сниппетов тоже будут на php.
Вот так мы можем конвернуть из ogg в любой формат данных с помощью ffmpeg через системный вызов:
<?php
namespace App\AudioConverter;
class AudioConverter
{
    public function convertAudio(string $pathToSource): AudioConversionResult
    {
        $outputFileName = 'temp_ffmpeg_output.wav';
        $cmd = "ffmpeg -y -i \"$pathToSource\" -b:a 128k $outputFileName 2>&1";
        $ffmpegStdout = popen($cmd, 'r');
        $stdout = '';
        if (is_resource($ffmpegStdout)) {
            while (!feof($ffmpegStdout)) {
                $stdout .= fread($ffmpegStdout, 4096);
            }
        }
        pclose($ffmpegStdout);
        preg_match('/Duration: (.*?),/', $stdout, $matches);
        $timeDuration = $matches[1];
        list($hours, $minutes, $seconds) = explode(':', $timeDuration);
        $totalSeconds = ((int)$hours * 3600) + ((int)$minutes * 60) + (int)$seconds;
        $roundedSeconds = ceil($totalSeconds);
        return new AudioConversionResult(
            pathToFile: $outputFileName,
            duration: $roundedSeconds
        );
    }
}Я очень хотел сделать на стороне бота подсчет затрат, чтобы видеть статистику использования, поэтому так неизящно приходится вызывать ffmpeg для того, чтобы захватить stdout и затем регулярным выражением вытащить длительность аудио.
Для того, чтобы получить распознанный текст сообщения, сделаем запрос к API:
<?php
namespace App\Clients\OpenAI;
use App\Clients\OpenAI\Exceptions\TooLargeFileException;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Utils;
class OpenAIApiClient
{
    // some other methods here... 
    public function getTranscription(string $pathToFileAudio): string
    {
        $outputFileSize = filesize($pathToFileAudio);
        // Check if the file size is greater than 25 MB (in bytes)
        $maxFileSize = 25 * 1024 * 1024; // 25 MB in bytes
        if ($outputFileSize > $maxFileSize) {
            throw new TooLargeFileException("Max audio file size is $maxFileSize but $outputFileSize was given.");
        }
        $headers = [
            'Authorization' => 'Bearer ' . $this->apiKey
        ];
        $options = [
            'multipart' => [
                [
                    'name' => 'model',
                    'contents' => 'whisper-1'
                ],
                [
                    'name' => 'file',
                    'contents' => Utils::tryFopen($pathToFileAudio, 'r'),
                    'filename' => $pathToFileAudio,
                    'headers' => [
                        'Content-Type' => '<Content-type header>'
                    ]
                ]
            ]
        ];
        $request = new Request('POST', 'https://api.openai.com/v1/audio/transcriptions', $headers);
        $response = $this->httpClient->sendAsync($request, $options)->wait();
        return json_decode((string)$response->getBody(), true)['text'];
    }
}Задача #2 – Получить краткое содержание ответа
После того, как мы преобразовали голосовое сообщение в текст, нам нужно получить его краткий пересказ. Тут все очень просто, и выбирать в настоящее время особо не из чего. Используем модель gpt-3.5-turbo. Опять же спасибо OpenAI за API.
Должен сказать, что использование API от нейронных сетей - штука довольна забавная. По факту, у вас есть один endpoint, и в каждом запросе вы пишите сопроводительное сообщение на любом естественном языке о том, что и в каком формате хотите получить в ответ.
В случае с gpt-3.5-turbo мы должны передать просто массив сообщений. Сообщения могут быть трех типов (ролей):
- "system" - сообщения для языковой модели, где мы вводим какую-то мета информацию и сообщаем ей, что мы от нее хотим; 
- "assistant" - то, что языковая модель уже сгенерировала ранее (либо мы хотим заставить ее так думать); 
- "user" - пользовательские сообщения. 
В нашем случае, запрос к модели может выглядеть так:
POST https://api.openai.com/v1/chat/completions
{
  "model": "gpt-3.5-turbo",
  "messages": [
    {
      "role": "system",
      "content": "Ты должен дать краткое описание сообщения в 1-2 предложениях. Ответ дай на русском языке."
    },
    {
      "role": "user",
      "content": "... сюда отправим текст распознанного аудио-сообщения..."
    }
  ]
}Кстати, у меня были эксперименты, когда я просил модель отдать мне результат в формате json. В целом это работает, правда, иногда она добавляет ненужные текстовые прелюдии перед json, так что приходится очищать строку.
Задача #3 – Отправить результат в telegram чат
К сожалению, сервис от OpenAI в частности chat/completions нельзя назвать стабильным. Несколько раз в день я получаю сообщение об ошибке, где он возвращает json с текстом, что сервис временно недоступен. Также на практике оказалось, что ± 60% сообщений после распознавания остаются короткими, длиной не больше 200-300 символов. Решил, что будем использовать ChatGPT только для действительно длинных сообщений (>300 символов), а для небольших будем просто отдавать оригинальный текст.
Для короткого голосового:
<?php
namespace App\Telegram\Messages;
class VoiceMessageRecognitionResultOnlyMessage extends Message
{
    protected string $recognizedText;
    public function __construct(string $recognizedText)
    {
        $this->recognizedText = $recognizedText;
    }
    public function getMessageContent(): array
    {
        return [
            "????<b>Распознанное сообщение:</b>",
            "",
            $this->recognizedText
        ];
    }
    public function getParseMode(): string
    {
        return Message::PARSE_MODE_HTML;
    }
}Для длинного голосового:
<?php
namespace App\Telegram\Messages;
class VoiceMessageSummaryWithRecognitionResultMessage extends Message
{
    protected string $recognizedText;
    protected string $summary;
    public function __construct(string $recognizedText, string $summary)
    {
        $this->recognizedText = $recognizedText;
        $this->summary = $summary;
    }
    public function getMessageContent(): array
    {
        return [
            "✂️<b>Краткое содержание:</b>",
            "",
            $this->summary,
            "",
            "????<b>Распознанное сообщение:</b>",
            "",
            $this->recognizedText
        ];
    }
    public function getParseMode(): string
    {
        return Message::PARSE_MODE_HTML;
    }
}Текущая статистика и стоимость использования API от OpenAI
Ниже статистика использования бота за последний месяц:
Total audio recognition count: 1075
Total summarize count: 482
Total audio recognition cost: 4.2397$
Total summarize cost: 0.09$
Total cost: 4.3297$У меня были опасения, что бот будет выходить в копеечку, но, кажется, причин переживать совсем нет. На всякий случай разместил ссылку на донат в bio профиля бота.
Заключение
По личному опыту и первому фидбеку бот получился очень полезным. Сейчас я добавляю его во все общие чаты с друзьями, где я состою, и мне прямо очень нравится этот дополнительный функционал. Особенно чудно, когда видишь голосовое на 5 минут, и краткое содержание “Автор сообщения отвергает предложенную ему встречу и обосновывает причину отказа”. В групповых чатах бот автоматически обрабатывает все аудио сообщения, присылает краткое содержание и расшифровку. Голосовые из личной переписки можно отправлять боту напрямую.
Посмотреть на итоговый результат можно тут. Если идея бота вам понравилось, пишите, я сейчас раздумываю о том, чтобы выложить все исходники на github. Может быть, благодаря сообществу, получится сделать еще что-нибудь интересное для развития бота.
Комментарии (12)
 - edvardpotter05.06.2023 17:38- А с оплатой OpenAI API проблем не было? Или оплачивать только через карточку зарубежного банка? 
 - NickyX305.06.2023 17:38+1- И наконец, последний аргумент в пользу whisper - цена. Он кратно дешевле. - Он вообще-то в опесорсе, ставите себе на сервер и никаких ограничений + умеет чисто на CPU, правда медленнее, чем с GPU.  - play_to Автор05.06.2023 17:38- Круто, не знал. Надо будет заняться вопросом на выходных)  - NickyX305.06.2023 17:38+1- Более того, он и под Windows взлетает с полтычка даже на "полярисах", но в любом варианте требуется CPU не ниже intel core третьего поколения (там добавили FP16). - Плюсом достойные результаты зависят от модели, условно 500 мегабайтная делает ошибочки, 1500 Мб уже существенно лучше, но тут уж все зависит от размера доступной памяти как в системе, так и на GPU.  - play_to Автор05.06.2023 17:38- С другой стороны, пока стоимость использования whisper на мощностях от OpenAI не такая большая. И только если нагрузка на бот вырастет, тогда будет экономически целесообразно переходить. 
 
 
 
 
           
 
kiff
Так как в премиум подписке ТГ есть функция аудио в текст, было бы неплохо сравнить данные подходы.
play_to Автор
Так я же в статье как раз писал про это :)
Тем более, основная идея не просто в реализации speech-to-text, а в использовании нейронки для получения супер краткого пересказа голосового, без воды.
0x6b73ca
А можно пример json с ошибкой?
play_to Автор
С ошибкой чего? О том, что сервис недоступен?)